Merge branch 'libryujinx_bionic_ModManager_CheatImport' into 'libryujinx_bionic'

Added Mod and Cheat Manager

See merge request kenji-nx/ryujinx!11
This commit is contained in:
BeZide 2025-12-09 15:24:14 -06:00
commit 913fd7ad12
7 changed files with 1024 additions and 17 deletions

View file

@ -0,0 +1,319 @@
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"
}
/* -------- Paths -------- */
private fun cheatsDirExternal(activity: Activity, titleId: String): File {
val base = activity.getExternalFilesDir(null) // /storage/emulated/0/Android/data/<pkg>/files
return File(base, "mods/contents/$titleId/cheats")
}
private fun allCheatDirs(activity: Activity, titleId: String): List<File> {
return listOf(cheatsDirExternal(activity, titleId))
.distinct()
.filter { it.exists() && it.isDirectory }
}
/* -------- Parser -------- */
private fun parseCheatNames(text: String): List<String> {
// 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: Load cheats -------- */
fun loadCheatsFromDisk(activity: Activity, titleId: String): List<CheatItem> {
val dirs = allCheatDirs(activity, titleId)
if (dirs.isEmpty()) {
Log.d("CheatFs", "No cheat dirs for $titleId (checked internal+external).")
return emptyList()
}
val out = mutableListOf<CheatItem>()
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: Apply selection IMMEDIATELY on disk -------- */
fun applyCheatSelectionOnDisk(activity: Activity, titleId: String, enabledKeys: Set<String>) {
// We pick exactly ONE BUILDID file (the “best”) and toggle sections within it.
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
// Normalize enabled set: keys are "<BUILDID>-<SectionName>"
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}")
}
}
/* -------- Implementation: apply selection (use ';' only for comments) -------- */
private fun pickBestBuildFile(files: List<File>): 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 ""
}
/**
* Removes ONE leading comment marker (';') + optional space.
* Only at absolute column 0 (no leading spaces allowed).
*/
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
}
/**
* Comments out the line if it doesn't already start with ';'.
* Atmosphère uses ';' we strictly use that here as well.
*/
private fun commentOut(raw: String): String {
val t = raw.trimStart()
if (t.isEmpty()) return raw
if (t.startsWith(";")) return raw
return "; $raw"
}
/**
* Rewrites the file:
* - Do not insert markers
* - For each section, comment/uncomment the body according to enabledSections
* - Preserve pure comment/blank lines (only ';')
*/
// Helpers: trim trailing blank lines / normalize header
private fun trimTrailingBlankLines(lines: MutableList<String>) {
while (lines.isNotEmpty() && lines.last().trim().isEmpty()) {
lines.removeAt(lines.lastIndex)
}
}
private fun joinHeaderBufferOnce(header: List<String>): String {
// Keep header lines unchanged, but remove trailing blanks and insert exactly one blank line after
val buf = header.toMutableList()
trimTrailingBlankLines(buf)
return if (buf.isEmpty()) "" else buf.joinToString("\n") + "\n\n"
}
/**
* Rewrites the file:
* - Do not insert markers
* - For each section, comment/uncomment the body according to enabledSections
* - Preserve pure comment/blank lines
* - Ensure exactly ONE blank line between sections, and exactly ONE trailing newline at EOF.
*/
private fun rewriteCheatFile(original: String, enabledSections: Set<String>): String {
val lines = original.replace("\uFEFF", "").lines()
val out = StringBuilder(original.length + 1024)
var currentSection: String? = null
val currentBlock = ArrayList<String>()
val headerBuffer = ArrayList<String>()
var sawAnySection = false
var wroteAnySection = false
fun flushCurrent() {
val sec = currentSection ?: return
// Remove trailing blank lines in the block to avoid growing gaps
trimTrailingBlankLines(currentBlock)
val enabled = enabledSections.contains(sec.lowercase())
// Insert exactly one blank line between sections (but not before the first)
if (wroteAnySection) out.append('\n')
out.append('[').append(sec).append(']').append('\n')
if (enabled) {
// Uncomment (only one leading ';' at column 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 {
// Disable: anything not starting with ';' and not blank gets commented out
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()
// Prepend header (with exactly one blank line after, if present)
val headerText = joinHeaderBufferOnce(headerBuffer)
if (headerText.isNotEmpty()) {
out.insert(0, headerText)
}
// Global normalization: collapse 3+ newlines to 2, and ensure exactly ONE trailing '\n'
val result = out.toString()
.replace(Regex("\n{3,}"), "\n\n") // never more than 1 blank line between sections
.trimEnd() + "\n" // exactly one newline at the end
return result
}
private fun cheatsDirPreferredForWrite(activity: Activity, titleId: String): File {
// Preferred write location: external app-specific storage
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
}
/**
* Imports a .txt from a SAF Uri into the title's cheats folder.
* Returns the destination File on success.
*/
fun importCheatTxt(activity: Activity, titleId: String, source: Uri): Result<File> {
return runCatching {
// Persist read permission if possible
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)
}
}
// After import: we could re-scan/normalize immediately,
// but we leave the file as provided.
target
}
}

View file

@ -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<String> {
return prefs.getStringSet(key(titleId), emptySet())?.toMutableSet() ?: mutableSetOf()
}
fun setEnabled(titleId: String, keys: Set<String>) {
prefs.edit().putStringSet(key(titleId), keys.toSet()).apply()
}
}

View file

@ -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
/* -------- Paths -------- */
private fun modsRootExternal(activity: Activity): File {
// /storage/emulated/0/Android/data/<pkg>/files/sdcard/atmosphere/contents
return File(activity.getExternalFilesDir(null), "sdcard/atmosphere/contents")
}
private fun modsTitleDir(activity: Activity, titleIdUpper: String): File {
// TITLEID must be uppercase
return File(modsRootExternal(activity), titleIdUpper)
}
private fun modDir(activity: Activity, titleIdUpper: String, modName: String): File {
return File(modsTitleDir(activity, titleIdUpper), modName)
}
/* -------- List & Delete -------- */
fun listMods(activity: Activity, titleId: String): List<String> {
val titleIdUpper = titleId.trim().uppercase()
val dir = modsTitleDir(activity, titleIdUpper)
if (!dir.exists() || !dir.isDirectory) return emptyList()
return dir.listFiles { f -> f.isDirectory } // NAME folders
?.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())
}
// NEW: Multi-import. Top-level folders inside the ZIP are treated as mod names.
data class ImportModsResult(
val imported: List<String>,
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))
}
}
// Prepare each top-level folder (mod name) once (delete existing folder if present).
val preparedMods = mutableSetOf<String>()
val importedMods = linkedSetOf<String>() // stable order
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('\\', '/') // normalize
// Safety filter & skip empty names
if (rawName.isBlank() || rawName.startsWith("/") || rawName.contains("..")) {
zis.closeEntry()
entry = zis.nextEntry
continue
}
// Top-level: first segment before the first '/'
val slash = rawName.indexOf('/')
val topLevel = if (slash > 0) rawName.substring(0, slash) else rawName
if (topLevel.isBlank()) {
zis.closeEntry()
entry = zis.nextEntry
continue
}
// Remaining path inside the mod folder
val relPath = if (slash >= 0 && slash + 1 < rawName.length) rawName.substring(slash + 1) else ""
// Only process entries that are inside a mod folder (we expect NAME/... structures)
if (relPath.isBlank() && entry.isDirectory.not()) {
// File directly at top level (e.g., NAME.txt) → ignore
zis.closeEntry()
entry = zis.nextEntry
continue
}
// Prepare mod folder (once; remove old folder if present)
if (preparedMods.add(topLevel)) {
val modFolder = modDir(activity, titleIdUpper, topLevel)
if (modFolder.exists()) modFolder.safeDeleteRecursively()
modFolder.mkdirs()
importedMods += topLevel
}
// Destination path: .../TITLEID/<topLevel>/<relPath>
val dest = if (relPath.isBlank()) {
// just a directory entry (NAME/ or 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: clean up already created mod folders
importedMods.forEach { name ->
runCatching { modDir(activity, titleIdUpper, name).safeDeleteRecursively() }
}
ImportModsResult(imported = emptyList(), ok = false)
}
}
private fun resolveDisplayNameAndSize(cr: ContentResolver, uri: Uri): Pair<String?, Long> {
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
}

View file

@ -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<CheatItem>()) }
val enabledCheatKeys = remember { mutableStateOf(mutableSetOf<String>()) }
// NEW: Mods UI state
val openModsDialog = remember { mutableStateOf(false) }
val modsForSelected = remember { mutableStateOf(listOf<String>()) }
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()) {
// only accept .txt
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()
// then refresh list
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 (read)
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()}%"
}
// refresh list
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 before start (auto-start path)
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 before 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
) {
// LEFT: Import .txt
TextButton(onClick = {
importCheatLauncher.launch(arrayOf("text/plain", "text/*", "*/*"))
}) { Text("Import .txt") }
// RIGHT: 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 row
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)
)
}
// List of 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 before 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 before start
val tId = gameModel.titleId ?: ""
val act = viewModel.activity
val success = viewModel.mainViewModel?.loadGame(gameModel) ?: false
if (success == 1) {
launchOnUiThread { viewModel.mainViewModel?.navigateToGame() }

View file

@ -24,7 +24,7 @@
<Type Name="Silk.NET.Vulkan.Extensions.KHR.KhrSwapchain"
Dynamic="Required All"/>
</Assembly>
<Assembly Name="Ryujinx.HLE">
<Assembly Name="Ryujinx.HLE" Dynamic="Required All">
<Type Name="Ryujinx.HLE.HOS.Services.Nv.NvDrvServices.NvHostChannel.NvHostGpuDeviceFile"
Dynamic="Required All" />
<Type Name="Ryujinx.HLE.HOS.Services.Fs.IFileSystemProxyForLoader"
@ -531,6 +531,27 @@
Dynamic="Required All" />
<Type Name="Ryujinx.HLE.HOS.Services.Pcv.Rgltr.IRegulatorManager"
Dynamic="Required All" />
<!-- Explicitly root the generic tamper operations that are constructed via MakeGenericType -->
<Type Name="Ryujinx.HLE.HOS.Tamper.Operations.OpMov`1[[System.Byte]]"
Dynamic="Required All" />
<Type Name="Ryujinx.HLE.HOS.Tamper.Operations.OpMov`1[[System.UInt16]]"
Dynamic="Required All" />
<Type Name="Ryujinx.HLE.HOS.Tamper.Operations.OpMov`1[[System.UInt32]]"
Dynamic="Required All" />
<Type Name="Ryujinx.HLE.HOS.Tamper.Operations.OpMov`1[[System.UInt64]]"
Dynamic="Required All" />
<Type Name="Ryujinx.HLE.HOS.Tamper.Operations.OpAdd`1[[System.Byte]]"
Dynamic="Required All" />
<Type Name="Ryujinx.HLE.HOS.Tamper.Operations.OpAdd`1[[System.UInt16]]"
Dynamic="Required All" />
<Type Name="Ryujinx.HLE.HOS.Tamper.Operations.OpAdd`1[[System.UInt32]]"
Dynamic="Required All" />
<Type Name="Ryujinx.HLE.HOS.Tamper.Operations.OpAdd`1[[System.UInt64]]"
Dynamic="Required All" />
</Assembly>
<!-- AOT/Trimming: keep the dynamic binder & expression tree runtime -->
<Assembly Name="Microsoft.CSharp" Dynamic="Required All" />
<Assembly Name="System.Linq.Expressions" Dynamic="Required All" />
</Application>
</Directives>
</Directives>

View file

@ -50,6 +50,9 @@ namespace Ryujinx.HLE.HOS
_programs.Enqueue(program);
_programDictionary.TryAdd($"{buildId}-{name}", program);
// NEW: Enable by default (on Android there's currently no UI that calls EnableCheats).
program.IsEnabled = true;
}
Activate();
@ -139,6 +142,12 @@ namespace Ryujinx.HLE.HOS
// Re-enqueue the tampering program because the process is still valid.
_programs.Enqueue(program);
// NEW: If the cheat is (still) disabled, keep rotating but do not execute.
if (!program.IsEnabled)
{
return true;
}
Logger.Debug?.Print(LogClass.TamperMachine, $"Running tampering program {program.Name}");
try
@ -159,10 +168,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);
//}
// NEW: full stack trace
Logger.Debug?.Print(LogClass.TamperMachine, ex.ToString());
}
return true;
@ -181,7 +193,7 @@ namespace Ryujinx.HLE.HOS
}
}
// Clear the input because player one is not conected.
// Clear the input because player one is not connected.
Volatile.Write(ref _pressedKeys, 0);
}
}

View file

@ -2,6 +2,7 @@
<PropertyGroup>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<IsTrimmable>false</IsTrimmable>
</PropertyGroup>
<ItemGroup>