10 Commits
Author SHA1 Message Date
jpolo1224 da6cf3bb49 Add PPU and SPU cache clearing
Two rows under a Compiled Code Cache section on the Performance tab, next to the
recompiler settings and separate from the shader cache on the Renderer tab, which
holds GPU pipelines rather than recompiled code.

The layout is <files>/cache/cache/, with ppu-<hash>-<name> directories for
firmware modules at the top level and <TITLEID>/ppu-<hash>-EBOOT.BIN per game.
The SPU cache is a spu-*.dat inside those, so the two options are not symmetric
and are worded accordingly: clearing SPU removes only those files and leaves
booting as fast as it was, while clearing PPU removes the directories outright
and necessarily takes the SPU caches with them.

Both report how much was freed, and both refuse while a game is loaded, since a
running VM holds those files open and is still writing to them.
2026-08-07 00:01:59 -04:00
jpolo1224 dbc43ec6dc Guarantee . and .. from sys_fs_opendir on host directories
fs::unix_dir::read is a bare readdir passthrough, so a listing is whatever the
host filesystem reports, in whatever order. ext4 conventionally yields . and ..
first; Android's FUSE layer over exFAT does not emit them at all. A directory
holding one file therefore left a single entry, and the stable_sort that follows
starts at data.begin() + 2, so it ran with first past last. That is undefined
behaviour rather than a no-op: it corrupted memory and the guest died later
inside libfs reading a wild pointer, with nothing at the crash site pointing back
at the cause.

Not only a crash guard. The PS3 returns both entries from opendir, so any game
walking a directory on the host filesystem was getting a listing the console
would never produce. iso_device synthesises them already, which is why the same
game booted from an .iso was unaffected and every folder format game on an SD
card was exposed. Found by booting Minecraft both ways.
2026-08-06 23:58:46 -04:00
jpolo1224 92816b9424 Fix two crashes booting folder format games
Boot audio aborted the process. init_audio does ensure() on
Emu.GetCallbacks().make_video_source(), and the Android callbacks return nullptr
because there is no media backend, so ensure() killed the app outright. It fired
for any game whose folder holds a SND0.AT3, which is every folder format game:
for an .iso the fs::is_file check in rsx::thread::thread looks inside the mounted
virtual device and never finds one, so every .iso booted so far dodged it by
accident. A null source is now handled and logged. Boot music does not play,
which it could not have anyway. PKG installed games were exposed to this too,
since they also sit on the real filesystem.

PPU compilation ran out of memory. jit_core_allocator::limit() sizes the LLVM
compile workers on core count alone, which is right on a desktop and fatal on a
handheld: eight workers on a 7 GB device aborted inside
llvm::report_bad_alloc_error partway through a large title, with Max LLVM
Compile Threads left at 0 for "use every core". The limit is now bounded by
physical memory as well, roughly one worker per 1.5 GB and never below one. It
only caps the automatic default; an explicit setting is still honoured.
2026-08-06 23:48:51 -04:00
jpolo1224 f04aa0e823 Close the progress dialog when work is complete but its text is held
The progress dialog server only leaves its loop when the counters match AND
g_progr_text is empty. That text is refcounted across nested progress scopes, so
a leaked reference leaves the loop spinning forever: the dialog is never closed,
and the cleanup that resets g_progr_ptotal never runs either, which is what
ppu_thread::cpu_task waits on before switching to overlay-message mode.

Seen on device with a fully booted, running game sitting behind a "Building SPU
Cache... 941 of 941" dialog for over ten minutes. The RSX thread and two SPU
threads were at 97%, syscall counters were climbing, and nothing had compiled
since six minutes in. Note the label is stale in that state: the server only
overwrites its cached text when it receives a non-empty one, so the last
meaningful message stays on screen and says nothing about which scope leaked.

Closes the dialog once the counters are complete and have been completely idle
for roughly five seconds. wait_no_update_count resets on any change to any
counter or to the text, so work in progress can never reach the threshold. The
warning names the held text, which is what will identify the leaking scope.

This is a safety net. The reference leak itself is still there.
2026-08-06 23:34:25 -04:00
jpolo1224 2c06abaf57 Fix aspect ratio, PKG install crash and folder boot; add split PKG and uninstall
The aspect ratio rows did nothing because Rpcs3Bridge's PS3/Video branch has an
explicit key list ending in `else -> return false`, and neither the new Display
Aspect Override nor Stretch To Display Area was in it, so both were dropped into
Unsupported.note(). Stretch had only ever reached the core through the legacy
EmuCore/GS AspectRatio key, which is why Display Mode looked inert too. Audited
the rest: all 66 PS3 keys applyTo writes are handled now. Screen aspect is also
in the in-game menu, where you can see what you are changing.

Installing a package crashed because a successful install queued the new title
for precompilation, and that path calls Emu.SetState(running), g_fxo->init<> and
vm::init(). Those are safe once, during onboarding, and not in a process that has
already booted a game. Extraction had finished by then, so the title still showed
up on the next launch. Nothing precompiles on install now; it happens on first
boot like any other game.

Installed titles booted to a black screen because BootGame was handed the game
directory. RPCSX boots them by the bootable path the installer reports, which is
the EBOOT, so directories are resolved through locateEbootPath first.

Split packages can now be installed. package_reader::extract_data always took a
deque of readers, only the entry point was single file, so a game split into
parts could not be installed at all. Select all the parts and confirm; they are
sorted by name and handed over together.

Installed titles can be uninstalled from the same screen. The path is checked
against dev_hdd0/game before anything is deleted, so it cannot touch a ROM folder.
2026-08-06 23:09:33 -04:00
jpolo1224 60aa81287f Fix first round of community reports
Settings that would not stick: the eleven Ps3Settings overlay fields were
wired into the Overlay tab but missing from all four of Settings.kt's
serialisation paths, so they only ever lived in memory and reverted the next
time the screen re-read the store. Added them to toJson, fromJson, diffFrom,
merge and the per-tab reset list.

In-game menu lag: every settingsSet ended in SaveSettings(g_cfg.to_string()),
which serialises the whole config and writes it out. applyTo pushes about 165
keys per change, so one toggle cost 165 whole-config writes on the UI thread.
Added settingsBeginBatch/settingsEndBatch and wrapped applyTo in it.

Folder format games are now detected, both the disc layout with PS3_GAME and
an installed game folder with PARAM.SFO next to USRDIR, and probeDiscInfo
reads either without mounting anything. The emulator's own dev_hdd0/game and
games directories are scanned alongside the user's ROM folders, which is why
nothing installed was ever showing up.

Package installer: the native side already handled pkg, pup, edat and iso,
but nothing in the app ever called it. Added a screen and a drawer entry.

Screen aspect ratio: the PS3 only signalled 4:3 or 16:9 so there was no way
to fill a handheld panel without stretching. Added an output aspect override
with presets and a custom slider.

All core settings screen, generated from the config tree rather than written
by hand, so nodes added upstream are editable with no app change.

Also: 176 of the 245 settings search entries still named PCSX2 settings, the
tagline still said PlayStation 2, and a missing fw.json on first run printed
a stack trace that read as a crash.
2026-08-06 21:32:13 -04:00
jpolo1224 4a3d9e322f Point the in-app links at ARMSX3 and RPCS3
What's New was reading releases from the ARMSX2 repo, and the About page
credited PCSX2 and linked to it. The navigation drawer had the same stale
GitHub link.

The website entry in the drawer still goes to armsx2.net since there is no
ARMSX3 site yet.
2026-08-06 10:02:59 -04:00
jpolo1224 5ceb0d84c1 Update README.md 2026-08-06 09:08:18 -04:00
jpolo1224 4ccd0efe75 Update README.md 2026-08-06 09:07:26 -04:00
jpolo1224 622f6306e9 Update README.md 2026-08-06 09:06:42 -04:00
32 changed files with 1588 additions and 294 deletions
+3
View File
@@ -161,3 +161,6 @@ android/**/keystore.properties
3rdparty/librashader/
android/app-upstream/
android/**/cpp/libadrenotools/
# Kotlin incremental-compile scratch dir
android/armsx3-ui/.kotlin/
+4 -7
View File
@@ -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
-------
+2 -2
View File
@@ -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,
@@ -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(
@@ -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