Join the GPU and CPU pass tables on the same ordinal, and reach external storage

The two by-pass tables were joined on counters that reset at different points.
tick_frame runs from on_frame_end, before flip; the GPU timer rotates its slot at
the top of flip and then drops every non-frame region on the fresh slot, which is
flip's own overlay and calibration passes -- and those still incremented the CPU
counter. So the CPU ordinal ran ahead by the number of present-path passes and
the two tables described different passes. A whole anomaly came out of that: a
pass whose GPU cost was joined to a neighbour's workload read as 36x the per-draw
cost of its peers. The comment claiming both reset on the same boundary was
wrong. Reset where the GPU slot actually rotates instead.

Also adds a Storage Access Framework route to the package installer. The in-app
browser walks java.io.File, which only reaches storage this process can open by
path, so a .pkg on a USB-OTG drive or an SD card was unreachable and had to be
copied to internal storage first. Packages are handed over as the descriptor SAF
already returned -- the native side takes a raw fd, so nothing is copied and a
4 GB package costs no extra space; licences are 16 bytes and their installer
wants a real file, so those alone are staged.
This commit is contained in:
jpolo1224
2026-08-10 22:28:33 -04:00
parent 3338eedfcd
commit 0cbab0c3ae
4 changed files with 137 additions and 4 deletions
@@ -394,6 +394,7 @@ val EN: Map<String, String> = mapOf(
"packages.description" to "Install a .pkg game, update or DLC, or a .rap licence file. Some games need both: the .pkg holds the content and the .rap unlocks it. Installed titles are added to your library automatically, and updates and DLC need the base game installed first.",
"packages.select.title" to "Select a .pkg or .rap file",
"packages.select.action" to "Choose file",
"packages.select.external" to "Choose from USB or SD card",
"packages.installing" to "Installing. Large packages can take a few minutes.",
"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.",
@@ -2,6 +2,7 @@ package com.armsx2.ui.packages
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
@@ -175,6 +176,106 @@ fun PackageInstallerScreen(onBack: () -> Unit) {
// licence, the obvious thing to do, could therefore only ever fail.
//
// Packages install FIRST: a licence unlocks content the package has to have written.
/**
* Install from a Storage Access Framework pick.
*
* The in-app browser walks java.io.File, which only reaches storage this process can open
* by path -- internal, and its own external dirs. A .pkg on a USB-OTG drive or on some SD
* cards is not reachable that way at all, so those users had to copy multi-gigabyte files
* to internal storage first. Reported as issue #16.
*
* Packages are handed over as the descriptor SAF already gave us: the native side takes a
* raw fd, so nothing is copied and a 4 GB package costs no extra space. Licences are 16
* bytes and their installer wants a real file, so those alone are staged into the cache.
*/
fun installFromUris(uris: List<android.net.Uri>) {
if (uris.isEmpty()) return
busy = true
message = null
MainActivityRuntime.invoke {
var nativeFailure: String? = null
val ok = withContext(Dispatchers.IO) {
runCatching {
val resolver = context.contentResolver
fun displayName(uri: android.net.Uri): String =
runCatching {
resolver.query(uri, null, null, null, null)?.use { c ->
val i = c.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
if (i >= 0 && c.moveToFirst()) c.getString(i) else null
}
}.getOrNull() ?: uri.lastPathSegment.orEmpty()
val named = uris.map { it to displayName(it) }
val licenceUris = named.filter { (_, n) ->
n.endsWith(".rap", true) || n.endsWith(".edat", true)
}
val packageUris = named.filterNot { (_, n) ->
n.endsWith(".rap", true) || n.endsWith(".edat", true)
}
val label = if (uris.size == 1) named[0].second else "${uris.size} files"
val id = ProgressRepository.create(context, "Installing $label")
progressId = id
val progressEntry = ProgressRepository.getItem(id)
var result = true
if (packageUris.isNotEmpty()) {
val descriptors = packageUris.mapNotNull { (uri, _) ->
runCatching { resolver.openFileDescriptor(uri, "r") }.getOrNull()
}
if (descriptors.size != packageUris.size) {
descriptors.forEach { runCatching { it.close() } }
return@runCatching false
}
try {
result = if (descriptors.size == 1) {
RPCSX.instance.install(descriptors[0].fd, id)
} else {
RPCSX.instance.installSplitPkg(descriptors.map { it.fd }.toIntArray(), id)
}
} finally {
descriptors.forEach { runCatching { it.close() } }
}
}
for ((uri, name) in licenceUris) {
if (!result) break
val staged = java.io.File(context.cacheDir, name)
val copied = runCatching {
resolver.openInputStream(uri)?.use { input ->
staged.outputStream().use { out -> input.copyTo(out) }
} != null
}.getOrDefault(false)
if (!copied) { result = false; break }
result = if (name.endsWith(".rap", true)) {
Licences.installRap(staged)
} else {
val d = ParcelFileDescriptor.open(staged, ParcelFileDescriptor.MODE_READ_ONLY)
try { RPCSX.instance.installKey(d.fd, id, "") } finally { runCatching { d.close() } }
}
runCatching { staged.delete() }
}
progressEntry?.value?.takeIf { it.isFailed() }?.let {
nativeFailure = it.message.value
}
result
}.getOrDefault(false)
}
busy = false
progressId = null
message = if (ok) {
GameLibraryRepository(context).invalidateCache()
installed = readInstalled()
licences = readLicences()
I18n.get("packages.install.done")
} else {
nativeFailure?.takeIf { it.isNotBlank() } ?: I18n.get("packages.install.failed")
}
}
}
fun install(files: List<java.io.File>) {
if (files.isEmpty()) return
showBrowser = false
@@ -327,6 +428,10 @@ fun PackageInstallerScreen(onBack: () -> Unit) {
)
}
val safPicker = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.OpenMultipleDocuments(),
) { uris -> if (!uris.isNullOrEmpty()) installFromUris(uris) }
if (showBrowser) {
FileBrowserDialog(
title = str("packages.select.title"),
@@ -398,6 +503,14 @@ fun PackageInstallerScreen(onBack: () -> Unit) {
Button(onClick = { showBrowser = true }) {
Text(str("packages.select.action"))
}
// Reaches storage the in-app browser cannot open by path: USB-OTG,
// and SD cards on devices that only expose them through SAF.
Button(
onClick = { safPicker.launch(arrayOf("*/*")) },
modifier = Modifier.padding(top = 8.dp),
) {
Text(str("packages.select.external"))
}
}
message?.let {
+11
View File
@@ -332,6 +332,17 @@ void VKGSRender::queue_swap_request()
// reported nothing at all for the whole session while looking perfectly healthy.
vk::get_gpu_timer().begin(*m_current_command_buffer, vk::gpu_timer::region::frame);
// Restart CPU pass numbering HERE, where the GPU timer's slot actually rotates, so an
// ordinal names the same pass on both sides. It used to reset in tick_frame, which runs
// from on_frame_end -- before flip -- while flip's own overlay and calibration passes went
// on incrementing it and were dropped GPU-side. The CPU ordinal therefore ran ahead by the
// number of present-path passes and the two by-pass tables described different passes.
//
// This site rather than next_frame(): queue_swap_request has one call site, so the mid-frame
// flush_command_queue reopen cannot falsely restart numbering, and flip's passes land after
// the reset where they belong rather than taking ordinals 0..k-1.
rsx::prof::g_pass_ordinal = umax;
// Set up new pointers for the next frame
advance_queued_frames();
}
+12 -4
View File
@@ -245,10 +245,18 @@ namespace rsx::prof
g_acc.frames++;
// Restart pass numbering so an ordinal means the same pass here as it does in the GPU
// timer, whose event index resets on the same boundary. Wraps to zero on the first
// pass; anything counted before one opens lands out of range and is discarded.
g_pass_ordinal = umax;
// Pass numbering is NOT restarted here.
//
// It used to be, on the claim that the GPU timer's event index resets on the same
// boundary. It does not. tick_frame runs from on_frame_end, BEFORE flip; the GPU timer
// rotates its slot at the top of flip and then drops every non-frame region recorded on
// the fresh slot -- which is flip's own overlay and calibration passes. Those passes
// still increment this counter, so the CPU ordinal ran ahead of the GPU ordinal by the
// number of present-path passes, and the two by-pass tables described different passes.
//
// A whole "anomaly" came out of that: a pass whose GPU cost was joined to another pass's
// workload read as 36x the per-draw cost of its neighbours. Reset at the flip point
// instead, where the GPU slot actually rotates. See VKPresent.cpp.
// Report on a frame boundary rather than a timer, so per-frame costs divide by a
// whole number of frames and a long stall lands in the window that contains it.