mirror of
https://github.com/ARMSX2/ARMSX3.git
synced 2026-08-24 16:58:52 -07:00
App: make pause, restart and the FPS cap do what they say, and add trophies
Pause reached the core for the first time. Rpcs3Bridge.pause() set a bool and returned, on the belief that RPCS3 has no explicit pause entry point -- Emu.Pause() exists and _rpcsx_surfaceEvent has always called it on surface loss, which is why backgrounding the app was the only thing that paused. Exported as _rpcsx_pause through all four layers; resume already reached the core, so the pair was asymmetric. Restart no longer crashes: setCustomDriver dlclose'd the previous driver handle, and applyRendererPrefs re-applies the driver on every start, so restart unloaded the library VMA had resolved vkGetPhysicalDeviceMemoryProperties2 out of. ~VKGSRender then freed its heaps and UpdateVulkanBudget called into an unmapped mapping. An ICD cannot be unloaded while anything resolved from it is reachable, so it is no longer closed. Restart no longer returns to the library either: shutdown() set stopRequested, called kill() and returned with the VM still live, so the run loop's finally started the replacement and the in-flight teardown killed it -- two BootGame calls, then Unloading ISO, by which point the restart flag was spent. shutdown() now waits (bounded) for the core to report Stopped, and the restart is queued on vmStopControl behind it. FPS cap applies at every value. ConfigStore recorded a persistent core override of Video@@Frame limit=60 and Settings rewrote it on every push, both from a migration escaping a stored 120 -- but that node is the cap control, and overrides replay last, so presets were pinned at 60 while 20 and 45 worked through Second Frame Limit. The Vblank Rate force stays, since Frame limit Auto resolves to it. Stale overrides are cleared once. 90 and 120 dropped from the row: the min() in the pacer discards them. Cover art for PKG installs: the library grid's fallback chain stopped one leg short of the extracted ICON0.PNG while the in-game menu's did not. Both now share one chain, so they cannot diverge again. has()/discIconFile require bytes rather than existence, and the staging rename is checked instead of discarded. Licences are grouped per game and collapsed instead of a flat list of content ids. Trophies: a library-wide browser and an in-game tab for the running title, reading TROPCONF.SFM and TROPUSR.DAT directly -- no account, no network. The in-game set is identified from the core's own current_trophy_name (try_get, since get<> would construct it outside emulation and hand back an empty name), falling back to TROPDIR on disk because a game registers its context lazily. Note the entry stride there is 16 + entries_size, not entries_size. Also: renderer.upscale.label was defined twice, so Internal Resolution was dead.
This commit is contained in:
@@ -35,8 +35,10 @@ struct RPCSXApi {
|
||||
int (*getState)();
|
||||
void (*kill)();
|
||||
void (*resume)();
|
||||
void (*pause)();
|
||||
void (*openHomeMenu)();
|
||||
std::string (*getTitleId)();
|
||||
std::string (*getCurrentTrophyName)();
|
||||
bool (*surfaceEvent)(JNIEnv *env, jobject surface, jint event);
|
||||
void (*surfaceSizeChanged)(int width, int height);
|
||||
bool (*usbDeviceEvent)(int fd, int vendorId, int productId, int event);
|
||||
@@ -122,8 +124,10 @@ struct RPCSXLibrary : RPCSXApi {
|
||||
result.getState = reinterpret_cast<decltype(getState)>(dlsym(handle, "_rpcsx_getState"));
|
||||
result.kill = reinterpret_cast<decltype(kill)>(dlsym(handle, "_rpcsx_kill"));
|
||||
result.resume = reinterpret_cast<decltype(resume)>(dlsym(handle, "_rpcsx_resume"));
|
||||
result.pause = reinterpret_cast<decltype(pause)>(dlsym(handle, "_rpcsx_pause"));
|
||||
result.openHomeMenu = reinterpret_cast<decltype(openHomeMenu)>(dlsym(handle, "_rpcsx_openHomeMenu"));
|
||||
result.getTitleId = reinterpret_cast<decltype(getTitleId)>(dlsym(handle, "_rpcsx_getTitleId"));
|
||||
result.getCurrentTrophyName = reinterpret_cast<decltype(getCurrentTrophyName)>(dlsym(handle, "_rpcsx_getCurrentTrophyName"));
|
||||
result.surfaceEvent = reinterpret_cast<decltype(surfaceEvent)>(dlsym(handle, "_rpcsx_surfaceEvent"));
|
||||
result.surfaceSizeChanged = reinterpret_cast<decltype(surfaceSizeChanged)>(dlsym(handle, "_rpcsx_surfaceSizeChanged"));
|
||||
result.usbDeviceEvent = reinterpret_cast<decltype(usbDeviceEvent)>(dlsym(handle, "_rpcsx_usbDeviceEvent"));
|
||||
@@ -355,6 +359,18 @@ extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_resume(JNIEnv *env,
|
||||
return rpcsxLib.resume();
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_pause(JNIEnv *env,
|
||||
jobject) {
|
||||
// Same null guard as resume: the core is dlopen()ed separately and may not be
|
||||
// up yet. A missing symbol also means an older core, so an app built against
|
||||
// this cannot assume the export is there.
|
||||
if (rpcsxLib.pause == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
return rpcsxLib.pause();
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_openHomeMenu(JNIEnv *env,
|
||||
jobject) {
|
||||
// The core is dlopen()ed separately and may not be up yet -- during
|
||||
@@ -379,6 +395,21 @@ Java_net_rpcsx_RPCSX_getTitleId(JNIEnv *env, jobject) {
|
||||
return wrap(env, rpcsxLib.getTitleId());
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jstring JNICALL
|
||||
Java_net_rpcsx_RPCSX_getCurrentTrophyName(JNIEnv *env, jobject) {
|
||||
// The core is dlopen()ed separately and may not be up yet -- during
|
||||
// onboarding, or if it failed to load. Calling through a null pointer
|
||||
// is an instant SIGSEGV, so fail the call instead.
|
||||
//
|
||||
// Also null on an OLDER core that predates this export, since it is resolved
|
||||
// by dlsym: the frontend must treat null as "unknown", not as "no trophies".
|
||||
if (rpcsxLib.getCurrentTrophyName == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return wrap(env, rpcsxLib.getCurrentTrophyName());
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_surfaceEvent(
|
||||
JNIEnv *env, jobject, jobject surface, jint event) {
|
||||
// The core is dlopen()ed separately and may not be up yet -- during
|
||||
@@ -784,10 +815,27 @@ Java_net_rpcsx_RPCSX_setCustomDriver(JNIEnv *env, jobject, jstring jpath,
|
||||
}
|
||||
}
|
||||
|
||||
auto prevLoader = rpcsxLib.setCustomDriver(loader);
|
||||
if (prevLoader != nullptr) {
|
||||
::dlclose(prevLoader);
|
||||
}
|
||||
// Deliberately NOT dlclose()ing the previous handle.
|
||||
//
|
||||
// A Vulkan driver cannot be unloaded while anything resolved out of it is still reachable, and
|
||||
// from here there is no way to know that. VMA caches vkGetPhysicalDeviceMemoryProperties2 in the
|
||||
// allocator at creation time, so the address lives inside the driver library for as long as the
|
||||
// renderer does.
|
||||
//
|
||||
// Restart is the one flow where a start races a teardown that has not finished:
|
||||
// applyRendererPrefs() re-applies the driver on EVERY start, so it dlopen'd a new handle and
|
||||
// closed the old one while the previous VKGSRender was still unwinding. Its destructor then
|
||||
// freed its data heaps, VMA went to refresh its budget, and called through a pointer into a
|
||||
// library that was no longer mapped -- "Segfault executing location <addr> at <addr>", inside
|
||||
// VmaAllocator_T::UpdateVulkanBudget. The give-away was the fault address landing on the same
|
||||
// offset every time with a different base: a live function in an unmapped library, not a
|
||||
// corrupted pointer. That is the "Restart crashes the app" report, and the same for
|
||||
// apply-and-restart after picking a driver.
|
||||
//
|
||||
// Leaking one handle per driver SWITCH is the cheap side of this trade: it is bounded by how
|
||||
// many times a user changes driver in a session, the mapping is shared, and dlclose on an ICD
|
||||
// is not something the loader promises to honour anyway.
|
||||
rpcsxLib.setCustomDriver(loader);
|
||||
|
||||
return true;
|
||||
#else
|
||||
|
||||
@@ -24,8 +24,17 @@ object DiscIcons {
|
||||
|
||||
fun fileFor(titleId: String): File = File(dir(), "$titleId.png")
|
||||
|
||||
/** True when this game's icon has already been extracted. */
|
||||
fun has(titleId: String): Boolean = fileFor(titleId).isFile
|
||||
/**
|
||||
* True when this game's icon has already been extracted.
|
||||
*
|
||||
* Length, not isFile: an empty file is indistinguishable from a real one to isFile, and
|
||||
* it is a shape this can actually end up in -- the extraction writes to a staging name
|
||||
* and renames, and neither the write nor the rename is checked. An empty icon then reads
|
||||
* as "already extracted" forever, and the card falls through to the text placeholder
|
||||
* because there is nothing for Coil to decode. Requiring bytes makes that self-repairing:
|
||||
* the next scan re-probes and overwrites it.
|
||||
*/
|
||||
fun has(titleId: String): Boolean = fileFor(titleId).length() > 0L
|
||||
|
||||
fun clear() {
|
||||
runCatching { dir().listFiles()?.forEach { it.delete() } }
|
||||
|
||||
@@ -330,7 +330,10 @@ data class GameInfo(
|
||||
* 404, and always match the disc. There is no PS3 equivalent of xlenore's
|
||||
* ps2-covers to point at anyway.
|
||||
*/
|
||||
val discIconFile: java.io.File? get() = serial?.let { DiscIcons.fileFor(it) }?.takeIf { it.isFile }
|
||||
// Length rather than isFile, for the same reason as DiscIcons.has: an empty file passes
|
||||
// isFile, hands Coil something undecodable, and costs the card its placeholder-vs-cover
|
||||
// decision. Nothing is a better answer than zero bytes.
|
||||
val discIconFile: java.io.File? get() = serial?.let { DiscIcons.fileFor(it) }?.takeIf { it.length() > 0L }
|
||||
|
||||
private fun coverUrlFor(s: String): String {
|
||||
// PS3 art comes from aldostools/Resources, which is flat: COV/<TITLE_ID>.JPG
|
||||
|
||||
@@ -53,6 +53,8 @@ object ConfigStore {
|
||||
// One-time flip of existing all-on OSD saves to the new default-off.
|
||||
private const val KEY_OSD_OFF_MIGRATED = "config.migrated.osdDefaultOff"
|
||||
private const val KEY_OSD_SCALE_MIGRATED = "config.migrated.osdScale65"
|
||||
/** One-time removal of the Frame limit core override that made the FPS cap inert. */
|
||||
private const val KEY_FRAME_LIMIT_UNPINNED = "config.migrated.frameLimitUnpinned"
|
||||
// One-time reconcile for the fresh-install + reused-data-folder case (people who
|
||||
// can't update in place and re-point setup at their old folder). See reconcileReusedFolder.
|
||||
private const val KEY_FOLDER_RECONCILE = "config.migrated.folderReconcile"
|
||||
@@ -373,16 +375,32 @@ object ConfigStore {
|
||||
runCatching {
|
||||
CoreSettingOverrides.record(SettingsScope.Global, null, "Video@@Vblank Rate", "60")
|
||||
}
|
||||
// Frame limit as well as Vblank Rate. Vblank alone did not hold: the override is
|
||||
// stored and the two beside it apply, yet the live value came back as 120. Frame
|
||||
// limit is the dedicated cap and does not depend on the vblank path at all, so
|
||||
// whichever of the two takes, the result is 60.
|
||||
runCatching {
|
||||
CoreSettingOverrides.record(SettingsScope.Global, null, "Video@@Frame limit", "60")
|
||||
}
|
||||
MainActivityRuntime.prefs.edit { putBoolean(KEY_VBLANK_60, true) }
|
||||
}
|
||||
|
||||
// The migration above used to pin Video@@Frame limit to 60 as well, as a second way to
|
||||
// reach a 60Hz cap in case the Vblank Rate override did not hold.
|
||||
//
|
||||
// Frame limit is not a spare knob: it is the node the Display FPS Cap row writes. Pinned as
|
||||
// a core override it replayed AFTER every settings push, so the cap silently did nothing for
|
||||
// every value that uses the enum -- 30, 50, 60, 120 -- while 20 and 45 appeared to work
|
||||
// because those are not presets and go to the free-form Second Frame Limit instead.
|
||||
// Measured: the UI wrote '30', settingsSet accepted it, and the core still reported
|
||||
// frame_limit=_60 on every flip.
|
||||
//
|
||||
// Nothing is lost by dropping it. Frame limit Auto resolves to the vblank rate
|
||||
// (RSXThread.cpp, the _auto case), and the Vblank Rate override above is already 60, so the
|
||||
// 60Hz default this was protecting still holds.
|
||||
//
|
||||
// forgetEverywhere, not a scoped forget: overrides live in two stores across two scopes, and
|
||||
// an install that ran the old migration has the Global one recorded already. Anyone who
|
||||
// deliberately set a Frame limit in All Core Settings loses that override here, which is the
|
||||
// right trade against a cap control that cannot work.
|
||||
if (!MainActivityRuntime.prefs.getBoolean(KEY_FRAME_LIMIT_UNPINNED, false)) {
|
||||
runCatching { CoreSettingOverrides.forgetEverywhere("Video@@Frame limit") }
|
||||
MainActivityRuntime.prefs.edit { putBoolean(KEY_FRAME_LIMIT_UNPINNED, true) }
|
||||
}
|
||||
|
||||
// Diagnostic settings that got recorded as core overrides during the profiling work
|
||||
// and would otherwise follow people into a release build.
|
||||
//
|
||||
|
||||
@@ -1294,10 +1294,18 @@ data class Settings(
|
||||
// A deliberate change in All Core Settings still wins, because CoreSettingOverrides
|
||||
// replays immediately below this.
|
||||
runCatching { net.rpcsx.RPCSX.instance.settingsSet("Video@@Vblank Rate", "60") }
|
||||
// And the cap itself. Frame limit Auto resolves to the vblank rate, so with the line
|
||||
// above it would already be 60; setting it explicitly means the cap does not depend
|
||||
// on the vblank path holding, which it did not. Enum node, so the value is quoted.
|
||||
runCatching { net.rpcsx.RPCSX.instance.settingsSet("Video@@Frame limit", "\"60\"") }
|
||||
// Frame limit is deliberately NOT forced here any more.
|
||||
//
|
||||
// It used to be written to "60" on every push as a belt-and-braces way to reach a 60Hz cap
|
||||
// alongside the Vblank Rate above. But this is the same node the Display FPS Cap row writes,
|
||||
// and this push runs after it, so choosing 30 wrote the enum and then this overwrote it --
|
||||
// the cap did nothing for every preset value while 20 and 45 worked, because those take the
|
||||
// free-form Second Frame Limit path instead. The core reported frame_limit=_60 on every flip
|
||||
// no matter what the UI had just been told.
|
||||
//
|
||||
// The 60Hz intent survives without it: Frame limit Auto resolves to the vblank rate, which
|
||||
// the line above pins to 60. ConfigStore also clears the stale core override that pinned
|
||||
// this node, since that replayed even later than this did.
|
||||
// Left at upstream's 100: busy-wait on a reservation rather than sleeping.
|
||||
//
|
||||
// This was dropped to 20 while the emulator was starved for cores, on the reasoning that
|
||||
|
||||
+43
-2
@@ -191,9 +191,36 @@ class GameLibraryRepository(private val context: Context) {
|
||||
// Everything the probe produces is already durable: the serial and title are in the
|
||||
// library cache, and the icon is on disk under disc-icons. So a disc we have seen
|
||||
// before never needs mounting again.
|
||||
//
|
||||
// That last clause is only true if the extraction actually succeeded once. The serial
|
||||
// and title are durable by construction -- this loop is reading them -- but the icon
|
||||
// is a separate file that may never have been written: probeDiscInfo answers "{}"
|
||||
// whenever a game is loaded, and the serial then comes from the FILENAME instead,
|
||||
// which for dev_hdd0/game/<title id> is indistinguishable from one the SFO gave us.
|
||||
// Seeding on the serial alone made that miss permanent, because every later rescan
|
||||
// skipped the one thing that would repair it. Reported as a PKG-installed title
|
||||
// showing a text placeholder for good, with its ICON0.PNG sitting unread in the game
|
||||
// folder and its path already recorded in games.json.
|
||||
//
|
||||
// So gate the skip on the icon as well, for the games that have one. Re-probing costs
|
||||
// one mount, once, and only for a game actually missing it; a game whose icon is on
|
||||
// disk still never mounts again, which is what the reasoning above is protecting.
|
||||
loadCached().games.forEach { game ->
|
||||
val serial = game.serial?.takeIf { it.isNotBlank() } ?: return@forEach
|
||||
val path = runCatching { game.uri.path }.getOrNull() ?: return@forEach
|
||||
// Folders only, and that is not a convenience: re-probing an ISO means load_iso ->
|
||||
// vfs::mount, which is the process-wide mount this whole seeding exists to avoid, and
|
||||
// it has crashed the app for real -- twice in one day, faulting in
|
||||
// manual_typemap::init<vfs_manager> from this very thread while a boot was starting.
|
||||
// A directory is read straight off disk by read_sfo_game_info with no mount at all,
|
||||
// so it carries none of that risk. The game this was reported for was a PKG install
|
||||
// (folder form) whose ICON0.PNG was sitting there unread, which is exactly the case
|
||||
// that stays covered.
|
||||
val isFolder = game.extension.equals("folder", ignoreCase = true)
|
||||
if (isFolder && !DiscIcons.has(serial)) {
|
||||
android.util.Log.i(ScanTag, "re-probing folder '$serial': no usable disc icon on disk")
|
||||
return@forEach
|
||||
}
|
||||
discInfoCache.putIfAbsent(path, DiscInfo(serial, game.title))
|
||||
}
|
||||
|
||||
@@ -582,8 +609,22 @@ class GameLibraryRepository(private val context: Context) {
|
||||
// title ID until it has already parsed the SFO.
|
||||
if (o.optBoolean("icon")) {
|
||||
val staged = DiscIcons.fileFor(PendingIcon)
|
||||
if (staged.isFile) {
|
||||
staged.renameTo(DiscIcons.fileFor(id))
|
||||
val target = DiscIcons.fileFor(id)
|
||||
// renameTo answers false instead of throwing when the target already exists,
|
||||
// and the answer was discarded: a re-extraction for a title that already had
|
||||
// an icon silently kept the old file and left the staging one behind. Clear
|
||||
// the target first, and say so if it still fails -- a stale or empty icon must
|
||||
// not outlive the probe that was meant to replace it, because every reader
|
||||
// downstream treats "a file is there" as "the cover is good".
|
||||
if (staged.length() > 0L) {
|
||||
target.delete()
|
||||
if (!staged.renameTo(target)) {
|
||||
android.util.Log.w(ScanTag, " could not place disc icon for $id")
|
||||
staged.delete()
|
||||
}
|
||||
} else {
|
||||
android.util.Log.w(ScanTag, " probe claimed an icon for $id, staged 0 bytes")
|
||||
staged.delete()
|
||||
}
|
||||
}
|
||||
DiscInfo(id, o.optString("title"))
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
package com.armsx2.data.trophies
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import net.rpcsx.RPCSX
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
|
||||
/**
|
||||
* Reader for RPCS3's NATIVE PS3 trophy data — the real thing the games write, not
|
||||
* RetroAchievements (which has no PS3 support at all, which is why the RA screen is
|
||||
* hidden in ARMSX3).
|
||||
*
|
||||
* Where the data lives, and why this reads it from disk rather than through JNI:
|
||||
*
|
||||
* config/dev_hdd0/home/<user>/trophy/<NPWRxxxxx_00>/
|
||||
* TROPCONF.SFM — the trophy DEFINITIONS, installed from the game's TROPHY.TRP.
|
||||
* Plain XML: <title-name>, then one <trophy id hidden ttype pid>
|
||||
* per trophy with <name> and <detail> children.
|
||||
* TROPUSR.DAT — the user's UNLOCK STATE. Big-endian binary, written by
|
||||
* sceNpTrophyUnlockTrophy via TROPUSRLoader::Save.
|
||||
* ICON0.PNG — the game's icon; TROP000.PNG… the per-trophy icons.
|
||||
*
|
||||
* The emulator's own loaders (rpcs3/Loader/TROPUSR.cpp, and the overlay's
|
||||
* load_trophies in Emu/RSX/Overlays/Trophies/overlay_trophy_list_dialog.cpp) sit behind
|
||||
* vfs::get, so reaching them needs the core dlopen()ed AND its VFS mounted — neither is
|
||||
* guaranteed in the library, which is exactly where this screen is used. These files are
|
||||
* inside the app's own external files dir, so plain java.io reads them with no core at
|
||||
* all: no JNI, no native rebuild, and the browser works before a game has ever booted.
|
||||
*
|
||||
* The parse mirrors TROPUSR.h/.cpp field for field; see [readTropUsr] for the one
|
||||
* non-obvious part (the entry stride).
|
||||
*/
|
||||
object TrophyRepository {
|
||||
|
||||
private const val TAG = "Trophies"
|
||||
|
||||
/** TROPUSR.DAT magic, from TROPUSR.cpp's TROPUSR_MAGIC. */
|
||||
private const val TROPUSR_MAGIC = 0x818F54AD.toInt()
|
||||
|
||||
/**
|
||||
* Microseconds from 0001-01-01 to 1970-01-01 (719162 days).
|
||||
*
|
||||
* A trophy timestamp is a CellRtcTick — sceNpTrophyUnlockTrophy stores
|
||||
* cellRtcGetCurrentTick's value straight into the entry — and cellRtc counts
|
||||
* microseconds from year 1 UTC (see tick_to_date_time in cellRtc.cpp). Subtracting
|
||||
* this turns it into a Unix epoch.
|
||||
*/
|
||||
private const val RTC_EPOCH_US = 62135596800L * 1_000_000L
|
||||
|
||||
enum class Grade { Unknown, Platinum, Gold, Silver, Bronze }
|
||||
|
||||
data class Trophy(
|
||||
val id: Int,
|
||||
/** Real name from TROPCONF.SFM. Masked by the UI while a hidden trophy is locked. */
|
||||
val name: String,
|
||||
val description: String,
|
||||
val grade: Grade,
|
||||
val hidden: Boolean,
|
||||
val unlocked: Boolean,
|
||||
/** Unix millis, or null when the file carries no timestamp (never unlocked, or an
|
||||
* unlock written by something that did not stamp it). */
|
||||
val unlockedAt: Long?,
|
||||
/** TROP%03d.PNG for this trophy, or null when the icon is missing. */
|
||||
val icon: File?,
|
||||
)
|
||||
|
||||
data class Game(
|
||||
/** The trophy folder name, e.g. NPWR05636_00. The only stable id here — a trophy
|
||||
* set is keyed by comm id, not by the game's title id. */
|
||||
val commId: String,
|
||||
val title: String,
|
||||
val detail: String,
|
||||
val icon: File?,
|
||||
val trophies: List<Trophy>,
|
||||
) {
|
||||
val total: Int get() = trophies.size
|
||||
val unlocked: Int get() = trophies.count { it.unlocked }
|
||||
val percent: Int get() = if (total > 0) 100 * unlocked / total else 0
|
||||
}
|
||||
|
||||
/** Root of the emulator's HDD, i.e. what RPCS3 mounts as /dev_hdd0. */
|
||||
private fun hdd0(): File = File(RPCSX.getHdd0Dir())
|
||||
|
||||
/**
|
||||
* The trophy directories to scan.
|
||||
*
|
||||
* Prefers the logged-in user (Rpcs3Bridge logs in "00000001"), but falls back to
|
||||
* whichever user folder actually holds a trophy dir: getUser() reaches through JNI
|
||||
* into the core, which returns null when the core is not open yet, and a browser that
|
||||
* showed nothing until a game had booted would look broken.
|
||||
*/
|
||||
private fun trophyRoots(): List<File> {
|
||||
val home = File(hdd0(), "home")
|
||||
val users = home.listFiles().orEmpty().filter { it.isDirectory }
|
||||
val preferred = runCatching { RPCSX.instance.getUser() }.getOrNull()?.takeIf { it.isNotBlank() }
|
||||
val ordered = if (preferred != null) {
|
||||
users.sortedBy { it.name != preferred }
|
||||
} else {
|
||||
users
|
||||
}
|
||||
val roots = ordered.map { File(it, "trophy") }.filter { it.isDirectory }
|
||||
// One user is the norm; only that user's sets are shown. Scanning every user would
|
||||
// merge two people's progress into one list.
|
||||
return roots.take(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every trophy set on disk, newest-played first is NOT assumed — sorted by title so the
|
||||
* list is stable across sessions.
|
||||
*
|
||||
* Blocking disk work: call from Dispatchers.IO.
|
||||
*/
|
||||
fun load(): List<Game> {
|
||||
val dirs = trophyRoots().flatMap { it.listFiles().orEmpty().filter { d -> d.isDirectory } }
|
||||
return dirs.mapNotNull { readGame(it) }.sortedBy { it.title.lowercase() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The RUNNING game's trophy set, or null when it has none (or none can be identified).
|
||||
*
|
||||
* Blocking disk work: call from Dispatchers.IO.
|
||||
*
|
||||
* IDENTIFYING THE SET is the whole difficulty here, because trophy folders are named by
|
||||
* NPWR comm id and nothing on the folder says which title it belongs to. Two sources,
|
||||
* in order:
|
||||
*
|
||||
* 1. The core's own `current_trophy_name`, via [RPCSX.getCurrentTrophyName]. This is
|
||||
* exactly what RPCS3's home menu uses to pick the set for its native overlay list,
|
||||
* written by sceNpTrophyCreateContext. Authoritative, and works for disc and
|
||||
* installed titles alike.
|
||||
* 2. The title's TROPDIR, whose subfolders ARE the NPWR ids the title ships. Used only
|
||||
* when (1) is empty, which happens for a real reason: a game creates its trophy
|
||||
* context lazily, often not until you reach a menu, so early in a boot the core
|
||||
* genuinely does not know yet. This covers INSTALLED titles only — a disc game's
|
||||
* TROPDIR is inside the ISO and never lands on the HDD.
|
||||
*/
|
||||
fun loadCurrentGame(): Game? {
|
||||
val roots = trophyRoots()
|
||||
if (roots.isEmpty()) return null
|
||||
|
||||
for (commId in currentGameCommIds()) {
|
||||
val dir = roots.asSequence().map { File(it, commId) }.firstOrNull { it.isDirectory }
|
||||
?: continue
|
||||
readGame(dir)?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate trophy folder names for the running game, best guess first. Empty when
|
||||
* nothing identifies it.
|
||||
*/
|
||||
private fun currentGameCommIds(): List<String> {
|
||||
val fromCore = runCatching { RPCSX.instance.getCurrentTrophyName() }
|
||||
.getOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (fromCore != null) return listOf(fromCore)
|
||||
|
||||
val titleId = runCatching { RPCSX.instance.getTitleId() }
|
||||
.getOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return emptyList()
|
||||
// TROPDIR/<NPWRxxxxx_00>/TROPHY.TRP — the folder names are the comm ids.
|
||||
return File(hdd0(), "game/$titleId/TROPDIR").listFiles().orEmpty()
|
||||
.filter { it.isDirectory }
|
||||
.map { it.name }
|
||||
}
|
||||
|
||||
private fun readGame(dir: File): Game? {
|
||||
val conf = File(dir, "TROPCONF.SFM")
|
||||
if (!conf.isFile) {
|
||||
Log.i(TAG, "skipping ${dir.name}: no TROPCONF.SFM")
|
||||
return null
|
||||
}
|
||||
val parsed = runCatching { readTropConf(conf) }.getOrElse {
|
||||
Log.w(TAG, "failed to parse ${conf.absolutePath}", it)
|
||||
return null
|
||||
}
|
||||
if (parsed.trophies.isEmpty()) return null
|
||||
|
||||
// Unlock state is optional: TROPUSR.DAT only exists once the game has registered its
|
||||
// trophy context. Without it every trophy simply reads as locked, which is correct.
|
||||
val state = runCatching { readTropUsr(File(dir, "TROPUSR.DAT")) }.getOrElse {
|
||||
Log.w(TAG, "failed to parse TROPUSR.DAT in ${dir.name}", it)
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
val trophies = parsed.trophies.map { def ->
|
||||
val entry = state[def.id]
|
||||
Trophy(
|
||||
id = def.id,
|
||||
name = def.name,
|
||||
description = def.detail,
|
||||
// ttype from the XML is what the native overlay uses; the grade duplicated in
|
||||
// TROPUSR table 4 is the fallback for a set with a missing/odd ttype.
|
||||
grade = def.grade.takeIf { it != Grade.Unknown } ?: entry?.grade ?: Grade.Unknown,
|
||||
hidden = def.hidden,
|
||||
unlocked = entry?.unlocked == true,
|
||||
unlockedAt = entry?.takeIf { it.unlocked }?.timestamp?.let(::tickToUnixMillis),
|
||||
// Locale.ROOT: the default locale would render the digits in its own numeral
|
||||
// system for e.g. Arabic, and the file name is ASCII.
|
||||
icon = File(dir, String.format(java.util.Locale.ROOT, "TROP%03d.PNG", def.id))
|
||||
.takeIf { it.isFile },
|
||||
)
|
||||
}
|
||||
|
||||
return Game(
|
||||
commId = dir.name,
|
||||
title = parsed.title.ifBlank { dir.name },
|
||||
detail = parsed.detail,
|
||||
icon = File(dir, "ICON0.PNG").takeIf { it.isFile },
|
||||
trophies = trophies,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A CellRtcTick to Unix millis, or null when it is absent or implausible.
|
||||
*
|
||||
* Range-checked rather than trusted: a tick of 0 means "no timestamp", and a corrupt
|
||||
* entry would otherwise render as a date in the year 1 or 30000.
|
||||
*/
|
||||
private fun tickToUnixMillis(tick: Long): Long? {
|
||||
if (tick <= RTC_EPOCH_US) return null
|
||||
val millis = (tick - RTC_EPOCH_US) / 1000L
|
||||
// 1980-01-01 .. 2100-01-01. The PS3 itself did not exist before the lower bound.
|
||||
return millis.takeIf { it in 315_532_800_000L..4_102_444_800_000L }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TROPCONF.SFM (definitions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private data class TrophyDef(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val detail: String,
|
||||
val grade: Grade,
|
||||
val hidden: Boolean,
|
||||
)
|
||||
|
||||
private data class TropConf(
|
||||
val title: String,
|
||||
val detail: String,
|
||||
val trophies: List<TrophyDef>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parse the definitions.
|
||||
*
|
||||
* The file is plain XML with a signature COMMENT before the root, and no XML
|
||||
* declaration — XmlPullParser handles both. Attribute names and the 'y' test on
|
||||
* `hidden` follow the native overlay's reload() exactly.
|
||||
*/
|
||||
private fun readTropConf(file: File): TropConf {
|
||||
val parser = XmlPullParserFactory.newInstance().apply { isNamespaceAware = false }
|
||||
.newPullParser()
|
||||
var title = ""
|
||||
var titleDetail = ""
|
||||
val trophies = ArrayList<TrophyDef>()
|
||||
|
||||
file.inputStream().use { stream ->
|
||||
parser.setInput(stream, null)
|
||||
// Fields of the <trophy> currently being read; null id = not inside one.
|
||||
var id: Int? = null
|
||||
var hidden = false
|
||||
var grade = Grade.Unknown
|
||||
var name = ""
|
||||
var detail = ""
|
||||
// Which leaf we are collecting text into. <name>/<detail> appear both at
|
||||
// trophyconf level (title-name/title-detail are separate tags) and inside a
|
||||
// <trophy>, so the text handler has to know where it is.
|
||||
var leaf = ""
|
||||
|
||||
var event = parser.eventType
|
||||
while (event != XmlPullParser.END_DOCUMENT) {
|
||||
when (event) {
|
||||
XmlPullParser.START_TAG -> when (val tag = parser.name) {
|
||||
"trophy" -> {
|
||||
id = parser.getAttributeValue(null, "id")?.trim()?.toIntOrNull()
|
||||
hidden = parser.getAttributeValue(null, "hidden")
|
||||
?.firstOrNull()?.lowercaseChar() == 'y'
|
||||
grade = gradeOf(parser.getAttributeValue(null, "ttype"))
|
||||
name = ""
|
||||
detail = ""
|
||||
leaf = ""
|
||||
}
|
||||
else -> leaf = tag
|
||||
}
|
||||
XmlPullParser.TEXT -> {
|
||||
val text = parser.text ?: ""
|
||||
// Appended unconditionally, not skipped when blank: a parser is free to
|
||||
// split a run of text at an entity reference, and dropping the blank
|
||||
// pieces would silently glue "a & b" into "a&b". leaf is cleared on
|
||||
// every END_TAG, so inter-element whitespace is never collected.
|
||||
when {
|
||||
leaf == "title-name" && id == null -> title += text
|
||||
leaf == "title-detail" && id == null -> titleDetail += text
|
||||
leaf == "name" && id != null -> name += text
|
||||
leaf == "detail" && id != null -> detail += text
|
||||
}
|
||||
}
|
||||
XmlPullParser.END_TAG -> {
|
||||
if (parser.name == "trophy") {
|
||||
id?.let {
|
||||
trophies += TrophyDef(
|
||||
id = it,
|
||||
name = name.trim(),
|
||||
detail = detail.trim(),
|
||||
grade = grade,
|
||||
hidden = hidden,
|
||||
)
|
||||
}
|
||||
id = null
|
||||
}
|
||||
leaf = ""
|
||||
}
|
||||
}
|
||||
event = parser.next()
|
||||
}
|
||||
}
|
||||
|
||||
return TropConf(title.trim(), titleDetail.trim(), trophies.sortedBy { it.id })
|
||||
}
|
||||
|
||||
private fun gradeOf(ttype: String?): Grade = when (ttype?.firstOrNull()?.uppercaseChar()) {
|
||||
'B' -> Grade.Bronze
|
||||
'S' -> Grade.Silver
|
||||
'G' -> Grade.Gold
|
||||
'P' -> Grade.Platinum
|
||||
else -> Grade.Unknown
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TROPUSR.DAT (unlock state)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private data class UsrEntry(val unlocked: Boolean, val timestamp: Long, val grade: Grade)
|
||||
|
||||
/**
|
||||
* Parse the unlock state, keyed by trophy id.
|
||||
*
|
||||
* Layout, from TROPUSR.h:
|
||||
* 0x00 u32 magic, u32 unk1, u32 tables_count, u32 unk2, char reserved[32]
|
||||
* 0x30 tables_count * { u32 type, u32 entries_size, u32 unk1, u32 entries_count,
|
||||
* u64 offset, u64 reserved } (32 bytes each)
|
||||
* then, per table, entries_count records at `offset`.
|
||||
*
|
||||
* All big-endian.
|
||||
*
|
||||
* THE STRIDE IS NOT entries_size. entries_size is the size of an entry's PAYLOAD after
|
||||
* its 16-byte header (type/size/id/unk1), so a record is 16 + entries_size bytes:
|
||||
* table 4 reports 0x50 and its records are 96 bytes, table 6 reports 0x60 and its
|
||||
* records are 112 — which is exactly sizeof(TROPUSREntry4/6), the stride RPCS3 gets for
|
||||
* free by reading the structs directly. Using entries_size as the stride parses
|
||||
* garbage that still looks superficially plausible (verified against a real file: it
|
||||
* yielded 5 entries out of 29 and grade "unknown" for all of them).
|
||||
*
|
||||
* Table 4 carries the grade; table 6 the unlock flag and timestamps.
|
||||
*/
|
||||
private fun readTropUsr(file: File): Map<Int, UsrEntry> {
|
||||
if (!file.isFile) return emptyMap()
|
||||
val bytes = file.readBytes()
|
||||
if (bytes.size < 0x30) return emptyMap()
|
||||
|
||||
val buf = java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.BIG_ENDIAN)
|
||||
if (buf.getInt(0) != TROPUSR_MAGIC) {
|
||||
Log.w(TAG, "${file.name}: bad magic")
|
||||
return emptyMap()
|
||||
}
|
||||
val tableCount = buf.getInt(8)
|
||||
if (tableCount <= 0 || tableCount > 32) return emptyMap()
|
||||
|
||||
val grades = HashMap<Int, Grade>()
|
||||
val states = HashMap<Int, Pair<Boolean, Long>>()
|
||||
|
||||
for (t in 0 until tableCount) {
|
||||
val head = 0x30 + t * 32
|
||||
if (head + 32 > bytes.size) break
|
||||
val type = buf.getInt(head)
|
||||
val entrySize = buf.getInt(head + 4)
|
||||
val entryCount = buf.getInt(head + 12)
|
||||
val offset = buf.getLong(head + 16)
|
||||
if (entrySize <= 0 || entryCount <= 0 || offset < 0) continue
|
||||
val stride = 16 + entrySize
|
||||
// Longest field this reads is table 6's timestamp2, at body+24..body+31.
|
||||
val needed = 16 + 32
|
||||
for (i in 0 until entryCount) {
|
||||
val base = offset + i.toLong() * stride
|
||||
// Bounds-check the bytes actually read, not just the nominal record: a bogus
|
||||
// entries_size would otherwise let the last record's fields run off the end.
|
||||
if (base < 0 || base + maxOf(stride, needed) > bytes.size) break
|
||||
val body = (base + 16).toInt()
|
||||
when (type) {
|
||||
4 -> {
|
||||
val id = buf.getInt(body)
|
||||
grades[id] = usrGradeOf(buf.getInt(body + 4))
|
||||
}
|
||||
6 -> {
|
||||
val id = buf.getInt(body)
|
||||
val unlocked = buf.getInt(body + 4) == 1
|
||||
// timestamp1 at body+16, timestamp2 at body+24. RPCS3's
|
||||
// GetTrophyTimestamp returns timestamp2; UnlockTrophy writes the same
|
||||
// tick to both, so they agree in practice.
|
||||
val timestamp = buf.getLong(body + 24)
|
||||
states[id] = unlocked to timestamp
|
||||
}
|
||||
// Other tables are unused here, as in RPCS3.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return states.mapValues { (id, state) ->
|
||||
UsrEntry(unlocked = state.first, timestamp = state.second, grade = grades[id] ?: Grade.Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
/** TROPUSRLoader::trophy_grade — note it is NOT the same numbering as ttype. */
|
||||
private fun usrGradeOf(value: Int): Grade = when (value) {
|
||||
1 -> Grade.Platinum
|
||||
2 -> Grade.Gold
|
||||
3 -> Grade.Silver
|
||||
4 -> Grade.Bronze
|
||||
else -> Grade.Unknown
|
||||
}
|
||||
}
|
||||
@@ -398,6 +398,31 @@ val EN: Map<String, String> = mapOf(
|
||||
"core.settings.unavailable" to "The emulator core is not loaded, so its settings cannot be read.",
|
||||
"core.settings.scope.game" to "Changes are remembered for this game only",
|
||||
"core.settings.scope.global" to "Changes are remembered for every game",
|
||||
// PS3 trophies (TrophiesScreen). Numbered placeholders (%1/%2/%3) rather than %d, because
|
||||
// several of these take more than one number and a translator has to be able to reorder them.
|
||||
"trophies.title" to "Trophies",
|
||||
"trophies.loading" to "Reading trophy data…",
|
||||
"trophies.overall" to "%1 of %2 earned across %3 game(s)",
|
||||
"trophies.progress" to "%1 / %2 earned (%3%)",
|
||||
"trophies.earned" to "Earned",
|
||||
"trophies.notEarned" to "Not earned",
|
||||
"trophies.earnedOn" to "Earned %s",
|
||||
"trophies.grade.bronze" to "Bronze",
|
||||
"trophies.grade.silver" to "Silver",
|
||||
"trophies.grade.gold" to "Gold",
|
||||
"trophies.grade.platinum" to "Platinum",
|
||||
"trophies.hidden.name" to "Hidden trophy",
|
||||
"trophies.hidden.desc" to "This trophy is hidden",
|
||||
"trophies.showHidden" to "Show hidden trophies",
|
||||
"trophies.showHidden.desc" to "Some games hide a trophy until you earn it, usually because its name gives away a twist. This lists them, still without their real names.",
|
||||
"trophies.allHidden" to "Every trophy in this set is hidden and not yet earned.",
|
||||
"trophies.empty.title" to "No trophies yet",
|
||||
"trophies.empty.body" to "Trophies appear here once you play a game that has them — the game installs its trophy set the first time it runs, and this reads the same data the console would. A game with no trophy set never adds one.",
|
||||
// In-game (pause menu) trophies tab. Separate from empty.* above: "this game has none" and
|
||||
// "you have none at all" are different facts and must not share a string.
|
||||
"trophies.viewTrophies" to "View trophies",
|
||||
"trophies.none.title" to "No trophies for this game",
|
||||
"trophies.none.body" to "This game either has no trophy set, or has not opened it yet — many games only do that once you reach a menu or start playing. Check again later in the session.",
|
||||
"packages.title" to "Install Package",
|
||||
"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",
|
||||
@@ -415,6 +440,8 @@ val EN: Map<String, String> = mapOf(
|
||||
"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.licences.count" to "%d licence(s)",
|
||||
"packages.licences.unattributed" to "Unattributed",
|
||||
"packages.uninstall" to "Uninstall",
|
||||
"packages.uninstall.confirmTitle" to "Uninstall this title?",
|
||||
"packages.uninstall.confirmBody" to "This deletes %s and everything installed with it. Save data stored separately is not touched. This cannot be undone.",
|
||||
@@ -1110,7 +1137,6 @@ val EN: Map<String, String> = mapOf(
|
||||
"renderer.shaderPack.presets" to "presets",
|
||||
"renderer.shaderPack.starting" to "Starting…",
|
||||
"renderer.upscale.description" to "Internal resolution. Higher values are sharper but can expose game-specific bloom or alignment artifacts.",
|
||||
"renderer.upscale.label" to "Upscale",
|
||||
"savestate.autoLoadOnBoot" to "Auto-load last state on boot",
|
||||
"savestate.autoSaveInterval.description" to "Save automatically while you play, so a crash or a flat battery costs at most this much progress. It writes the same auto-save slot as the option above, so your numbered slots stay yours. Saving a PS3 state stops and reloads the game, which takes several seconds each time — keep the interval long, 15 minutes or more.",
|
||||
"savestate.autoSaveInterval.every" to "Every %d min",
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.armsx2.ui.home.HomeScreen
|
||||
import com.armsx2.ui.language.LanguageScreen
|
||||
import com.armsx2.ui.saves.SaveManagerScreen
|
||||
import com.armsx2.ui.textures.TextureManagerScreen
|
||||
import com.armsx2.ui.trophies.TrophiesScreen
|
||||
import com.armsx2.ui.settingshub.SettingsScreen
|
||||
|
||||
@Composable
|
||||
@@ -106,6 +107,7 @@ fun AppNavigation() {
|
||||
AppRoute.ControllerManager -> ControllerManagerScreen(onBack = UiNavigator::home)
|
||||
AppRoute.TextureManager -> TextureManagerScreen(onBack = UiNavigator::home)
|
||||
AppRoute.Achievements -> AchievementsScreen(onBack = UiNavigator::home)
|
||||
AppRoute.Trophies -> TrophiesScreen(onBack = UiNavigator::home)
|
||||
AppRoute.Language -> LanguageScreen(
|
||||
onBack = { UiNavigator.navigate(AppRoute.Settings(SettingsCategory.General)) },
|
||||
)
|
||||
|
||||
@@ -19,6 +19,9 @@ sealed interface AppRoute {
|
||||
data object ControllerManager : AppRoute
|
||||
data object TextureManager : AppRoute
|
||||
data object Achievements : AppRoute
|
||||
// PS3 trophies, read from the emulator's own dev_hdd0 trophy folders. Distinct from
|
||||
// Achievements above, which is the (hidden) RetroAchievements screen.
|
||||
data object Trophies : AppRoute
|
||||
data object Language : AppRoute
|
||||
data object News : AppRoute
|
||||
data object Friends : AppRoute
|
||||
|
||||
@@ -199,7 +199,12 @@ private fun DrawerContent(selected: AppRoute, onNavigate: (AppRoute) -> Unit, on
|
||||
// below, which only points the emulator at your BIOS file.
|
||||
DrawerItem("bios.boot.title", "▶️", onAction = { MainActivityRuntime.startBios(); onDismiss() }),
|
||||
// ARMSX3: RetroAchievements removed - RA has no PS3 support at all, so
|
||||
// the screen could only ever be empty.
|
||||
// the screen could only ever be empty. PS3 TROPHIES take its slot: RPCS3
|
||||
// tracks the real ones the games unlock, and its own list is reachable only
|
||||
// from inside a running game (the home menu's Trophies item, which shows
|
||||
// that game's set alone). This is the across-titles browser, which was
|
||||
// Qt-only upstream and so had no Android entry point at all.
|
||||
DrawerItem("trophies.title", "🏆", AppRoute.Trophies),
|
||||
DrawerItem("action.settings", "⚙️", AppRoute.Settings()),
|
||||
// Everything the core exposes, generated from its config tree rather than
|
||||
// hand-written. The curated tabs above stay small on purpose; this is the
|
||||
@@ -369,6 +374,7 @@ private fun sameDestination(current: AppRoute, target: AppRoute): Boolean = when
|
||||
AppRoute.ControllerManager -> current is AppRoute.ControllerManager
|
||||
AppRoute.TextureManager -> current is AppRoute.TextureManager
|
||||
AppRoute.Achievements -> current is AppRoute.Achievements
|
||||
AppRoute.Trophies -> current is AppRoute.Trophies
|
||||
AppRoute.Language -> current is AppRoute.Language
|
||||
AppRoute.News -> current is AppRoute.News
|
||||
AppRoute.Friends -> current is AppRoute.Friends
|
||||
|
||||
@@ -710,7 +710,21 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
if (restartNow) {
|
||||
start()
|
||||
// Queued on vmStopControl rather than called here, because "the run loop has
|
||||
// exited" is NOT "the stop has finished". stop() enqueues NativeApp.shutdown()
|
||||
// on that same single-thread executor, and shutdown() sets stopRequested and
|
||||
// calls kill(). This finally block runs as soon as boot() returns -- which
|
||||
// stopRequested is exactly what causes -- so calling start() straight from
|
||||
// here raced ahead of the shutdown that released it, and the kill then landed
|
||||
// on the VM that had just started, killing it too. Traced on a Restart press:
|
||||
// START_VM 6ms after the stop began, then a SECOND START_VM 15s later once
|
||||
// the freshly-killed VM unwound, which is the "Restart kicks you back to the
|
||||
// library" report.
|
||||
//
|
||||
// vmStopControl is single-threaded, so this cannot begin until the pending
|
||||
// shutdown has returned. execute() and not submit().get(): waiting here would
|
||||
// block the run-loop thread that kill() may itself be waiting on.
|
||||
vmStopControl.execute { start() }
|
||||
} else {
|
||||
WindowImpl.toolbarVisible.value = true
|
||||
WindowImpl.showLibrary.value = false
|
||||
@@ -1039,7 +1053,8 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
if (restartNow) {
|
||||
start()
|
||||
// Same ordering as the game path above: queued behind any pending shutdown.
|
||||
vmStopControl.execute { start() }
|
||||
} else {
|
||||
// BIOS exit had no cleanup at all — it relied entirely on stop()'s racy
|
||||
// branch, so quitting the BIOS also left the launcher stuck in its rotation.
|
||||
|
||||
@@ -28,7 +28,9 @@ import kotlinx.coroutines.flow.first
|
||||
|
||||
/** A full manager screen shown as an overlay over the paused game (in-game menu). */
|
||||
enum class InGameScreen {
|
||||
Settings, CoreSettings, Achievements, Controls, Skins, Textures, SaveState, LoadState
|
||||
Settings, CoreSettings, Achievements, Controls, Skins, Textures, SaveState, LoadState,
|
||||
// PS3 trophies for the RUNNING title (the library's Trophies screen, scoped).
|
||||
Trophies,
|
||||
}
|
||||
|
||||
object WindowImpl {
|
||||
@@ -193,6 +195,17 @@ object WindowImpl {
|
||||
InGameScreen.LoadState -> com.armsx2.ui.saves.SaveStatePickerScreen(
|
||||
mode = com.armsx2.ui.saves.SaveMode.Load, onBack = dismiss,
|
||||
)
|
||||
// The library's Trophies screen, scoped to the running title. Same
|
||||
// screen, not a second implementation. Keyed to the same ViewModel the
|
||||
// pause menu's Trophies pane uses, so the set it already scanned is
|
||||
// reused instead of being read off disk again.
|
||||
InGameScreen.Trophies -> com.armsx2.ui.trophies.TrophiesScreen(
|
||||
onBack = dismiss,
|
||||
currentGameOnly = true,
|
||||
viewModel = androidx.lifecycle.viewmodel.compose.viewModel(
|
||||
key = com.armsx2.ui.emulation.InGameTrophiesVmKey,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,42 @@ import coil.request.ImageRequest
|
||||
import com.armsx2.CustomCovers
|
||||
import com.armsx2.GameInfo
|
||||
|
||||
/**
|
||||
* Try each cover source in turn, falling through to [placeholder] when none load.
|
||||
*
|
||||
* There were two hand-rolled copies of this, one here and one in the library grid, and they
|
||||
* disagreed: this one ended its chain at the extracted ICON0.PNG, the grid's ended one step
|
||||
* earlier at the remote cover. So the in-game menu showed a PS3 game's own artwork while the
|
||||
* library showed a text placeholder for the same game -- reported for a European PSN title,
|
||||
* because the art repo's COV set is keyed by USA title IDs and has no entry for it.
|
||||
*
|
||||
* Both were also only ONE retry deep, which hid the divergence: the retry slot was spent on
|
||||
* the regional cover, and whether the local icon ever got a turn depended on whether that URL
|
||||
* happened to differ from the first one. A chain has no such limit, and one chain cannot
|
||||
* disagree with itself.
|
||||
*/
|
||||
@Composable
|
||||
fun CoverFallbackChain(
|
||||
models: List<Any>,
|
||||
contentDescription: String,
|
||||
contentScale: ContentScale,
|
||||
placeholder: @Composable () -> Unit,
|
||||
) {
|
||||
val head = models.firstOrNull()
|
||||
if (head == null) {
|
||||
placeholder()
|
||||
return
|
||||
}
|
||||
SubcomposeAsyncImage(
|
||||
model = head,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = contentScale,
|
||||
loading = { placeholder() },
|
||||
error = { CoverFallbackChain(models.drop(1), contentDescription, contentScale, placeholder) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GameCoverArt(game: GameInfo, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
@@ -43,26 +79,18 @@ fun GameCoverArt(game: GameInfo, modifier: Modifier = Modifier) {
|
||||
error = {
|
||||
// Cover Region can point at a release the art repo has no cover for; falling straight
|
||||
// to the placeholder would BLANK a cover the user already had (reported for the in-game
|
||||
// menu, which uses this component). Retry with this disc's own serial first.
|
||||
// aldostools does not have art for every title. Rather than drop
|
||||
// straight to a text placeholder, fall back to the ICON0.PNG we
|
||||
// extracted from the disc itself -- wrong shape, but it is the real
|
||||
// game's art and beats nothing.
|
||||
val discUrl: Any? = if (customCover == null) {
|
||||
game.discCoverUrl?.takeIf { it != game.coverUrl } ?: game.discIconFile
|
||||
} else null
|
||||
if (discUrl != null) {
|
||||
SubcomposeAsyncImage(
|
||||
model = discUrl,
|
||||
contentDescription = game.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
loading = { GameCoverPlaceholder(game.title, game.serial) },
|
||||
error = { GameCoverPlaceholder(game.title, game.serial) },
|
||||
)
|
||||
} else {
|
||||
GameCoverPlaceholder(game.title, game.serial)
|
||||
}
|
||||
// menu, which uses this component). Retry with this disc's own serial first, then with
|
||||
// the ICON0.PNG extracted from the disc itself -- wrong shape, but it is the real
|
||||
// game's art and beats nothing, and aldostools does not have art for every title.
|
||||
CoverFallbackChain(
|
||||
models = if (customCover != null) emptyList() else listOfNotNull(
|
||||
game.discCoverUrl?.takeIf { it != game.coverUrl },
|
||||
game.discIconFile,
|
||||
),
|
||||
contentDescription = game.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
placeholder = { GameCoverPlaceholder(game.title, game.serial) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -373,6 +373,7 @@ private fun MenuPage(
|
||||
EmulationMenuTab.Controls -> ControlsPane(state, viewModel)
|
||||
EmulationMenuTab.Options -> OptionsPane(state, viewModel)
|
||||
EmulationMenuTab.Achievements -> AchievementsPane(state, viewModel)
|
||||
EmulationMenuTab.Trophies -> TrophiesPane(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -513,6 +514,9 @@ private fun tabGlyph(tab: EmulationMenuTab): String = when (tab) {
|
||||
EmulationMenuTab.Controls -> "🎮"
|
||||
EmulationMenuTab.Options -> "⚙"
|
||||
EmulationMenuTab.Achievements -> "🏆"
|
||||
// The trophy cup, same glyph the library drawer's Trophies row uses. It does not collide
|
||||
// with the RA tab above because that one is filtered out of the rail on ARMSX3.
|
||||
EmulationMenuTab.Trophies -> "🏆"
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -967,7 +971,12 @@ private fun PerformancePane(state: EmulationMenuUiState, viewModel: EmulationMen
|
||||
}
|
||||
HorizontalOptions(
|
||||
title = str("perf.displayFpsCap.label"),
|
||||
options = listOf(0, 20, 30, 45, 60, 90, 120).map {
|
||||
// 90 and 120 removed: neither can take effect. RPCS3 caps the presented rate at the
|
||||
// Frame limit enum, which tops out at 60 for anything the PS3 outputs, so a Second Frame
|
||||
// Limit above that loses the min() at RSXThread.cpp:3676 and the rate stays 60. Offering
|
||||
// them just invited "the cap does nothing" reports for the two values where that is true
|
||||
// by construction. (Measured: second=90.00 -> limit=60.00.)
|
||||
options = listOf(0, 20, 30, 45, 60).map {
|
||||
it to if (it == 0) str("setup.toggle.off") else "$it FPS"
|
||||
},
|
||||
selected = settings.fpsLimit,
|
||||
@@ -1293,6 +1302,79 @@ private fun AchievementsPane(state: EmulationMenuUiState, viewModel: EmulationMe
|
||||
state.achievements.forEach { item -> InGameAchievementRow(item) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The running game's PS3 trophies. RPCS3's own data, not RetroAchievements.
|
||||
*
|
||||
* The rows are [com.armsx2.ui.trophies.TrophyRow] — the SAME composable the library's Trophies
|
||||
* screen uses, not a copy — so the two lists cannot drift apart. Scoping is
|
||||
* TrophyRepository.loadCurrentGame(), which asks the core for its `current_trophy_name`.
|
||||
*
|
||||
* Its ViewModel is keyed apart from the library screen's so the two do not fight over one
|
||||
* instance: this pane and the full in-game screen deliberately SHARE that keyed instance, so
|
||||
* opening the full list reuses what the pane already loaded instead of rescanning.
|
||||
*/
|
||||
@Composable
|
||||
private fun TrophiesPane(viewModel: EmulationMenuViewModel) {
|
||||
val trophies: com.armsx2.ui.trophies.TrophiesViewModel =
|
||||
androidx.lifecycle.viewmodel.compose.viewModel(key = InGameTrophiesVmKey)
|
||||
val state = trophies.state.value
|
||||
// Re-read on every entry to the tab: a trophy can unlock while the game is running, and the
|
||||
// set itself only appears once the game creates its trophy context.
|
||||
LaunchedEffect(Unit) { trophies.refresh(currentGameOnly = true) }
|
||||
|
||||
val game = state.games.firstOrNull()
|
||||
|
||||
CompactAction(
|
||||
str("trophies.viewTrophies"),
|
||||
"🏆",
|
||||
Modifier.fillMaxWidth(),
|
||||
viewModel::openTrophies,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
SectionCard(str("trophies.title")) {
|
||||
when {
|
||||
state.loading && game == null -> Text(
|
||||
str("trophies.loading"),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Most PS3 games have no trophy set at all, and plenty of those that do only
|
||||
// register it once you reach a menu — so this is an ordinary state, not an error.
|
||||
game == null -> Text(
|
||||
str("trophies.none.body"),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> {
|
||||
Text(
|
||||
game.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
com.armsx2.i18n.I18n.get("trophies.progress")
|
||||
.replace("%1", game.unlocked.toString())
|
||||
.replace("%2", game.total.toString())
|
||||
.replace("%3", game.percent.toString()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inline list, mirroring the RA pane: the gateway above is still there for the full screen
|
||||
// (with the show-hidden toggle), but the common case is a glance at what is left.
|
||||
game?.let { set ->
|
||||
trophies.visibleTrophies(set).forEach { com.armsx2.ui.trophies.TrophyRow(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared ViewModel key for the two in-game trophy surfaces (this pane and the full screen). */
|
||||
internal const val InGameTrophiesVmKey = "trophies-ingame"
|
||||
|
||||
@Composable
|
||||
private fun InGameAchievementRow(item: AchievementItem) {
|
||||
Surface(
|
||||
|
||||
@@ -19,6 +19,9 @@ enum class EmulationMenuTab(val titleKey: String) {
|
||||
Controls("tab.controls"),
|
||||
Options("action.settings"),
|
||||
Achievements("ra.title"),
|
||||
// ARMSX3's answer to the (hidden) Achievements tab: the RUNNING game's real PS3
|
||||
// trophies, read from RPCS3's own dev_hdd0 trophy data.
|
||||
Trophies("trophies.title"),
|
||||
// No Friends tab. It lived at the end of a rail that scrolls, so reaching it meant knowing it
|
||||
// was there and then hunting for it — it is a header button with its own overlay instead.
|
||||
;
|
||||
@@ -169,6 +172,9 @@ class EmulationMenuViewModel(application: Application) : AndroidViewModel(applic
|
||||
0 -> requestToggleHardcore()
|
||||
1 -> openAchievements()
|
||||
}
|
||||
EmulationMenuTab.Trophies -> when (state.value.selectedAction) {
|
||||
0 -> openTrophies()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +366,9 @@ class EmulationMenuViewModel(application: Application) : AndroidViewModel(applic
|
||||
/** Open the full RetroAchievements screen (list + options) over the paused game. */
|
||||
fun openAchievements() = com.armsx2.ui.WindowImpl.openInGameScreen(com.armsx2.ui.InGameScreen.Achievements)
|
||||
|
||||
/** Open the full trophy list over the paused game, scoped to the running title. */
|
||||
fun openTrophies() = com.armsx2.ui.WindowImpl.openInGameScreen(com.armsx2.ui.InGameScreen.Trophies)
|
||||
|
||||
fun updateSettings(transform: (Settings) -> Settings) {
|
||||
// ★ Transform the LIVE shared settings, not this screen's snapshot. state.value.settings is
|
||||
// only refreshed in load(), so every write here shipped the whole Settings object as it
|
||||
@@ -381,6 +390,9 @@ class EmulationMenuViewModel(application: Application) : AndroidViewModel(applic
|
||||
EmulationMenuTab.Controls -> 2
|
||||
EmulationMenuTab.Options -> 5
|
||||
EmulationMenuTab.Achievements -> 2
|
||||
// Just the "view trophies" gateway. The rows below it are read-only, and the
|
||||
// show-hidden toggle lives on the full screen the gateway opens.
|
||||
EmulationMenuTab.Trophies -> 1
|
||||
}
|
||||
|
||||
private fun Int.floorMod(modulus: Int): Int = ((this % modulus) + modulus) % modulus
|
||||
|
||||
@@ -112,6 +112,7 @@ import com.armsx2.GameInfo
|
||||
import com.armsx2.i18n.str
|
||||
import com.armsx2.runtime.MainActivityRuntime
|
||||
import com.armsx2.ui.common.ArmsBackdrop
|
||||
import com.armsx2.ui.common.CoverFallbackChain
|
||||
import com.armsx2.ui.common.ArmsTopBar
|
||||
import com.armsx2.ui.common.EmptyState
|
||||
import com.armsx2.ui.common.FileBrowserDialog
|
||||
@@ -1298,20 +1299,19 @@ private fun GameCover(
|
||||
error = {
|
||||
// A regional cover that isn't in the art repo would otherwise blank a cover the
|
||||
// user already had — reported as "some games lose their covers when switching
|
||||
// regions". Retry with this disc's own serial before giving up.
|
||||
val discUrl = custom?.let { null } ?: game.discCoverUrl
|
||||
if (discUrl != null && discUrl != model) {
|
||||
SubcomposeAsyncImage(
|
||||
model = discUrl,
|
||||
contentDescription = game.displayTitle(EnglishTitles.enabled.value),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = contentScale,
|
||||
loading = { CoverPlaceholder(game.displayTitle(EnglishTitles.enabled.value), game.serial, showText = placeholderText) },
|
||||
error = { CoverPlaceholder(game.displayTitle(EnglishTitles.enabled.value), game.serial, showText = placeholderText) },
|
||||
)
|
||||
} else {
|
||||
CoverPlaceholder(game.displayTitle(EnglishTitles.enabled.value), game.serial, showText = placeholderText)
|
||||
}
|
||||
// regions". Retry with this disc's own serial, then with the game's own
|
||||
// ICON0.PNG: this chain used to stop at the URL, which is why a European PSN
|
||||
// title showed a text placeholder here while the in-game menu showed its
|
||||
// artwork, the art repo's COV set being keyed by USA title IDs.
|
||||
CoverFallbackChain(
|
||||
models = if (custom != null) emptyList() else listOfNotNull(
|
||||
game.discCoverUrl?.takeIf { it != model },
|
||||
game.discIconFile,
|
||||
),
|
||||
contentDescription = game.displayTitle(EnglishTitles.enabled.value),
|
||||
contentScale = contentScale,
|
||||
placeholder = { CoverPlaceholder(game.displayTitle(EnglishTitles.enabled.value), game.serial, showText = placeholderText) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
+71
-5
@@ -185,6 +185,20 @@ private fun readInstalled(): List<InstalledTitle> =
|
||||
* the id sits between the first dash and the underscore. Used only to put a recognisable name
|
||||
* beside a licence; a file that has been renamed simply gets no name, which is what it had.
|
||||
*/
|
||||
/**
|
||||
* What to call a group of licences: the installed game's real name where we have it, otherwise the
|
||||
* bare title id, otherwise "unattributed".
|
||||
*
|
||||
* The name is only used when it differs from the id -- readInstalled falls back to the folder name
|
||||
* (which IS the id) for a title whose PARAM.SFO could not be read, and showing "NPEB00856" as
|
||||
* though it were a game name would just be the id twice.
|
||||
*/
|
||||
private fun groupLabelFor(titleId: String?, installed: List<InstalledTitle>): String {
|
||||
if (titleId == null) return I18n.get("packages.licences.unattributed")
|
||||
val named = installed.firstOrNull { it.id == titleId }?.name?.takeIf { it != titleId }
|
||||
return named ?: titleId
|
||||
}
|
||||
|
||||
private fun licenceTitleId(file: java.io.File): String? =
|
||||
file.nameWithoutExtension
|
||||
.substringAfter('-', "")
|
||||
@@ -845,11 +859,62 @@ fun PackageInstallerScreen(onBack: () -> Unit) {
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
licences.forEach { file ->
|
||||
// A licence's name is its content id and nothing else, so a row of them is
|
||||
// sixteen indistinguishable hex-and-dash strings. The title id inside the
|
||||
// content id is the same id the install folder is named for, so when the
|
||||
// game this licence unlocks is installed, the row can say which game it is.
|
||||
// Grouped by game, one collapsed row per title, because a library's worth of .rap
|
||||
// files is otherwise a flat wall of indistinguishable content ids -- the same
|
||||
// reason the cheats browser groups its PNACH files per game rather than listing
|
||||
// every patch at once. Requested for that parity.
|
||||
//
|
||||
// Keyed on the title id already carried inside the content id, which is the id the
|
||||
// install folder is named for, so a group can be labelled with the real game name
|
||||
// whenever the game it unlocks is installed. Licences whose name carries no id
|
||||
// cannot be attributed and get their own group at the end rather than being hidden.
|
||||
val licenceGroups = licences.groupBy { licenceTitleId(it) }
|
||||
val orderedGroups = licenceGroups.entries
|
||||
.sortedWith(compareBy({ it.key == null }, { groupLabelFor(it.key, installed) }))
|
||||
|
||||
orderedGroups.forEach { (titleId, groupFiles) ->
|
||||
val label = groupLabelFor(titleId, installed)
|
||||
// Collapsed by default: the point of the grouping is that the screen opens
|
||||
// short. A single-licence group is expanded, since hiding one row behind a
|
||||
// disclosure costs a tap and saves nothing.
|
||||
var expanded by remember(titleId) { mutableStateOf(groupFiles.size == 1) }
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.25f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.clickable { expanded = !expanded }
|
||||
.padding(start = 14.dp, end = 14.dp, top = 10.dp, bottom = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
I18n.get("packages.licences.count")
|
||||
.replace("%d", groupFiles.size.toString()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
if (expanded) "▴" else "▾",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!expanded) return@forEach
|
||||
|
||||
groupFiles.forEach { file ->
|
||||
val owner = licenceTitleId(file)
|
||||
?.let { id -> installed.firstOrNull { it.id == id } }
|
||||
?.takeIf { it.name != it.id }
|
||||
@@ -887,6 +952,7 @@ fun PackageInstallerScreen(onBack: () -> Unit) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
package com.armsx2.ui.trophies
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.ColorMatrix
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import coil.compose.AsyncImage
|
||||
import com.armsx2.data.trophies.TrophyRepository
|
||||
import com.armsx2.i18n.I18n
|
||||
import com.armsx2.i18n.str
|
||||
import com.armsx2.ui.common.ArmsBackdrop
|
||||
import com.armsx2.ui.common.ArmsTopBar
|
||||
import com.armsx2.ui.common.EmptyState
|
||||
import com.armsx2.ui.common.RoundAction
|
||||
import com.armsx2.ui.common.SettingSwitchRow
|
||||
import com.armsx2.ui.common.StatusChip
|
||||
import com.armsx2.ui.settings.controllerFocusable
|
||||
import com.armsx2.ui.theme.Success
|
||||
|
||||
/**
|
||||
* PS3 trophies, browsable from the library — RPCS3's own native trophy data, not
|
||||
* RetroAchievements (RA has no PS3 sets at all, which is why that screen is hidden here).
|
||||
*
|
||||
* WHY THIS EXISTS ALONGSIDE THE NATIVE OVERLAY. RPCS3 already has a trophy list of its own
|
||||
* (Emu/RSX/Overlays/Trophies/overlay_trophy_list_dialog.cpp) and it works on Android: the
|
||||
* home menu grows a Trophies item once a game calls sceNpTrophyRegisterContext. But that
|
||||
* list is reachable only from inside a running game and only ever shows THAT game's set —
|
||||
* `current_trophy_name` is what the home menu passes it. Browsing across titles was Qt-only
|
||||
* (rpcs3qt/trophy_manager_dialog.cpp), so on Android there was no way to see a set without
|
||||
* booting its game. This screen is that missing manager, not a reimplementation of the
|
||||
* in-game list; the in-game path is left to the native overlay.
|
||||
*
|
||||
* It also shows the unlock DATE, which the native overlay reads but never displays (it only
|
||||
* sorts by the timestamp).
|
||||
*
|
||||
* @param currentGameOnly scope the screen to the RUNNING game's set. Used by the in-game
|
||||
* menu; the library's own Trophies screen leaves it false and lists everything.
|
||||
*/
|
||||
@Composable
|
||||
fun TrophiesScreen(
|
||||
onBack: () -> Unit,
|
||||
currentGameOnly: Boolean = false,
|
||||
viewModel: TrophiesViewModel = viewModel(),
|
||||
) {
|
||||
val state = viewModel.state.value
|
||||
LaunchedEffect(currentGameOnly) { viewModel.refresh(currentGameOnly) }
|
||||
|
||||
val totalTrophies = state.games.sumOf { it.total }
|
||||
val totalUnlocked = state.games.sumOf { it.unlocked }
|
||||
|
||||
ArmsBackdrop {
|
||||
Column(Modifier.fillMaxSize().verticalScroll(rememberScrollState())) {
|
||||
ArmsTopBar(
|
||||
title = str("trophies.title"),
|
||||
subtitle = when {
|
||||
state.loading -> str("trophies.loading")
|
||||
state.games.isEmpty() -> null
|
||||
// Scoped to one game: "across 1 game" is noise, so name the game instead.
|
||||
currentGameOnly -> state.games.first().title
|
||||
else -> I18n.get("trophies.overall")
|
||||
.replace("%1", totalUnlocked.toString())
|
||||
.replace("%2", totalTrophies.toString())
|
||||
.replace("%3", state.games.size.toString())
|
||||
},
|
||||
leading = { RoundAction("←", str("action.back"), onBack) },
|
||||
// Must re-refresh in the SAME scope. A bare `viewModel::refresh` would take
|
||||
// the default and silently widen the in-game screen to every game.
|
||||
actions = {
|
||||
RoundAction(
|
||||
"↻",
|
||||
str("games.card.refresh"),
|
||||
onClick = { viewModel.refresh(currentGameOnly) },
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (state.games.isNotEmpty()) {
|
||||
SettingSwitchRow(
|
||||
title = str("trophies.showHidden"),
|
||||
description = str("trophies.showHidden.desc"),
|
||||
checked = state.showHidden,
|
||||
onCheckedChange = viewModel::setShowHidden,
|
||||
)
|
||||
}
|
||||
|
||||
when {
|
||||
state.loading && state.games.isEmpty() -> Box(
|
||||
Modifier.fillMaxWidth().height(220.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) { CircularProgressIndicator() }
|
||||
|
||||
// Two different facts, so two different messages: "this game has none"
|
||||
// is not "you have none".
|
||||
state.games.isEmpty() -> EmptyState(
|
||||
title = str(
|
||||
if (currentGameOnly) "trophies.none.title" else "trophies.empty.title",
|
||||
),
|
||||
message = str(
|
||||
if (currentGameOnly) "trophies.none.body" else "trophies.empty.body",
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().height(300.dp),
|
||||
)
|
||||
|
||||
else -> state.games.forEach { game ->
|
||||
GameGroup(game, viewModel, singleGame = state.games.size == 1)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One collapsible trophy set.
|
||||
*
|
||||
* Collapsed by default so the screen opens as a short list of games rather than a wall of
|
||||
* every trophy the user owns — the same grouping the package installer's licence list and
|
||||
* the cheats browser use. A lone set is expanded, since hiding one group behind a
|
||||
* disclosure costs a tap and saves nothing.
|
||||
*/
|
||||
@Composable
|
||||
private fun GameGroup(
|
||||
game: TrophyRepository.Game,
|
||||
viewModel: TrophiesViewModel,
|
||||
singleGame: Boolean,
|
||||
) {
|
||||
var expanded by remember(game.commId) { mutableStateOf(singleGame) }
|
||||
val toggle = { expanded = !expanded }
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
.controllerFocusable(
|
||||
controllerId = "trophies.game:${game.commId}",
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
onConfirm = toggle,
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth().clickable(onClick = toggle).padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TrophyImage(game.icon, fallback = "🎮", size = 54)
|
||||
Spacer(Modifier.width(12.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
game.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
I18n.get("trophies.progress")
|
||||
.replace("%1", game.unlocked.toString())
|
||||
.replace("%2", game.total.toString())
|
||||
.replace("%3", game.percent.toString()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
// A platinum that is actually earned is the headline fact about a set, so it gets a
|
||||
// chip of its own rather than being one row among thirty.
|
||||
if (game.trophies.any { it.grade == TrophyRepository.Grade.Platinum && it.unlocked }) {
|
||||
StatusChip(str("trophies.grade.platinum"), GradePlatinum)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
if (expanded) "▴" else "▾",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (!expanded) return
|
||||
|
||||
val trophies = viewModel.visibleTrophies(game)
|
||||
if (trophies.isEmpty()) {
|
||||
Text(
|
||||
str("trophies.allHidden"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
)
|
||||
return
|
||||
}
|
||||
Column(
|
||||
Modifier.fillMaxWidth().padding(start = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
trophies.forEach { TrophyRow(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One trophy row. Internal rather than private so the in-game menu's Trophies pane renders the
|
||||
* exact same row instead of a near-copy that would drift from this one.
|
||||
*/
|
||||
@Composable
|
||||
internal fun TrophyRow(trophy: TrophyRepository.Trophy) {
|
||||
// A hidden trophy that has not been earned is masked, exactly as the native overlay masks
|
||||
// it: showing the name would defeat the point of the game hiding it. Once earned, the real
|
||||
// name and description are shown.
|
||||
val masked = trophy.hidden && !trophy.unlocked
|
||||
val name = if (masked) str("trophies.hidden.name") else trophy.name
|
||||
val description = if (masked) str("trophies.hidden.desc") else trophy.description
|
||||
|
||||
Surface(
|
||||
Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(15.dp),
|
||||
color = if (trophy.unlocked) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
if (trophy.unlocked) MaterialTheme.colorScheme.primary.copy(alpha = 0.55f)
|
||||
else MaterialTheme.colorScheme.outline.copy(alpha = 0.4f),
|
||||
),
|
||||
) {
|
||||
Row(Modifier.padding(10.dp), verticalAlignment = Alignment.CenterVertically) {
|
||||
// Locked icons are desaturated, matching the native list (its image_info takes the
|
||||
// locked flag and greys the bitmap). A hidden one shows no icon at all, since the
|
||||
// artwork itself is usually a spoiler.
|
||||
TrophyImage(
|
||||
file = trophy.icon.takeUnless { masked },
|
||||
fallback = "🏆",
|
||||
size = 46,
|
||||
greyscale = !trophy.unlocked,
|
||||
)
|
||||
Spacer(Modifier.width(11.dp))
|
||||
Column(Modifier.weight(1f)) {
|
||||
Text(
|
||||
name.ifBlank { "#${trophy.id}" },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = if (trophy.unlocked) FontWeight.Bold else FontWeight.Normal,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (description.isNotBlank()) {
|
||||
Text(
|
||||
description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
// The unlock date the native overlay reads but never shows.
|
||||
trophy.unlockedAt?.let { at ->
|
||||
Text(
|
||||
I18n.get("trophies.earnedOn").replace("%s", formatDateTime(at)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Success,
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
StatusChip(gradeLabel(trophy.grade), gradeColor(trophy.grade))
|
||||
StatusChip(
|
||||
if (trophy.unlocked) str("trophies.earned") else str("trophies.notEarned"),
|
||||
if (trophy.unlocked) Success else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A trophy or game icon straight off the emulator's HDD, with a glyph fallback. */
|
||||
@Composable
|
||||
private fun TrophyImage(file: java.io.File?, fallback: String, size: Int, greyscale: Boolean = false) {
|
||||
val shape = RoundedCornerShape(11.dp)
|
||||
if (file == null) {
|
||||
Surface(Modifier.size(size.dp), shape = shape, color = MaterialTheme.colorScheme.surfaceVariant) {
|
||||
Box(contentAlignment = Alignment.Center) { Text(fallback) }
|
||||
}
|
||||
return
|
||||
}
|
||||
AsyncImage(
|
||||
model = file,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(size.dp).clip(shape).alpha(if (greyscale) 0.55f else 1f),
|
||||
contentScale = ContentScale.Crop,
|
||||
colorFilter = if (greyscale) {
|
||||
ColorFilter.colorMatrix(ColorMatrix().apply { setToSaturation(0f) })
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// The four PS3 trophy metals. Fixed colours rather than theme roles: a bronze trophy is
|
||||
// bronze-coloured on the console and in every other trophy list a user has seen.
|
||||
private val GradeBronze = Color(0xFFCD7F32)
|
||||
private val GradeSilver = Color(0xFFC0C4CC)
|
||||
private val GradeGold = Color(0xFFE8B923)
|
||||
private val GradePlatinum = Color(0xFF9AD5E8)
|
||||
|
||||
private fun gradeColor(grade: TrophyRepository.Grade): Color = when (grade) {
|
||||
TrophyRepository.Grade.Bronze -> GradeBronze
|
||||
TrophyRepository.Grade.Silver -> GradeSilver
|
||||
TrophyRepository.Grade.Gold -> GradeGold
|
||||
TrophyRepository.Grade.Platinum -> GradePlatinum
|
||||
TrophyRepository.Grade.Unknown -> GradeSilver
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun gradeLabel(grade: TrophyRepository.Grade): String = when (grade) {
|
||||
TrophyRepository.Grade.Bronze -> str("trophies.grade.bronze")
|
||||
TrophyRepository.Grade.Silver -> str("trophies.grade.silver")
|
||||
TrophyRepository.Grade.Gold -> str("trophies.grade.gold")
|
||||
TrophyRepository.Grade.Platinum -> str("trophies.grade.platinum")
|
||||
TrophyRepository.Grade.Unknown -> "?"
|
||||
}
|
||||
|
||||
/** Device-locale short date and time, so the row reads the way the rest of the system does. */
|
||||
private fun formatDateTime(millis: Long): String = java.text.DateFormat
|
||||
.getDateTimeInstance(java.text.DateFormat.MEDIUM, java.text.DateFormat.SHORT)
|
||||
.format(java.util.Date(millis))
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.armsx2.ui.trophies
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.armsx2.data.trophies.TrophyRepository
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
data class TrophiesUiState(
|
||||
val games: List<TrophyRepository.Game> = emptyList(),
|
||||
val loading: Boolean = true,
|
||||
/** Mirrors the native overlay's square-button toggle (HOME_MENU_TROPHY_SHOW_HIDDEN_TROPHIES):
|
||||
* off by default, so an unearned hidden trophy does not spoil itself by existing. */
|
||||
val showHidden: Boolean = false,
|
||||
/** True when this instance is scoped to the running game (the in-game menu), so the UI can
|
||||
* say "this game has no trophies" instead of "you have no trophies at all". */
|
||||
val currentGameOnly: Boolean = false,
|
||||
)
|
||||
|
||||
class TrophiesViewModel(application: Application) : AndroidViewModel(application) {
|
||||
var state = androidx.compose.runtime.mutableStateOf(TrophiesUiState())
|
||||
private set
|
||||
|
||||
/**
|
||||
* Rescan the trophy folders.
|
||||
*
|
||||
* All of it on Dispatchers.IO: a set is two file reads plus a stat per trophy icon, and a
|
||||
* library's worth of sets on the main thread is exactly the ANR the texture screen had.
|
||||
*
|
||||
* [currentGameOnly] narrows the scan to the running game's set (the in-game menu). The
|
||||
* default is the library-wide scan, so existing callers are unchanged.
|
||||
*/
|
||||
fun refresh(currentGameOnly: Boolean = false) {
|
||||
viewModelScope.launch {
|
||||
state.value = state.value.copy(loading = true, currentGameOnly = currentGameOnly)
|
||||
val games = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
if (currentGameOnly) {
|
||||
listOfNotNull(TrophyRepository.loadCurrentGame())
|
||||
} else {
|
||||
TrophyRepository.load()
|
||||
}
|
||||
}.getOrDefault(emptyList())
|
||||
}
|
||||
state.value = state.value.copy(games = games, loading = false)
|
||||
}
|
||||
}
|
||||
|
||||
fun setShowHidden(show: Boolean) {
|
||||
state.value = state.value.copy(showHidden = show)
|
||||
}
|
||||
|
||||
/** Trophies of [game] as the list should be shown, applying the hidden-trophy rule. */
|
||||
fun visibleTrophies(game: TrophyRepository.Game): List<TrophyRepository.Trophy> =
|
||||
game.trophies.filter { !(it.hidden && !it.unlocked) || state.value.showHidden }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user