mirror of
https://github.com/ARMSX2/ARMSX3.git
synced 2026-08-24 16:58:52 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da6cf3bb49 | ||
|
|
dbc43ec6dc | ||
|
|
92816b9424 | ||
|
|
f04aa0e823 | ||
|
|
2c06abaf57 | ||
|
|
60aa81287f | ||
|
|
4a3d9e322f | ||
|
|
5ceb0d84c1 | ||
|
|
4ccd0efe75 | ||
|
|
622f6306e9 |
@@ -161,3 +161,6 @@ android/**/keystore.properties
|
||||
3rdparty/librashader/
|
||||
android/app-upstream/
|
||||
android/**/cpp/libadrenotools/
|
||||
|
||||
# Kotlin incremental-compile scratch dir
|
||||
android/armsx3-ui/.kotlin/
|
||||
|
||||
@@ -3,15 +3,14 @@ ARMSX3
|
||||
|
||||
Proof of concept Android port of RPCS3.
|
||||
|
||||
This is early work. A game boots and plays, but it is slow and most of it is
|
||||
untested. It is not a usable emulator yet.
|
||||
Uses the latest RPCS3 upstream code (the recent ARM64 improvements included).
|
||||
|
||||
Status
|
||||
------
|
||||
|
||||
Skate 3 boots, loads and reaches gameplay at roughly 20 to 30 fps on a
|
||||
From my testing, I only tried Skate 3. It boots, loads and reaches gameplay at roughly 20 to 30 fps on a
|
||||
Snapdragon 8 Gen 2. Rendering, audio, touch controls and physical controllers
|
||||
work. Almost nothing else has been tested.
|
||||
work. Almost nothing else has been tested. So the main stop gap at the moment is performance/speed.
|
||||
|
||||
Differences from upstream RPCS3
|
||||
-------------------------------
|
||||
@@ -82,9 +81,7 @@ Discord's developer portal and drop it in app/libs/ and
|
||||
app/src/main/cpp/discord_sdk/ if you want that feature. The build skips it
|
||||
otherwise.
|
||||
|
||||
Running it needs PS3 firmware, which is not included. Install PS3UPDAT.PUP from
|
||||
Sony's support site through the setup screen in the app.
|
||||
|
||||
Running it needs PS3 firmware, which is not included.
|
||||
License
|
||||
-------
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ android {
|
||||
applicationId = "com.armsx3"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 1
|
||||
versionName = "0.2.0-alpha"
|
||||
versionCode = 3
|
||||
versionName = "0.2.2-alpha"
|
||||
|
||||
// ARMSX2's UI reads these. STORAGE_ALL_FILES gates the all-files
|
||||
// storage path in onboarding; IN_APP_UPDATER gates self-update (off:
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <sys/resource.h>
|
||||
#include <unistd.h>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
|
||||
#if defined(__aarch64__)
|
||||
#include <adrenotools/driver.h>
|
||||
@@ -43,6 +44,10 @@ struct RPCSXApi {
|
||||
std::string (*getUser)();
|
||||
std::string (*settingsGet)(std::string_view path);
|
||||
bool (*settingsSet)(std::string_view path, std::string_view valueString);
|
||||
void (*settingsBeginBatch)();
|
||||
void (*settingsEndBatch)();
|
||||
bool (*installSplitPkg)(JNIEnv *env, const int *fds, int count, long progressId);
|
||||
bool (*uninstallGame)(std::string_view path);
|
||||
std::string (*getVersion)();
|
||||
void *(*setCustomDriver)(void *driverHandle);
|
||||
bool (*saveState)();
|
||||
@@ -114,6 +119,10 @@ struct RPCSXLibrary : RPCSXApi {
|
||||
result.getUser = reinterpret_cast<decltype(getUser)>(dlsym(handle, "_rpcsx_getUser"));
|
||||
result.settingsGet = reinterpret_cast<decltype(settingsGet)>(dlsym(handle, "_rpcsx_settingsGet"));
|
||||
result.settingsSet = reinterpret_cast<decltype(settingsSet)>(dlsym(handle, "_rpcsx_settingsSet"));
|
||||
result.settingsBeginBatch = reinterpret_cast<decltype(settingsBeginBatch)>(dlsym(handle, "_rpcsx_settingsBeginBatch"));
|
||||
result.settingsEndBatch = reinterpret_cast<decltype(settingsEndBatch)>(dlsym(handle, "_rpcsx_settingsEndBatch"));
|
||||
result.installSplitPkg = reinterpret_cast<decltype(installSplitPkg)>(dlsym(handle, "_rpcsx_installSplitPkg"));
|
||||
result.uninstallGame = reinterpret_cast<decltype(uninstallGame)>(dlsym(handle, "_rpcsx_uninstallGame"));
|
||||
result.getVersion = reinterpret_cast<decltype(getVersion)>(dlsym(handle, "_rpcsx_getVersion"));
|
||||
result.setCustomDriver = reinterpret_cast<decltype(setCustomDriver)>(dlsym(handle, "_rpcsx_setCustomDriver"));
|
||||
result.saveState = reinterpret_cast<decltype(saveState)>(dlsym(handle, "_rpcsx_saveState"));
|
||||
@@ -456,6 +465,51 @@ extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_settingsSet(
|
||||
return rpcsxLib.settingsSet(unwrap(env, jpath), unwrap(env, jvalue));
|
||||
}
|
||||
|
||||
// Defer the config file write until endBatch. Null-safe like the rest: an older core
|
||||
// .so simply has no such symbol, and every settingsSet then saves as it always did.
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_net_rpcsx_RPCSX_settingsBeginBatch(JNIEnv *, jobject) {
|
||||
if (rpcsxLib.settingsBeginBatch != nullptr) {
|
||||
rpcsxLib.settingsBeginBatch();
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_net_rpcsx_RPCSX_settingsEndBatch(JNIEnv *, jobject) {
|
||||
if (rpcsxLib.settingsEndBatch != nullptr) {
|
||||
rpcsxLib.settingsEndBatch();
|
||||
}
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_installSplitPkg(
|
||||
JNIEnv *env, jobject, jintArray jfds, jlong progressId) {
|
||||
if (rpcsxLib.installSplitPkg == nullptr || jfds == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const jsize count = env->GetArrayLength(jfds);
|
||||
if (count <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Copy out rather than pinning: the install blocks for minutes, and holding a
|
||||
// critical/pinned array across that would fight the GC the whole time.
|
||||
std::vector<int> fds(static_cast<std::size_t>(count));
|
||||
env->GetIntArrayRegion(jfds, 0, count, reinterpret_cast<jint *>(fds.data()));
|
||||
|
||||
return rpcsxLib.installSplitPkg(env, fds.data(), static_cast<int>(count),
|
||||
static_cast<long>(progressId));
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL
|
||||
Java_net_rpcsx_RPCSX_uninstallGame(JNIEnv *env, jobject, jstring jpath) {
|
||||
if (rpcsxLib.uninstallGame == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return rpcsxLib.uninstallGame(unwrap(env, jpath));
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL
|
||||
Java_net_rpcsx_RPCSX_supportsCustomDriverLoading(JNIEnv *env,
|
||||
jobject instance) {
|
||||
|
||||
@@ -20,7 +20,7 @@ import java.net.URL
|
||||
*/
|
||||
object News {
|
||||
private const val TAG = "News"
|
||||
private const val RELEASES_URL = "https://api.github.com/repos/ARMSX2/ARMSX2/releases?per_page=20"
|
||||
private const val RELEASES_URL = "https://api.github.com/repos/ARMSX2/ARMSX3/releases?per_page=20"
|
||||
private const val CACHE_FILE = "releases.json"
|
||||
private const val CACHE_TTL_MS = 6L * 60 * 60 * 1000 // 6h; releases are not frequent
|
||||
private const val MAX_BODY_BYTES = 512 * 1024
|
||||
|
||||
@@ -181,6 +181,14 @@ data class Ps3Settings(
|
||||
val resolution: Int = 2,
|
||||
val anisoFilter: Int = 0,
|
||||
val audioRenderer: Int = 2,
|
||||
/**
|
||||
* Output aspect override in permille (1778 = 16:9, 1333 = 4:3), 0 = follow the game.
|
||||
*
|
||||
* The PS3 only ever signalled 4:3 or 16:9, so RPCS3's video_aspect cannot express a
|
||||
* handheld panel (20:9, 19.5:9) and Stretch was the only way to fill one -- at the cost
|
||||
* of distorting the image. Permille because RPCS3's cfg has no float type.
|
||||
*/
|
||||
val displayAspect: Int = 0,
|
||||
// ---- Performance overlay (RPCS3 perf_overlay) ----
|
||||
/** PS3: Video/Performance Overlay / Enabled. */
|
||||
val overlayEnabled: Boolean = false,
|
||||
@@ -878,8 +886,29 @@ data class Settings(
|
||||
return "#%06X%02X".format(rgb, a)
|
||||
}
|
||||
|
||||
/** Push every field into emucore via NativeApp.setSetting + commit. */
|
||||
/**
|
||||
* Push every field into emucore via NativeApp.setSetting + commit.
|
||||
*
|
||||
* Wrapped in a settings batch. Each PS3 key reaching the core ran
|
||||
* Emulator::SaveSettings(g_cfg.to_string(), ""), which serialises the ENTIRE config
|
||||
* to YAML and writes it out -- and [applyToInner] pushes ~165 keys, so one toggle in
|
||||
* the in-game menu cost 165 full serialisations plus 165 file writes on the UI thread.
|
||||
* That is the reported menu lag. Batching collapses them into one write.
|
||||
*
|
||||
* try/finally because applyToInner has an early return on the INI-export path; leaving
|
||||
* the batch open there would defer the NEXT change's save indefinitely.
|
||||
*/
|
||||
fun applyTo() {
|
||||
val batched = emitSink == null && MainActivityRuntime.nativeReady.value
|
||||
if (batched) runCatching { net.rpcsx.RPCSX.instance.settingsBeginBatch() }
|
||||
try {
|
||||
applyToInner()
|
||||
} finally {
|
||||
if (batched) runCatching { net.rpcsx.RPCSX.instance.settingsEndBatch() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyToInner() {
|
||||
// Speedhacks
|
||||
// PS3 core settings, routed by Rpcs3Bridge to the RPCS3 config tree.
|
||||
put("PS3/Core", "PPU Decoder", "enum", ps3.ppuDecoder.toString())
|
||||
@@ -898,6 +927,7 @@ data class Settings(
|
||||
// Stretch is the only fit mode the CORE participates in; the rest are
|
||||
// surface layout. Keeping them in sync stops "Stretch" looking inert.
|
||||
put("PS3/Video", "Stretch To Display Area", "bool", (displayFitMode == 1).toString())
|
||||
put("PS3/Video", "Display Aspect Override", "int", ps3.displayAspect.coerceIn(0, 4000).toString())
|
||||
put("PS3/Overlay", "Enabled", "bool", ps3.overlayEnabled.toString())
|
||||
put("PS3/Overlay", "Detail level", "enum", ps3.overlayDetail.toString())
|
||||
put("PS3/Overlay", "Enable Framerate Graph", "bool", ps3.overlayFramerateGraph.toString())
|
||||
@@ -1848,6 +1878,24 @@ data class Settings(
|
||||
put("ps3PpuNanHandling", ps3.ppuNanHandling)
|
||||
put("ps3AccurateDfma", ps3.accurateDfma)
|
||||
put("ps3SetDazFtz", ps3.setDazFtz)
|
||||
// RPCS3 performance-overlay fields. These were declared on Ps3Settings and wired
|
||||
// into OverlayTab but never added to ANY of the four serialisation paths, so every
|
||||
// one of them lived only in memory: changing "Performance Overlay" or "Detail Level"
|
||||
// worked until SettingsViewModel.load() re-read the store on the next screen entry,
|
||||
// which snapped them back to the defaults. Reported as "anything I change on this
|
||||
// settings tab just reverts to default".
|
||||
put("ps3DisplayAspect", ps3.displayAspect)
|
||||
put("ps3OverlayEnabled", ps3.overlayEnabled)
|
||||
put("ps3OverlayDetail", ps3.overlayDetail)
|
||||
put("ps3OverlayPosition", ps3.overlayPosition)
|
||||
put("ps3OverlayFontSize", ps3.overlayFontSize)
|
||||
put("ps3OverlayOpacity", ps3.overlayOpacity)
|
||||
put("ps3OverlayFramerateGraph", ps3.overlayFramerateGraph)
|
||||
put("ps3OverlayFrametimeGraph", ps3.overlayFrametimeGraph)
|
||||
put("ps3OverlayBodyColor", ps3.overlayBodyColor)
|
||||
put("ps3OverlayBodyBg", ps3.overlayBodyBg)
|
||||
put("ps3OverlayTitleColor", ps3.overlayTitleColor)
|
||||
put("ps3OverlayTitleBg", ps3.overlayTitleBg)
|
||||
put("ps3HleLwmutex", ps3.hleLwmutex)
|
||||
put("ps3SleepTimers", ps3.sleepTimers)
|
||||
put("ps3DebugConsoleMode", ps3.debugConsoleMode)
|
||||
@@ -2164,6 +2212,18 @@ data class Settings(
|
||||
ppuNanHandling = json.optBoolean("ps3PpuNanHandling", def.ps3.ppuNanHandling),
|
||||
accurateDfma = json.optBoolean("ps3AccurateDfma", def.ps3.accurateDfma),
|
||||
setDazFtz = json.optBoolean("ps3SetDazFtz", def.ps3.setDazFtz),
|
||||
displayAspect = json.optInt("ps3DisplayAspect", def.ps3.displayAspect),
|
||||
overlayEnabled = json.optBoolean("ps3OverlayEnabled", def.ps3.overlayEnabled),
|
||||
overlayDetail = json.optInt("ps3OverlayDetail", def.ps3.overlayDetail),
|
||||
overlayPosition = json.optInt("ps3OverlayPosition", def.ps3.overlayPosition),
|
||||
overlayFontSize = json.optInt("ps3OverlayFontSize", def.ps3.overlayFontSize),
|
||||
overlayOpacity = json.optInt("ps3OverlayOpacity", def.ps3.overlayOpacity),
|
||||
overlayFramerateGraph = json.optBoolean("ps3OverlayFramerateGraph", def.ps3.overlayFramerateGraph),
|
||||
overlayFrametimeGraph = json.optBoolean("ps3OverlayFrametimeGraph", def.ps3.overlayFrametimeGraph),
|
||||
overlayBodyColor = json.optInt("ps3OverlayBodyColor", def.ps3.overlayBodyColor),
|
||||
overlayBodyBg = json.optInt("ps3OverlayBodyBg", def.ps3.overlayBodyBg),
|
||||
overlayTitleColor = json.optInt("ps3OverlayTitleColor", def.ps3.overlayTitleColor),
|
||||
overlayTitleBg = json.optInt("ps3OverlayTitleBg", def.ps3.overlayTitleBg),
|
||||
hleLwmutex = json.optBoolean("ps3HleLwmutex", def.ps3.hleLwmutex),
|
||||
sleepTimers = json.optInt("ps3SleepTimers", def.ps3.sleepTimers),
|
||||
debugConsoleMode = json.optBoolean("ps3DebugConsoleMode", def.ps3.debugConsoleMode),
|
||||
@@ -2466,6 +2526,18 @@ data class Settings(
|
||||
if (current.ps3.ppuNanHandling != base.ps3.ppuNanHandling) j.put("ps3PpuNanHandling", current.ps3.ppuNanHandling)
|
||||
if (current.ps3.accurateDfma != base.ps3.accurateDfma) j.put("ps3AccurateDfma", current.ps3.accurateDfma)
|
||||
if (current.ps3.setDazFtz != base.ps3.setDazFtz) j.put("ps3SetDazFtz", current.ps3.setDazFtz)
|
||||
if (current.ps3.displayAspect != base.ps3.displayAspect) j.put("ps3DisplayAspect", current.ps3.displayAspect)
|
||||
if (current.ps3.overlayEnabled != base.ps3.overlayEnabled) j.put("ps3OverlayEnabled", current.ps3.overlayEnabled)
|
||||
if (current.ps3.overlayDetail != base.ps3.overlayDetail) j.put("ps3OverlayDetail", current.ps3.overlayDetail)
|
||||
if (current.ps3.overlayPosition != base.ps3.overlayPosition) j.put("ps3OverlayPosition", current.ps3.overlayPosition)
|
||||
if (current.ps3.overlayFontSize != base.ps3.overlayFontSize) j.put("ps3OverlayFontSize", current.ps3.overlayFontSize)
|
||||
if (current.ps3.overlayOpacity != base.ps3.overlayOpacity) j.put("ps3OverlayOpacity", current.ps3.overlayOpacity)
|
||||
if (current.ps3.overlayFramerateGraph != base.ps3.overlayFramerateGraph) j.put("ps3OverlayFramerateGraph", current.ps3.overlayFramerateGraph)
|
||||
if (current.ps3.overlayFrametimeGraph != base.ps3.overlayFrametimeGraph) j.put("ps3OverlayFrametimeGraph", current.ps3.overlayFrametimeGraph)
|
||||
if (current.ps3.overlayBodyColor != base.ps3.overlayBodyColor) j.put("ps3OverlayBodyColor", current.ps3.overlayBodyColor)
|
||||
if (current.ps3.overlayBodyBg != base.ps3.overlayBodyBg) j.put("ps3OverlayBodyBg", current.ps3.overlayBodyBg)
|
||||
if (current.ps3.overlayTitleColor != base.ps3.overlayTitleColor) j.put("ps3OverlayTitleColor", current.ps3.overlayTitleColor)
|
||||
if (current.ps3.overlayTitleBg != base.ps3.overlayTitleBg) j.put("ps3OverlayTitleBg", current.ps3.overlayTitleBg)
|
||||
if (current.ps3.hleLwmutex != base.ps3.hleLwmutex) j.put("ps3HleLwmutex", current.ps3.hleLwmutex)
|
||||
if (current.ps3.sleepTimers != base.ps3.sleepTimers) j.put("ps3SleepTimers", current.ps3.sleepTimers)
|
||||
if (current.ps3.debugConsoleMode != base.ps3.debugConsoleMode) j.put("ps3DebugConsoleMode", current.ps3.debugConsoleMode)
|
||||
@@ -2749,6 +2821,18 @@ data class Settings(
|
||||
ppuNanHandling = if (overrides.has("ps3PpuNanHandling")) overrides.getBoolean("ps3PpuNanHandling") else base.ps3.ppuNanHandling,
|
||||
accurateDfma = if (overrides.has("ps3AccurateDfma")) overrides.getBoolean("ps3AccurateDfma") else base.ps3.accurateDfma,
|
||||
setDazFtz = if (overrides.has("ps3SetDazFtz")) overrides.getBoolean("ps3SetDazFtz") else base.ps3.setDazFtz,
|
||||
displayAspect = if (overrides.has("ps3DisplayAspect")) overrides.getInt("ps3DisplayAspect") else base.ps3.displayAspect,
|
||||
overlayEnabled = if (overrides.has("ps3OverlayEnabled")) overrides.getBoolean("ps3OverlayEnabled") else base.ps3.overlayEnabled,
|
||||
overlayDetail = if (overrides.has("ps3OverlayDetail")) overrides.getInt("ps3OverlayDetail") else base.ps3.overlayDetail,
|
||||
overlayPosition = if (overrides.has("ps3OverlayPosition")) overrides.getInt("ps3OverlayPosition") else base.ps3.overlayPosition,
|
||||
overlayFontSize = if (overrides.has("ps3OverlayFontSize")) overrides.getInt("ps3OverlayFontSize") else base.ps3.overlayFontSize,
|
||||
overlayOpacity = if (overrides.has("ps3OverlayOpacity")) overrides.getInt("ps3OverlayOpacity") else base.ps3.overlayOpacity,
|
||||
overlayFramerateGraph = if (overrides.has("ps3OverlayFramerateGraph")) overrides.getBoolean("ps3OverlayFramerateGraph") else base.ps3.overlayFramerateGraph,
|
||||
overlayFrametimeGraph = if (overrides.has("ps3OverlayFrametimeGraph")) overrides.getBoolean("ps3OverlayFrametimeGraph") else base.ps3.overlayFrametimeGraph,
|
||||
overlayBodyColor = if (overrides.has("ps3OverlayBodyColor")) overrides.getInt("ps3OverlayBodyColor") else base.ps3.overlayBodyColor,
|
||||
overlayBodyBg = if (overrides.has("ps3OverlayBodyBg")) overrides.getInt("ps3OverlayBodyBg") else base.ps3.overlayBodyBg,
|
||||
overlayTitleColor = if (overrides.has("ps3OverlayTitleColor")) overrides.getInt("ps3OverlayTitleColor") else base.ps3.overlayTitleColor,
|
||||
overlayTitleBg = if (overrides.has("ps3OverlayTitleBg")) overrides.getInt("ps3OverlayTitleBg") else base.ps3.overlayTitleBg,
|
||||
hleLwmutex = if (overrides.has("ps3HleLwmutex")) overrides.getBoolean("ps3HleLwmutex") else base.ps3.hleLwmutex,
|
||||
sleepTimers = if (overrides.has("ps3SleepTimers")) overrides.getInt("ps3SleepTimers") else base.ps3.sleepTimers,
|
||||
debugConsoleMode = if (overrides.has("ps3DebugConsoleMode")) overrides.getBoolean("ps3DebugConsoleMode") else base.ps3.debugConsoleMode,
|
||||
|
||||
+106
-3
@@ -43,7 +43,43 @@ class GameLibraryRepository(private val context: Context) {
|
||||
* scanner starts extracting a field it did not before.
|
||||
*/
|
||||
fun cacheKey(directories: List<String>): String =
|
||||
"v$ScanSchemaVersion|" + directories.sorted().joinToString("|")
|
||||
"v$ScanSchemaVersion|" +
|
||||
(directories.sorted() + internalGameDirectories().map { it.absolutePath })
|
||||
.joinToString("|")
|
||||
|
||||
/**
|
||||
* The emulator's OWN game storage, always scanned on top of the user's ROM folders.
|
||||
*
|
||||
* A PKG install and anything dropped into RPCS3's games directory land here, never in
|
||||
* a ROM folder, so neither was reachable: this library replaced net.rpcsx's
|
||||
* GameRepository, which did read both of these, and the paths came with it. Reported as
|
||||
* "doesn't detect disc games from config folder like other ps3 emus".
|
||||
*
|
||||
* Both hold games in folder form, so [isPs3GameFolder] is what actually finds them.
|
||||
*/
|
||||
/** True when the emulator's own storage holds games, ROM folders or not. */
|
||||
fun hasInternalGames(): Boolean = internalGameDirectories().any {
|
||||
runCatching { it.listFiles()?.isNotEmpty() }.getOrNull() == true
|
||||
}
|
||||
|
||||
private fun internalGameDirectories(): List<File> = listOf(
|
||||
File(RPCSX.rootDirectory, "config/dev_hdd0/game"),
|
||||
File(RPCSX.rootDirectory, "config/games"),
|
||||
).filter { runCatching { it.isDirectory }.getOrDefault(false) }
|
||||
|
||||
/**
|
||||
* Drop the cached scan so the next library load re-reads storage.
|
||||
*
|
||||
* Needed after an install: it adds a game inside a directory that was already in the
|
||||
* cache key, so nothing about the key changes and the library would keep serving the
|
||||
* pre-install list.
|
||||
*/
|
||||
fun invalidateCache() {
|
||||
MainActivityRuntime.prefs.edit {
|
||||
remove("gamesCacheKey")
|
||||
remove("gamesCacheDir")
|
||||
}
|
||||
}
|
||||
|
||||
fun loadCached(): CachedLibrary {
|
||||
val cachedKey = MainActivityRuntime.prefs.getString("gamesCacheKey", null)
|
||||
@@ -94,6 +130,10 @@ class GameLibraryRepository(private val context: Context) {
|
||||
tree?.let { scanDocumentTree(it, collected, 0) }
|
||||
}
|
||||
}
|
||||
internalGameDirectories().forEach { dir ->
|
||||
android.util.Log.i(ScanTag, "internal dir=${dir.absolutePath}")
|
||||
scanRawDirectory(dir, collected, 0)
|
||||
}
|
||||
android.util.Log.i(ScanTag, "scan done: ${collected.size} game(s)")
|
||||
collected.values.sortedBy { it.title.lowercase() }.also { saveCache(directories, it) }
|
||||
}
|
||||
@@ -203,6 +243,16 @@ class GameLibraryRepository(private val context: Context) {
|
||||
val children = runCatching { directory.listFiles() }.getOrNull() ?: return
|
||||
children.forEach { file ->
|
||||
if (file.isDirectory) {
|
||||
// Same leaf rule as the raw scan. No SFO probe here: the core opens
|
||||
// by path and a content:// tree has none to give, so the title comes
|
||||
// from the folder name -- as it already does for a SAF-listed .iso.
|
||||
if (runCatching { isPs3GameDocument(file) }.getOrDefault(false)) {
|
||||
output.putIfAbsent(
|
||||
file.uri.toString(),
|
||||
createGame(file.uri, file.name ?: "", "folder", null),
|
||||
)
|
||||
return@forEach
|
||||
}
|
||||
scanDocumentTree(file, output, depth + 1)
|
||||
return@forEach
|
||||
}
|
||||
@@ -214,6 +264,46 @@ class GameLibraryRepository(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this directory IS a game rather than a folder containing games.
|
||||
*
|
||||
* Two shapes, both common and neither an .iso:
|
||||
* - a JB folder dump, which keeps the disc layout: <dir>/PS3_GAME/PARAM.SFO
|
||||
* (PS3_DISC.SFB alongside it on a real disc rip)
|
||||
* - an installed/HDD game folder -- PARAM.SFO next to USRDIR. This is what
|
||||
* RPCS3's own games directory holds, and the shape prototypes that never
|
||||
* got a retail master ship in.
|
||||
*
|
||||
* Case-insensitive: these come off FAT/exFAT cards and out of archives, so
|
||||
* "PS3_GAME" is as likely to be "ps3_game".
|
||||
*/
|
||||
private fun isPs3GameFolder(directory: File): Boolean {
|
||||
fun child(vararg path: String): File? {
|
||||
var current: File = directory
|
||||
for (segment in path) {
|
||||
current = current.listFiles()
|
||||
?.firstOrNull { it.name.equals(segment, ignoreCase = true) }
|
||||
?: return null
|
||||
}
|
||||
return current
|
||||
}
|
||||
if (child("PS3_GAME", "PARAM.SFO")?.isFile == true) return true
|
||||
if (child("PS3_DISC.SFB")?.isFile == true) return true
|
||||
return child("PARAM.SFO")?.isFile == true && child("USRDIR")?.isDirectory == true
|
||||
}
|
||||
|
||||
/** [isPs3GameFolder] over a SAF tree. */
|
||||
private fun isPs3GameDocument(directory: DocumentFile): Boolean {
|
||||
val children = runCatching { directory.listFiles() }.getOrNull() ?: return false
|
||||
fun find(name: String) = children.firstOrNull { it.name.equals(name, ignoreCase = true) }
|
||||
if (find("PS3_DISC.SFB")?.isFile == true) return true
|
||||
find("PS3_GAME")?.takeIf { it.isDirectory }?.let { gameDir ->
|
||||
val inner = runCatching { gameDir.listFiles() }.getOrNull().orEmpty()
|
||||
if (inner.any { it.isFile && it.name.equals("PARAM.SFO", ignoreCase = true) }) return true
|
||||
}
|
||||
return find("PARAM.SFO")?.isFile == true && find("USRDIR")?.isDirectory == true
|
||||
}
|
||||
|
||||
private fun scanRawDirectory(
|
||||
directory: File,
|
||||
output: MutableMap<String, GameInfo>,
|
||||
@@ -223,6 +313,18 @@ class GameLibraryRepository(private val context: Context) {
|
||||
val children = runCatching { directory.listFiles() }.getOrNull() ?: return
|
||||
children.forEach { file ->
|
||||
if (file.isDirectory) {
|
||||
// A game folder is a leaf: emit it and do NOT descend. Descending
|
||||
// also used to add USRDIR/EBOOT.BIN as its own bogus entry, since
|
||||
// "bin" is in gameExtensions.
|
||||
if (runCatching { isPs3GameFolder(file) }.getOrDefault(false)) {
|
||||
val folderUri = Uri.fromFile(file)
|
||||
android.util.Log.i(ScanTag, " game folder '${file.name}'")
|
||||
output.putIfAbsent(
|
||||
folderUri.toString(),
|
||||
createGame(folderUri, file.name, "folder", null, probeDisc(file)),
|
||||
)
|
||||
return@forEach
|
||||
}
|
||||
scanRawDirectory(file, output, depth + 1)
|
||||
return@forEach
|
||||
}
|
||||
@@ -380,8 +482,9 @@ class GameLibraryRepository(private val context: Context) {
|
||||
data class CachedLibrary(val key: String?, val games: List<GameInfo>)
|
||||
|
||||
private companion object {
|
||||
/** v2: PS3 title ID + title + ICON0.PNG read from the disc's PARAM.SFO. */
|
||||
const val ScanSchemaVersion = 4
|
||||
/** v2: PS3 title ID + title + ICON0.PNG read from the disc's PARAM.SFO.
|
||||
* v5: folder-format games (JB folder / installed game folder). */
|
||||
const val ScanSchemaVersion = 5
|
||||
const val ScanTag = "ARMSX3-Scan"
|
||||
/** Staging name for an extracted icon, renamed once the title ID is known. */
|
||||
const val PendingIcon = "__pending"
|
||||
|
||||
@@ -185,7 +185,7 @@ val EN: Map<String, String> = mapOf(
|
||||
"news.noNotes" to "No notes for this release.",
|
||||
"news.showMore" to "Show more",
|
||||
"news.showLess" to "Show less",
|
||||
"about.tagline" to "Fast, modern PlayStation 2 emulation for Android.",
|
||||
"about.tagline" to "Fast, modern PlayStation 3 emulation for Android.",
|
||||
"about.appVersion" to "App version",
|
||||
"about.coreVersion" to "Emulator version",
|
||||
"about.device" to "Device",
|
||||
@@ -201,7 +201,7 @@ val EN: Map<String, String> = mapOf(
|
||||
"about.display" to "Display",
|
||||
"about.architecture" to "Architecture",
|
||||
"about.pageSize" to "Memory page",
|
||||
"about.pcsx2.title" to "PCSX2 project",
|
||||
"about.pcsx2.title" to "RPCS3 project",
|
||||
"about.pcsx2.description" to "ARMSX3 is built on the open-source RPCS3 emulator.",
|
||||
// --- drawer About section: external links ---
|
||||
"about.section.header" to "About",
|
||||
@@ -388,6 +388,24 @@ val EN: Map<String, String> = mapOf(
|
||||
"action.ok" to "OK",
|
||||
"action.save" to "Save",
|
||||
"action.edit" to "Edit",
|
||||
"core.settings.title" to "All Core Settings",
|
||||
"core.settings.description" to "Every setting the emulator core exposes, read straight from it. Advanced: there are no safety rails here, and most people want the normal Settings screens instead.",
|
||||
"core.settings.unavailable" to "The emulator core is not loaded, so its settings cannot be read.",
|
||||
"packages.title" to "Install Package",
|
||||
"packages.description" to "Install a .pkg game, update or DLC. Installed titles are added to your library automatically. Updates and DLC need the base game installed first.",
|
||||
"packages.select.title" to "Select a .pkg file",
|
||||
"packages.select.action" to "Choose file",
|
||||
"packages.installing" to "Installing. Large packages can take a few minutes.",
|
||||
"packages.install.done" to "Installed. It will appear in your library on the next scan.",
|
||||
"packages.install.failed" to "Install failed. The file may be encrypted, incomplete or not a PS3 package.",
|
||||
"packages.multiHint" to "Tap several .pkg files to select them all if a game is split into parts, then confirm.",
|
||||
"packages.installed.header" to "Installed titles",
|
||||
"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.",
|
||||
"packages.uninstall.done" to "Uninstalled.",
|
||||
"packages.uninstall.failed" to "Could not uninstall that title.",
|
||||
"browse.installSelected" to "Install %d",
|
||||
"patches.editor.new" to "New patch file",
|
||||
"patches.editor.paste" to "Paste",
|
||||
"patches.editor.placeholder" to "patch=1,EE,00000000,extended,00000000",
|
||||
@@ -559,6 +577,11 @@ val EN: Map<String, String> = mapOf(
|
||||
"overlay.color.title" to "Title Colour",
|
||||
"overlay.color.titleBg" to "Title Background",
|
||||
"renderer.consoleAspect.label" to "Console Aspect Ratio",
|
||||
"renderer.screenAspect.label" to "Screen Aspect Ratio",
|
||||
"renderer.screenAspect.description" to "The shape the game image is fitted into on your screen. Auto follows the game. Pick your panel's ratio to fill the screen without the distortion Stretch causes. This is separate from Console Aspect Ratio above, which is what the game thinks it is drawing.",
|
||||
"renderer.screenAspect.custom" to "Custom",
|
||||
"renderer.screenAspect.customValue" to "Custom Aspect Ratio",
|
||||
"renderer.screenAspect.customValue.description" to "Width relative to height. 1.78:1 is 16:9, 2.22:1 is 20:9.",
|
||||
"renderer.consoleAspect.description" to "The aspect the emulated PS3 reports to the game. The console only ever signalled 4:3 or 16:9, so those are the only real options \u2014 Auto leaves it to the game. This is what the game renders for; how it is fitted to YOUR screen is the setting below.",
|
||||
"renderer.fit.auto" to "Fit",
|
||||
"renderer.fit.stretch" to "Stretch",
|
||||
@@ -1052,7 +1075,7 @@ val EN: Map<String, String> = mapOf(
|
||||
"pad.stickTarget.analogDefault" to "Analog (default)",
|
||||
"pad.stickTarget.hotkeys" to "Hotkeys",
|
||||
"pad.stickTarget.intro" to "Choose what this stick direction sends. Works regardless of which physical buttons are bound.",
|
||||
"pad.stickTarget.ps2Buttons" to "PS2 Buttons",
|
||||
"pad.stickTarget.ps2Buttons" to "Face Buttons",
|
||||
"pad.testRumble.player1" to "Test rumble — Player 1",
|
||||
"pad.testRumble.player2" to "Test rumble — Player 2",
|
||||
"pad.touchHaptics.description" to "Vibrate briefly when you press an on-screen button (like PPSSPP / Azahar). Separate from controller rumble.",
|
||||
@@ -1134,7 +1157,7 @@ val EN: Map<String, String> = mapOf(
|
||||
"perf.displayResolution.1xPs2" to "1x PS2",
|
||||
"perf.displayResolution.2xPs2" to "2x PS2",
|
||||
"perf.displayResolution.3xPs2" to "3x PS2",
|
||||
"perf.displayResolution.description" to "Reduces the display resolution to significantly decrease device heat and battery drain. Screen = full quality (off). 3x PS2 = High Quality, 2x PS2 = Balanced, 1x PS2 = Battery Saver / Max Performance. Separate from the internal rendering resolution — menus stay sharp either way.",
|
||||
"perf.displayResolution.description" to "Reduces the display resolution to significantly decrease device heat and battery drain. Screen = full quality (off). 1080p = High Quality, 720p = Balanced, 540p = Battery Saver / Max Performance. Separate from the internal rendering resolution — menus stay sharp either way.",
|
||||
"perf.displayResolution.label" to "Display Resolution (HW scaler)",
|
||||
"perf.displayResolution.screen" to "Screen",
|
||||
"perf.screenRes.label" to "Screen resolution override",
|
||||
@@ -1327,6 +1350,14 @@ val EN: Map<String, String> = mapOf(
|
||||
"renderer.blendingAccuracy.description" to "Controls alpha/blending precision. Basic is faster; higher can fix effects.",
|
||||
"renderer.blendingAccuracy.label" to "Blending Accuracy",
|
||||
"renderer.brightness.label" to "Brightness",
|
||||
"perf.caches.title" to "Compiled Code Cache",
|
||||
"perf.clearSpuCache.label" to "Clear SPU Cache",
|
||||
"perf.clearSpuCache.description" to "Deletes recompiled SPU programs, keeping the compiled PPU modules so games still boot quickly. The SPU cache rebuilds the next time you play.",
|
||||
"perf.clearPpuCache.label" to "Clear PPU Cache",
|
||||
"perf.clearPpuCache.description" to "Deletes every recompiled PPU module, and the SPU caches stored inside them. The next boot of each game recompiles from scratch and takes a while.",
|
||||
"perf.clearCache.done" to "Cleared %d item(s), %s freed.",
|
||||
"perf.clearCache.alreadyEmpty" to "Cache is already empty.",
|
||||
"perf.clearCache.stopFirst" to "Close the game first — its cache is in use.",
|
||||
"renderer.clearShaderCache.alreadyEmpty" to "Shader cache is already empty.",
|
||||
"renderer.clearShaderCache.description" to "Wipes the compiled Vulkan + GL shader/pipeline caches. Use if a game renders corrupt after a driver swap or update — the next launch rebuilds them clean.",
|
||||
"renderer.clearShaderCache.label" to "Clear Shader Cache",
|
||||
@@ -1385,7 +1416,7 @@ val EN: Map<String, String> = mapOf(
|
||||
"pad.analogExtra.label" to "Extra button on left stick",
|
||||
"pad.analogExtra.description" to "Adds a button just above the on-screen left stick. Slide your thumb up onto it without lifting off — you keep steering while it's held, so running forward and sprinting is one motion. Useful for sprint (GTA, Silent Hill) and jump (God of War, Kingdom Hearts). Move and resize it in Customise Touch Layout, like any other button.",
|
||||
"pad.analogExtra.button" to "Extra button action",
|
||||
"pad.analogExtra.button.description" to "Which PS2 button the extra stick button presses.",
|
||||
"pad.analogExtra.button.description" to "Which controller button the extra stick button presses.",
|
||||
"renderer.section.overlayArt" to "Overlay Artwork",
|
||||
"renderer.overlayArt.browse" to "Browse overlays to download",
|
||||
"renderer.overlayArt.browsing" to "Loading list…",
|
||||
|
||||
@@ -93,6 +93,10 @@ fun AppNavigation() {
|
||||
onOpenAbout = { UiNavigator.navigate(AppRoute.About) },
|
||||
)
|
||||
is AppRoute.BiosManager -> BiosManagerScreen(onBack = UiNavigator::home, game = destination.game)
|
||||
AppRoute.PackageInstaller ->
|
||||
com.armsx2.ui.packages.PackageInstallerScreen(onBack = UiNavigator::home)
|
||||
AppRoute.CoreSettings ->
|
||||
com.armsx2.ui.settings.CoreSettingsScreen(onBack = UiNavigator::home)
|
||||
AppRoute.MemoryCardManager -> MemoryCardScreen(onBack = UiNavigator::home)
|
||||
AppRoute.SaveManager -> SaveManagerScreen(onBack = UiNavigator::home)
|
||||
AppRoute.ControllerManager -> ControllerManagerScreen(onBack = UiNavigator::home)
|
||||
|
||||
@@ -13,6 +13,8 @@ sealed interface AppRoute {
|
||||
// (from the library long-press) without the game being loaded; null = global,
|
||||
// opened from the drawer (falls back to the currently loaded game if any).
|
||||
data class BiosManager(val game: GameInfo? = null) : AppRoute
|
||||
data object PackageInstaller : AppRoute
|
||||
data object CoreSettings : AppRoute
|
||||
data object MemoryCardManager : AppRoute
|
||||
data object SaveManager : AppRoute
|
||||
data object ControllerManager : AppRoute
|
||||
|
||||
@@ -77,7 +77,7 @@ private val ExitRed = Color(0xFFE60012)
|
||||
// Links hand these to the Discord/GitHub apps when they're installed and fall back to the
|
||||
// browser when they aren't, so there's no app-specific scheme to special-case.
|
||||
private const val DiscordUrl = "https://discord.gg/2Tynvwhc4A"
|
||||
private const val GithubUrl = "https://github.com/ARMSX2/ARMSX2"
|
||||
private const val GithubUrl = "https://github.com/ARMSX2/ARMSX3"
|
||||
private const val WebsiteUrl = "https://armsx2.net/"
|
||||
|
||||
/**
|
||||
@@ -201,6 +201,10 @@ private fun DrawerContent(selected: AppRoute, onNavigate: (AppRoute) -> Unit, on
|
||||
// ARMSX3: RetroAchievements removed - RA has no PS3 support at all, so
|
||||
// the screen could only ever be empty.
|
||||
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
|
||||
// escape hatch for the rest of the PS3 config.
|
||||
DrawerItem("core.settings.title", "🧩", AppRoute.CoreSettings),
|
||||
)
|
||||
val managers = listOf(
|
||||
// Moved off the library overflow menu, which was its only entry point. Sits first, beside
|
||||
@@ -208,6 +212,9 @@ private fun DrawerContent(selected: AppRoute, onNavigate: (AppRoute) -> Unit, on
|
||||
DrawerItem("games.overflow.setup", "📂",
|
||||
onAction = { MainActivityRuntime.reopenSetup(); onDismiss() }),
|
||||
DrawerItem("setup.step.bios.title", "📀", AppRoute.BiosManager()),
|
||||
// Install .pkg games/updates/DLC. The native installer was always there; this
|
||||
// is the entry point it never had.
|
||||
DrawerItem("packages.title", "📦", AppRoute.PackageInstaller),
|
||||
// ARMSX3: PS2 memory cards removed - PS3 uses HDD save data instead.
|
||||
DrawerItem("savestate.title.loadManage", "📥", AppRoute.SaveManager),
|
||||
DrawerItem("tab.controls", "🕹️", AppRoute.ControllerManager),
|
||||
@@ -356,6 +363,8 @@ private fun sameDestination(current: AppRoute, target: AppRoute): Boolean = when
|
||||
AppRoute.Home -> current is AppRoute.Home
|
||||
is AppRoute.Settings -> current is AppRoute.Settings
|
||||
is AppRoute.BiosManager -> current is AppRoute.BiosManager
|
||||
AppRoute.PackageInstaller -> current is AppRoute.PackageInstaller
|
||||
AppRoute.CoreSettings -> current is AppRoute.CoreSettings
|
||||
AppRoute.MemoryCardManager -> current is AppRoute.MemoryCardManager
|
||||
AppRoute.SaveManager -> current is AppRoute.SaveManager
|
||||
AppRoute.ControllerManager -> current is AppRoute.ControllerManager
|
||||
|
||||
@@ -35,8 +35,8 @@ import com.armsx2.ui.common.ArmsTopBar
|
||||
import com.armsx2.ui.common.GlassPanel
|
||||
import com.armsx2.ui.common.RoundAction
|
||||
|
||||
private const val RepositoryUrl = "https://github.com/ARMSX2/ARMSX2"
|
||||
private const val Pcsx2RepositoryUrl = "https://github.com/PCSX2/pcsx2"
|
||||
private const val RepositoryUrl = "https://github.com/ARMSX2/ARMSX3"
|
||||
private const val Rpcs3RepositoryUrl = "https://github.com/RPCS3/rpcs3"
|
||||
|
||||
@Composable
|
||||
fun AboutScreen(onBack: () -> Unit, viewModel: AboutViewModel = viewModel()) {
|
||||
@@ -108,24 +108,24 @@ fun AboutScreen(onBack: () -> Unit, viewModel: AboutViewModel = viewModel()) {
|
||||
if (compact) {
|
||||
ProjectCard(
|
||||
title = str("about.repository.title"),
|
||||
repository = "ARMSX2/ARMSX2",
|
||||
repository = "ARMSX2/ARMSX3",
|
||||
description = str("about.repository.description"),
|
||||
glyph = "⌘",
|
||||
onOpen = { uriHandler.openUri(RepositoryUrl) },
|
||||
)
|
||||
ProjectCard(
|
||||
title = str("about.pcsx2.title"),
|
||||
repository = "PCSX2/pcsx2",
|
||||
repository = "RPCS3/rpcs3",
|
||||
description = str("about.pcsx2.description"),
|
||||
glyph = "PS2",
|
||||
glyph = "PS3",
|
||||
secondary = true,
|
||||
onOpen = { uriHandler.openUri(Pcsx2RepositoryUrl) },
|
||||
onOpen = { uriHandler.openUri(Rpcs3RepositoryUrl) },
|
||||
)
|
||||
} else {
|
||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ProjectCard(
|
||||
title = str("about.repository.title"),
|
||||
repository = "ARMSX2/ARMSX2",
|
||||
repository = "ARMSX2/ARMSX3",
|
||||
description = str("about.repository.description"),
|
||||
glyph = "⌘",
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -133,12 +133,12 @@ fun AboutScreen(onBack: () -> Unit, viewModel: AboutViewModel = viewModel()) {
|
||||
)
|
||||
ProjectCard(
|
||||
title = str("about.pcsx2.title"),
|
||||
repository = "PCSX2/pcsx2",
|
||||
repository = "RPCS3/rpcs3",
|
||||
description = str("about.pcsx2.description"),
|
||||
glyph = "PS2",
|
||||
glyph = "PS3",
|
||||
modifier = Modifier.weight(1f),
|
||||
secondary = true,
|
||||
onOpen = { uriHandler.openUri(Pcsx2RepositoryUrl) },
|
||||
onOpen = { uriHandler.openUri(Rpcs3RepositoryUrl) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateListOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -65,8 +66,14 @@ fun FileBrowserDialog(
|
||||
extensions: Set<String> = emptySet(),
|
||||
onPick: (File) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
/** Let the user tick several files and confirm once. Used for split .pkg sets,
|
||||
* whose parts only install correctly when handed over together. */
|
||||
allowMultiple: Boolean = false,
|
||||
onPickMultiple: ((List<File>) -> Unit)? = null,
|
||||
) {
|
||||
var dir by remember { mutableStateOf(defaultBrowseRoot()) }
|
||||
// Absolute paths, so a selection survives navigating away and back.
|
||||
val selected = remember { mutableStateListOf<File>() }
|
||||
|
||||
// Directories first, then matching files, each alphabetical — the order
|
||||
// people expect from a file manager.
|
||||
@@ -119,6 +126,13 @@ fun FileBrowserDialog(
|
||||
overflow = TextOverflow.MiddleEllipsis,
|
||||
)
|
||||
}
|
||||
if (allowMultiple && selected.isNotEmpty()) {
|
||||
// Sorted by name: split parts are .pkg_0/.pkg_1/... and the
|
||||
// installer requires them in order.
|
||||
TextButton(onClick = {
|
||||
onPickMultiple?.invoke(selected.sortedBy { it.name.lowercase() })
|
||||
}) { Text(str("browse.installSelected").format(selected.size)) }
|
||||
}
|
||||
TextButton(onClick = onDismiss) { Text(str("action.cancel")) }
|
||||
}
|
||||
|
||||
@@ -140,16 +154,25 @@ fun FileBrowserDialog(
|
||||
item { BrowserRow("..", isDir = true) { dir = parent } }
|
||||
}
|
||||
items(entries) { entry ->
|
||||
val ticked = allowMultiple && entry in selected
|
||||
BrowserRow(
|
||||
label = entry.name,
|
||||
label = if (ticked) "✓ ${entry.name}" else entry.name,
|
||||
isDir = entry.isDirectory,
|
||||
detail = if (entry.isFile) {
|
||||
"%.1f MB".format(entry.length() / 1_048_576f)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
highlighted = ticked,
|
||||
) {
|
||||
if (entry.isDirectory) dir = entry else onPick(entry)
|
||||
when {
|
||||
entry.isDirectory -> dir = entry
|
||||
// In multi-select a tap toggles instead of confirming;
|
||||
// confirming is the button in the header.
|
||||
allowMultiple ->
|
||||
if (!selected.remove(entry)) selected.add(entry)
|
||||
else -> onPick(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (entries.isEmpty()) {
|
||||
@@ -174,14 +197,18 @@ private fun BrowserRow(
|
||||
label: String,
|
||||
isDir: Boolean,
|
||||
detail: String? = null,
|
||||
highlighted: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(
|
||||
if (isDir) MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
|
||||
else MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f),
|
||||
when {
|
||||
highlighted -> MaterialTheme.colorScheme.primary.copy(alpha = 0.35f)
|
||||
isDir -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
|
||||
else -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.45f)
|
||||
},
|
||||
RoundedCornerShape(12.dp),
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
|
||||
@@ -85,6 +85,10 @@ import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/** Screen-aspect presets in permille, index-aligned with the in-game picker. Mirrors
|
||||
* SCREEN_ASPECTS in RendererTab minus the Custom entry (no slider in the quick menu). */
|
||||
private val IN_GAME_SCREEN_ASPECTS = listOf(0, 1333, 1600, 1778, 2000, 2167, 2222, 2333)
|
||||
|
||||
@Composable
|
||||
fun EmulationMenuScreen(viewModel: EmulationMenuViewModel = viewModel()) {
|
||||
val state = viewModel.state.value
|
||||
@@ -811,6 +815,24 @@ private fun GraphicsPane(state: EmulationMenuUiState, viewModel: EmulationMenuVi
|
||||
onSelect = { v -> viewModel.updateSettings { it.copy(displayFitMode = v) } },
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
// Screen aspect, same list as the Renderer tab. In-game is where you actually
|
||||
// want this -- you are looking at the picture while you change it. Custom is
|
||||
// omitted here on purpose: a slider belongs on the settings screen, and the
|
||||
// presets are what a handheld user needs. A custom value set in Settings shows
|
||||
// as no selection here and is left alone unless a preset is picked.
|
||||
HorizontalOptions(
|
||||
title = str("renderer.screenAspect.label"),
|
||||
options = listOf(
|
||||
str("common.auto"), "4:3", "16:10", "16:9", "18:9", "19.5:9", "20:9", "21:9",
|
||||
).mapIndexed { index, label -> index to label },
|
||||
selected = IN_GAME_SCREEN_ASPECTS.indexOf(settings.ps3.displayAspect),
|
||||
onSelect = { v ->
|
||||
viewModel.updateSettings {
|
||||
it.copy(ps3 = it.ps3.copy(displayAspect = IN_GAME_SCREEN_ASPECTS[v]))
|
||||
}
|
||||
},
|
||||
)
|
||||
Spacer(Modifier.height(6.dp))
|
||||
// RSX accuracy -- the levers that actually matter on this core, and the
|
||||
// reason the PS2 GS rows above had to go rather than just be hidden.
|
||||
HorizontalOptions(
|
||||
|
||||
@@ -59,7 +59,9 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// pins the library empty forever: the key still matches on every
|
||||
// later launch, so nothing ever rescans. An empty library is never
|
||||
// a useful cached result, so never trust one.
|
||||
pendingInitialScan = romDirectories.isNotEmpty() &&
|
||||
// Installed (PKG) games live in the emulator's own storage, so a user with no
|
||||
// ROM folder at all can still have a library worth scanning.
|
||||
pendingInitialScan = (romDirectories.isNotEmpty() || repository.hasInternalGames()) &&
|
||||
(cached.games.isEmpty() || cached.key != repository.cacheKey(romDirectories))
|
||||
android.util.Log.i("ARMSX3-Scan", "load(first): dirs=$romDirectories nativeReady=$nativeReady cachedKey=${cached.key} newKey=${repository.cacheKey(romDirectories)} cachedGames=${cached.games.size} pending=$pendingInitialScan")
|
||||
state.value = buildState(
|
||||
@@ -80,7 +82,9 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
if (directories.isEmpty() || scanJob?.isActive == true) return
|
||||
if ((directories.isEmpty() && !repository.hasInternalGames()) ||
|
||||
scanJob?.isActive == true
|
||||
) return
|
||||
scanJob = scope.launch {
|
||||
val initialScan = pendingInitialScan && state.value.allGames.isEmpty()
|
||||
state.value = state.value.copy(
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
package com.armsx2.ui.packages
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import android.os.ParcelFileDescriptor
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.armsx2.data.library.GameLibraryRepository
|
||||
import com.armsx2.i18n.I18n
|
||||
import com.armsx2.i18n.str
|
||||
import com.armsx2.runtime.MainActivityRuntime
|
||||
import com.armsx2.ui.common.ArmsBackdrop
|
||||
import com.armsx2.ui.common.FileBrowserDialog
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.rpcsx.ProgressRepository
|
||||
import net.rpcsx.RPCSX
|
||||
|
||||
/**
|
||||
* Install a .pkg (game, update or DLC) into the emulator's own storage.
|
||||
*
|
||||
* The native side already did all of this -- _rpcsx_install dispatches on the file's
|
||||
* magic and hands a PKG to installPkg -- but RPCSX.instance.install() had ZERO callers,
|
||||
* so there was no way to reach it from the UI. Reported by two testers as a serious
|
||||
* oversight, and it read as a missing feature rather than a missing button.
|
||||
*
|
||||
* Installs land in config/dev_hdd0/game, which GameLibraryRepository now scans, so the
|
||||
* title shows up in the library on the next scan. The cache is keyed by folder set and
|
||||
* would not notice a new game inside a folder it already knows, hence invalidateCache().
|
||||
*/
|
||||
/**
|
||||
* Titles installed into the emulator's own storage, i.e. everything a PKG install
|
||||
* produced. Read from disk rather than from the library, so uninstall still works when
|
||||
* the library cache is stale and can never point at a user's ROM folder.
|
||||
*/
|
||||
private fun readInstalled(): List<java.io.File> =
|
||||
java.io.File(RPCSX.rootDirectory, "config/dev_hdd0/game")
|
||||
.listFiles()
|
||||
?.filter { it.isDirectory }
|
||||
?.sortedBy { it.name }
|
||||
.orEmpty()
|
||||
|
||||
@Composable
|
||||
fun PackageInstallerScreen(onBack: () -> Unit) {
|
||||
val context = LocalContext.current
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
var message by remember { mutableStateOf<String?>(null) }
|
||||
var showBrowser by remember { mutableStateOf(false) }
|
||||
var progressId by remember { mutableStateOf<Long?>(null) }
|
||||
var installed by remember { mutableStateOf(readInstalled()) }
|
||||
var confirmRemove by remember { mutableStateOf<java.io.File?>(null) }
|
||||
|
||||
// getItem returns MutableState<ProgressEntry>; reading .value here and .longValue
|
||||
// below is what subscribes this composable to the native progress callbacks.
|
||||
val progress = ProgressRepository.getItem(progressId)?.value
|
||||
val fraction = progress?.let {
|
||||
if (it.isIndeterminate()) null
|
||||
else (it.value.longValue.toFloat() / it.max.longValue.coerceAtLeast(1)).coerceIn(0f, 1f)
|
||||
}
|
||||
|
||||
// One path for both the single pick and the multi-part pick. Every descriptor stays
|
||||
// open for the whole install: the native side takes raw fds and releases the handles
|
||||
// itself, so closing them early would pull the file out from under the extractor.
|
||||
fun install(files: List<java.io.File>) {
|
||||
if (files.isEmpty()) return
|
||||
showBrowser = false
|
||||
busy = true
|
||||
message = null
|
||||
MainActivityRuntime.invoke {
|
||||
val ok = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val label = if (files.size == 1) files[0].name
|
||||
else "${files.size} package parts"
|
||||
val id = ProgressRepository.create(context, "Installing $label")
|
||||
progressId = id
|
||||
val descriptors = files.map {
|
||||
ParcelFileDescriptor.open(it, ParcelFileDescriptor.MODE_READ_ONLY)
|
||||
}
|
||||
try {
|
||||
if (descriptors.size == 1) {
|
||||
RPCSX.instance.install(descriptors[0].fd, id)
|
||||
} else {
|
||||
RPCSX.instance.installSplitPkg(
|
||||
descriptors.map { it.fd }.toIntArray(), id,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
descriptors.forEach { runCatching { it.close() } }
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
busy = false
|
||||
progressId = null
|
||||
// I18n.get, not str(): this runs inside a coroutine, and str() is a
|
||||
// @Composable that can only be called during composition.
|
||||
message = if (ok) {
|
||||
// Force the library to re-read storage; the folder set is unchanged
|
||||
// so nothing else would prompt a rescan.
|
||||
GameLibraryRepository(context).invalidateCache()
|
||||
installed = readInstalled()
|
||||
I18n.get("packages.install.done")
|
||||
} else {
|
||||
I18n.get("packages.install.failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deleting a game folder is not undoable, so it is confirmed rather than done on tap.
|
||||
confirmRemove?.let { target ->
|
||||
AlertDialog(
|
||||
onDismissRequest = { confirmRemove = null },
|
||||
title = { Text(str("packages.uninstall.confirmTitle")) },
|
||||
text = { Text(str("packages.uninstall.confirmBody").format(target.name)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
confirmRemove = null
|
||||
MainActivityRuntime.invoke {
|
||||
val ok = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
RPCSX.instance.uninstallGame(target.absolutePath)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
installed = readInstalled()
|
||||
if (ok) GameLibraryRepository(context).invalidateCache()
|
||||
message = I18n.get(
|
||||
if (ok) "packages.uninstall.done" else "packages.uninstall.failed",
|
||||
)
|
||||
}
|
||||
}) { Text(str("packages.uninstall")) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { confirmRemove = null }) { Text(str("action.cancel")) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
if (showBrowser) {
|
||||
FileBrowserDialog(
|
||||
title = str("packages.select.title"),
|
||||
// PUP is deliberately absent: firmware has its own screen, and routing it
|
||||
// through here would let someone install firmware from a menu that says
|
||||
// nothing about it. EDAT rides along because _rpcsx_install handles it and
|
||||
// it is what DLC licences arrive as.
|
||||
extensions = setOf("pkg", "edat"),
|
||||
// Split releases ship as several .pkg parts that only install correctly when
|
||||
// handed to the installer together, the way RPCS3 desktop does it.
|
||||
allowMultiple = true,
|
||||
onPickMultiple = { files -> install(files) },
|
||||
onPick = { file -> install(listOf(file)) },
|
||||
onDismiss = { showBrowser = false },
|
||||
)
|
||||
}
|
||||
|
||||
ArmsBackdrop {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
str("packages.title"),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
||||
Surface(
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
Text(
|
||||
str("packages.description"),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Text(
|
||||
str("packages.multiHint"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
if (busy) {
|
||||
if (fraction != null) {
|
||||
LinearProgressIndicator(
|
||||
progress = { fraction },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
} else {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
Text(
|
||||
str("packages.installing"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
Button(onClick = { showBrowser = true }) {
|
||||
Text(str("packages.select.action"))
|
||||
}
|
||||
}
|
||||
|
||||
message?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Installed titles, with uninstall. Only what is under the emulator's own
|
||||
// dev_hdd0/game is listed, so this can never remove a disc or ROM folder.
|
||||
if (installed.isNotEmpty()) {
|
||||
Text(
|
||||
str("packages.installed.header"),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().heightIn(max = 320.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
items(installed, key = { it.absolutePath }) { dir ->
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(start = 14.dp, end = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
dir.name,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = { confirmRemove = dir }) {
|
||||
Text(str("packages.uninstall"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(onClick = onBack) { Text(str("action.back")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
package com.armsx2.ui.settings
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
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.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.armsx2.i18n.I18n
|
||||
import com.armsx2.i18n.str
|
||||
import com.armsx2.ui.common.ArmsBackdrop
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import net.rpcsx.RPCSX
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Every RPCS3 setting, generated from the core's own config tree.
|
||||
*
|
||||
* The curated tabs are deliberately small and opinionated -- they cover what a handheld
|
||||
* player actually touches. But the PS3 config is far larger than any hand-written screen
|
||||
* can track, and a static UI silently goes stale the moment upstream adds or renames a
|
||||
* node (which is exactly how 176 of the 245 search-index entries ended up naming PCSX2
|
||||
* settings). Nothing here is hardcoded: _rpcsx_settingsGet("") walks g_cfg and emits the
|
||||
* whole tree with each node's type, current value, default, enum variants and range, and
|
||||
* this renders whatever it finds.
|
||||
*
|
||||
* Writes go straight back through settingsSet, so a node added upstream tomorrow is
|
||||
* editable here with no app change.
|
||||
*/
|
||||
|
||||
/** One editable leaf of the core config tree. */
|
||||
private data class CoreSetting(
|
||||
val path: String,
|
||||
val name: String,
|
||||
val section: String,
|
||||
val type: String,
|
||||
val value: String,
|
||||
val default: String,
|
||||
val variants: List<String>,
|
||||
val min: Long?,
|
||||
val max: Long?,
|
||||
)
|
||||
|
||||
/** Flatten the tree the core emits into a list of leaves, remembering each one's path. */
|
||||
private fun flatten(
|
||||
node: JSONObject,
|
||||
prefix: String,
|
||||
section: String,
|
||||
out: MutableList<CoreSetting>,
|
||||
) {
|
||||
for (key in node.keys()) {
|
||||
val child = node.optJSONObject(key) ?: continue
|
||||
// A leaf carries "type"; anything else is a container. That is the only structural
|
||||
// signal the emitter gives, and it is unambiguous: cfg::node never emits "type".
|
||||
val type = child.optString("type", "")
|
||||
// Paths use "@@" because that is what find_cfg_node splits on.
|
||||
val path = if (prefix.isEmpty()) key else "$prefix@@$key"
|
||||
if (type.isEmpty()) {
|
||||
flatten(child, path, if (prefix.isEmpty()) key else section, out)
|
||||
continue
|
||||
}
|
||||
val variants = child.optJSONArray("variants")?.let { array ->
|
||||
List(array.length()) { array.optString(it) }
|
||||
}.orEmpty()
|
||||
out += CoreSetting(
|
||||
path = path,
|
||||
name = key,
|
||||
section = section.ifEmpty { key },
|
||||
type = type,
|
||||
// Bools arrive as real JSON booleans, everything else as strings.
|
||||
value = if (type == "bool") child.optBoolean("value").toString()
|
||||
else child.optString("value"),
|
||||
default = if (type == "bool") child.optBoolean("default").toString()
|
||||
else child.optString("default"),
|
||||
variants = variants,
|
||||
min = child.optString("min").toLongOrNull(),
|
||||
max = child.optString("max").toLongOrNull(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CoreSettingsScreen(onBack: () -> Unit) {
|
||||
var all by remember { mutableStateOf<List<CoreSetting>>(emptyList()) }
|
||||
var query by remember { mutableStateOf("") }
|
||||
var error by remember { mutableStateOf<String?>(null) }
|
||||
// Bumped after every write so the tree is re-read and dependent nodes (a value the core
|
||||
// clamped, say) show what the core actually stored rather than what we sent.
|
||||
var revision by remember { mutableStateOf(0) }
|
||||
|
||||
LaunchedEffect(revision) {
|
||||
val loaded = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val raw = RPCSX.instance.settingsGet("")
|
||||
if (raw.isBlank()) return@runCatching emptyList<CoreSetting>()
|
||||
buildList { flatten(JSONObject(raw), "", "", this) }
|
||||
}
|
||||
}
|
||||
// I18n.get, not str(): this is a coroutine body, and str() is @Composable.
|
||||
loaded.onSuccess {
|
||||
all = it
|
||||
error = if (it.isEmpty()) I18n.get("core.settings.unavailable") else null
|
||||
}
|
||||
loaded.onFailure { error = I18n.get("core.settings.unavailable") }
|
||||
}
|
||||
|
||||
fun write(setting: CoreSetting, raw: String) {
|
||||
// settingsSet takes JSON: bools and numbers bare, enums and strings quoted.
|
||||
val encoded = when (setting.type) {
|
||||
"bool", "int", "uint", "float" -> raw
|
||||
else -> JSONObject.quote(raw)
|
||||
}
|
||||
runCatching { RPCSX.instance.settingsSet(setting.path, encoded) }
|
||||
revision++
|
||||
}
|
||||
|
||||
val filtered = remember(all, query) {
|
||||
if (query.isBlank()) all
|
||||
else all.filter {
|
||||
it.name.contains(query, ignoreCase = true) ||
|
||||
it.section.contains(query, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
ArmsBackdrop {
|
||||
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
|
||||
Text(
|
||||
str("core.settings.title"),
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Text(
|
||||
str("core.settings.description"),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 8.dp),
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = { query = it },
|
||||
label = { Text(str("action.search")) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
|
||||
error?.let {
|
||||
Text(
|
||||
it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxWidth().padding(top = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
items(filtered, key = { it.path }) { setting ->
|
||||
Surface(
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(Modifier.padding(horizontal = 12.dp, vertical = 4.dp)) {
|
||||
Text(
|
||||
setting.section,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
CoreSettingRow(setting) { write(setting, it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextButton(onClick = onBack, modifier = Modifier.padding(top = 8.dp)) {
|
||||
Text(str("action.back"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Pick a widget from the node's declared type, not from a hardcoded table. */
|
||||
@Composable
|
||||
private fun CoreSettingRow(setting: CoreSetting, onWrite: (String) -> Unit) {
|
||||
when {
|
||||
setting.type == "bool" -> ToggleRow(
|
||||
setting.name,
|
||||
setting.value == "true",
|
||||
) { onWrite(it.toString()) }
|
||||
|
||||
setting.variants.isNotEmpty() -> SegmentedGridRow(
|
||||
label = setting.name,
|
||||
options = setting.variants,
|
||||
selectedIndex = setting.variants.indexOf(setting.value).coerceAtLeast(0),
|
||||
columns = 2,
|
||||
onChange = { onWrite(setting.variants[it]) },
|
||||
)
|
||||
|
||||
// Only ranged numerics get a slider. An unbounded int (a port number, a byte
|
||||
// count) would give a slider covering the whole 32-bit range, which is useless.
|
||||
(setting.type == "int" || setting.type == "uint") &&
|
||||
setting.min != null && setting.max != null &&
|
||||
setting.max - setting.min in 1..100_000 -> IntSliderRow(
|
||||
label = setting.name,
|
||||
value = (setting.value.toLongOrNull() ?: setting.min).coerceIn(setting.min, setting.max).toInt(),
|
||||
min = setting.min.toInt(),
|
||||
max = setting.max.toInt(),
|
||||
onChange = { onWrite(it.toString()) },
|
||||
)
|
||||
|
||||
else -> {
|
||||
var text by remember(setting.path, setting.value) { mutableStateOf(setting.value) }
|
||||
// rememberUpdatedState so the focus callback -- which Compose may hold across
|
||||
// recompositions -- reads the CURRENT text rather than whatever it captured
|
||||
// when the modifier was first built.
|
||||
val latest by rememberUpdatedState(text)
|
||||
val original by rememberUpdatedState(setting.value)
|
||||
OutlinedTextField(
|
||||
value = text,
|
||||
onValueChange = { text = it },
|
||||
label = { Text(setting.name) },
|
||||
singleLine = true,
|
||||
// Commit on focus loss, not per keystroke: every write re-reads the whole
|
||||
// tree, and doing that per character would rebuild the list constantly.
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 6.dp)
|
||||
.onFocusChanged { state ->
|
||||
if (!state.isFocused && latest != original) onWrite(latest)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,10 +12,30 @@ import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import android.content.Context
|
||||
import android.widget.Toast
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.armsx2.EmuState
|
||||
import com.armsx2.config.Settings
|
||||
import com.armsx2.i18n.I18n
|
||||
import com.armsx2.i18n.str
|
||||
import com.armsx2.runtime.MainActivityRuntime
|
||||
import com.armsx2.ui.Colors
|
||||
import com.armsx2.ui.InGameOverlay
|
||||
import androidx.core.content.edit
|
||||
import java.io.File
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -333,5 +353,127 @@ fun PerformanceTab(state: MutableState<Settings>) {
|
||||
onChange = { apply(s.copy(ps3 = s.ps3.copy(spuXFloat = it))) },
|
||||
)
|
||||
}
|
||||
SettingsDivider()
|
||||
// Compiled-code caches. Separate from the shader cache on the Renderer tab:
|
||||
// these hold recompiled PPU/SPU code, not GPU pipelines.
|
||||
CollapsibleSection(str("perf.caches.title")) {
|
||||
ClearCacheRow(spuOnly = true)
|
||||
SettingsDivider()
|
||||
ClearCacheRow(spuOnly = false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Root of RPCS3's compiled-code cache: `<files>/cache/cache/`.
|
||||
*
|
||||
* Holds `ppu-<hash>-<name>` directories for firmware modules at the top level, plus
|
||||
* `<TITLEID>/ppu-<hash>-EBOOT.BIN` per game. The SPU cache is a `spu-*.dat` file INSIDE
|
||||
* those directories, which is why clearing PPU necessarily clears SPU with it.
|
||||
*/
|
||||
private fun recompilerCacheRoot(context: Context): File =
|
||||
File(MainActivityRuntime.assetCopyRoot(context), "cache/cache")
|
||||
|
||||
/** Every `ppu-*` directory, both the top-level firmware ones and the per-title ones. */
|
||||
private fun ppuCacheDirs(root: File): List<File> = buildList {
|
||||
root.listFiles()?.forEach { entry ->
|
||||
if (!entry.isDirectory) return@forEach
|
||||
if (entry.name.startsWith("ppu-")) add(entry)
|
||||
// A title id directory; its ppu-* dirs live one level down.
|
||||
else entry.listFiles()?.forEach { if (it.isDirectory && it.name.startsWith("ppu-")) add(it) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun File.sizeRecursive(): Long =
|
||||
runCatching { walkTopDown().filter { it.isFile }.sumOf { it.length() } }.getOrDefault(0L)
|
||||
|
||||
private fun formatBytes(bytes: Long): String = when {
|
||||
bytes >= 1024L * 1024 * 1024 -> "%.1f GB".format(bytes / (1024.0 * 1024 * 1024))
|
||||
bytes >= 1024L * 1024 -> "%.0f MB".format(bytes / (1024.0 * 1024))
|
||||
bytes > 0 -> "%.0f KB".format(bytes / 1024.0)
|
||||
else -> "0 KB"
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the SPU cache only, or the whole PPU cache.
|
||||
*
|
||||
* SPU-only leaves the compiled PPU modules in place, so booting stays as fast as it was and
|
||||
* only the SPU programs rebuild. Clearing PPU removes the directories outright, which takes
|
||||
* the SPU caches nested inside them too.
|
||||
*/
|
||||
private fun clearRecompilerCache(root: File, spuOnly: Boolean): Pair<Int, Long> {
|
||||
var count = 0
|
||||
var bytes = 0L
|
||||
|
||||
if (spuOnly) {
|
||||
root.walkTopDown()
|
||||
.filter { it.isFile && it.name.startsWith("spu-") && it.extension == "dat" }
|
||||
.toList() // materialise before deleting, so the walk is not mutated under itself
|
||||
.forEach { file ->
|
||||
val size = file.length()
|
||||
if (runCatching { file.delete() }.getOrDefault(false)) {
|
||||
count++
|
||||
bytes += size
|
||||
}
|
||||
}
|
||||
return count to bytes
|
||||
}
|
||||
|
||||
ppuCacheDirs(root).forEach { dir ->
|
||||
val size = dir.sizeRecursive()
|
||||
if (runCatching { dir.deleteRecursively() }.getOrDefault(false)) {
|
||||
count++
|
||||
bytes += size
|
||||
}
|
||||
}
|
||||
return count to bytes
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ClearCacheRow(spuOnly: Boolean) {
|
||||
val context = LocalContext.current
|
||||
val status = remember { mutableStateOf("") }
|
||||
val labelKey = if (spuOnly) "perf.clearSpuCache.label" else "perf.clearPpuCache.label"
|
||||
val descKey = if (spuOnly) "perf.clearSpuCache.description" else "perf.clearPpuCache.description"
|
||||
|
||||
Box(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(rowAura())
|
||||
.clickable {
|
||||
// Refuse while a game is loaded: the running VM holds these files open and
|
||||
// is still writing to them, so deleting underneath it achieves nothing useful
|
||||
// and risks a half-written cache on the next boot.
|
||||
if (MainActivityRuntime.eState.value != EmuState.STOPPED) {
|
||||
status.value = I18n.get("perf.clearCache.stopFirst")
|
||||
} else {
|
||||
val (count, bytes) = clearRecompilerCache(recompilerCacheRoot(context), spuOnly)
|
||||
status.value = if (count > 0) {
|
||||
I18n.get("perf.clearCache.done").format(count, formatBytes(bytes))
|
||||
} else {
|
||||
I18n.get("perf.clearCache.alreadyEmpty")
|
||||
}
|
||||
}
|
||||
Toast.makeText(context, status.value, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
.padding(horizontal = 6.dp, vertical = 5.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Column {
|
||||
Text(
|
||||
str(labelKey),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Spacer(Modifier.height(2.dp))
|
||||
Text(
|
||||
status.value.ifEmpty { I18n.get(descKey) },
|
||||
color = Colors.pasx2_blue,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,23 @@ internal val UPSCALE_OPTIONS = listOf(
|
||||
UpscaleOption(8.0f, "8x"),
|
||||
)
|
||||
|
||||
/** Screen-aspect presets in permille, index-aligned with the picker's options. Anything not
|
||||
* in this list is a custom value, which is what selects the Custom entry and its slider. */
|
||||
private val SCREEN_ASPECTS = listOf(
|
||||
0, // Auto - follow whatever the game signalled (4:3 or 16:9)
|
||||
1333, // 4:3
|
||||
1600, // 16:10
|
||||
1778, // 16:9
|
||||
2000, // 18:9
|
||||
2167, // 19.5:9
|
||||
2222, // 20:9
|
||||
2333, // 21:9
|
||||
)
|
||||
|
||||
/** Seed for Custom. Deliberately NOT a preset value: landing on one would re-select that
|
||||
* preset and hide the slider the user just asked for. */
|
||||
private const val CUSTOM_ASPECT_SEED = 2100
|
||||
|
||||
@Composable
|
||||
fun RendererTab(state: MutableState<Settings>) {
|
||||
val s = state.value
|
||||
@@ -207,6 +224,42 @@ fun RendererTab(state: MutableState<Settings>) {
|
||||
onChange = { apply(s.copy(displayFitMode = it)) },
|
||||
)
|
||||
SettingsDivider()
|
||||
// SCREEN aspect, distinct from Console Aspect above. Console aspect is what the
|
||||
// GAME thinks it is drawing (4:3 or 16:9, all the PS3 could signal); this is the
|
||||
// shape of the letterbox its image is fitted into on YOUR panel. Without it a
|
||||
// 20:9 handheld had only Fit (pillarboxed) or Stretch (distorted), which is what
|
||||
// "no way to adjust between custom aspect ratios like armsx2" was about.
|
||||
// Stored in permille to match the core's cfg::_int.
|
||||
SegmentedGridRow(
|
||||
label = str("renderer.screenAspect.label"),
|
||||
options = listOf(
|
||||
str("common.auto"), "4:3", "16:10", "16:9", "18:9", "19.5:9", "20:9", "21:9",
|
||||
str("renderer.screenAspect.custom"),
|
||||
),
|
||||
selectedIndex = SCREEN_ASPECTS.indexOf(s.ps3.displayAspect)
|
||||
.let { if (it >= 0) it else SCREEN_ASPECTS.size },
|
||||
columns = 3,
|
||||
description = str("renderer.screenAspect.description"),
|
||||
onChange = { index ->
|
||||
// Last option is Custom: seed the slider from the panel's own ratio so it
|
||||
// starts somewhere useful instead of snapping the image on selection.
|
||||
val permille = SCREEN_ASPECTS.getOrNull(index) ?: CUSTOM_ASPECT_SEED
|
||||
apply(s.copy(ps3 = s.ps3.copy(displayAspect = permille)))
|
||||
},
|
||||
)
|
||||
if (s.ps3.displayAspect !in SCREEN_ASPECTS) {
|
||||
SettingsDivider()
|
||||
IntSliderRow(
|
||||
label = str("renderer.screenAspect.customValue"),
|
||||
value = s.ps3.displayAspect.coerceIn(1000, 3000),
|
||||
min = 1000,
|
||||
max = 3000,
|
||||
description = str("renderer.screenAspect.customValue.description"),
|
||||
valueFormatter = { String.format(java.util.Locale.US, "%.2f:1", it / 1000f) },
|
||||
onChange = { apply(s.copy(ps3 = s.ps3.copy(displayAspect = it))) },
|
||||
)
|
||||
}
|
||||
SettingsDivider()
|
||||
// PS3 output resolution -- what the console reports to the game.
|
||||
// Separate from Resolution Scale above, which is internal upscaling.
|
||||
SegmentedGridRow(
|
||||
|
||||
+5
-1
@@ -43,7 +43,7 @@ internal val SETTINGS_CATEGORY_FIELDS: Map<SettingsCategory, List<String>> = map
|
||||
"shadeBoost", "shadeBoostBrightness", "shadeBoostContrast", "shadeBoostGamma",
|
||||
"shadeBoostSaturation", "shaderChainEnabled", "shaderChainParams", "shaderChainPreset",
|
||||
"textureFiltering", "texturePreloading", "triFilter", "tvShader", "upscaleFloat",
|
||||
"vsyncEnable",
|
||||
"vsyncEnable", "displayFitMode", "ps3DisplayAspect",
|
||||
),
|
||||
// AudioTab.kt
|
||||
SettingsCategory.Audio to listOf(
|
||||
@@ -64,6 +64,10 @@ internal val SETTINGS_CATEGORY_FIELDS: Map<SettingsCategory, List<String>> = map
|
||||
"osdShowGpuStats", "osdShowGsStats", "osdShowHardwareInfo", "osdShowInputs",
|
||||
"osdShowMessages", "osdShowResolution", "osdShowSettings", "osdShowSpeed",
|
||||
"osdShowVersion", "osdShowVps",
|
||||
// RPCS3's own performance overlay (the lower half of the tab).
|
||||
"ps3OverlayEnabled", "ps3OverlayDetail", "ps3OverlayPosition", "ps3OverlayFontSize",
|
||||
"ps3OverlayOpacity", "ps3OverlayFramerateGraph", "ps3OverlayFrametimeGraph",
|
||||
"ps3OverlayBodyColor", "ps3OverlayBodyBg", "ps3OverlayTitleColor", "ps3OverlayTitleBg",
|
||||
),
|
||||
// FixesTab.kt — also owns the GameDB fixes and the recompiler toggles, which moved here
|
||||
// from Performance and from the retired Recompiler tab.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user