Remove a title's shader and PPU cache when uninstalling it

Uninstalling only ever removed dev_hdd0/game/<TITLEID>, so the title's compiled
code and shader cache stayed on disk forever. On my device that was between 7 and
58 MB per title, and one of those caches belonged to a game I had already removed.

I made it a checkbox on the existing confirmation rather than doing it silently,
defaulted on, which is how RPCS3 desktop's own remove dialog treats caches. The
row is hidden when there is no cache, and it shows the measured size so you can
see what you are freeing. The size is measured off the main thread because a cache
directory holds hundreds of files and this runs while the dialog is opening.

The cache goes only after the native uninstall reports success, since dropping the
cache for a title that is still installed would just cost a recompile. The title
id is validated before the recursive delete: it comes from a directory listing,
but a path separator or a dot dot in it would resolve outside the per title
folder, so anything that is not a single plain segment is refused.

Save data, trophies and licences are deliberately left alone. Those belong to the
user rather than to the install, and desktop does not offer to remove them either.
This commit is contained in:
Zulux91
2026-08-10 04:16:00 -04:00
committed by jpolo1224
parent e5673c43ea
commit de34f7f173
2 changed files with 74 additions and 1 deletions
@@ -401,6 +401,7 @@ val EN: Map<String, String> = mapOf(
"packages.install.done" to "Installed. It will appear in your library on the next scan.",
"packages.install.failed" to "Install failed. The file may be encrypted, incomplete or not a PS3 package.",
"packages.multiHint" to "Tap several .pkg files to select them all if a game is split into parts, then confirm. Licence files are installed one at a time.",
"packages.uninstall.alsoCache" to "Also remove cached shaders and compiled code (%s)",
"packages.installed.header" to "Installed titles",
"packages.licences.header" to "Installed licences",
"packages.uninstall" to "Uninstall",
@@ -11,7 +11,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.clickable
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -140,6 +142,41 @@ private fun isInstalledContent(dir: java.io.File): Boolean {
}.getOrNull() == true
}
/**
* A title's compiled-code and shader cache.
*
* Keyed by title id, which is also the name of the title's folder under dev_hdd0/game --
* rpcs3::utils::get_cache_dir() appends Emu.GetTitleID() to <root>/cache/cache/, and a PKG
* installs into a directory named for the same id. Measured at 7-58 MB per title, which is
* why uninstalling without it leaves the bulk of the disk usage behind.
*/
private fun cacheDirFor(titleId: String): java.io.File =
java.io.File(RPCSX.rootDirectory, "cache/cache/$titleId")
private fun dirSize(dir: java.io.File): Long =
if (!dir.isDirectory) 0L else dir.walkTopDown().filter { it.isFile }.sumOf { it.length() }
private fun formatSize(bytes: Long): String = when {
bytes >= 1024L * 1024L -> "%.0f MB".format(bytes / 1024.0 / 1024.0)
else -> "%.0f KB".format(bytes / 1024.0)
}
/**
* Delete a title's cache directory.
*
* The name is checked rather than trusted: it comes from a directory listing, but a path
* separator or a dot-dot in it would resolve outside the per-title folder and take the whole
* cache or more with it. Anything but a single plain segment is refused.
*/
private fun removeCacheFor(titleId: String): Boolean = runCatching {
if (titleId.isBlank() || titleId == "." || titleId == ".." ||
titleId.contains('/') || titleId.contains('\\')
) return false
val dir = cacheDirFor(titleId)
if (!dir.isDirectory) return false
dir.deleteRecursively()
}.getOrDefault(false)
@Composable
fun PackageInstallerScreen(onBack: () -> Unit) {
val context = LocalContext.current
@@ -216,19 +253,54 @@ fun PackageInstallerScreen(onBack: () -> Unit) {
// Deleting a game folder is not undoable, so it is confirmed rather than done on tap.
confirmRemove?.let { target ->
// Uninstall only ever removed dev_hdd0/game/<TITLEID>, so the title's compiled-code and
// shader cache -- by far the larger of the two on disk -- stayed forever. Offered as a
// checkbox rather than done silently, and defaulted on, which is how RPCS3 desktop's own
// remove dialog treats caches. Save data, trophies and licences are deliberately NOT
// touched: those are the user's, not the install's.
var alsoRemoveCache by remember(target) { mutableStateOf(true) }
// Off the main thread: a cache directory holds hundreds of files and this runs while the
// dialog is opening.
val cacheBytes by androidx.compose.runtime.produceState(0L, target) {
value = withContext(Dispatchers.IO) { dirSize(cacheDirFor(target.name)) }
}
AlertDialog(
onDismissRequest = { confirmRemove = null },
title = { Text(str("packages.uninstall.confirmTitle")) },
text = { Text(str("packages.uninstall.confirmBody").format(target.name)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(str("packages.uninstall.confirmBody").format(target.name))
// Hidden when there is no cache, so the row never offers to free nothing.
if (cacheBytes > 0) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { alsoRemoveCache = !alsoRemoveCache },
) {
Checkbox(checked = alsoRemoveCache, onCheckedChange = { alsoRemoveCache = it })
Text(
str("packages.uninstall.alsoCache").format(formatSize(cacheBytes)),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
},
confirmButton = {
TextButton(onClick = {
confirmRemove = null
val removeCache = alsoRemoveCache
val titleId = target.name
MainActivityRuntime.invoke {
val ok = withContext(Dispatchers.IO) {
runCatching {
RPCSX.instance.uninstallGame(target.absolutePath)
}.getOrDefault(false)
}
// Only after the game itself is gone: dropping the cache for a title that
// is still installed would just cost the user a recompile.
if (ok && removeCache) {
withContext(Dispatchers.IO) { removeCacheFor(titleId) }
}
installed = readInstalled()
licences = readLicences()
if (ok) GameLibraryRepository(context).invalidateCache()