mirror of
https://github.com/ARMSX2/ARMSX2.git
synced 2026-08-24 16:50:16 -07:00
Patch: stop patches arming themselves, and make disabling one stick
Reported as "patches apply with every patch setting off, and won't turn off" — SOTC/KH2/GOW2. One chain of defects, verified on device: - PatchManagerViewModel.refresh() called syncAllEnableLists() unconditionally, so merely OPENING the Patch Manager persisted every uncommented group of every on-disk .pnach as enabled. Community pnach files ship uncommented, and patches are matched by NAME, so a name like "60 FPS" then armed the same-named group in any of the ~4000 bundled files, for games never opened. Removed; import still registers its own file, which was the only legitimate use. - EnumeratePnachFiles fell back to the bundled zip even when disk files existed, contradicting its own "prefer files on disk" comment. Deleting a pnach silently promoted the identically-named bundled group in its place. - delete() removed the file but never dropped its names from the enable list, so they stayed armed forever. - ReloadPatchAffectingOptions never reset CurrentCustomAspectRatio, which only ever gets set, so 16:9 survived disabling widescreen. - LocalCheatRow and OnlineEntryRow armed the row under the cursor on D-pad Right, so scrolling a cheat list enabled everything you passed. Confirm only now. Patches cannot be un-applied without a reboot: PatchCommand has no original-value field and UnloadPatches never touches guest RAM, so disabling one mid-session only stops it being re-written.
This commit is contained in:
+15
-1
@@ -399,7 +399,15 @@ void Patch::EnumeratePnachFiles(const std::string_view serial, u32 crc, bool che
|
||||
}
|
||||
|
||||
// Otherwise fall back to the zip.
|
||||
if (cheats || unlabeled_patch_found || !OpenPatchesZip())
|
||||
//
|
||||
// "Otherwise" has to include "a disk file was found", which it previously didn't: the guard
|
||||
// ignored disk_patch_files, so the bundled copy loaded ALONGSIDE the user's file every time,
|
||||
// contradicting the "prefer files on disk" comment above. Two user-visible consequences, both
|
||||
// reported: a hand-edited pnach couldn't fully replace the bundled one (dedupe-by-name only
|
||||
// hid the bundled group while the disk name existed), and DELETING a pnach silently promoted
|
||||
// the identically-named bundled group in its place — so a patch the user had removed carried
|
||||
// on applying, with nothing left on disk to explain why.
|
||||
if (cheats || unlabeled_patch_found || !disk_patch_files.empty() || !OpenPatchesZip())
|
||||
return;
|
||||
|
||||
// Prefer filename with serial.
|
||||
@@ -871,6 +879,12 @@ bool Patch::ReloadPatchAffectingOptions()
|
||||
EmuConfig.GS.InterlaceMode = static_cast<GSInterlaceMode>(Host::GetIntSettingValue(
|
||||
"EmuCore/GS", "deinterlace_mode", static_cast<int>(Pcsx2Config::GSOptions::DEFAULT_INTERLACE_MODE)));
|
||||
|
||||
// Clear the patch-requested aspect before re-deriving it. ApplyPatchSettingOverrides only ever
|
||||
// SETS CurrentCustomAspectRatio, so without this the last widescreen patch's ratio survived
|
||||
// the patch being disabled and GSRenderer kept reading it (it takes any value > 0 in
|
||||
// preference to the real aspect) — i.e. "I turned widescreen off and the game is still 16:9".
|
||||
EmuConfig.CurrentCustomAspectRatio = 0.0f;
|
||||
|
||||
ApplyPatchSettingOverrides();
|
||||
|
||||
// Return true if any config setting changed
|
||||
|
||||
@@ -11,6 +11,8 @@ import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -29,6 +31,7 @@ import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -77,6 +80,7 @@ fun PatchManagerScreen(onBack: () -> Unit, game: GameInfo? = null, viewModel: Pa
|
||||
actions = {
|
||||
RoundAction("+", str("action.import"), { picker.launch(arrayOf("text/plain", "application/octet-stream", "*/*")) })
|
||||
RoundAction("🗀", str("patches.import.folder"), { folderPicker.launch(null) })
|
||||
RoundAction("✎", str("patches.editor.new"), viewModel::newEditor)
|
||||
RoundAction("↻", str("games.card.refresh"), viewModel::refresh)
|
||||
},
|
||||
)
|
||||
@@ -102,6 +106,9 @@ fun PatchManagerScreen(onBack: () -> Unit, game: GameInfo? = null, viewModel: Pa
|
||||
}
|
||||
}
|
||||
}
|
||||
if (state.editorPath != null) {
|
||||
PnachEditor(state, viewModel)
|
||||
}
|
||||
(state.error ?: state.message)?.let { message ->
|
||||
androidx.compose.runtime.DisposableEffect(Unit) {
|
||||
com.armsx2.MenuSfx.play(com.armsx2.MenuSfx.Event.POPUP_OPEN)
|
||||
@@ -116,6 +123,80 @@ fun PatchManagerScreen(onBack: () -> Unit, game: GameInfo? = null, viewModel: Pa
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw .pnach text editor.
|
||||
*
|
||||
* A plain text buffer, not a structured code form: pnach is what people copy off the web, headers
|
||||
* and comments included, so anything that re-serialised it would mangle the paste.
|
||||
*
|
||||
* The explicit Paste button matters more than it looks — on a handheld with no touchscreen there is
|
||||
* no way to reach the long-press paste menu, which is most of why "paste a code you found" didn't
|
||||
* work here before.
|
||||
*/
|
||||
@Composable
|
||||
private fun PnachEditor(state: PatchManagerUiState, viewModel: PatchManagerViewModel) {
|
||||
val clipboard = androidx.compose.ui.platform.LocalClipboardManager.current
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.72f)),
|
||||
) {
|
||||
Surface(
|
||||
Modifier.fillMaxSize().padding(10.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)),
|
||||
) {
|
||||
Column(Modifier.fillMaxSize().padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
state.editorName.ifBlank { str("patches.editor.new") },
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val paste = {
|
||||
clipboard.getText()?.text?.let { pasted ->
|
||||
// Append rather than replace: the buffer already holds a gametitle line
|
||||
// (new file) or the user's existing codes (edit), and clobbering either
|
||||
// is never what "paste" is meant to do.
|
||||
viewModel.updateEditorText(
|
||||
if (state.editorText.isEmpty()) pasted
|
||||
else state.editorText.trimEnd() + "\n" + pasted,
|
||||
)
|
||||
}
|
||||
Unit
|
||||
}
|
||||
TextButton(
|
||||
onClick = paste,
|
||||
modifier = Modifier.controllerFocusable("patches.editor.paste", onConfirm = paste),
|
||||
) { Text(str("patches.editor.paste")) }
|
||||
TextButton(
|
||||
onClick = viewModel::closeEditor,
|
||||
modifier = Modifier.controllerFocusable("patches.editor.cancel", onConfirm = viewModel::closeEditor),
|
||||
) { Text(str("action.cancel")) }
|
||||
TextButton(
|
||||
onClick = viewModel::saveEditor,
|
||||
enabled = !state.editorLoading,
|
||||
modifier = Modifier.controllerFocusable("patches.editor.save", onConfirm = viewModel::saveEditor),
|
||||
) { Text(str("action.save")) }
|
||||
}
|
||||
Spacer(Modifier.height(6.dp))
|
||||
OutlinedTextField(
|
||||
value = state.editorText,
|
||||
onValueChange = viewModel::updateEditorText,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
textStyle = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = androidx.compose.ui.text.font.FontFamily.Monospace,
|
||||
),
|
||||
placeholder = { Text(str("patches.editor.placeholder")) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Outdated cheats/patches (built for the old 1.7 core) are our #1 cause of false "it broke
|
||||
// in the new version" reports — this warning sits on both patch-screen entry points.
|
||||
@Composable
|
||||
@@ -202,24 +283,12 @@ private fun PatchOptions(state: PatchManagerUiState, viewModel: PatchManagerView
|
||||
GlassPanel(modifier) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
SectionTitle(str("ra.options.header"), str("scope.global"))
|
||||
SettingSwitchRow(
|
||||
str("patches.enablePatches.label"), str("patches.applyAtBoot"), state.settings.enablePatches,
|
||||
onCheckedChange = { value -> viewModel.update { it.copy(enablePatches = value) } },
|
||||
modifier = Modifier.controllerFocusable(
|
||||
"patches.enablePatches",
|
||||
onConfirm = { viewModel.update { it.copy(enablePatches = !state.settings.enablePatches) } },
|
||||
onLeft = { if (state.settings.enablePatches) viewModel.update { it.copy(enablePatches = false) } },
|
||||
onRight = { if (!state.settings.enablePatches) viewModel.update { it.copy(enablePatches = true) } },
|
||||
),
|
||||
)
|
||||
SettingSwitchRow(
|
||||
str("patches.cheats.label"), str("patches.pasteImportHint"), state.settings.enableCheats,
|
||||
onCheckedChange = { value -> viewModel.update { it.copy(enableCheats = value) } },
|
||||
modifier = Modifier.controllerFocusable(
|
||||
"patches.enableCheats",
|
||||
onConfirm = { viewModel.update { it.copy(enableCheats = !state.settings.enableCheats) } },
|
||||
onLeft = { if (state.settings.enableCheats) viewModel.update { it.copy(enableCheats = false) } },
|
||||
onRight = { if (!state.settings.enableCheats) viewModel.update { it.copy(enableCheats = true) } },
|
||||
),
|
||||
)
|
||||
SettingSwitchRow(
|
||||
@@ -231,8 +300,6 @@ private fun PatchOptions(state: PatchManagerUiState, viewModel: PatchManagerView
|
||||
modifier = Modifier.controllerFocusable(
|
||||
"patches.widescreen",
|
||||
onConfirm = { viewModel.update { it.copy(enableWideScreenPatches = !state.settings.enableWideScreenPatches) } },
|
||||
onLeft = { if (state.settings.enableWideScreenPatches) viewModel.update { it.copy(enableWideScreenPatches = false) } },
|
||||
onRight = { if (!state.settings.enableWideScreenPatches) viewModel.update { it.copy(enableWideScreenPatches = true) } },
|
||||
),
|
||||
)
|
||||
SettingSwitchRow(
|
||||
@@ -241,8 +308,6 @@ private fun PatchOptions(state: PatchManagerUiState, viewModel: PatchManagerView
|
||||
modifier = Modifier.controllerFocusable(
|
||||
"patches.noInterlacing",
|
||||
onConfirm = { viewModel.update { it.copy(enableNoInterlacingPatches = !state.settings.enableNoInterlacingPatches) } },
|
||||
onLeft = { if (state.settings.enableNoInterlacingPatches) viewModel.update { it.copy(enableNoInterlacingPatches = false) } },
|
||||
onRight = { if (!state.settings.enableNoInterlacingPatches) viewModel.update { it.copy(enableNoInterlacingPatches = true) } },
|
||||
),
|
||||
)
|
||||
// HostFS (host: filesystem) — lets ELF/homebrew and certain advanced mods read
|
||||
@@ -253,8 +318,6 @@ private fun PatchOptions(state: PatchManagerUiState, viewModel: PatchManagerView
|
||||
modifier = Modifier.controllerFocusable(
|
||||
"patches.hostFs",
|
||||
onConfirm = { viewModel.update { it.copy(hostFs = !state.settings.hostFs) } },
|
||||
onLeft = { if (state.settings.hostFs) viewModel.update { it.copy(hostFs = false) } },
|
||||
onRight = { if (!state.settings.hostFs) viewModel.update { it.copy(hostFs = true) } },
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -342,12 +405,12 @@ private fun OnlineBrowser(
|
||||
private fun OnlineEntryRow(entry: PatchRepo.Entry, checked: Boolean, onToggle: () -> Unit) {
|
||||
Surface(
|
||||
onClick = onToggle,
|
||||
// Confirm (A) only — same reason as LocalCheatRow: scrolling past an entry must not tick
|
||||
// it for install.
|
||||
modifier = Modifier.fillMaxWidth().controllerFocusable(
|
||||
"patches.online.entry.${entry.name}",
|
||||
RoundedCornerShape(14.dp),
|
||||
onConfirm = onToggle,
|
||||
onLeft = { if (checked) onToggle() },
|
||||
onRight = { if (!checked) onToggle() },
|
||||
),
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
color = if (checked) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface,
|
||||
@@ -453,6 +516,7 @@ private fun PatchFiles(state: PatchManagerUiState, viewModel: PatchManagerViewMo
|
||||
onExpand = { viewModel.expandLocal(file) },
|
||||
onToggleCheat = viewModel::toggleLocalCheat,
|
||||
onSetAllCheats = viewModel::setAllLocalCheats,
|
||||
onEdit = { viewModel.openEditor(file) },
|
||||
onDelete = { viewModel.delete(file) },
|
||||
)
|
||||
}
|
||||
@@ -564,6 +628,7 @@ private fun PatchFileRow(
|
||||
onExpand: () -> Unit,
|
||||
onToggleCheat: (String) -> Unit,
|
||||
onSetAllCheats: (Boolean) -> Unit,
|
||||
onEdit: () -> Unit,
|
||||
onDelete: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
@@ -583,6 +648,7 @@ private fun PatchFileRow(
|
||||
Text(file.name, style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
Text(file.parentFile?.name.orEmpty(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
TextButton(onClick = onEdit, modifier = Modifier.controllerFocusable("patches.file.${file.absolutePath}.edit", onConfirm = onEdit)) { Text(str("action.edit")) }
|
||||
TextButton(onClick = onDelete, modifier = Modifier.controllerFocusable("patches.file.${file.absolutePath}.delete", onConfirm = onDelete)) { Text(str("action.delete")) }
|
||||
}
|
||||
if (expanded) {
|
||||
@@ -634,11 +700,12 @@ private fun PatchFileRow(
|
||||
@Composable
|
||||
private fun LocalCheatRow(cheat: PatchRepo.LocalCheat, onToggle: () -> Unit) {
|
||||
Row(
|
||||
// Confirm (A) only — no onLeft/onRight. D-pad Right used to enable the cheat under the
|
||||
// cursor, so simply scrolling down a cheat list armed everything you passed. Enabling a
|
||||
// patch or cheat must always be a deliberate press.
|
||||
Modifier.fillMaxWidth().clickable(onClick = onToggle).controllerFocusable(
|
||||
"patches.cheat.${cheat.name}",
|
||||
onConfirm = onToggle,
|
||||
onLeft = { if (cheat.enabled) onToggle() },
|
||||
onRight = { if (!cheat.enabled) onToggle() },
|
||||
).padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
|
||||
+167
-15
@@ -42,6 +42,14 @@ data class PatchManagerUiState(
|
||||
val bundledEntry: String = "",
|
||||
val bundledCheats: List<PatchRepo.LocalCheat> = emptyList(),
|
||||
val bundledUnlabelled: Int = 0,
|
||||
// Raw .pnach text editor (#hanafuda: "add the cheat editor back so I can paste codes I grabbed
|
||||
// from the web"). Null path = closed. editorNew distinguishes "create" from "edit" so Save
|
||||
// knows whether it has to invent a filename.
|
||||
val editorPath: String? = null,
|
||||
val editorName: String = "",
|
||||
val editorText: String = "",
|
||||
val editorNew: Boolean = false,
|
||||
val editorLoading: Boolean = false,
|
||||
val message: String? = null,
|
||||
val error: String? = null,
|
||||
)
|
||||
@@ -83,10 +91,18 @@ class PatchManagerViewModel(application: Application) : AndroidViewModel(applica
|
||||
}.distinctBy { it.absolutePath }.sortedBy { it.name.lowercase() }
|
||||
state.value = state.value.copy(settings = scopedSettings(), files = files)
|
||||
loadBundled(serial, crc, files.isNotEmpty())
|
||||
// Reflect every file's on-disk enabled cheats into the native Enable list so
|
||||
// labelled cheats apply even for imported/pre-enabled files the user never
|
||||
// toggled in-app (see syncAllEnableLists / pushEnableList).
|
||||
syncAllEnableLists(files)
|
||||
// NOTHING IS ENABLED HERE. This used to call syncAllEnableLists(files), which walked every
|
||||
// .pnach on disk and persisted every uncommented group in it as "enabled" — so merely
|
||||
// OPENING this screen armed patches the user had never touched. Community pnach files ship
|
||||
// with their patch= lines uncommented, so that meant names like "60 FPS" and
|
||||
// "Widescreen 16:9" went into the enable list wholesale; and because the core matches
|
||||
// enabled patches purely BY NAME, those names then armed the identically-named group in
|
||||
// any of the ~4000 bundled pnach files, for games the user had never opened this screen
|
||||
// for. That is the "patches apply with all patch settings off, and won't turn off" bug
|
||||
// (KH2's bundled [60 FPS], and 16:9 appearing uninvited).
|
||||
//
|
||||
// Import still registers its own file (see import's syncEnableListForFile call), which was
|
||||
// the only legitimate reason this existed. Reading a screen must never persist state.
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -266,9 +282,26 @@ class PatchManagerViewModel(application: Application) : AndroidViewModel(applica
|
||||
}
|
||||
|
||||
fun delete(file: File) {
|
||||
// Disarm before deleting. The enable list is a list of NAMES, not of files, so deleting the
|
||||
// file left its names armed forever — and the core would then satisfy them from the
|
||||
// identically-named bundled group, meaning "I deleted the patch and it still applies".
|
||||
// Read the names off disk while the file still exists.
|
||||
val names = runCatching {
|
||||
PatchRepo.parseInstalled(file.readText(), file.parentFile?.name ?: "cheats").second
|
||||
.mapNotNull { it.name.takeIf(String::isNotBlank) }.distinct()
|
||||
}.getOrDefault(emptyList())
|
||||
val success = runCatching { file.delete() }.getOrDefault(false)
|
||||
state.value = if (success) state.value.copy(message = "Deleted ${file.name}.") else state.value.copy(error = "Unable to delete ${file.name}.")
|
||||
if (success) reloadCore()
|
||||
if (success) {
|
||||
if (names.isNotEmpty() && MainActivityRuntime.nativeReady.value) {
|
||||
val cheatsSection = file.parentFile?.name == "cheats"
|
||||
// all = the names to drop, enabled = nothing to re-add.
|
||||
runCatching {
|
||||
NativeApp.setEnabledPatches(cheatsSection, names.toTypedArray(), emptyArray())
|
||||
}
|
||||
}
|
||||
reloadCore()
|
||||
}
|
||||
refresh()
|
||||
}
|
||||
|
||||
@@ -665,6 +698,132 @@ class PatchManagerViewModel(application: Application) : AndroidViewModel(applica
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Raw .pnach text editor ------------------------------------------
|
||||
//
|
||||
// Requested by hanafuda: codes found on the web are raw `patch=` lines, and without an editor
|
||||
// the only way in was to write the file on a PC and side-load it. Editing the FILE (rather
|
||||
// than offering some structured code-entry form) is deliberate — pnach is the format people
|
||||
// copy, comments and section headers included, so anything that re-serialised it would mangle
|
||||
// what they pasted.
|
||||
|
||||
/** Open an existing .pnach for editing. */
|
||||
fun openEditor(file: File) {
|
||||
state.value = state.value.copy(
|
||||
editorPath = file.absolutePath,
|
||||
editorName = file.name,
|
||||
editorText = "",
|
||||
editorNew = false,
|
||||
editorLoading = true,
|
||||
)
|
||||
viewModelScope.launch {
|
||||
val text = withContext(Dispatchers.IO) {
|
||||
runCatching { file.readText() }.getOrDefault("")
|
||||
}
|
||||
if (state.value.editorPath == file.absolutePath) {
|
||||
state.value = state.value.copy(editorText = text, editorLoading = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a new .pnach for the game in context.
|
||||
*
|
||||
* Named `<SERIAL>_<CRC>.pnach` because that is the only shape the core loads. When the CRC
|
||||
* isn't known yet the name still gets created, and [saveEditor] reports that it won't load —
|
||||
* better than refusing to let someone paste their codes.
|
||||
*/
|
||||
fun newEditor() {
|
||||
state.value = state.value.copy(
|
||||
editorPath = "",
|
||||
editorName = "",
|
||||
editorText = "",
|
||||
editorNew = true,
|
||||
editorLoading = true,
|
||||
)
|
||||
viewModelScope.launch {
|
||||
// liveCrc() can read the disc, so keep it off the main thread.
|
||||
val (serial, crc) = withContext(Dispatchers.IO) { bestSerial() to liveCrc() }
|
||||
val name = when {
|
||||
serial != null && crc != null -> "${serial}_$crc.pnach"
|
||||
serial != null -> "$serial.pnach"
|
||||
else -> "patch.pnach"
|
||||
}
|
||||
if (state.value.editorNew) {
|
||||
state.value = state.value.copy(
|
||||
editorName = name,
|
||||
editorLoading = false,
|
||||
// A skeleton, so the format is obvious to someone pasting for the first time.
|
||||
// The group header matters: an UNLABELLED patch auto-applies, a labelled one
|
||||
// has to be switched on, and people pasting raw codes expect them to work.
|
||||
editorText = "gametitle=${MainActivityRuntime.contextGame.value?.title.orEmpty()}\n" +
|
||||
"\n" +
|
||||
"// Paste codes below. Lines look like:\n" +
|
||||
"// patch=1,EE,00000000,extended,00000000\n",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun updateEditorText(text: String) {
|
||||
state.value = state.value.copy(editorText = text)
|
||||
}
|
||||
|
||||
fun closeEditor() {
|
||||
state.value = state.value.copy(
|
||||
editorPath = null, editorName = "", editorText = "", editorNew = false, editorLoading = false,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the editor buffer to disk and reload the core.
|
||||
*
|
||||
* Does NOT touch the enable list. A pasted file's groups arm only when the user switches them
|
||||
* on in the list below — auto-arming whatever a file happens to contain is exactly the bug
|
||||
* that made patches apply with every patch setting off.
|
||||
*/
|
||||
fun saveEditor() {
|
||||
val snapshot = state.value
|
||||
val path = snapshot.editorPath ?: return
|
||||
val text = snapshot.editorText
|
||||
val name = snapshot.editorName.trim().ifBlank { "patch.pnach" }
|
||||
.let { if (it.endsWith(".pnach", true)) it else "$it.pnach" }
|
||||
viewModelScope.launch {
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val target = if (snapshot.editorNew) {
|
||||
// Cheats folder: that's where the manager's own installs land, and it is
|
||||
// the section the core reads user cheats from.
|
||||
val dir = patchDirectories().first().apply { mkdirs() }
|
||||
uniqueFile(dir, name)
|
||||
} else {
|
||||
File(path)
|
||||
}
|
||||
target.parentFile?.mkdirs()
|
||||
target.writeText(text)
|
||||
target
|
||||
}
|
||||
}
|
||||
state.value = result.fold(
|
||||
onSuccess = { f ->
|
||||
val loadable = Regex("^[A-Z]{4}-\\d{5}_[0-9A-F]{8}", RegexOption.IGNORE_CASE)
|
||||
.containsMatchIn(f.name) ||
|
||||
Regex("^[0-9A-F]{8}([^0-9A-F]|$)", RegexOption.IGNORE_CASE).containsMatchIn(f.name)
|
||||
state.value.copy(
|
||||
editorPath = null, editorName = "", editorText = "", editorNew = false,
|
||||
message = if (loadable) "Saved ${f.name}."
|
||||
else "Saved ${f.name}, but the core only loads <SERIAL>_<CRC>.pnach — " +
|
||||
"launch this game once, then rename or re-save.",
|
||||
)
|
||||
},
|
||||
onFailure = { state.value.copy(error = "Could not save the patch file.") },
|
||||
)
|
||||
if (result.isSuccess) {
|
||||
reloadCore()
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun patchDirectories(): List<File> {
|
||||
val root = File(MainActivityRuntime.assetCopyRoot(getApplication()))
|
||||
return listOf(File(root, "cheats"), File(root, "patches"), File(root, "cheats_ws"))
|
||||
@@ -718,14 +877,7 @@ class PatchManagerViewModel(application: Application) : AndroidViewModel(applica
|
||||
runCatching { NativeApp.setEnabledPatches(cheatsSection, all, enabled) }
|
||||
}
|
||||
|
||||
/** Reflect every installed file's on-disk body state into the native Enable lists
|
||||
* (off-thread — parses each pnach). The file body stays the persistent source of
|
||||
* truth; this reconciles the runtime list PCSX2 requires for labelled groups so
|
||||
* imported/pre-enabled cheats apply without an in-app toggle. */
|
||||
private fun syncAllEnableLists(files: List<File>) {
|
||||
if (!MainActivityRuntime.nativeReady.value || files.isEmpty()) return
|
||||
kotlin.concurrent.thread(name = "armsx2-cheat-enable-sync") {
|
||||
runCatching { for (file in files) syncEnableListForFile(file) }
|
||||
}
|
||||
}
|
||||
// syncAllEnableLists() was removed deliberately — see the note in refresh(). Reflecting every
|
||||
// on-disk file's body into the enable list is only ever correct for a file the user just
|
||||
// imported, which import does for itself. Don't reintroduce a bulk variant.
|
||||
}
|
||||
|
||||
@@ -460,6 +460,13 @@ fun FixesTab(state: MutableState<Settings>) {
|
||||
HelpText(str("perf.gamedbFixes.help"))
|
||||
ToggleRow(str("perf.fix.skipBios"), s.enableFastBoot, description = str("perf.fix.skipBios.desc")) { apply(s.copy(enableFastBoot = it)) }
|
||||
ToggleRow(str("perf.fix.gamedbFixes"), s.enableGameFixes, description = str("perf.fix.gamedbFixes.desc")) { apply(s.copy(enableGameFixes = it)) }
|
||||
// Compatibility patches sat in the Patches screen under the name "Enable Patches",
|
||||
// where nothing said they were the per-game COMPATIBILITY set PCSX2 ships — users
|
||||
// read it as "turn patches on/off" and switched it off, or blamed it for a
|
||||
// widescreen hack it never controlled. It is the same class of thing as the GameDB
|
||||
// fixes above, so it belongs beside them. Widescreen / cheats / no-interlacing stay
|
||||
// in the Patches screen; those really are patch choices.
|
||||
ToggleRow(str("perf.fix.compatPatches"), s.enablePatches, description = str("perf.fix.compatPatches.desc")) { apply(s.copy(enablePatches = it)) }
|
||||
ToggleRow(str("perf.fix.skipMpeg"), s.gamefixSkipMpeg, description = str("perf.fix.skipMpeg.desc")) { apply(s.copy(enableGameFixes = true, gamefixSkipMpeg = it)) }
|
||||
if (s.gamefixSkipMpeg) HelpText(str("perf.fix.skipMpeg.warning"))
|
||||
ToggleRow(str("perf.fix.fmvSoftware"), s.gamefixSoftwareRendererFmv, description = str("perf.fix.fmvSoftware.desc")) { apply(s.copy(enableGameFixes = true, gamefixSoftwareRendererFmv = it)) }
|
||||
|
||||
@@ -175,6 +175,14 @@ public class NativeApp {
|
||||
* enough — a patch is inert unless its name is enabled here.
|
||||
*/
|
||||
public static native void setEnabledPatches(boolean cheats, String[] allNames, String[] enabledNames);
|
||||
/**
|
||||
* One-time repair: drop the GLOBAL [Patches]/[Cheats] "Enable" lists.
|
||||
* <p>
|
||||
* Older builds filled these automatically just by opening the Patch Manager, and because
|
||||
* patches are enabled by NAME those entries armed the same-named group in the bundled pnach
|
||||
* archive for every game. Per-game lists are left alone. Call once, gated on a pref.
|
||||
*/
|
||||
public static native void purgeGlobalPatchEnableLists();
|
||||
public static native String getGameTitle(String path);
|
||||
public static native String getGameSerial();
|
||||
public static native String getGameCRC();
|
||||
|
||||
Reference in New Issue
Block a user