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:
jpolo1224
2026-07-30 01:08:54 -04:00
parent 15538d066d
commit 10f4f73fe8
5 changed files with 286 additions and 38 deletions
+15 -1
View File
@@ -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,
) {
@@ -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();