From f2bf79ab6b7cae0ac706b35b1f377d359bb63d84 Mon Sep 17 00:00:00 2001 From: BeZide93 Date: Tue, 28 Oct 2025 08:53:29 +0100 Subject: [PATCH] Added Mod and Cheat Manager --- .../org/kenjinx/android/cheats/CheatFs.kt | 317 ++++++++++++ .../org/kenjinx/android/cheats/CheatPrefs.kt | 16 + .../java/org/kenjinx/android/cheats/ModFs.kt | 195 ++++++++ .../org/kenjinx/android/views/HomeViews.kt | 463 +++++++++++++++++- src/LibKenjinx/rd.xml | 25 +- src/Ryujinx.HLE/HOS/TamperMachine.cs | 20 +- src/Ryujinx.HLE/Ryujinx.HLE.csproj | 1 + 7 files changed, 1020 insertions(+), 17 deletions(-) create mode 100644 src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatFs.kt create mode 100644 src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatPrefs.kt create mode 100644 src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/ModFs.kt diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatFs.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatFs.kt new file mode 100644 index 000000000..bde41a778 --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatFs.kt @@ -0,0 +1,317 @@ +package org.kenjinx.android.cheats + +import android.app.Activity +import android.util.Log +import java.io.File +import java.nio.charset.Charset +import android.net.Uri +import android.provider.OpenableColumns +import android.content.Intent + +data class CheatItem(val buildId: String, val name: String) { + val key get() = "$buildId-$name" +} + +/* -------- Pfade -------- */ + +private fun cheatsDirExternal(activity: Activity, titleId: String): File { + val base = activity.getExternalFilesDir(null) // /storage/emulated/0/Android/data//files + return File(base, "mods/contents/$titleId/cheats") +} + +private fun allCheatDirs(activity: Activity, titleId: String): List { + return listOf(cheatsDirExternal(activity, titleId)) + .distinct() + .filter { it.exists() && it.isDirectory } +} + +/* -------- Parser -------- */ + +private fun parseCheatNames(text: String): List { + // Trim BOM, CRLF tolerant + val clean = text.replace("\uFEFF", "") + val rx = Regex("""(?m)^\s*\[(.+?)\]\s*$""") + return rx.findAll(clean) + .map { it.groupValues[1].trim() } + .filter { it.isNotEmpty() } + .toList() +} + +/* -------- Public: Cheats laden -------- */ + +fun loadCheatsFromDisk(activity: Activity, titleId: String): List { + val dirs = allCheatDirs(activity, titleId) + if (dirs.isEmpty()) { + Log.d("CheatFs", "No cheat dirs for $titleId (checked internal+external).") + return emptyList() + } + + val out = mutableListOf() + for (dir in dirs) { + dir.listFiles { f -> f.isFile && f.name.endsWith(".txt", ignoreCase = true) }?.forEach { file -> + val buildId = file.nameWithoutExtension + val text = runCatching { file.readText(Charset.forName("UTF-8")) }.getOrElse { "" } + parseCheatNames(text).forEach { nm -> + out += CheatItem(buildId, nm) + } + } + } + + return out + .distinctBy { it.key.lowercase() } + .sortedWith(compareBy({ it.buildId.lowercase() }, { it.name.lowercase() })) +} + +/* -------- Public: Auswahl SOFORT auf Disk anwenden -------- */ + +fun applyCheatSelectionOnDisk(activity: Activity, titleId: String, enabledKeys: Set) { + // Wir wählen genau EINE BUILDID-Datei (die „beste“), und schalten darin Sections. + val dirs = allCheatDirs(activity, titleId) + val allTxt = dirs.flatMap { d -> + d.listFiles { f -> f.isFile && f.name.endsWith(".txt", ignoreCase = true) }?.toList() ?: emptyList() + } + if (allTxt.isEmpty()) { + Log.d("CheatFs", "applyCheatSelectionOnDisk: no *.txt found for $titleId") + return + } + + val buildFile = pickBestBuildFile(allTxt) + val text = runCatching { buildFile.readText(Charset.forName("UTF-8")) }.getOrElse { "" } + if (text.isEmpty()) return + + // Enabled-Set normalisieren: Keys sind "-" + val enabledSections = enabledKeys.asSequence() + .mapNotNull { key -> + val dash = key.indexOf('-') + if (dash <= 0) null else key.substring(dash + 1).trim() + } + .map { it.lowercase() } + .toSet() + + val rewritten = rewriteCheatFile(text, enabledSections) + + runCatching { + buildFile.writeText(rewritten, Charset.forName("UTF-8")) + }.onFailure { + Log.w("CheatFs", "Failed to write ${buildFile.absolutePath}: ${it.message}") + } +} + +/* -------- Implementierung: Auswahl anwenden (nur ';' als Kommentar) -------- */ + +private fun pickBestBuildFile(files: List): File { + fun looksHexName(p: File): Boolean { + val n = p.nameWithoutExtension + return n.length >= 16 && n.all { it.isDigit() || it.lowercaseChar() in 'a'..'f' } + } + return files.firstOrNull(::looksHexName) + ?: files.maxByOrNull { runCatching { it.lastModified() }.getOrDefault(0L) } + ?: files.first() +} + +private fun isSectionHeader(line: String): Boolean { + val t = line.trim() + return t.length > 2 && t.first() == '[' && t.contains(']') +} + +private fun sectionNameFromHeader(line: String): String { + val t = line.trim() + val close = t.indexOf(']') + return if (t.startsWith("[") && close > 1) t.substring(1, close).trim() else "" +} + +/** + * Entfernt EIN führendes Kommentarzeichen (';') + optionales Leerzeichen. + * Nur am absoluten Zeilenanfang (keine führenden Spaces erlaubt). + */ +private fun uncommentOnce(raw: String): String { + if (raw.isEmpty()) return raw + return if (raw.startsWith(";")) { + raw.drop(1).let { if (it.startsWith(" ")) it.drop(1) else it } + } else raw +} + +/** + * Kommentiert die Zeile aus, wenn sie nicht bereits mit ';' beginnt. + * Atmosphère nutzt ';' – das verwenden wir ausschließlich. + */ +private fun commentOut(raw: String): String { + val t = raw.trimStart() + if (t.isEmpty()) return raw + if (t.startsWith(";")) return raw + return "; $raw" +} + +/** + * Schreibt die Datei neu: + * - Keine Marker einfügen + * - Pro Section den Body gemäß enabled/disabled (enabledSections) kommentieren/entkommentieren + * - Reine Kommentar-/Leerzeilen (nur ';') bleiben erhalten + */ +// Hilfsfunktionen: trailing Blankzeilen trimmen / Header normalisieren +private fun trimTrailingBlankLines(lines: MutableList) { + while (lines.isNotEmpty() && lines.last().trim().isEmpty()) { + lines.removeAt(lines.lastIndex) + } +} + +private fun joinHeaderBufferOnce(header: List): String { + // Header-Zeilen unverändert, aber trailing Blanks entfernen und genau 1 Leerzeile danach + val buf = header.toMutableList() + trimTrailingBlankLines(buf) + return if (buf.isEmpty()) "" else buf.joinToString("\n") + "\n\n" +} + +/** + * Schreibt die Datei neu: + * - Keine Marker einfügen + * - Pro Section den Body gemäß enabled/disabled (enabledSections) kommentieren/entkommentieren + * - Reine Kommentar-/Leerzeilen bleiben erhalten + * - Zwischen Sections genau EINE Leerzeile, am Ende genau EIN Newline. + */ +private fun rewriteCheatFile(original: String, enabledSections: Set): String { + val lines = original.replace("\uFEFF", "").lines() + + val out = StringBuilder(original.length + 1024) + + var currentSection: String? = null + val currentBlock = ArrayList() + val headerBuffer = ArrayList() + var sawAnySection = false + var wroteAnySection = false + + fun flushCurrent() { + val sec = currentSection ?: return + + // trailing Blankzeilen im Block entfernen, damit keine doppelten Abstände wachsen + trimTrailingBlankLines(currentBlock) + + val enabled = enabledSections.contains(sec.lowercase()) + + // Zwischen Sections genau eine Leerzeile einfügen (aber nicht vor der ersten) + if (wroteAnySection) out.append('\n') + + out.append('[').append(sec).append(']').append('\n') + + if (enabled) { + // Entkommentieren (nur ein führendes ';' an Spalte 0) + for (l in currentBlock) { + val trimmed = l.trim() + if (trimmed.isEmpty() || (trimmed.startsWith(";") && trimmed.length <= 1)) { + out.append(l).append('\n') + } else { + if (l.startsWith(";")) { + out.append( + l.drop(1).let { if (it.startsWith(" ")) it.drop(1) else it } + ).append('\n') + } else { + out.append(l).append('\n') + } + } + } + } else { + // Disablen: alles, was nicht schon mit ';' beginnt und nicht leer ist, auskommentieren + for (l in currentBlock) { + val t = l.trim() + if (t.isEmpty() || t.startsWith(";")) { + out.append(l).append('\n') + } else { + out.append("; ").append(l).append('\n') + } + } + } + + wroteAnySection = true + currentSection = null + currentBlock.clear() + } + + for (raw in lines) { + if (isSectionHeader(raw)) { + flushCurrent() + currentSection = sectionNameFromHeader(raw) + sawAnySection = true + continue + } + + if (!sawAnySection) { + headerBuffer.add(raw) + } else { + currentBlock.add(raw) + } + } + flushCurrent() + + // Header vorn einsetzen (mit genau einer Leerzeile danach, falls vorhanden) + val headerText = joinHeaderBufferOnce(headerBuffer) + if (headerText.isNotEmpty()) { + out.insert(0, headerText) + } + + // Globale Normalisierung: 3+ Newlines -> 2, und am Ende genau EIN '\n' + var result = out.toString() + .replace(Regex("\n{3,}"), "\n\n") // nie mehr als 1 Leerzeile zwischen Abschnitten + .trimEnd() + "\n" // genau ein Newline am Ende + + return result +} +private fun cheatsDirPreferredForWrite(activity: Activity, titleId: String): File { + val dir = cheatsDirExternal(activity, titleId) + if (!dir.exists()) dir.mkdirs() + return dir +} + +private fun getDisplayName(activity: Activity, uri: Uri): String? { + return runCatching { + val cr = activity.contentResolver + cr.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { c -> + if (c.moveToFirst()) c.getString(0) else null + } + }.getOrNull() +} + +private fun uniqueFile(targetDir: File, baseName: String): File { + var name = baseName + if (!name.lowercase().endsWith(".txt")) name += ".txt" + var out = File(targetDir, name) + var idx = 1 + val stem = name.substringBeforeLast(".") + val ext = ".txt" + while (out.exists()) { + out = File(targetDir, "$stem ($idx)$ext") + idx++ + } + return out +} + +/** + * Importiert eine .txt aus einem SAF-Uri in den Cheats-Ordner des Titels. + * Gibt das Zieldatei-Objekt zurück, wenn erfolgreich. + */ +fun importCheatTxt(activity: Activity, titleId: String, source: Uri): Result { + return runCatching { + // Lese-Rechte ggf. dauerhaft sichern + try { + activity.contentResolver.takePersistableUriPermission( + source, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } catch (_: Throwable) {} + + val targetDir = cheatsDirPreferredForWrite(activity, titleId) + + val display = getDisplayName(activity, source) ?: "cheats.txt" + val target = uniqueFile(targetDir, display) + + activity.contentResolver.openInputStream(source).use { ins -> + requireNotNull(ins) { "InputStream null" } + target.outputStream().use { outs -> + ins.copyTo(outs) + } + } + + // nach Import: optional sofort neu einlesen/normalisieren wäre möglich, + // aber wir belassen die Datei so wie geliefert. + target + } +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatPrefs.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatPrefs.kt new file mode 100644 index 000000000..667efd61f --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/CheatPrefs.kt @@ -0,0 +1,16 @@ +package org.kenjinx.android.cheats + +import android.content.Context + +class CheatPrefs(private val context: Context) { + private fun key(titleId: String) = "cheats_$titleId" + private val prefs get() = context.getSharedPreferences("cheats", Context.MODE_PRIVATE) + + fun getEnabled(titleId: String): MutableSet { + return prefs.getStringSet(key(titleId), emptySet())?.toMutableSet() ?: mutableSetOf() + } + + fun setEnabled(titleId: String, keys: Set) { + prefs.edit().putStringSet(key(titleId), keys.toSet()).apply() + } +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/ModFs.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/ModFs.kt new file mode 100644 index 000000000..dea14ed5f --- /dev/null +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/cheats/ModFs.kt @@ -0,0 +1,195 @@ +package org.kenjinx.android.cheats + +import android.app.Activity +import android.content.ContentResolver +import android.net.Uri +import android.provider.OpenableColumns +import android.util.Log +import java.io.File +import java.io.InputStream +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +/* -------- Pfade -------- */ + +private fun modsRootExternal(activity: Activity): File { + // /storage/emulated/0/Android/data//files/sdcard/atmosphere/contents + return File(activity.getExternalFilesDir(null), "sdcard/atmosphere/contents") +} + +private fun modsTitleDir(activity: Activity, titleIdUpper: String): File { + // TITLEID muss groß geschrieben sein + return File(modsRootExternal(activity), titleIdUpper) +} + +private fun modDir(activity: Activity, titleIdUpper: String, modName: String): File { + return File(modsTitleDir(activity, titleIdUpper), modName) +} + +/* -------- Auflisten & Löschen -------- */ + +fun listMods(activity: Activity, titleId: String): List { + val titleIdUpper = titleId.trim().uppercase() + val dir = modsTitleDir(activity, titleIdUpper) + if (!dir.exists() || !dir.isDirectory) return emptyList() + + return dir.listFiles { f -> f.isDirectory } // NAME-Ordner + ?.map { it.name } + ?.sortedBy { it.lowercase() } + ?: emptyList() +} + +fun deleteMod(activity: Activity, titleId: String, modName: String): Boolean { + val target = modDir(activity, titleId.trim().uppercase(), modName) + return target.safeDeleteRecursively() +} + +private fun File.safeDeleteRecursively(): Boolean { + if (!exists()) return true + return try { + walkBottomUp().forEach { + runCatching { if (it.isDirectory) it.delete() else it.delete() } + } + !exists() + } catch (_: Throwable) { + false + } +} + +/* -------- Import ZIP -------- */ + +data class ImportProgress( + val bytesRead: Long, + val totalBytes: Long, + val currentEntry: String = "" +) { + val fraction: Float + get() = if (totalBytes <= 0) 0f else (bytesRead.coerceAtMost(totalBytes).toFloat() / totalBytes.toFloat()) +} + +// NEU: Multi-Import. Top-Level-Ordner in der ZIP sind die Mod-Namen. +data class ImportModsResult( + val imported: List, + val ok: Boolean +) + +fun importModsZip( + activity: Activity, + titleId: String, + zipUri: Uri, + onProgress: (ImportProgress) -> Unit +): ImportModsResult { + val titleIdUpper = titleId.trim().uppercase() + val baseDir = modsTitleDir(activity, titleIdUpper).apply { mkdirs() } + + val (_, totalBytes) = resolveDisplayNameAndSize(activity.contentResolver, zipUri) + var bytes = 0L + fun bump(read: Int, entryName: String = "") { + if (read > 0) { + bytes += read + onProgress(ImportProgress(bytesRead = bytes, totalBytes = totalBytes, currentEntry = entryName)) + } + } + + // Für jeden Top-Level-Ordner (Mod-Name) einmalig vorbereiten (ggf. alten Ordner löschen). + val preparedMods = mutableSetOf() + val importedMods = linkedSetOf() // Reihenfolge stabil + + return try { + activity.contentResolver.openInputStream(zipUri).use { raw -> + if (raw == null) return@use + ZipInputStream(raw).use { zis -> + var entry = zis.nextEntry + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + + while (entry != null) { + val rawName = entry.name.replace('\\', '/') // normalisieren + // Sicherheitsfilter & leere Namen überspringen + if (rawName.isBlank() || rawName.startsWith("/") || rawName.contains("..")) { + zis.closeEntry() + entry = zis.nextEntry + continue + } + + // Top-Level: erster Segment vor dem ersten '/' + val slash = rawName.indexOf('/') + val topLevel = if (slash > 0) rawName.substring(0, slash) else rawName + if (topLevel.isBlank()) { + zis.closeEntry() + entry = zis.nextEntry + continue + } + + // restlicher Pfad innerhalb des Mod-Ordners + val relPath = if (slash >= 0 && slash + 1 < rawName.length) rawName.substring(slash + 1) else "" + + // Nur Einträge verarbeiten, die innerhalb eines Modordners liegen (wir wollen NAME/... Strukturen) + if (relPath.isBlank() && entry.isDirectory.not()) { + // Datei direkt im Top-Level (z.B. NAME.txt) ignorieren + zis.closeEntry() + entry = zis.nextEntry + continue + } + + // Mod-Ordner vorbereiten (einmalig: ggf. alten Ordner entfernen) + if (preparedMods.add(topLevel)) { + val modFolder = modDir(activity, titleIdUpper, topLevel) + if (modFolder.exists()) modFolder.safeDeleteRecursively() + modFolder.mkdirs() + importedMods += topLevel + } + + // Zielpfad: .../TITLEID// + val dest = if (relPath.isBlank()) { + // nur ein Ordner-Eintrag (NAME/ oder NAME/exefs/) + File(modDir(activity, titleIdUpper, topLevel), "") + } else { + File(modDir(activity, titleIdUpper, topLevel), relPath) + } + + if (entry.isDirectory) { + dest.mkdirs() + } else { + dest.parentFile?.mkdirs() + dest.outputStream().use { os -> + var n = zis.read(buffer) + while (n > 0) { + os.write(buffer, 0, n) + bump(n, rawName) + n = zis.read(buffer) + } + } + } + + zis.closeEntry() + entry = zis.nextEntry + } + } + } + + ImportModsResult(imported = importedMods.toList(), ok = importedMods.isNotEmpty()) + } catch (t: Throwable) { + Log.w("ModFs", "importModsZip failed: ${t.message}") + // Best effort: schon angelegte Mods sauber entfernen + importedMods.forEach { name -> + runCatching { modDir(activity, titleIdUpper, name).safeDeleteRecursively() } + } + ImportModsResult(imported = emptyList(), ok = false) + } +} + +private fun resolveDisplayNameAndSize(cr: ContentResolver, uri: Uri): Pair { + var name: String? = null + var size: Long = -1 + try { + cr.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME, OpenableColumns.SIZE), null, null, null)?.use { c -> + if (c.moveToFirst()) { + val nameIdx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val sizeIdx = c.getColumnIndex(OpenableColumns.SIZE) + if (nameIdx >= 0) name = c.getString(nameIdx) + if (sizeIdx >= 0) size = c.getLong(sizeIdx) + } + } + } catch (_: Throwable) {} + return name to size +} diff --git a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt index fd9e3c206..ef7c796cc 100644 --- a/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt +++ b/src/KenjinxAndroid/app/src/main/java/org/kenjinx/android/views/HomeViews.kt @@ -88,6 +88,18 @@ import org.kenjinx.android.viewmodels.HomeViewModel import org.kenjinx.android.viewmodels.QuickSettings import org.kenjinx.android.widgets.SimpleAlertDialog +// NEW: Cheats +import org.kenjinx.android.cheats.CheatPrefs +import org.kenjinx.android.cheats.CheatItem +import org.kenjinx.android.cheats.loadCheatsFromDisk +import org.kenjinx.android.cheats.applyCheatSelectionOnDisk +import org.kenjinx.android.cheats.importCheatTxt + +// NEW: Mods +import org.kenjinx.android.cheats.listMods +import org.kenjinx.android.cheats.deleteMod +import org.kenjinx.android.cheats.importModsZip + class HomeViews { companion object { const val ListImageSize = 150 @@ -124,7 +136,127 @@ class HomeViews { var isFabVisible by remember { mutableStateOf(true) } val isNavigating = remember { mutableStateOf(false) } + // NEW: Cheats UI state + val openCheatsDialog = remember { mutableStateOf(false) } + val cheatsForSelected = remember { mutableStateOf(listOf()) } + val enabledCheatKeys = remember { mutableStateOf(mutableSetOf()) } + + // NEW: Mods UI state + val openModsDialog = remember { mutableStateOf(false) } + val modsForSelected = remember { mutableStateOf(listOf()) } + val importProgress = remember { mutableStateOf(0f) } + val importBusy = remember { mutableStateOf(false) } + val importStatusText = remember { mutableStateOf("") } + + // Shortcut-Dialog-State + val showShortcutDialog = remember { mutableStateOf(false) } + val shortcutName = remember { mutableStateOf("") } + val context = LocalContext.current + val activity = LocalContext.current as? Activity + + // NEW: Cheats Import (.txt) + val importCheatLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + val gm = viewModel.mainViewModel?.selected + val act = viewModel.activity + val titleId = gm?.titleId ?: "" + if (uri != null && act != null && titleId.isNotEmpty()) { + // nur .txt akzeptieren + val okExt = runCatching { + DocumentFile.fromSingleUri(act, uri)?.name?.lowercase()?.endsWith(".txt") == true + }.getOrElse { false } + if (!okExt) { + Toast.makeText(act, "Please select a .txt file", Toast.LENGTH_SHORT).show() + return@rememberLauncherForActivityResult + } + + val res = importCheatTxt(act, titleId, uri) + if (res.isSuccess) { + Toast.makeText(act, "Imported: ${res.getOrNull()?.name}", Toast.LENGTH_SHORT).show() + // danach Liste aktualisieren + cheatsForSelected.value = loadCheatsFromDisk(act, titleId) + } else { + Toast.makeText(act, "Import failed: ${res.exceptionOrNull()?.message}", Toast.LENGTH_LONG).show() + } + } + } + // NEW: Launcher for Mods + val pickModZipLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + val gm = viewModel.mainViewModel?.selected + val act = viewModel.activity + val titleId = gm?.titleId ?: "" + if (uri != null && act != null && titleId.isNotEmpty()) { + // Persist permission (lesen) + try { + act.contentResolver.takePersistableUriPermission(uri, Intent.FLAG_GRANT_READ_URI_PERMISSION) + } catch (_: Exception) {} + + importBusy.value = true + importProgress.value = 0f + importStatusText.value = "Starting…" + + thread { + val res = importModsZip( + act, + titleId, + uri + ) { prog -> + importProgress.value = prog.fraction + importStatusText.value = if (prog.currentEntry.isNotEmpty()) + "Copying: ${prog.currentEntry}" + else + "Copying… ${(prog.fraction * 100).toInt()}%" + } + + // Liste aktualisieren + modsForSelected.value = listMods(act, titleId) + importBusy.value = false + + launchOnUiThread { + val msg = if (res.ok) + "Imported: ${res.imported.joinToString(", ")}" + else + "Import failed" + Toast.makeText(act, msg, Toast.LENGTH_SHORT).show() + } + } + + } + } + + + // Launcher for "Custom icon" (OpenDocument) + val pickImageLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri: Uri? -> + val gm = viewModel.mainViewModel?.selected + if (uri != null && gm != null && activity != null) { + val bmp = runCatching { + context.contentResolver.openInputStream(uri).use { BitmapFactory.decodeStream(it) } + }.getOrNull() + + val label = shortcutName.value.ifBlank { gm.titleName ?: "Start Game" } + val gameUri = resolveGameUri(gm) + if (gameUri != null) { + ShortcutUtils.persistReadWrite(activity, gameUri) + + ShortcutUtils.pinShortcutForGame( + activity = activity, + gameUri = gameUri, + label = label, + iconBitmap = bmp + ) { + + } + } else { + showError.value = "Shortcut failed (no game URI found)." + } + } + } val nestedScrollConnection = remember { object : NestedScrollConnection { @@ -241,7 +373,7 @@ class HomeViews { Icon(Icons.Filled.Settings, contentDescription = "Settings") } - } + } OutlinedTextField( value = query.value, @@ -256,13 +388,13 @@ class HomeViews { singleLine = true, shape = RoundedCornerShape(8.dp), colors = OutlinedTextFieldDefaults.colors( - focusedContainerColor = Color.Transparent, - unfocusedContainerColor = Color.Transparent, - disabledContainerColor = Color.Transparent, - errorContainerColor = Color.Transparent, - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.outline, - ) + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + errorContainerColor = Color.Transparent, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + ) ) } }, @@ -386,8 +518,15 @@ class HomeViews { thread { showLoading.value = true + + // NEW: Push Cheats vor dem Start (Auto-Start Pfad) + val gm = viewModel.mainViewModel.loadGameModel.value!! + val tId = gm.titleId ?: "" + val act = viewModel.activity + + val success = viewModel.mainViewModel.loadGame( - viewModel.mainViewModel.loadGameModel.value!!, + gm, true, viewModel.mainViewModel.forceNceAndPptc.value ) @@ -415,10 +554,17 @@ class HomeViews { if (showAppActions.value) { IconButton(onClick = { if (viewModel.mainViewModel?.selected != null) { + + // NEW: Push Cheats vor dem Start (Run-Button) + val gmSel = viewModel.mainViewModel!!.selected!! + val tId = gmSel.titleId ?: "" + val act = viewModel.activity + + thread { showLoading.value = true val success = viewModel.mainViewModel.loadGame( - viewModel.mainViewModel.selected!! + gmSel ) if (success == 1) { launchOnUiThread { @@ -489,6 +635,39 @@ class HomeViews { openDlcDialog.value = true } ) + // NEW: Manage Cheats + DropdownMenuItem( + text = { Text(text = "Manage Cheats") }, + onClick = { + showAppMenu.value = false + val gm = viewModel.mainViewModel?.selected + val act = viewModel.activity + if (gm != null && !gm.titleId.isNullOrEmpty() && act != null) { + val titleId = gm.titleId!! + cheatsForSelected.value = loadCheatsFromDisk(act, titleId) + enabledCheatKeys.value = CheatPrefs(act).getEnabled(titleId) + openCheatsDialog.value = true + } else { + showError.value = "No title selected." + } + } + ) + // NEW: Manage Mods + DropdownMenuItem( + text = { Text(text = "Manage Mods") }, + onClick = { + showAppMenu.value = false + val gm = viewModel.mainViewModel?.selected + val act = viewModel.activity + if (gm != null && !gm.titleId.isNullOrEmpty() && act != null) { + val titleId = gm.titleId!! + modsForSelected.value = listMods(act, titleId) + openModsDialog.value = true + } else { + showError.value = "No title selected." + } + } + ) } } } @@ -500,6 +679,258 @@ class HomeViews { } ) + // --- Cheats Bottom Sheet --- + if (openCheatsDialog.value) { + ModalBottomSheet( + onDismissRequest = { openCheatsDialog.value = false } + ) { + val gm = viewModel.mainViewModel?.selected + val act = viewModel.activity + val titleId = gm?.titleId ?: "" + + Column(Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + // LINKS: Import .txt + TextButton(onClick = { + importCheatLauncher.launch(arrayOf("text/plain", "text/*", "*/*")) + }) { Text("Import .txt") } + + // RECHTS: Cancel + Save + Row { + TextButton(onClick = { openCheatsDialog.value = false }) { Text("Cancel") } + TextButton(onClick = { + val act2 = act + if (act2 != null && titleId.isNotEmpty()) { + CheatPrefs(act2).setEnabled(titleId, enabledCheatKeys.value) + applyCheatSelectionOnDisk(act2, titleId, enabledCheatKeys.value) + cheatsForSelected.value = loadCheatsFromDisk(act2, titleId) + } + openCheatsDialog.value = false + }) { Text("Save") } + } + } + + + Text("Manage Cheats", style = MaterialTheme.typography.titleLarge) + Text( + text = gm?.titleName ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + modifier = Modifier.padding(bottom = 8.dp) + ) + + if (cheatsForSelected.value.isEmpty()) { + Text("No cheats found for this title.") + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp) + ) { + items(cheatsForSelected.value) { cheat -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column( + Modifier + .weight(1f) + .padding(end = 12.dp) + ) { + Text(cheat.name, maxLines = 2, overflow = TextOverflow.Ellipsis) + Text( + cheat.buildId, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f) + ) + } + val checked = enabledCheatKeys.value.contains(cheat.key) + androidx.compose.material3.Switch( + checked = checked, + onCheckedChange = { isOn -> + enabledCheatKeys.value = + enabledCheatKeys.value.toMutableSet().apply { + if (isOn) add(cheat.key) else remove(cheat.key) + } + } + ) + } + } + } + } + + + } + } + } + + // --- Mods Bottom Sheet --- + if (openModsDialog.value) { + ModalBottomSheet( + onDismissRequest = { openModsDialog.value = false } + ) { + val gm = viewModel.mainViewModel?.selected + val act = viewModel.activity + val titleId = gm?.titleId ?: "" + + Column(Modifier.padding(16.dp)) { + Text("Manage Mods", style = MaterialTheme.typography.titleLarge) + Text( + text = gm?.titleName ?: "", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.6f), + modifier = Modifier.padding(bottom = 8.dp) + ) + + // Import-Zeile + Row( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + TextButton( + enabled = !importBusy.value, + onClick = { pickModZipLauncher.launch(arrayOf("application/zip", "application/octet-stream", "*/*")) } + ) { Text("Import .zip") } + } + + // Progress + if (importBusy.value) { + androidx.compose.material3.LinearProgressIndicator( + progress = { importProgress.value }, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp) + ) + Text( + importStatusText.value, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f), + modifier = Modifier.padding(bottom = 8.dp) + ) + } + + // Liste der Mods + if (modsForSelected.value.isEmpty()) { + Text("No mods found for this title.") + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 12.dp) + ) { + items(modsForSelected.value) { modName -> + Row( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(modName, maxLines = 2, overflow = TextOverflow.Ellipsis) + Row { + TextButton( + onClick = { + val a = act + if (a != null && titleId.isNotEmpty()) { + thread { + val ok = deleteMod(a, titleId, modName) + if (ok) { + modsForSelected.value = listMods(a, titleId) + } + } + } + } + ) { Text("Delete") } + } + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = { openModsDialog.value = false }) { Text("Close") } + } + } + } + } + // --- Shortcut-Dialog + if (showShortcutDialog.value) { + val gm = viewModel.mainViewModel?.selected + AlertDialog( + onDismissRequest = { showShortcutDialog.value = false }, + title = { Text("Create shortcut") }, + text = { + Column { + OutlinedTextField( + value = shortcutName.value, + onValueChange = { shortcutName.value = it }, + label = { Text("Name") }, + singleLine = true + ) + Text( + text = "Choose icon:", + modifier = Modifier.padding(top = 12.dp) + ) + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + ) { + TextButton(onClick = { + // App icon (Grid image) + if (gm != null && activity != null) { + val gameUri = resolveGameUri(gm) + if (gameUri != null) { + // persist rights for the game file + ShortcutUtils.persistReadWrite(activity, gameUri) + + val bmp = decodeGameIcon(gm) + val label = shortcutName.value.ifBlank { gm.titleName ?: "Start Game" } + + ShortcutUtils.pinShortcutForGame( + activity = activity, + gameUri = gameUri, + label = label, + iconBitmap = bmp + ) { } + showShortcutDialog.value = false + } else { + showShortcutDialog.value = false + } + } else { + showShortcutDialog.value = false + } + }) { Text("App icon") } + + TextButton(onClick = { + // Custom icon: open picker + pickImageLauncher.launch(arrayOf("image/*")) + showShortcutDialog.value = false + }) { Text("Custom icon") } + } + } + }, + confirmButton = { + TextButton(onClick = { showShortcutDialog.value = false }) { + Text("Close") + } + } + ) + } + // --- Version badge bottom left above the entire content VersionBadge( modifier = Modifier.align(Alignment.BottomStart) @@ -540,6 +971,12 @@ class HomeViews { ) { thread { showLoading.value = true + + // NEW: Push Cheats vor dem Start + val tId = gameModel.titleId ?: "" + val act = viewModel.activity + + val success = viewModel.mainViewModel?.loadGame(gameModel) ?: false if (success == 1) { launchOnUiThread { viewModel.mainViewModel?.navigateToGame() } @@ -631,6 +1068,12 @@ class HomeViews { ) { thread { showLoading.value = true + + // NEW: Push Cheats vor dem Start + val tId = gameModel.titleId ?: "" + val act = viewModel.activity + + val success = viewModel.mainViewModel?.loadGame(gameModel) ?: false if (success == 1) { launchOnUiThread { viewModel.mainViewModel?.navigateToGame() } diff --git a/src/LibKenjinx/rd.xml b/src/LibKenjinx/rd.xml index 2faf502ad..416d790f1 100644 --- a/src/LibKenjinx/rd.xml +++ b/src/LibKenjinx/rd.xml @@ -24,7 +24,7 @@ - + + + + + + + + + + + + + + - \ No newline at end of file + diff --git a/src/Ryujinx.HLE/HOS/TamperMachine.cs b/src/Ryujinx.HLE/HOS/TamperMachine.cs index a22e87cb1..60f0583d1 100644 --- a/src/Ryujinx.HLE/HOS/TamperMachine.cs +++ b/src/Ryujinx.HLE/HOS/TamperMachine.cs @@ -50,6 +50,9 @@ namespace Ryujinx.HLE.HOS _programs.Enqueue(program); _programDictionary.TryAdd($"{buildId}-{name}", program); + + // NEU: Standardmäßig einschalten (bei Android gibt es (noch) keine UI, die EnableCheats aufruft) + program.IsEnabled = true; } Activate(); @@ -138,7 +141,11 @@ namespace Ryujinx.HLE.HOS // Re-enqueue the tampering program because the process is still valid. _programs.Enqueue(program); - + // NEU: Wenn der Cheat (noch) disabled ist – nur weiter rotieren, nicht ausführen. + if (!program.IsEnabled) + { + return true; + } Logger.Debug?.Print(LogClass.TamperMachine, $"Running tampering program {program.Name}"); try @@ -159,10 +166,13 @@ namespace Ryujinx.HLE.HOS { Logger.Debug?.Print(LogClass.TamperMachine, $"The tampering program {program.Name} crashed, this can happen while the game is starting"); - if (!string.IsNullOrEmpty(ex.Message)) - { - Logger.Debug?.Print(LogClass.TamperMachine, ex.Message); - } + //if (!string.IsNullOrEmpty(ex.Message)) + //{ + // Logger.Debug?.Print(LogClass.TamperMachine, ex.Message); + //} + + // NEU: kompletter Stacktrace + Logger.Debug?.Print(LogClass.TamperMachine, ex.ToString()); } return true; diff --git a/src/Ryujinx.HLE/Ryujinx.HLE.csproj b/src/Ryujinx.HLE/Ryujinx.HLE.csproj index ad5fcca6d..8adc087c1 100644 --- a/src/Ryujinx.HLE/Ryujinx.HLE.csproj +++ b/src/Ryujinx.HLE/Ryujinx.HLE.csproj @@ -2,6 +2,7 @@ true + false