mirror of
https://git.ryujinx.app/kenji-nx/ryujinx.git
synced 2025-12-13 13:37:08 +00:00
Added Mod and Cheat Manager
This commit is contained in:
parent
78db4c365f
commit
f2bf79ab6b
7 changed files with 1020 additions and 17 deletions
|
|
@ -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/<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: Cheats laden -------- */
|
||||
|
||||
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: Auswahl SOFORT auf Disk anwenden -------- */
|
||||
|
||||
fun applyCheatSelectionOnDisk(activity: Activity, titleId: String, enabledKeys: Set<String>) {
|
||||
// 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 "<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}")
|
||||
}
|
||||
}
|
||||
|
||||
/* -------- Implementierung: Auswahl anwenden (nur ';' als Kommentar) -------- */
|
||||
|
||||
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 ""
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<String>) {
|
||||
while (lines.isNotEmpty() && lines.last().trim().isEmpty()) {
|
||||
lines.removeAt(lines.lastIndex)
|
||||
}
|
||||
}
|
||||
|
||||
private fun joinHeaderBufferOnce(header: List<String>): 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>): 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
|
||||
|
||||
// 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<File> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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/<pkg>/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<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-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<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))
|
||||
}
|
||||
}
|
||||
|
||||
// Für jeden Top-Level-Ordner (Mod-Name) einmalig vorbereiten (ggf. alten Ordner löschen).
|
||||
val preparedMods = mutableSetOf<String>()
|
||||
val importedMods = linkedSetOf<String>() // 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/<topLevel>/<relPath>
|
||||
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<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
|
||||
}
|
||||
|
|
@ -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()) {
|
||||
// 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() }
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
<!-- Explizit die generischen Tamper-Operationen rooten, die per MakeGenericType gebaut werden -->
|
||||
<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: dynamic binder & expression tree runtime behalten -->
|
||||
<Assembly Name="Microsoft.CSharp" Dynamic="Required All" />
|
||||
<Assembly Name="System.Linq.Expressions" Dynamic="Required All" />
|
||||
</Application>
|
||||
</Directives>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
<PropertyGroup>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<IsTrimmable>false</IsTrimmable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue