Achievements: library-wide RA progress, including games never played

Progress could only ever be shown for a game that had been loaded, because
the core is only able to report on the game it currently has: set sizes for
everything else are not on the device at all. So a game you own but have
never launched showed nothing, which is most of a library.

Fetch it from RetroAchievements instead — and this needs two requests for
an entire library, not one per game:

  - API_GetGameList (i=21 PS2, f=1, h=1) returns every PS2 set's size
    together with its MD5 hashes. Cached on disk for a week; set sizes
    change on the order of months.
  - API_GetUserCompletionProgress returns NumAwarded / NumAwardedHardcore
    / MaxPossible per game id, paginated 500 at a time.

Matching is by disc hash, the only reliable key: RA carries no PS2 serials
and title matching would confuse regional variants and multi-disc sets.
Achievements::GetGameHashForImage computes it without booting, mirroring
the game-list scanner's open/detect/read/close over CDVDapi_Iso. It refuses
while a VM is valid — CDVD is a global, so repointing it mid-session would
swap the disc out from under the running game.

A game with a set and no unlocks now correctly reads 0/N rather than
nothing. Automatic syncs are limited to one a day and only run once a web
API key is present; the RA panel has a manual "Sync library" button that
ignores the interval. The web API key is a separate credential from the
login token, so it is entered once in the panel and trimmed on the way in
(the site's copy button brings whitespace, which would otherwise look like
the feature silently failing).

Also captures progress on every RA sound, so the figure moves as
achievements are earned instead of waiting for the slow poll.

Requested by Isshin.
This commit is contained in:
jpolo1224
2026-07-26 12:23:34 -04:00
committed by jpolo1224
parent 1f949b6b0a
commit 05b52de95e
11 changed files with 433 additions and 2 deletions
+30
View File
@@ -4,6 +4,7 @@
#include "Achievements.h"
#include "BuildVersion.h"
#include "CDVD/CDVD.h"
#include "CDVD/CDVDcommon.h"
#include "Elfheader.h"
#include "Host.h"
#include "GS/Renderers/Common/GSTexture.h"
@@ -349,6 +350,35 @@ std::string Achievements::GetGameHash(const std::string& elf_path)
return hash_str;
}
std::string Achievements::GetGameHashForImage(const std::string& image_path)
{
// CDVD is a global, not thread-local (see the same note in GameList::GetIsoSerialAndCRC), so
// repointing it while a game is running would swap that game's disc out from under it. Refuse
// rather than corrupt a live session.
if (VMManager::HasValidVM())
{
Console.Warning("Achievements: refusing to hash '%s' while a VM is running.", image_path.c_str());
return {};
}
Error error;
CDVD = &CDVDapi_Iso;
if (!CDVD->open(image_path, &error))
{
Console.Error(fmt::format("Achievements: CDVD open of '{}' failed: {}", image_path, error.GetDescription()));
return {};
}
// Mirrors the game-list scanner: detect, read the disc info, hash the boot ELF, close. The hash
// is of the ELF name plus its contents, so it needs the ELF path from SYSTEM.CNF rather than the
// image path — cdvdGetDiscInfo hands that back.
DoCDVDdetectDiskType();
std::string elf_path;
cdvdGetDiscInfo(nullptr, &elf_path, nullptr, nullptr, nullptr);
std::string hash = elf_path.empty() ? std::string() : GetGameHash(elf_path);
DoCDVDclose();
return hash;
}
void Achievements::DownloadImage(std::string url, std::string cache_filename)
{
+9
View File
@@ -99,6 +99,15 @@ namespace Achievements
/// Returns the RetroAchievements ID for the current game.
u32 GetGameID();
/// Computes the RetroAchievements hash for a disc image WITHOUT booting it, so a frontend can
/// identify a whole library against RA's game list. Returns an empty string if the image cannot
/// be read or carries no PS2 boot ELF.
///
/// Repoints the global CDVD at `image_path`, so it refuses to run while a VM is valid — doing it
/// anyway would pull the disc out from under the running game. Call from a background thread with
/// no VM active, as the game list scanner does.
std::string GetGameHashForImage(const std::string& image_path);
/// Returns true if the current game has any achievements or leaderboards.
bool HasAchievementsOrLeaderboards();
@@ -488,6 +488,24 @@ Java_kr_co_iefriends_pcsx2_NativeApp_initialize(JNIEnv *env, jclass clazz,
HTTPDownloaderAndroid::BindFromJNI(env);
}
// RetroAchievements hash for a disc image, computed WITHOUT booting it. This is what lets the
// library show "0/40" for a game that has never been played: the core only knows about the game it
// currently has loaded, so set sizes for everything else have to come from RA's game list, and the
// hash is the only key that matches reliably (RA carries no PS2 serials).
//
// Repoints the global CDVD, so it returns "" while a VM is running rather than disturbing it.
// Callers must be off the UI thread — it reads the disc.
extern "C"
JNIEXPORT jstring JNICALL
Java_kr_co_iefriends_pcsx2_NativeApp_getAchievementsHashForPath(JNIEnv* env, jclass,
jstring p_szpath) {
const std::string path = GetJavaString(env, p_szpath);
if (path.empty())
return env->NewStringUTF("");
const std::string hash = Achievements::GetGameHashForImage(path);
return env->NewStringUTF(hash.c_str());
}
extern "C"
JNIEXPORT jstring JNICALL
Java_kr_co_iefriends_pcsx2_NativeApp_getGameTitle(JNIEnv *env, jclass clazz,
@@ -23,6 +23,17 @@ object AchievementsProgress {
* blanking a real figure. Blocking (builds and parses the set JSON), so keep it off hot paths;
* it is cheap enough once per pause or on a slow poll.
*/
/** [snapshot] for whatever is loaded now. Called from the RA sound hook, which fires on every
* unlock, so the library figure moves as achievements are earned instead of lagging the poll. */
@JvmStatic
fun snapshotCurrentGame() {
val serial = runCatching {
com.armsx2.runtime.MainActivityRuntime.currentGame.value?.serial
?: NativeApp.getGameSerial()
}.getOrNull()
snapshot(serial)
}
fun snapshot(serial: String?) {
val s = serial?.takeIf { it.isNotEmpty() } ?: return
val json = runCatching { NativeApp.getAchievementsJSON() }.getOrNull().orEmpty()
@@ -41,7 +41,9 @@ object DiscIdentity {
fun crcOf(uri: android.net.Uri): String? = of(uri)?.crc
private fun nativePath(uri: android.net.Uri): String =
/** Internal-use path the native side expects for [uri]. Also used by [com.armsx2.RaLibrary]
* to hash a disc for RetroAchievements identification. */
fun nativePath(uri: android.net.Uri): String =
if (uri.scheme == "file") uri.path ?: uri.toString() else uri.toString()
private fun probe(path: String): Id? {
@@ -0,0 +1,268 @@
package com.armsx2
import android.content.Context
import com.armsx2.runtime.MainActivityRuntime
import kr.co.iefriends.pcsx2.NativeApp
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/**
* Library-wide RetroAchievements progress: set sizes and unlock counts for every game, including
* games that have never been launched.
*
* The core can only report achievements for the game it currently has loaded, so anything else has
* to come from RetroAchievements. That does NOT mean a request per game RA publishes the whole
* console catalogue, hashes included, in one call:
*
* 1. `API_GetGameList` (i=21 PS2, f=1 with-achievements, h=1 with-hashes) one request giving
* every PS2 set's size keyed by MD5. Cached on disk; set sizes barely ever change.
* 2. `API_GetUserCompletionProgress` paginated, giving NumAwarded / NumAwardedHardcore /
* MaxPossible per RA game id for everything the user has touched.
*
* Locally each library game is hashed via [NativeApp.getAchievementsHashForPath] (reads the disc's
* boot ELF, no boot required) and matched against the catalogue by hash the only reliable key,
* since RA carries no PS2 serials and title matching would mismatch regional variants.
*
* Result: two network requests for an entire library, and a game with a set but no unlocks correctly
* shows 0/N instead of nothing.
*
* Needs the user's RA **web API key**, which is a different credential from the emulator login token
* (RA exposes it on the site's settings page). Without one this does nothing and the library falls
* back to what was captured while playing.
*/
object RaLibrary {
private const val PS2_CONSOLE_ID = 21 // rc_consoles.h: RC_CONSOLE_PLAYSTATION_2
private const val API = "https://retroachievements.org/API"
private const val CATALOG_FILE = "ra_ps2_catalog.json"
private const val HASH_PREFIX = "ra.hash."
private const val PROGRESS_PAGE = 500 // API max per page
private const val KEY_WEB_API_KEY = "ra.webApiKey"
private const val KEY_USER = "ra.webUserName"
private const val KEY_LAST_SYNC = "ra.lastLibrarySync"
private const val KEY_CATALOG_FETCHED = "ra.catalogFetchedAt"
/** Automatic syncs are rate-limited to once a day; the manual button ignores this. */
private const val SYNC_MIN_INTERVAL_MS = 24L * 60 * 60 * 1000
/** Catalogue refresh interval. New sets appear on RA steadily but not by the hour. */
private const val CATALOG_TTL_MS = 7L * 24 * 60 * 60 * 1000
/** md5 (lowercase) -> RA game id, and RA game id -> achievement count. */
private class Catalog(val hashToGame: Map<String, Int>, val gameToTotal: Map<Int, Int>)
val syncing = androidx.compose.runtime.mutableStateOf(false)
val lastResult = androidx.compose.runtime.mutableStateOf("")
var webApiKey: String
get() = runCatching { MainActivityRuntime.prefs.getString(KEY_WEB_API_KEY, "").orEmpty() }
.getOrDefault("")
set(value) {
runCatching {
MainActivityRuntime.prefs.edit().putString(KEY_WEB_API_KEY, value.trim()).apply()
}
}
var userName: String
get() = runCatching { MainActivityRuntime.prefs.getString(KEY_USER, "").orEmpty() }
.getOrDefault("")
set(value) {
val v = value.trim()
if (v.isEmpty() || v == userName) return
runCatching { MainActivityRuntime.prefs.edit().putString(KEY_USER, v).apply() }
}
fun configured(): Boolean = webApiKey.isNotEmpty() && userName.isNotEmpty()
/** Library paths + serials from the last scan, so a manual sync can run from the RA panel
* (which has no game list of its own). */
@Volatile
private var lastKnownGames: List<Pair<String, String>> = emptyList()
/**
* Called whenever the library list changes. Runs a sync at most once a day unless [force]
* this is a two-request operation on someone else's servers, not something to repeat on every
* navigation, and set sizes change on the order of weeks.
*/
fun onLibraryLoaded(games: List<GameInfo>, force: Boolean = false) {
lastKnownGames = games.mapNotNull { g ->
val serial = g.serial?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
val path = runCatching { DiscIdentity.nativePath(g.uri) }.getOrNull()
?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
path to serial
}
if (!configured() || lastKnownGames.isEmpty()) return
val last = runCatching { MainActivityRuntime.prefs.getLong(KEY_LAST_SYNC, 0L) }.getOrDefault(0L)
if (!force && (System.currentTimeMillis() - last) < SYNC_MIN_INTERVAL_MS) return
syncInBackground()
}
/** Manual "sync now" — ignores the interval. Returns false when there is nothing to do. */
fun syncNow(): Boolean {
if (!configured() || lastKnownGames.isEmpty()) return false
syncInBackground()
return true
}
private fun syncInBackground() {
if (syncing.value) return
val context = MainActivityRuntime.instance?.applicationContext ?: return
val games = lastKnownGames
syncing.value = true
kotlin.concurrent.thread(isDaemon = true, name = "ra-library-sync") {
val result = runCatching { sync(context, games, userName) }
.getOrElse { it.message ?: "sync failed" }
runCatching {
MainActivityRuntime.prefs.edit()
.putLong(KEY_LAST_SYNC, System.currentTimeMillis()).apply()
}
lastResult.value = result
syncing.value = false
}
}
/**
* Blocking full sync over [games] (path to serial). Safe to call with no VM running only
* hashing repoints the global CDVD, and the native side refuses while a game is live.
* [onProgress] receives (done, total) so the UI can show movement on a large library.
*/
fun sync(
context: Context,
games: List<Pair<String, String>>,
userName: String,
onProgress: (Int, Int) -> Unit = { _, _ -> },
): String {
val key = webApiKey
if (key.isEmpty()) return "No RetroAchievements web API key set"
if (userName.isEmpty()) return "Not signed in to RetroAchievements"
val catalog = loadOrFetchCatalog(context, userName, key)
?: return "Could not fetch the RetroAchievements game list"
val progress = fetchUserProgress(userName, key)
?: return "Could not fetch your RetroAchievements progress"
var matched = 0
games.forEachIndexed { index, (path, serial) ->
onProgress(index, games.size)
if (serial.isEmpty()) return@forEachIndexed
// Hashing reads the disc, so remember it per serial — the image does not change.
val hash = cachedHash(serial) ?: hashAndCache(serial, path) ?: return@forEachIndexed
val gameId = catalog.hashToGame[hash] ?: return@forEachIndexed
val total = catalog.gameToTotal[gameId] ?: return@forEachIndexed
if (total <= 0) return@forEachIndexed
// Absent from the progress list simply means nothing earned yet: 0 of N, not "unknown".
val earned = progress[gameId]
PlayTime.recordAchievements(serial, earned?.first ?: 0, earned?.second ?: 0, total)
matched++
}
onProgress(games.size, games.size)
return "$matched of ${games.size} games matched"
}
// ---- catalogue ---------------------------------------------------------------------------
private fun loadOrFetchCatalog(context: Context, userName: String, key: String): Catalog? {
val file = File(MainActivityRuntime.assetCopyRoot(context), CATALOG_FILE)
val fetchedAt = runCatching { MainActivityRuntime.prefs.getLong(KEY_CATALOG_FETCHED, 0L) }
.getOrDefault(0L)
val fresh = file.isFile && (System.currentTimeMillis() - fetchedAt) < CATALOG_TTL_MS
if (fresh) {
parseCatalog(runCatching { file.readText() }.getOrNull().orEmpty())?.let { return it }
}
val body = get("$API/API_GetGameList.php?i=$PS2_CONSOLE_ID&f=1&h=1" +
"&z=${enc(userName)}&y=${enc(key)}") ?: return parseCatalog(
runCatching { file.readText() }.getOrNull().orEmpty() // stale beats nothing
)
val parsed = parseCatalog(body) ?: return null
runCatching {
file.writeText(body)
MainActivityRuntime.prefs.edit()
.putLong(KEY_CATALOG_FETCHED, System.currentTimeMillis()).apply()
}
return parsed
}
private fun parseCatalog(body: String): Catalog? {
if (body.isEmpty()) return null
return runCatching {
val arr = JSONArray(body)
val hashToGame = HashMap<String, Int>(arr.length() * 2)
val gameToTotal = HashMap<Int, Int>(arr.length())
for (i in 0 until arr.length()) {
val g = arr.optJSONObject(i) ?: continue
val id = g.optInt("ID", g.optInt("id", 0))
if (id == 0) continue
val total = g.optInt("NumAchievements", g.optInt("numAchievements", 0))
gameToTotal[id] = total
val hashes = g.optJSONArray("Hashes") ?: g.optJSONArray("hashes") ?: continue
for (h in 0 until hashes.length()) {
hashes.optString(h).takeIf { it.isNotEmpty() }
?.let { hashToGame[it.lowercase()] = id }
}
}
if (hashToGame.isEmpty()) null else Catalog(hashToGame, gameToTotal)
}.getOrNull()
}
// ---- user progress ----------------------------------------------------------------------
/** RA game id -> (softcore awarded, hardcore awarded). */
private fun fetchUserProgress(userName: String, key: String): Map<Int, Pair<Int, Int>>? {
val out = HashMap<Int, Pair<Int, Int>>()
var offset = 0
while (true) {
val body = get("$API/API_GetUserCompletionProgress.php?u=${enc(userName)}" +
"&c=$PROGRESS_PAGE&o=$offset&y=${enc(key)}") ?: return if (offset == 0) null else out
val page = runCatching { JSONObject(body) }.getOrNull() ?: return out
val results = page.optJSONArray("Results") ?: page.optJSONArray("results") ?: return out
for (i in 0 until results.length()) {
val r = results.optJSONObject(i) ?: continue
if (r.optInt("ConsoleID", r.optInt("consoleId", -1)) != PS2_CONSOLE_ID) continue
val id = r.optInt("GameID", r.optInt("gameId", 0))
if (id == 0) continue
out[id] = r.optInt("NumAwarded", r.optInt("numAwarded", 0)) to
r.optInt("NumAwardedHardcore", r.optInt("numAwardedHardcore", 0))
}
val total = page.optInt("Total", page.optInt("total", 0))
offset += results.length()
if (results.length() == 0 || offset >= total) break
}
return out
}
// ---- hashing ----------------------------------------------------------------------------
private fun cachedHash(serial: String): String? = runCatching {
MainActivityRuntime.prefs.getString(HASH_PREFIX + serial, null)?.takeIf { it.isNotEmpty() }
}.getOrNull()
private fun hashAndCache(serial: String, path: String): String? {
val hash = runCatching { NativeApp.getAchievementsHashForPath(path) }.getOrNull()
?.lowercase()?.takeIf { it.length == 32 } ?: return null
runCatching {
MainActivityRuntime.prefs.edit().putString(HASH_PREFIX + serial, hash).apply()
}
return hash
}
// ---- http -------------------------------------------------------------------------------
private fun enc(s: String): String =
java.net.URLEncoder.encode(s, "UTF-8")
private fun get(url: String): String? = runCatching {
(URL(url).openConnection() as HttpURLConnection).run {
requestMethod = "GET"
connectTimeout = 15_000
readTimeout = 30_000
setRequestProperty("Accept", "application/json")
try {
if (responseCode != HttpURLConnection.HTTP_OK) null
else inputStream.bufferedReader().use { it.readText() }
} finally {
disconnect()
}
}
}.getOrNull()
}
@@ -254,6 +254,12 @@ val EN: Map<String, String> = mapOf(
"app.library.recents" to "Recently played games",
"app.library.recents.desc" to "Show the Recently Played section on the library home screen.",
"app.library.opacity" to "Library opacity",
"ra.library.header" to "Library progress",
"ra.library.desc" to "Show achievement progress on every game in your library, including ones you have never played. Needs your RetroAchievements web API key (Settings → Keys on retroachievements.org) — that is a different credential from your password.",
"ra.library.apiKey" to "Web API key",
"ra.library.sync" to "Sync library",
"ra.library.syncing" to "Syncing…",
"ra.library.notReady" to "Sign in and scan your library first.",
"app.backup.export" to "Back up app data",
"app.backup.export.desc" to "Save states, memory cards, artwork, per-game settings, controller profiles, patches and all settings into one .zip. Games and BIOS are not included.",
"app.backup.import" to "Restore app data",
@@ -169,6 +169,7 @@ private fun AchievementAccount(
SectionTitle(state.userName.ifBlank { str("ra.account.signedIn") }, str("ra.options.header"), Modifier.weight(1f))
StatusChip("${state.items.count { it.unlocked }} / ${state.items.size}")
}
LibraryProgressSection()
SettingSwitchRow(
title = str("ra.mode.hardcore"),
description = str("patches.hardcoreNoticeCheatsDisabled"),
@@ -551,6 +552,73 @@ private fun AchievementFilterTabs(selected: AchFilter, items: List<AchievementIt
}
}
/**
* Library-wide progress. The core can only report achievements for the game it has loaded, so
* showing "0/40" on a game never played needs RetroAchievements' own game list two requests for a
* whole library, matched by disc hash. That uses the site's **web API key**, which is a separate
* credential from the login token above, so it has to be pasted in once.
*/
@Composable
private fun LibraryProgressSection() {
var key by remember { mutableStateOf(com.armsx2.RaLibrary.webApiKey) }
val syncing = com.armsx2.RaLibrary.syncing.value
val result = com.armsx2.RaLibrary.lastResult.value
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text(str("ra.library.header"), style = MaterialTheme.typography.titleSmall)
Text(
str("ra.library.desc"),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = key,
onValueChange = {
// Trim on entry: the site's copy button tends to bring whitespace with it, and a
// stray space produces a silent 401 that looks like the feature being broken.
key = it.trim()
com.armsx2.RaLibrary.webApiKey = key
},
label = { Text(str("ra.library.apiKey")) },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
shape = RoundedCornerShape(18.dp),
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
val sync = {
if (!com.armsx2.RaLibrary.syncNow()) {
com.armsx2.RaLibrary.lastResult.value = com.armsx2.i18n.I18n.get("ra.library.notReady")
}
}
Row(verticalAlignment = Alignment.CenterVertically) {
Button(
onClick = sync,
enabled = !syncing && key.isNotEmpty(),
shape = RoundedCornerShape(18.dp),
modifier = Modifier.controllerFocusable(
"ra.library.sync", RoundedCornerShape(18.dp), onConfirm = sync,
),
) {
if (syncing) {
CircularProgressIndicator(Modifier.size(16.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(8.dp))
}
Text(str(if (syncing) "ra.library.syncing" else "ra.library.sync"))
}
if (result.isNotEmpty() && !syncing) {
Spacer(Modifier.width(10.dp))
Text(
result,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
private fun LoginPanel(loading: Boolean, onLogin: (String, String) -> Unit, modifier: Modifier) {
var username by remember { mutableStateOf("") }
@@ -205,7 +205,11 @@ class AchievementsViewModel(application: Application) : AndroidViewModel(applica
val root = JSONObject(json)
return AchievementsUiState(
loggedIn = root.optBoolean("loggedIn"),
userName = root.optString("userName"),
userName = root.optString("userName").also {
// Persist it: a library-wide progress sync runs with no game loaded, so the
// panel is the only place the signed-in name is ever visible.
com.armsx2.RaLibrary.userName = it
},
// Reflect the PERSISTED ChallengeMode (what takes effect on the next boot), not
// the live rcheevos flag from the JSON — that's always off with no game running,
// which would make the library RA tab's Hardcore toggle snap back off after you
@@ -59,6 +59,9 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
initialized = cached.games.isNotEmpty() || !pendingInitialScan,
),
)
// Library-wide RetroAchievements progress. Hooked here because this is where the game
// list lives, and the sync needs paths to hash. No-op unless a web API key is set.
if (nativeReady) com.armsx2.RaLibrary.onLibraryLoaded(state.value.allGames)
if (nativeReady && pendingInitialScan) refresh()
} else if (nativeReady && pendingInitialScan) {
refresh()
@@ -80,6 +83,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
state.value = buildState(
state.value.copy(allGames = games, scanning = false, initialized = true),
)
com.armsx2.RaLibrary.onLibraryLoaded(games)
}.onFailure { failure ->
pendingInitialScan = false
state.value = state.value.copy(
@@ -207,6 +207,13 @@ public class NativeApp {
* (active=false, items=[]) when no game is loaded or not logged in. */
public static native String getAchievementsJSON();
/** RetroAchievements hash for a disc image, computed without booting it the key used to look a
* game up in RA's game list so the library can show progress for games never played. Empty
* string if the image is unreadable, has no PS2 boot ELF, or a VM is currently running (it
* repoints the global CDVD, so it declines rather than disturb a live game). Reads the disc:
* call off the UI thread. */
public static native String getAchievementsHashForPath(String imagePath);
/** Live RetroAchievements rich-presence string. Recomputed every
* second on the native side from the game's RAM. Empty when no game,
* no client, or RP not supported by the loaded set. */
@@ -402,6 +409,10 @@ public class NativeApp {
public static void playSound(String path) {
if (path == null || path.isEmpty()) return;
// An RA sound means the set just changed state, so re-read the counts now rather than waiting
// for the slow poll this is what makes the library's progress figure move as you play.
// Off-thread because it builds and parses the set JSON, and this call is on the emu thread.
new Thread(com.armsx2.AchievementsProgress::snapshotCurrentGame, "ach-progress").start();
// Cap concurrent players a burst of simultaneous unlocks (combo/milestone) could
// otherwise exhaust the device's MediaPlayer/codec pool and make start() no-op.
if (sActiveSounds.size() >= 4) return;