mirror of
https://github.com/ARMSX2/ARMSX3.git
synced 2026-08-24 16:58:52 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4b45eee27 | ||
|
|
87ccdb8515 | ||
|
|
e10f846924 | ||
|
|
db6ee86806 | ||
|
|
708582e523 | ||
|
|
8a6deab362 | ||
|
|
5b740f8921 | ||
|
|
11f043b529 | ||
|
|
b8987b8c92 | ||
|
|
403df1c651 | ||
|
|
614cdd74a3 | ||
|
|
76af990eed | ||
|
|
441c3f1bde | ||
|
|
fcdd7cd6af | ||
|
|
ad15f81d6e | ||
|
|
2df75aa604 | ||
|
|
b0c7a0260e | ||
|
|
7745a3c92d | ||
|
|
b09815e595 | ||
|
|
3791865e2f | ||
|
|
6831c87a0e | ||
|
|
b4378d8977 | ||
|
|
8fed09d40e | ||
|
|
8772786f9a | ||
|
|
5405d71e2e | ||
|
|
4101367b2d | ||
|
|
466d85d5b9 |
@@ -112,3 +112,6 @@
|
||||
path = 3rdparty/protobuf/protobuf
|
||||
url = ../../protocolbuffers/protobuf.git
|
||||
ignore = dirty
|
||||
[submodule "3rdparty/oboe/oboe"]
|
||||
path = 3rdparty/oboe/oboe
|
||||
url = https://github.com/google/oboe.git
|
||||
|
||||
Vendored
+6
@@ -141,6 +141,12 @@ else()
|
||||
add_subdirectory(cubeb EXCLUDE_FROM_ALL)
|
||||
endif()
|
||||
|
||||
# Oboe (Android only)
|
||||
if(ANDROID)
|
||||
message(STATUS "Using static oboe from 3rdparty")
|
||||
add_subdirectory(oboe EXCLUDE_FROM_ALL)
|
||||
endif()
|
||||
|
||||
# SoundTouch
|
||||
add_subdirectory(SoundTouch EXCLUDE_FROM_ALL)
|
||||
|
||||
|
||||
Vendored
+8
@@ -0,0 +1,8 @@
|
||||
# Oboe
|
||||
#
|
||||
# Android-only. Oboe wraps AAudio (and OpenSL ES on older devices) and carries a
|
||||
# per-device quirks database plus stream-restart handling, which is the part that
|
||||
# matters on the low-end parts where plain AAudio glitches.
|
||||
|
||||
add_subdirectory(oboe EXCLUDE_FROM_ALL)
|
||||
add_library(3rdparty::oboe ALIAS oboe)
|
||||
+1
Submodule 3rdparty/oboe/oboe added at 0da326e4ef
@@ -1,39 +1,8 @@
|
||||
ARMSX3
|
||||
======
|
||||
|
||||
Proof of concept Android port of RPCS3.
|
||||
|
||||
Uses the latest RPCS3 upstream code (the recent ARM64 improvements included).
|
||||
|
||||
Status
|
||||
------
|
||||
|
||||
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. So the main stop gap at the moment is performance/speed.
|
||||
|
||||
Differences from upstream RPCS3
|
||||
-------------------------------
|
||||
|
||||
Some of the fixes here are not in upstream and affect any ARM64 build, not only
|
||||
Android:
|
||||
|
||||
* Shaders declared runtime sized arrays inside uniform blocks, which requires
|
||||
VK_EXT_shader_uniform_buffer_unsized_array. Adreno does not support that
|
||||
extension, so every game pipeline failed to compile and nothing rendered.
|
||||
Concrete array bounds are emitted when the extension is missing.
|
||||
|
||||
* The ARM64 SPU block verification checksum folded two thirds of every block
|
||||
through an absolute difference. That collides on the near identical job
|
||||
binaries an SPU job manager streams through the same local store address, so
|
||||
a cached block could end up running against another job's code. It sums now.
|
||||
|
||||
* Thread affinity was compiled out on Android, and the core had no ARM
|
||||
big.LITTLE topology, so SPU and RSX threads were never placed on the fast
|
||||
cores.
|
||||
|
||||
* The LLVM JIT target was pinned to cortex-a34, an in order core from 2016. It
|
||||
detects the host now.
|
||||
|
||||
Building
|
||||
--------
|
||||
|
||||
+34
-3
@@ -1,3 +1,4 @@
|
||||
#include <cerrno>
|
||||
#include "File.h"
|
||||
#include "mutex.h"
|
||||
#include "StrFmt.h"
|
||||
@@ -684,8 +685,20 @@ namespace fs
|
||||
u64 result = 0;
|
||||
|
||||
// Loop because (huge?) read can be processed partially
|
||||
while (auto r = ::read(m_fd, buffer, count))
|
||||
for (;;)
|
||||
{
|
||||
const auto r = ::read(m_fd, buffer, count);
|
||||
|
||||
// EINTR is benign -- a signal landed mid-syscall -- and must be retried, not treated
|
||||
// as failure. Android app storage is FUSE-backed, where this genuinely happens, and
|
||||
// the ensure() below turns it into a process abort. Ported in spirit from
|
||||
// ouroboros420/rpcsx (92144f094).
|
||||
if (r < 0 && errno == EINTR)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!r) break; // EOF
|
||||
ensure(r > 0); // "file::read"
|
||||
count -= r;
|
||||
result += r;
|
||||
@@ -702,8 +715,17 @@ namespace fs
|
||||
u64 result = 0;
|
||||
|
||||
// For safety; see read()
|
||||
while (auto r = ::pread(m_fd, buffer, count, offset))
|
||||
for (;;)
|
||||
{
|
||||
const auto r = ::pread(m_fd, buffer, count, offset);
|
||||
|
||||
// See read(): retry EINTR rather than aborting.
|
||||
if (r < 0 && errno == EINTR)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!r) break; // EOF
|
||||
ensure(r > 0); // "file::read_at"
|
||||
count -= r;
|
||||
offset += r;
|
||||
@@ -721,8 +743,17 @@ namespace fs
|
||||
u64 result = 0;
|
||||
|
||||
// For safety; see read()
|
||||
while (auto r = ::write(m_fd, buffer, count))
|
||||
for (;;)
|
||||
{
|
||||
const auto r = ::write(m_fd, buffer, count);
|
||||
|
||||
// See read(): retry EINTR rather than aborting.
|
||||
if (r < 0 && errno == EINTR)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!r) break;
|
||||
ensure(r > 0); // "file::write"
|
||||
count -= r;
|
||||
result += r;
|
||||
|
||||
@@ -618,6 +618,22 @@ std::string jit_compiler::cpu(std::string_view _cpu)
|
||||
m_cpu = fallback_cpu_detection();
|
||||
}
|
||||
|
||||
#ifdef ARCH_ARM64
|
||||
// Detection reads the MIDR of whichever core happens to be running, so on big.LITTLE it
|
||||
// can name a small in-order core. JIT'd code runs on every core, so scheduling for the
|
||||
// smallest one is the wrong default -- fall back to the same wide out-of-order baseline
|
||||
// used when detection fails outright. Only the schedule/cost model is affected: the
|
||||
// instruction set still comes from setMAttrs (HWCAP-gated), so this can never emit an
|
||||
// illegal instruction. Ported from ouroboros420/rpcsx (cc3a18e29), widened to the
|
||||
// A5xx little cores that modern SoCs actually ship.
|
||||
if (m_cpu == "cortex-a34" || m_cpu == "cortex-a35" || m_cpu == "cortex-a53" ||
|
||||
m_cpu == "cortex-a55" || m_cpu == "cortex-a510" || m_cpu == "cortex-a520")
|
||||
{
|
||||
jit_log.notice("CPU detection named a little core ('%s'); using cortex-a78 as the schedule baseline.", m_cpu);
|
||||
m_cpu = "cortex-a78";
|
||||
}
|
||||
#endif
|
||||
|
||||
if (m_cpu == "sandybridge" ||
|
||||
m_cpu == "ivybridge" ||
|
||||
m_cpu == "haswell" ||
|
||||
@@ -754,6 +770,29 @@ jit_compiler::jit_compiler(const std::unordered_map<std::string, u64>& _link, st
|
||||
fmt::throw_exception("LLVM Emergency Exit Invoked: '%s'", out);
|
||||
}, nullptr);
|
||||
|
||||
// A separate handler from the fatal one -- LLVM installs and dispatches the two
|
||||
// independently. Without this, an allocation failure inside LLVM (SmallVector growth
|
||||
// while codegenning the enormous PPU symbol-resolver module, say) writes "LLVM ERROR:
|
||||
// out of memory" to fd 2 -- which goes nowhere in an Android app -- and calls abort():
|
||||
// a signal-6 death with nothing whatsoever in the log. Route it through the same
|
||||
// recoverable path as the fatal handler, so a guarded compile survives and anything
|
||||
// else at least says why it died. Allocating inside a bad-alloc handler is
|
||||
// best-effort, but the failures here are huge single allocations, so a short log
|
||||
// string still succeeds. Ported from ouroboros420/rpcsx (39a6a4c36).
|
||||
llvm::remove_bad_alloc_error_handler();
|
||||
llvm::install_bad_alloc_error_handler([](void*, const char* msg, bool)
|
||||
{
|
||||
const std::string_view out = msg ? msg : "";
|
||||
|
||||
if (g_llvm_fatal_message)
|
||||
{
|
||||
*g_llvm_fatal_message = out;
|
||||
thread_ctrl::silent_exit();
|
||||
}
|
||||
|
||||
fmt::throw_exception("LLVM Out Of Memory: '%s'", out);
|
||||
}, nullptr);
|
||||
|
||||
return true;
|
||||
}();
|
||||
|
||||
|
||||
@@ -2746,6 +2746,20 @@ const bool s_terminate_handler_set = []() -> bool
|
||||
{
|
||||
std::set_terminate([]()
|
||||
{
|
||||
// Re-entrancy guard. Under memory exhaustion the terminate path itself allocates
|
||||
// (report_fatal_error formats a message -> operator new; with -fno-exceptions a failed
|
||||
// allocation calls std::terminate again), which recurses forever and buries the real
|
||||
// crash under a stack of aborts. If terminate re-enters, hard-stop without allocating
|
||||
// so there is exactly one clean tombstone.
|
||||
// Ported from ouroboros420/rpcsx (281654906).
|
||||
static atomic_t<int> s_terminating{0};
|
||||
|
||||
if (s_terminating.exchange(1) != 0)
|
||||
{
|
||||
::signal(SIGABRT, SIG_DFL);
|
||||
std::abort();
|
||||
}
|
||||
|
||||
if (IsDebuggerPresent())
|
||||
{
|
||||
logs::listener::sync_all();
|
||||
|
||||
@@ -87,42 +87,13 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// ARMSX3: fail the build if the bundled ANGLE libraries are not there.
|
||||
//
|
||||
// This check exists because of a specific, expensive bug in ARMSX2: the repo's
|
||||
// blanket `*.so` gitignore rule swallowed the ANGLE prebuilts, they never made it
|
||||
// into release staging, the APK shipped without them, and the core fell back to
|
||||
// the system GLES driver in complete silence. Users reported "ANGLE is broken"
|
||||
// and there was nothing in any log to contradict them.
|
||||
//
|
||||
// jniLibs/.gitignore now un-ignores the two files by name. This task is the
|
||||
// second lock: packaging an APK that claims to support ANGLE without shipping
|
||||
// ANGLE is a build error, not a runtime surprise. The core-side counterpart is
|
||||
// the loud error in gl::es::egl_initialize() when the override library is
|
||||
// selected but cannot be dlopen'd.
|
||||
val verifyAngleLibs by tasks.registering {
|
||||
val angleLibs = listOf("libEGL_angle.so", "libGLESv2_angle.so")
|
||||
val jniLibDir = file("src/main/jniLibs/arm64-v8a")
|
||||
|
||||
doLast {
|
||||
val missing = angleLibs.filter { !File(jniLibDir, it).isFile }
|
||||
if (missing.isNotEmpty()) {
|
||||
throw GradleException(
|
||||
"ANGLE libraries missing from ${'$'}jniLibDir: ${'$'}{missing.joinToString(", ")}.\n" +
|
||||
"The OpenGL renderer's ANGLE option cannot work without them and would " +
|
||||
"silently fall back to the system GLES driver.\n" +
|
||||
"They are tracked in git - check them out, or remove the ANGLE option."
|
||||
)
|
||||
}
|
||||
|
||||
angleLibs.forEach {
|
||||
logger.lifecycle("ANGLE: packaging ${'$'}it (${'$'}{File(jniLibDir, it).length()} bytes)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }
|
||||
.configureEach { dependsOn(verifyAngleLibs) }
|
||||
// ARMSX3: the ANGLE prebuilts and the verifyAngleLibs task that guarded them used
|
||||
// to live here. They now live in android/armsx3-ui, which is the module that
|
||||
// actually ships (applicationId com.armsx3) and the module whose UI exposes the
|
||||
// OpenGL renderer's ANGLE option. This module builds nothing that ships, so the
|
||||
// guard here could never protect the APK it was written for -- and it never ran at
|
||||
// all: its message was written with `${'$'}`-style template escaping, and the `", "`
|
||||
// inside it closed the Kotlin string early, so this file did not compile.
|
||||
|
||||
base.archivesName = "rpcsx"
|
||||
|
||||
|
||||
@@ -29,8 +29,8 @@ android {
|
||||
applicationId = "com.armsx3"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
versionCode = 9
|
||||
versionName = "0.5"
|
||||
versionCode = 11
|
||||
versionName = "0.7"
|
||||
|
||||
// ARMSX2's UI reads these. STORAGE_ALL_FILES gates the all-files storage path in
|
||||
// onboarding; IN_APP_UPDATER gates the in-app GitHub-release updater.
|
||||
@@ -110,6 +110,51 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
// ARMSX3: fail the build if the bundled ANGLE libraries are not there.
|
||||
//
|
||||
// This check exists because of a specific, expensive bug in ARMSX2: the repo's
|
||||
// blanket `*.so` gitignore rule swallowed the ANGLE prebuilts, they never made it
|
||||
// into release staging, the APK shipped without them, and the core fell back to
|
||||
// the system GLES driver in complete silence. Users reported "ANGLE is broken"
|
||||
// and there was nothing in any log to contradict them.
|
||||
//
|
||||
// jniLibs/.gitignore un-ignores the two files by name. This task is the second
|
||||
// lock: packaging an APK that claims to support ANGLE without shipping ANGLE is a
|
||||
// build error, not a runtime surprise. The core-side counterpart is the loud error
|
||||
// in gl::es::egl_initialize() when the override library is selected but cannot be
|
||||
// dlopen'd; the UI-side counterpart is the MISSING_LIBS line that
|
||||
// MainActivityRuntime.applyAngleEnv logs when the option is on and the .so is not
|
||||
// in nativeLibraryDir.
|
||||
//
|
||||
// The claim being guarded is live in THIS module: RendererBackendSection ->
|
||||
// AngleDriverSection writes Settings.useAngleOpenGL, and applyAngleEnv turns it
|
||||
// into ARMSX2_ANGLE_EGL_LIBRARY. (Both the libraries and this task used to sit in
|
||||
// the stale android/armsx3-app module, which builds nothing that ships -- so the
|
||||
// guard could not fire for the APK it was meant to protect.)
|
||||
val verifyAngleLibs by tasks.registering {
|
||||
val angleLibs = listOf("libEGL_angle.so", "libGLESv2_angle.so")
|
||||
val jniLibDir = file("src/main/jniLibs/arm64-v8a")
|
||||
|
||||
doLast {
|
||||
val missing = angleLibs.filter { !jniLibDir.resolve(it).isFile }
|
||||
if (missing.isNotEmpty()) {
|
||||
throw GradleException(
|
||||
"ANGLE libraries missing from $jniLibDir: ${missing.joinToString(", ")}.\n" +
|
||||
"The OpenGL renderer's ANGLE option cannot work without them and would " +
|
||||
"silently fall back to the system GLES driver.\n" +
|
||||
"They are tracked in git - check them out, or remove the ANGLE option."
|
||||
)
|
||||
}
|
||||
|
||||
angleLibs.forEach {
|
||||
logger.lifecycle("ANGLE: packaging $it (${jniLibDir.resolve(it).length()} bytes)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }
|
||||
.configureEach { dependsOn(verifyAngleLibs) }
|
||||
|
||||
dependencies {
|
||||
// Discord Social SDK, staged locally rather than pulled from a repo: it is
|
||||
// proprietary and distributed per-application from the developer portal.
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
#include <algorithm>
|
||||
#include <android/api-level.h>
|
||||
#include <android/dlext.h>
|
||||
#include <android/log.h>
|
||||
#include <cstdio>
|
||||
#include <cstring>
|
||||
#include <dlfcn.h>
|
||||
#include <elf.h>
|
||||
#include <jni.h>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
@@ -19,6 +23,7 @@
|
||||
struct RPCSXApi {
|
||||
bool (*overlayPadData)(int port, int digital1, int digital2, int leftStickX,
|
||||
int leftStickY, int rightStickX, int rightStickY);
|
||||
bool (*overlayPadPressure)(int port, const int *values, int count);
|
||||
bool (*initialize)(std::string_view rootDir, std::string_view user);
|
||||
void (*setSocInfo)(std::string_view socInfo);
|
||||
bool (*processCompilationQueue)(JNIEnv *env);
|
||||
@@ -30,8 +35,13 @@ struct RPCSXApi {
|
||||
int (*getState)();
|
||||
void (*kill)();
|
||||
void (*resume)();
|
||||
void (*pause)();
|
||||
void (*openHomeMenu)();
|
||||
std::string (*getTitleId)();
|
||||
unsigned long long (*getFramePeriodNs)();
|
||||
unsigned long long (*getFrameWorkNs)();
|
||||
int (*getRsxThreadTid)();
|
||||
std::string (*getCurrentTrophyName)();
|
||||
bool (*surfaceEvent)(JNIEnv *env, jobject surface, jint event);
|
||||
void (*surfaceSizeChanged)(int width, int height);
|
||||
bool (*usbDeviceEvent)(int fd, int vendorId, int productId, int event);
|
||||
@@ -52,6 +62,7 @@ struct RPCSXApi {
|
||||
bool (*uninstallGame)(std::string_view path);
|
||||
std::string (*getVersion)();
|
||||
void *(*setCustomDriver)(void *driverHandle);
|
||||
void (*reportDriverProblem)(std::string message);
|
||||
bool (*saveState)();
|
||||
bool (*loadState)(unsigned int index);
|
||||
bool (*hasState)(unsigned int index);
|
||||
@@ -61,6 +72,7 @@ struct RPCSXApi {
|
||||
bool (*saveStateToSlot)(unsigned int slot);
|
||||
bool (*loadStateFromSlot)(unsigned int slot);
|
||||
bool (*hasStateInSlot)(unsigned int slot);
|
||||
std::string (*patchEngineVersion)();
|
||||
int (*patchesImport)(std::string_view content);
|
||||
std::string (*patchesList)(std::string_view serial);
|
||||
std::string (*probeDiscInfo)(std::string_view isoPath, std::string_view iconOut);
|
||||
@@ -104,6 +116,7 @@ struct RPCSXLibrary : RPCSXApi {
|
||||
|
||||
// clang-format off
|
||||
result.overlayPadData = reinterpret_cast<decltype(overlayPadData)>(dlsym(handle, "_rpcsx_overlayPadData"));
|
||||
result.overlayPadPressure = reinterpret_cast<decltype(overlayPadPressure)>(dlsym(handle, "_rpcsx_overlayPadPressure"));
|
||||
result.initialize = reinterpret_cast<decltype(initialize)>(dlsym(handle, "_rpcsx_initialize"));
|
||||
result.setSocInfo = reinterpret_cast<decltype(setSocInfo)>(dlsym(handle, "_rpcsx_setSocInfo"));
|
||||
result.processCompilationQueue = reinterpret_cast<decltype(processCompilationQueue)>(dlsym(handle, "_rpcsx_processCompilationQueue"));
|
||||
@@ -114,8 +127,13 @@ struct RPCSXLibrary : RPCSXApi {
|
||||
result.getState = reinterpret_cast<decltype(getState)>(dlsym(handle, "_rpcsx_getState"));
|
||||
result.kill = reinterpret_cast<decltype(kill)>(dlsym(handle, "_rpcsx_kill"));
|
||||
result.resume = reinterpret_cast<decltype(resume)>(dlsym(handle, "_rpcsx_resume"));
|
||||
result.pause = reinterpret_cast<decltype(pause)>(dlsym(handle, "_rpcsx_pause"));
|
||||
result.openHomeMenu = reinterpret_cast<decltype(openHomeMenu)>(dlsym(handle, "_rpcsx_openHomeMenu"));
|
||||
result.getTitleId = reinterpret_cast<decltype(getTitleId)>(dlsym(handle, "_rpcsx_getTitleId"));
|
||||
result.getFramePeriodNs = reinterpret_cast<decltype(getFramePeriodNs)>(dlsym(handle, "_rpcsx_getFramePeriodNs"));
|
||||
result.getFrameWorkNs = reinterpret_cast<decltype(getFrameWorkNs)>(dlsym(handle, "_rpcsx_getFrameWorkNs"));
|
||||
result.getRsxThreadTid = reinterpret_cast<decltype(getRsxThreadTid)>(dlsym(handle, "_rpcsx_getRsxThreadTid"));
|
||||
result.getCurrentTrophyName = reinterpret_cast<decltype(getCurrentTrophyName)>(dlsym(handle, "_rpcsx_getCurrentTrophyName"));
|
||||
result.surfaceEvent = reinterpret_cast<decltype(surfaceEvent)>(dlsym(handle, "_rpcsx_surfaceEvent"));
|
||||
result.surfaceSizeChanged = reinterpret_cast<decltype(surfaceSizeChanged)>(dlsym(handle, "_rpcsx_surfaceSizeChanged"));
|
||||
result.usbDeviceEvent = reinterpret_cast<decltype(usbDeviceEvent)>(dlsym(handle, "_rpcsx_usbDeviceEvent"));
|
||||
@@ -135,12 +153,14 @@ struct RPCSXLibrary : RPCSXApi {
|
||||
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.reportDriverProblem = reinterpret_cast<decltype(reportDriverProblem)>(dlsym(handle, "_rpcsx_reportDriverProblem"));
|
||||
result.saveState = reinterpret_cast<decltype(saveState)>(dlsym(handle, "_rpcsx_saveState"));
|
||||
result.loadState = reinterpret_cast<decltype(loadState)>(dlsym(handle, "_rpcsx_loadState"));
|
||||
result.hasState = reinterpret_cast<decltype(hasState)>(dlsym(handle, "_rpcsx_hasState"));
|
||||
result.saveStateToSlot = reinterpret_cast<decltype(saveStateToSlot)>(dlsym(handle, "_rpcsx_saveStateToSlot"));
|
||||
result.loadStateFromSlot = reinterpret_cast<decltype(loadStateFromSlot)>(dlsym(handle, "_rpcsx_loadStateFromSlot"));
|
||||
result.hasStateInSlot = reinterpret_cast<decltype(hasStateInSlot)>(dlsym(handle, "_rpcsx_hasStateInSlot"));
|
||||
result.patchEngineVersion = reinterpret_cast<decltype(patchEngineVersion)>(dlsym(handle, "_rpcsx_patchEngineVersion"));
|
||||
result.patchesImport = reinterpret_cast<decltype(patchesImport)>(dlsym(handle, "_rpcsx_patchesImport"));
|
||||
result.patchesList = reinterpret_cast<decltype(patchesList)>(dlsym(handle, "_rpcsx_patchesList"));
|
||||
result.probeDiscInfo = reinterpret_cast<decltype(probeDiscInfo)>(dlsym(handle, "_rpcsx_probeDiscInfo"));
|
||||
@@ -201,6 +221,35 @@ extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_overlayPadData(
|
||||
leftStickY, rightStickX, rightStickY);
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_overlayPadPressure(
|
||||
JNIEnv *env, jobject, jint port, jintArray values) {
|
||||
// Absent on a core older than this export: the pad still works, every button
|
||||
// is just digital, which is the behaviour that shipped before it existed.
|
||||
if (rpcsxLib.overlayPadPressure == nullptr || values == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const jsize count = env->GetArrayLength(values);
|
||||
if (count <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Critical rather than a copy: this runs on every input event that moves a
|
||||
// trigger, and the callee only reads the values before returning.
|
||||
auto *elems = static_cast<jint *>(env->GetPrimitiveArrayCritical(values, nullptr));
|
||||
if (elems == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
static_assert(sizeof(jint) == sizeof(int),
|
||||
"jint and int must match for the pressure array to be passed through");
|
||||
const bool ok = rpcsxLib.overlayPadPressure(
|
||||
port, reinterpret_cast<const int *>(elems), static_cast<int>(count));
|
||||
|
||||
env->ReleasePrimitiveArrayCritical(values, elems, JNI_ABORT);
|
||||
return ok;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_initialize(
|
||||
JNIEnv *env, jobject, jstring rootDir, jstring user, jstring socInfo) {
|
||||
// The core is dlopen()ed separately and may not be up yet -- during
|
||||
@@ -316,6 +365,18 @@ extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_resume(JNIEnv *env,
|
||||
return rpcsxLib.resume();
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_pause(JNIEnv *env,
|
||||
jobject) {
|
||||
// Same null guard as resume: the core is dlopen()ed separately and may not be
|
||||
// up yet. A missing symbol also means an older core, so an app built against
|
||||
// this cannot assume the export is there.
|
||||
if (rpcsxLib.pause == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
return rpcsxLib.pause();
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_openHomeMenu(JNIEnv *env,
|
||||
jobject) {
|
||||
// The core is dlopen()ed separately and may not be up yet -- during
|
||||
@@ -340,6 +401,21 @@ Java_net_rpcsx_RPCSX_getTitleId(JNIEnv *env, jobject) {
|
||||
return wrap(env, rpcsxLib.getTitleId());
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jstring JNICALL
|
||||
Java_net_rpcsx_RPCSX_getCurrentTrophyName(JNIEnv *env, jobject) {
|
||||
// The core is dlopen()ed separately and may not be up yet -- during
|
||||
// onboarding, or if it failed to load. Calling through a null pointer
|
||||
// is an instant SIGSEGV, so fail the call instead.
|
||||
//
|
||||
// Also null on an OLDER core that predates this export, since it is resolved
|
||||
// by dlsym: the frontend must treat null as "unknown", not as "no trophies".
|
||||
if (rpcsxLib.getCurrentTrophyName == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return wrap(env, rpcsxLib.getCurrentTrophyName());
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_surfaceEvent(
|
||||
JNIEnv *env, jobject, jobject surface, jint event) {
|
||||
// The core is dlopen()ed separately and may not be up yet -- during
|
||||
@@ -544,6 +620,26 @@ Java_net_rpcsx_RPCSX_supportsCustomDriverLoading(JNIEnv *env,
|
||||
return access("/dev/kgsl-3d0", F_OK) == 0;
|
||||
}
|
||||
|
||||
// Force the Adreno GPU to its maximum clocks, or release it back to normal scaling.
|
||||
//
|
||||
// Adreno's DVFS ramps clocks up only after it has already seen load, so a scene that suddenly
|
||||
// becomes GPU-bound stutters through the ramp every time it happens. Pinning the clocks removes
|
||||
// that at the cost of heat and battery, which is why it is opt-in rather than a default.
|
||||
//
|
||||
// Nothing here talks to the emulator core: adrenotools is linked into this JNI library, and
|
||||
// adrenotools_set_turbo opens /dev/kgsl-3d0 itself and silently does nothing on non-Adreno
|
||||
// hardware or if the open fails. So it is safe to call unconditionally, including before the core
|
||||
// is loaded. Ported from the RPCSX Android fork, which ships it default-off; kept default-off here
|
||||
// for the same reason.
|
||||
extern "C" JNIEXPORT void JNICALL
|
||||
Java_net_rpcsx_RPCSX_setGpuTurbo(JNIEnv *, jobject, jboolean on) {
|
||||
#if defined(__aarch64__)
|
||||
adrenotools_set_turbo(on == JNI_TRUE);
|
||||
#else
|
||||
(void) on;
|
||||
#endif
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jstring JNICALL
|
||||
Java_net_rpcsx_RPCSX_getVersion(JNIEnv *env, jobject) {
|
||||
// The core is dlopen()ed separately and may not be up yet -- during
|
||||
@@ -556,6 +652,145 @@ Java_net_rpcsx_RPCSX_getVersion(JNIEnv *env, jobject) {
|
||||
return wrap(env, rpcsxLib.getVersion());
|
||||
}
|
||||
|
||||
#if defined(__aarch64__)
|
||||
// Why a driver will not load, when the answer is knowable before trying.
|
||||
//
|
||||
// A community driver built against a newer NDK imports symbols versioned against a libc
|
||||
// this device does not have, and the linker then refuses it. adrenotools reports that to
|
||||
// logcat and quietly substitutes the system driver, so from the app's side the load
|
||||
// "succeeded" and the user runs a driver they did not choose.
|
||||
//
|
||||
// The requirement is stated in the file: DT_VERNEED / .gnu.version_r lists the libc
|
||||
// versions it needs, e.g. LIBC_36 for API 36. Comparing that against the running API
|
||||
// turns "failed to load" into the actual reason, which is the difference between a
|
||||
// usable bug report and a shrug. Mr Purple T29 needs LIBC_36 (Android 16) and its own
|
||||
// meta.json claims minApi 30, so the metadata cannot be trusted for this -- only the
|
||||
// binary can.
|
||||
//
|
||||
// Returns an empty string when nothing conclusive was found. Advisory only: the load is
|
||||
// still attempted, so a wrong answer here costs a log line and never a working driver.
|
||||
static std::string driver_libc_requirement_blocker(const std::string &soPath) {
|
||||
std::FILE *f = std::fopen(soPath.c_str(), "rb");
|
||||
if (f == nullptr) {
|
||||
return {};
|
||||
}
|
||||
|
||||
std::vector<char> data;
|
||||
std::fseek(f, 0, SEEK_END);
|
||||
const long size = std::ftell(f);
|
||||
|
||||
// Header tables live near the start and end; the symbol names they point at can be
|
||||
// anywhere, so read the whole file. These are ~15-20 MB and read once per driver
|
||||
// switch, not per boot.
|
||||
if (size <= 0 || size > (256 << 20)) {
|
||||
std::fclose(f);
|
||||
return {};
|
||||
}
|
||||
|
||||
std::fseek(f, 0, SEEK_SET);
|
||||
data.resize(static_cast<size_t>(size));
|
||||
const size_t got = std::fread(data.data(), 1, data.size(), f);
|
||||
std::fclose(f);
|
||||
|
||||
if (got != data.size() || data.size() < sizeof(Elf64_Ehdr)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const auto *ehdr = reinterpret_cast<const Elf64_Ehdr *>(data.data());
|
||||
|
||||
if (std::memcmp(ehdr->e_ident, ELFMAG, SELFMAG) != 0 ||
|
||||
ehdr->e_ident[EI_CLASS] != ELFCLASS64 || ehdr->e_shoff == 0 ||
|
||||
ehdr->e_shentsize != sizeof(Elf64_Shdr)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Section headers rather than PT_DYNAMIC: they carry file offsets directly, so no
|
||||
// vaddr-to-offset mapping is needed. Shared objects keep them; if they are gone, this
|
||||
// check simply declines to answer.
|
||||
const auto section_at = [&](size_t i) -> const Elf64_Shdr * {
|
||||
const size_t off = ehdr->e_shoff + i * sizeof(Elf64_Shdr);
|
||||
if (off + sizeof(Elf64_Shdr) > data.size()) {
|
||||
return nullptr;
|
||||
}
|
||||
return reinterpret_cast<const Elf64_Shdr *>(data.data() + off);
|
||||
};
|
||||
|
||||
for (size_t i = 0; i < ehdr->e_shnum; i++) {
|
||||
const Elf64_Shdr *sh = section_at(i);
|
||||
|
||||
if (sh == nullptr || sh->sh_type != SHT_GNU_verneed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const Elf64_Shdr *strtab = section_at(sh->sh_link);
|
||||
|
||||
if (strtab == nullptr || strtab->sh_offset >= data.size()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const char *strings = data.data() + strtab->sh_offset;
|
||||
const size_t strings_max = data.size() - strtab->sh_offset;
|
||||
|
||||
size_t offset = sh->sh_offset;
|
||||
int highest_libc = 0;
|
||||
|
||||
for (size_t entry = 0; entry < sh->sh_info; entry++) {
|
||||
if (offset + sizeof(Elf64_Verneed) > data.size()) {
|
||||
break;
|
||||
}
|
||||
|
||||
const auto *vn = reinterpret_cast<const Elf64_Verneed *>(data.data() + offset);
|
||||
size_t aux_offset = offset + vn->vn_aux;
|
||||
|
||||
for (size_t aux = 0; aux < vn->vn_cnt; aux++) {
|
||||
if (aux_offset + sizeof(Elf64_Vernaux) > data.size()) {
|
||||
break;
|
||||
}
|
||||
|
||||
const auto *vna = reinterpret_cast<const Elf64_Vernaux *>(data.data() + aux_offset);
|
||||
|
||||
if (vna->vna_name < strings_max) {
|
||||
const char *name = strings + vna->vna_name;
|
||||
int level = 0;
|
||||
|
||||
// Only LIBC_<n> is a device-capability statement. Anything else (LIBC,
|
||||
// LIBC_PRIVATE, other sonames) says nothing about the API level.
|
||||
if (std::sscanf(name, "LIBC_%d", &level) == 1 && level > highest_libc) {
|
||||
highest_libc = level;
|
||||
}
|
||||
}
|
||||
|
||||
if (vna->vna_next == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
aux_offset += vna->vna_next;
|
||||
}
|
||||
|
||||
if (vn->vn_next == 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
offset += vn->vn_next;
|
||||
}
|
||||
|
||||
const int device_api = android_get_device_api_level();
|
||||
|
||||
if (highest_libc > 0 && device_api > 0 && highest_libc > device_api) {
|
||||
char buf[256];
|
||||
std::snprintf(buf, sizeof(buf),
|
||||
"it requires LIBC_%d (Android API %d) but this device provides API %d",
|
||||
highest_libc, highest_libc, device_api);
|
||||
return buf;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
#endif // __aarch64__
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL
|
||||
Java_net_rpcsx_RPCSX_setCustomDriver(JNIEnv *env, jobject, jstring jpath,
|
||||
jstring jlibraryName, jstring jhookDir) {
|
||||
@@ -573,6 +808,26 @@ Java_net_rpcsx_RPCSX_setCustomDriver(JNIEnv *env, jobject, jstring jpath,
|
||||
__android_log_print(ANDROID_LOG_INFO, "RPCSX-UI", "Loading custom driver %s",
|
||||
path.c_str());
|
||||
|
||||
// Said before the attempt, because adrenotools swallows the failure: it logs to
|
||||
// logcat and hands back the system driver, so the caller cannot tell a real load
|
||||
// from a substitution, and the reason never reaches the emulator log at all.
|
||||
if (auto blocker = driver_libc_requirement_blocker(path + "/" + libraryName);
|
||||
!blocker.empty()) {
|
||||
const std::string report =
|
||||
"Custom driver '" + libraryName + "' cannot load on this device: " + blocker +
|
||||
". It was built against a newer NDK than this Android version supports; the "
|
||||
"driver's own metadata does not carry this. The system driver will be used "
|
||||
"instead.";
|
||||
|
||||
__android_log_print(ANDROID_LOG_ERROR, "RPCSX-UI", "%s", report.c_str());
|
||||
|
||||
// Also to the emulator log, which is the file that gets attached to issues.
|
||||
// logcat alone means the reason exists and no report ever contains it.
|
||||
if (rpcsxLib.reportDriverProblem != nullptr) {
|
||||
rpcsxLib.reportDriverProblem(report);
|
||||
}
|
||||
}
|
||||
|
||||
::dlerror();
|
||||
loader = adrenotools_open_libvulkan(
|
||||
RTLD_NOW, ADRENOTOOLS_DRIVER_CUSTOM, nullptr, (hookDir + "/").c_str(),
|
||||
@@ -586,10 +841,27 @@ Java_net_rpcsx_RPCSX_setCustomDriver(JNIEnv *env, jobject, jstring jpath,
|
||||
}
|
||||
}
|
||||
|
||||
auto prevLoader = rpcsxLib.setCustomDriver(loader);
|
||||
if (prevLoader != nullptr) {
|
||||
::dlclose(prevLoader);
|
||||
}
|
||||
// Deliberately NOT dlclose()ing the previous handle.
|
||||
//
|
||||
// A Vulkan driver cannot be unloaded while anything resolved out of it is still reachable, and
|
||||
// from here there is no way to know that. VMA caches vkGetPhysicalDeviceMemoryProperties2 in the
|
||||
// allocator at creation time, so the address lives inside the driver library for as long as the
|
||||
// renderer does.
|
||||
//
|
||||
// Restart is the one flow where a start races a teardown that has not finished:
|
||||
// applyRendererPrefs() re-applies the driver on EVERY start, so it dlopen'd a new handle and
|
||||
// closed the old one while the previous VKGSRender was still unwinding. Its destructor then
|
||||
// freed its data heaps, VMA went to refresh its budget, and called through a pointer into a
|
||||
// library that was no longer mapped -- "Segfault executing location <addr> at <addr>", inside
|
||||
// VmaAllocator_T::UpdateVulkanBudget. The give-away was the fault address landing on the same
|
||||
// offset every time with a different base: a live function in an unmapped library, not a
|
||||
// corrupted pointer. That is the "Restart crashes the app" report, and the same for
|
||||
// apply-and-restart after picking a driver.
|
||||
//
|
||||
// Leaking one handle per driver SWITCH is the cheap side of this trade: it is bounded by how
|
||||
// many times a user changes driver in a session, the mapping is shared, and dlclose on an ICD
|
||||
// is not something the loader promises to honour anyway.
|
||||
rpcsxLib.setCustomDriver(loader);
|
||||
|
||||
return true;
|
||||
#else
|
||||
@@ -663,6 +935,17 @@ Java_net_rpcsx_RPCSX_hasStateInSlot(JNIEnv *, jobject, jint slot) {
|
||||
// Patches
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
extern "C" JNIEXPORT jstring JNICALL
|
||||
Java_net_rpcsx_RPCSX_patchEngineVersion(JNIEnv *env, jobject) {
|
||||
// Empty rather than a guessed version: the caller asks precisely because it
|
||||
// must not name a schema version of its own.
|
||||
if (rpcsxLib.patchEngineVersion == nullptr) {
|
||||
return wrap(env, "");
|
||||
}
|
||||
|
||||
return wrap(env, rpcsxLib.patchEngineVersion());
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL
|
||||
Java_net_rpcsx_RPCSX_patchesImport(JNIEnv *env, jobject, jstring jcontent) {
|
||||
if (rpcsxLib.patchesImport == nullptr) {
|
||||
@@ -702,3 +985,29 @@ Java_net_rpcsx_RPCSX_patchSetEnabled(JNIEnv *env, jobject, jstring jhash,
|
||||
unwrap(env, jserial),
|
||||
unwrap(env, jappVersion), enabled);
|
||||
}
|
||||
|
||||
// ADPF telemetry. All three return 0 when unmeasured or on a core too old to export them,
|
||||
// and the Kotlin side treats 0 as "skip this update" rather than feeding the OS a bogus hint.
|
||||
extern "C" JNIEXPORT jlong JNICALL
|
||||
Java_net_rpcsx_RPCSX_getFramePeriodNs(JNIEnv *, jobject) {
|
||||
if (rpcsxLib.getFramePeriodNs == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
return static_cast<jlong>(rpcsxLib.getFramePeriodNs());
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jlong JNICALL
|
||||
Java_net_rpcsx_RPCSX_getFrameWorkNs(JNIEnv *, jobject) {
|
||||
if (rpcsxLib.getFrameWorkNs == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
return static_cast<jlong>(rpcsxLib.getFrameWorkNs());
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL
|
||||
Java_net_rpcsx_RPCSX_getRsxThreadTid(JNIEnv *, jobject) {
|
||||
if (rpcsxLib.getRsxThreadTid == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
return static_cast<jint>(rpcsxLib.getRsxThreadTid());
|
||||
}
|
||||
|
||||
@@ -24,8 +24,17 @@ object DiscIcons {
|
||||
|
||||
fun fileFor(titleId: String): File = File(dir(), "$titleId.png")
|
||||
|
||||
/** True when this game's icon has already been extracted. */
|
||||
fun has(titleId: String): Boolean = fileFor(titleId).isFile
|
||||
/**
|
||||
* True when this game's icon has already been extracted.
|
||||
*
|
||||
* Length, not isFile: an empty file is indistinguishable from a real one to isFile, and
|
||||
* it is a shape this can actually end up in -- the extraction writes to a staging name
|
||||
* and renames, and neither the write nor the rename is checked. An empty icon then reads
|
||||
* as "already extracted" forever, and the card falls through to the text placeholder
|
||||
* because there is nothing for Coil to decode. Requiring bytes makes that self-repairing:
|
||||
* the next scan re-probes and overwrites it.
|
||||
*/
|
||||
fun has(titleId: String): Boolean = fileFor(titleId).length() > 0L
|
||||
|
||||
fun clear() {
|
||||
runCatching { dir().listFiles()?.forEach { it.delete() } }
|
||||
|
||||
@@ -330,7 +330,10 @@ data class GameInfo(
|
||||
* 404, and always match the disc. There is no PS3 equivalent of xlenore's
|
||||
* ps2-covers to point at anyway.
|
||||
*/
|
||||
val discIconFile: java.io.File? get() = serial?.let { DiscIcons.fileFor(it) }?.takeIf { it.isFile }
|
||||
// Length rather than isFile, for the same reason as DiscIcons.has: an empty file passes
|
||||
// isFile, hands Coil something undecodable, and costs the card its placeholder-vs-cover
|
||||
// decision. Nothing is a better answer than zero bytes.
|
||||
val discIconFile: java.io.File? get() = serial?.let { DiscIcons.fileFor(it) }?.takeIf { it.length() > 0L }
|
||||
|
||||
private fun coverUrlFor(s: String): String {
|
||||
// PS3 art comes from aldostools/Resources, which is flat: COV/<TITLE_ID>.JPG
|
||||
|
||||
@@ -22,8 +22,14 @@ object Ps3PatchRepo {
|
||||
* RPCS3's official patch feed. `v` is the patch-engine version the server
|
||||
* uses to decide which schema to hand back, so it is not cosmetic -- an
|
||||
* older value returns patches this core cannot parse.
|
||||
*
|
||||
* The version comes from the core (patch_engine_version) rather than being
|
||||
* written here. It was spelled out as 1.2, which is correct only until
|
||||
* upstream bumps the constant: patch_engine::load rejects any file whose
|
||||
* Version header does not match, so the two have to move together.
|
||||
*/
|
||||
private const val PATCH_URL = "https://rpcs3.net/compatibility?patch&api=v1&v=1.2"
|
||||
private fun patchUrl(version: String) =
|
||||
"https://rpcs3.net/compatibility?patch&api=v1&v=$version"
|
||||
|
||||
data class Patch(
|
||||
val hash: String,
|
||||
@@ -49,11 +55,15 @@ object Ps3PatchRepo {
|
||||
data object Network : Result
|
||||
data class Server(val code: Int) : Result
|
||||
data object Parse : Result
|
||||
data object Checksum : Result
|
||||
}
|
||||
|
||||
fun download(): Result {
|
||||
val engineVersion = runCatching { RPCSX.instance.patchEngineVersion() }.getOrDefault("")
|
||||
if (engineVersion.isBlank()) return Result.Parse
|
||||
|
||||
val res = runCatching {
|
||||
com.armsx3.HttpClient.doRequest(PATCH_URL, userAgent = "ARMSX3")
|
||||
com.armsx3.HttpClient.doRequest(patchUrl(engineVersion), userAgent = "ARMSX3")
|
||||
}.getOrNull() ?: return Result.Network
|
||||
|
||||
if (res.statusCode != 200 || res.data.isEmpty()) return Result.Network
|
||||
@@ -62,19 +72,62 @@ object Ps3PatchRepo {
|
||||
// { "return_code": 0, "version": "1.2", "sha256": "...", "patch": "<yaml>" }
|
||||
// Handing the envelope straight to the YAML parser fails on the first
|
||||
// line, which is exactly what it did.
|
||||
val yaml = runCatching {
|
||||
val envelope = runCatching {
|
||||
val obj = org.json.JSONObject(String(res.data, Charsets.UTF_8))
|
||||
val code = obj.optInt("return_code", -1)
|
||||
if (code != 0) return Result.Server(code)
|
||||
obj.optString("patch")
|
||||
}.getOrNull()
|
||||
obj
|
||||
}.getOrNull() ?: return Result.Parse
|
||||
|
||||
if (yaml.isNullOrBlank()) return Result.Parse
|
||||
// The server picks the schema from the version we asked for, so a reply
|
||||
// for a different one is a server-side surprise rather than something to
|
||||
// hand to the parser: patch_engine::load would reject the whole file on
|
||||
// its Version header anyway, several megabytes later.
|
||||
if (envelope.optString("version") != engineVersion) return Result.Parse
|
||||
|
||||
val yaml = envelope.optString("patch")
|
||||
if (yaml.isBlank()) return Result.Parse
|
||||
|
||||
// Desktop RPCS3 verifies this digest before it writes anything
|
||||
// (patch_manager_dialog::handle_json), and the check was missing here.
|
||||
// Patches are writes into the guest executable, and move_file/hide_file
|
||||
// patches reach the emulator's own filesystem, so content that is not
|
||||
// what the server hashed does not get imported.
|
||||
val expected = envelope.optString("sha256")
|
||||
if (!expected.equals(sha256(yaml), ignoreCase = true)) return Result.Checksum
|
||||
|
||||
val n = runCatching { RPCSX.instance.patchesImport(yaml) }.getOrDefault(-1)
|
||||
return if (n >= 0) Result.Ok(n) else Result.Parse
|
||||
}
|
||||
|
||||
/**
|
||||
* Import a patch.yml the user picked themselves.
|
||||
*
|
||||
* No checksum here, unlike [download]: there is no publisher digest to compare a
|
||||
* local file against, and the user choosing the file IS the trust decision. The
|
||||
* core still parses it, so a malformed file is rejected rather than half-applied.
|
||||
*
|
||||
* Merges into patches/patch.yml like every other import, so a hand-added patch
|
||||
* sits alongside the downloaded database instead of replacing it.
|
||||
*/
|
||||
fun importLocal(context: Context, uri: android.net.Uri): Result {
|
||||
val yaml = runCatching {
|
||||
context.contentResolver.openInputStream(uri)?.use { stream ->
|
||||
stream.bufferedReader().readText()
|
||||
}
|
||||
}.getOrNull()
|
||||
|
||||
if (yaml.isNullOrBlank()) return Result.Network
|
||||
val n = runCatching { RPCSX.instance.patchesImport(yaml) }.getOrDefault(-1)
|
||||
return if (n >= 0) Result.Ok(n) else Result.Parse
|
||||
}
|
||||
|
||||
/** Lowercase hex SHA-256, the form rpcs3.net sends and desktop compares against. */
|
||||
private fun sha256(text: String): String =
|
||||
java.security.MessageDigest.getInstance("SHA-256")
|
||||
.digest(text.toByteArray(Charsets.UTF_8))
|
||||
.joinToString("") { "%02x".format(it) }
|
||||
|
||||
/**
|
||||
* Patches applicable to a serial. An empty serial lists everything, which is
|
||||
* what the standalone tab shows when no game is selected.
|
||||
|
||||
@@ -53,6 +53,8 @@ object ConfigStore {
|
||||
// One-time flip of existing all-on OSD saves to the new default-off.
|
||||
private const val KEY_OSD_OFF_MIGRATED = "config.migrated.osdDefaultOff"
|
||||
private const val KEY_OSD_SCALE_MIGRATED = "config.migrated.osdScale65"
|
||||
/** One-time removal of the Frame limit core override that made the FPS cap inert. */
|
||||
private const val KEY_FRAME_LIMIT_UNPINNED = "config.migrated.frameLimitUnpinned"
|
||||
// One-time reconcile for the fresh-install + reused-data-folder case (people who
|
||||
// can't update in place and re-point setup at their old folder). See reconcileReusedFolder.
|
||||
private const val KEY_FOLDER_RECONCILE = "config.migrated.folderReconcile"
|
||||
@@ -373,16 +375,32 @@ object ConfigStore {
|
||||
runCatching {
|
||||
CoreSettingOverrides.record(SettingsScope.Global, null, "Video@@Vblank Rate", "60")
|
||||
}
|
||||
// Frame limit as well as Vblank Rate. Vblank alone did not hold: the override is
|
||||
// stored and the two beside it apply, yet the live value came back as 120. Frame
|
||||
// limit is the dedicated cap and does not depend on the vblank path at all, so
|
||||
// whichever of the two takes, the result is 60.
|
||||
runCatching {
|
||||
CoreSettingOverrides.record(SettingsScope.Global, null, "Video@@Frame limit", "60")
|
||||
}
|
||||
MainActivityRuntime.prefs.edit { putBoolean(KEY_VBLANK_60, true) }
|
||||
}
|
||||
|
||||
// The migration above used to pin Video@@Frame limit to 60 as well, as a second way to
|
||||
// reach a 60Hz cap in case the Vblank Rate override did not hold.
|
||||
//
|
||||
// Frame limit is not a spare knob: it is the node the Display FPS Cap row writes. Pinned as
|
||||
// a core override it replayed AFTER every settings push, so the cap silently did nothing for
|
||||
// every value that uses the enum -- 30, 50, 60, 120 -- while 20 and 45 appeared to work
|
||||
// because those are not presets and go to the free-form Second Frame Limit instead.
|
||||
// Measured: the UI wrote '30', settingsSet accepted it, and the core still reported
|
||||
// frame_limit=_60 on every flip.
|
||||
//
|
||||
// Nothing is lost by dropping it. Frame limit Auto resolves to the vblank rate
|
||||
// (RSXThread.cpp, the _auto case), and the Vblank Rate override above is already 60, so the
|
||||
// 60Hz default this was protecting still holds.
|
||||
//
|
||||
// forgetEverywhere, not a scoped forget: overrides live in two stores across two scopes, and
|
||||
// an install that ran the old migration has the Global one recorded already. Anyone who
|
||||
// deliberately set a Frame limit in All Core Settings loses that override here, which is the
|
||||
// right trade against a cap control that cannot work.
|
||||
if (!MainActivityRuntime.prefs.getBoolean(KEY_FRAME_LIMIT_UNPINNED, false)) {
|
||||
runCatching { CoreSettingOverrides.forgetEverywhere("Video@@Frame limit") }
|
||||
MainActivityRuntime.prefs.edit { putBoolean(KEY_FRAME_LIMIT_UNPINNED, true) }
|
||||
}
|
||||
|
||||
// Diagnostic settings that got recorded as core overrides during the profiling work
|
||||
// and would otherwise follow people into a release build.
|
||||
//
|
||||
|
||||
@@ -176,6 +176,16 @@ data class Ps3Settings(
|
||||
val audioTimeStretch: Boolean = false,
|
||||
val audioBuffering: Boolean = true,
|
||||
val audioBufferMs: Int = 34,
|
||||
/** cubeb backend: 0 auto, 1 aaudio, 2 opensl, 3 audiotrack. Auto reaches AAudio on
|
||||
* anything modern; opensl trades latency for far fewer underruns on weak devices. */
|
||||
val audioCubebBackend: Int = 0,
|
||||
/** Pin Adreno to max clocks: removes DVFS ramp-up stutter in GPU-bound scenes, costs heat
|
||||
* and battery. Off by default, matching the RPCSX fork this came from. */
|
||||
val gpuTurbo: Boolean = false,
|
||||
/** Stops the core writing any log after startup. A real performance lever on games that
|
||||
* log heavily, but it also destroys the only artifact a bug report can carry, so it is
|
||||
* off by default and the UI says so plainly. */
|
||||
val silenceAllLogs: Boolean = false,
|
||||
val netEnabled: Boolean = false,
|
||||
val psnStatus: Boolean = false,
|
||||
val upnpEnabled: Boolean = false,
|
||||
@@ -1017,6 +1027,7 @@ data class Settings(
|
||||
// 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/Misc", "Silence All Logs", "bool", ps3.silenceAllLogs.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())
|
||||
@@ -1049,6 +1060,7 @@ data class Settings(
|
||||
put("PS3/Audio", "Enable Time Stretching", "bool", ps3.audioTimeStretch.toString())
|
||||
put("PS3/Audio", "Enable Buffering", "bool", ps3.audioBuffering.toString())
|
||||
put("PS3/Audio", "Desired Audio Buffer Duration", "int", ps3.audioBufferMs.toString())
|
||||
put("PS3/Audio", "Cubeb Backend", "int", ps3.audioCubebBackend.toString())
|
||||
put("PS3/Net", "Internet enabled", "enum", ps3.netEnabled.toString())
|
||||
put("PS3/Net", "PSN status", "enum", ps3.psnStatus.toString())
|
||||
put("PS3/Net", "UPNP Enabled", "bool", ps3.upnpEnabled.toString())
|
||||
@@ -1294,10 +1306,18 @@ data class Settings(
|
||||
// A deliberate change in All Core Settings still wins, because CoreSettingOverrides
|
||||
// replays immediately below this.
|
||||
runCatching { net.rpcsx.RPCSX.instance.settingsSet("Video@@Vblank Rate", "60") }
|
||||
// And the cap itself. Frame limit Auto resolves to the vblank rate, so with the line
|
||||
// above it would already be 60; setting it explicitly means the cap does not depend
|
||||
// on the vblank path holding, which it did not. Enum node, so the value is quoted.
|
||||
runCatching { net.rpcsx.RPCSX.instance.settingsSet("Video@@Frame limit", "\"60\"") }
|
||||
// Frame limit is deliberately NOT forced here any more.
|
||||
//
|
||||
// It used to be written to "60" on every push as a belt-and-braces way to reach a 60Hz cap
|
||||
// alongside the Vblank Rate above. But this is the same node the Display FPS Cap row writes,
|
||||
// and this push runs after it, so choosing 30 wrote the enum and then this overwrote it --
|
||||
// the cap did nothing for every preset value while 20 and 45 worked, because those take the
|
||||
// free-form Second Frame Limit path instead. The core reported frame_limit=_60 on every flip
|
||||
// no matter what the UI had just been told.
|
||||
//
|
||||
// The 60Hz intent survives without it: Frame limit Auto resolves to the vblank rate, which
|
||||
// the line above pins to 60. ConfigStore also clears the stale core override that pinned
|
||||
// this node, since that replayed even later than this did.
|
||||
// Left at upstream's 100: busy-wait on a reservation rather than sleeping.
|
||||
//
|
||||
// This was dropped to 20 while the emulator was starved for cores, on the reasoning that
|
||||
@@ -1986,8 +2006,11 @@ data class Settings(
|
||||
put("ps3ClocksScale", ps3.clocksScale)
|
||||
put("ps3ResolutionScale", ps3.resolutionScale)
|
||||
put("ps3MsaaMode", ps3.msaaMode)
|
||||
put("ps3AudioCubebBackend", ps3.audioCubebBackend)
|
||||
put("ps3ShaderMode", ps3.shaderMode)
|
||||
put("ps3WriteColorBuffers", ps3.writeColorBuffers)
|
||||
put("ps3GpuTurbo", ps3.gpuTurbo)
|
||||
put("ps3SilenceAllLogs", ps3.silenceAllLogs)
|
||||
put("ps3WriteDepthBuffer", ps3.writeDepthBuffer)
|
||||
put("ps3ReadColorBuffers", ps3.readColorBuffers)
|
||||
put("ps3ReadDepthBuffer", ps3.readDepthBuffer)
|
||||
@@ -2321,8 +2344,11 @@ data class Settings(
|
||||
clocksScale = json.optInt("ps3ClocksScale", def.ps3.clocksScale),
|
||||
resolutionScale = json.optInt("ps3ResolutionScale", def.ps3.resolutionScale),
|
||||
msaaMode = json.optInt("ps3MsaaMode", def.ps3.msaaMode),
|
||||
audioCubebBackend = json.optInt("ps3AudioCubebBackend", def.ps3.audioCubebBackend),
|
||||
shaderMode = json.optInt("ps3ShaderMode", def.ps3.shaderMode),
|
||||
writeColorBuffers = json.optBoolean("ps3WriteColorBuffers", def.ps3.writeColorBuffers),
|
||||
gpuTurbo = json.optBoolean("ps3GpuTurbo", def.ps3.gpuTurbo),
|
||||
silenceAllLogs = json.optBoolean("ps3SilenceAllLogs", def.ps3.silenceAllLogs),
|
||||
writeDepthBuffer = json.optBoolean("ps3WriteDepthBuffer", def.ps3.writeDepthBuffer),
|
||||
readColorBuffers = json.optBoolean("ps3ReadColorBuffers", def.ps3.readColorBuffers),
|
||||
readDepthBuffer = json.optBoolean("ps3ReadDepthBuffer", def.ps3.readDepthBuffer),
|
||||
@@ -2636,8 +2662,11 @@ data class Settings(
|
||||
if (current.ps3.clocksScale != base.ps3.clocksScale) j.put("ps3ClocksScale", current.ps3.clocksScale)
|
||||
if (current.ps3.resolutionScale != base.ps3.resolutionScale) j.put("ps3ResolutionScale", current.ps3.resolutionScale)
|
||||
if (current.ps3.msaaMode != base.ps3.msaaMode) j.put("ps3MsaaMode", current.ps3.msaaMode)
|
||||
if (current.ps3.audioCubebBackend != base.ps3.audioCubebBackend) j.put("ps3AudioCubebBackend", current.ps3.audioCubebBackend)
|
||||
if (current.ps3.shaderMode != base.ps3.shaderMode) j.put("ps3ShaderMode", current.ps3.shaderMode)
|
||||
if (current.ps3.writeColorBuffers != base.ps3.writeColorBuffers) j.put("ps3WriteColorBuffers", current.ps3.writeColorBuffers)
|
||||
if (current.ps3.gpuTurbo != base.ps3.gpuTurbo) j.put("ps3GpuTurbo", current.ps3.gpuTurbo)
|
||||
if (current.ps3.silenceAllLogs != base.ps3.silenceAllLogs) j.put("ps3SilenceAllLogs", current.ps3.silenceAllLogs)
|
||||
if (current.ps3.writeDepthBuffer != base.ps3.writeDepthBuffer) j.put("ps3WriteDepthBuffer", current.ps3.writeDepthBuffer)
|
||||
if (current.ps3.readColorBuffers != base.ps3.readColorBuffers) j.put("ps3ReadColorBuffers", current.ps3.readColorBuffers)
|
||||
if (current.ps3.readDepthBuffer != base.ps3.readDepthBuffer) j.put("ps3ReadDepthBuffer", current.ps3.readDepthBuffer)
|
||||
@@ -2932,8 +2961,11 @@ data class Settings(
|
||||
clocksScale = if (overrides.has("ps3ClocksScale")) overrides.getInt("ps3ClocksScale") else base.ps3.clocksScale,
|
||||
resolutionScale = if (overrides.has("ps3ResolutionScale")) overrides.getInt("ps3ResolutionScale") else base.ps3.resolutionScale,
|
||||
msaaMode = if (overrides.has("ps3MsaaMode")) overrides.getInt("ps3MsaaMode") else base.ps3.msaaMode,
|
||||
audioCubebBackend = if (overrides.has("ps3AudioCubebBackend")) overrides.getInt("ps3AudioCubebBackend") else base.ps3.audioCubebBackend,
|
||||
shaderMode = if (overrides.has("ps3ShaderMode")) overrides.getInt("ps3ShaderMode") else base.ps3.shaderMode,
|
||||
writeColorBuffers = if (overrides.has("ps3WriteColorBuffers")) overrides.getBoolean("ps3WriteColorBuffers") else base.ps3.writeColorBuffers,
|
||||
gpuTurbo = if (overrides.has("ps3GpuTurbo")) overrides.getBoolean("ps3GpuTurbo") else base.ps3.gpuTurbo,
|
||||
silenceAllLogs = if (overrides.has("ps3SilenceAllLogs")) overrides.getBoolean("ps3SilenceAllLogs") else base.ps3.silenceAllLogs,
|
||||
writeDepthBuffer = if (overrides.has("ps3WriteDepthBuffer")) overrides.getBoolean("ps3WriteDepthBuffer") else base.ps3.writeDepthBuffer,
|
||||
readColorBuffers = if (overrides.has("ps3ReadColorBuffers")) overrides.getBoolean("ps3ReadColorBuffers") else base.ps3.readColorBuffers,
|
||||
readDepthBuffer = if (overrides.has("ps3ReadDepthBuffer")) overrides.getBoolean("ps3ReadDepthBuffer") else base.ps3.readDepthBuffer,
|
||||
|
||||
+43
-2
@@ -191,9 +191,36 @@ class GameLibraryRepository(private val context: Context) {
|
||||
// Everything the probe produces is already durable: the serial and title are in the
|
||||
// library cache, and the icon is on disk under disc-icons. So a disc we have seen
|
||||
// before never needs mounting again.
|
||||
//
|
||||
// That last clause is only true if the extraction actually succeeded once. The serial
|
||||
// and title are durable by construction -- this loop is reading them -- but the icon
|
||||
// is a separate file that may never have been written: probeDiscInfo answers "{}"
|
||||
// whenever a game is loaded, and the serial then comes from the FILENAME instead,
|
||||
// which for dev_hdd0/game/<title id> is indistinguishable from one the SFO gave us.
|
||||
// Seeding on the serial alone made that miss permanent, because every later rescan
|
||||
// skipped the one thing that would repair it. Reported as a PKG-installed title
|
||||
// showing a text placeholder for good, with its ICON0.PNG sitting unread in the game
|
||||
// folder and its path already recorded in games.json.
|
||||
//
|
||||
// So gate the skip on the icon as well, for the games that have one. Re-probing costs
|
||||
// one mount, once, and only for a game actually missing it; a game whose icon is on
|
||||
// disk still never mounts again, which is what the reasoning above is protecting.
|
||||
loadCached().games.forEach { game ->
|
||||
val serial = game.serial?.takeIf { it.isNotBlank() } ?: return@forEach
|
||||
val path = runCatching { game.uri.path }.getOrNull() ?: return@forEach
|
||||
// Folders only, and that is not a convenience: re-probing an ISO means load_iso ->
|
||||
// vfs::mount, which is the process-wide mount this whole seeding exists to avoid, and
|
||||
// it has crashed the app for real -- twice in one day, faulting in
|
||||
// manual_typemap::init<vfs_manager> from this very thread while a boot was starting.
|
||||
// A directory is read straight off disk by read_sfo_game_info with no mount at all,
|
||||
// so it carries none of that risk. The game this was reported for was a PKG install
|
||||
// (folder form) whose ICON0.PNG was sitting there unread, which is exactly the case
|
||||
// that stays covered.
|
||||
val isFolder = game.extension.equals("folder", ignoreCase = true)
|
||||
if (isFolder && !DiscIcons.has(serial)) {
|
||||
android.util.Log.i(ScanTag, "re-probing folder '$serial': no usable disc icon on disk")
|
||||
return@forEach
|
||||
}
|
||||
discInfoCache.putIfAbsent(path, DiscInfo(serial, game.title))
|
||||
}
|
||||
|
||||
@@ -582,8 +609,22 @@ class GameLibraryRepository(private val context: Context) {
|
||||
// title ID until it has already parsed the SFO.
|
||||
if (o.optBoolean("icon")) {
|
||||
val staged = DiscIcons.fileFor(PendingIcon)
|
||||
if (staged.isFile) {
|
||||
staged.renameTo(DiscIcons.fileFor(id))
|
||||
val target = DiscIcons.fileFor(id)
|
||||
// renameTo answers false instead of throwing when the target already exists,
|
||||
// and the answer was discarded: a re-extraction for a title that already had
|
||||
// an icon silently kept the old file and left the staging one behind. Clear
|
||||
// the target first, and say so if it still fails -- a stale or empty icon must
|
||||
// not outlive the probe that was meant to replace it, because every reader
|
||||
// downstream treats "a file is there" as "the cover is good".
|
||||
if (staged.length() > 0L) {
|
||||
target.delete()
|
||||
if (!staged.renameTo(target)) {
|
||||
android.util.Log.w(ScanTag, " could not place disc icon for $id")
|
||||
staged.delete()
|
||||
}
|
||||
} else {
|
||||
android.util.Log.w(ScanTag, " probe claimed an icon for $id, staged 0 bytes")
|
||||
staged.delete()
|
||||
}
|
||||
}
|
||||
DiscInfo(id, o.optString("title"))
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
package com.armsx2.data.trophies
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import net.rpcsx.RPCSX
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
|
||||
/**
|
||||
* Reader for RPCS3's NATIVE PS3 trophy data — the real thing the games write, not
|
||||
* RetroAchievements (which has no PS3 support at all, which is why the RA screen is
|
||||
* hidden in ARMSX3).
|
||||
*
|
||||
* Where the data lives, and why this reads it from disk rather than through JNI:
|
||||
*
|
||||
* config/dev_hdd0/home/<user>/trophy/<NPWRxxxxx_00>/
|
||||
* TROPCONF.SFM — the trophy DEFINITIONS, installed from the game's TROPHY.TRP.
|
||||
* Plain XML: <title-name>, then one <trophy id hidden ttype pid>
|
||||
* per trophy with <name> and <detail> children.
|
||||
* TROPUSR.DAT — the user's UNLOCK STATE. Big-endian binary, written by
|
||||
* sceNpTrophyUnlockTrophy via TROPUSRLoader::Save.
|
||||
* ICON0.PNG — the game's icon; TROP000.PNG… the per-trophy icons.
|
||||
*
|
||||
* The emulator's own loaders (rpcs3/Loader/TROPUSR.cpp, and the overlay's
|
||||
* load_trophies in Emu/RSX/Overlays/Trophies/overlay_trophy_list_dialog.cpp) sit behind
|
||||
* vfs::get, so reaching them needs the core dlopen()ed AND its VFS mounted — neither is
|
||||
* guaranteed in the library, which is exactly where this screen is used. These files are
|
||||
* inside the app's own external files dir, so plain java.io reads them with no core at
|
||||
* all: no JNI, no native rebuild, and the browser works before a game has ever booted.
|
||||
*
|
||||
* The parse mirrors TROPUSR.h/.cpp field for field; see [readTropUsr] for the one
|
||||
* non-obvious part (the entry stride).
|
||||
*/
|
||||
object TrophyRepository {
|
||||
|
||||
private const val TAG = "Trophies"
|
||||
|
||||
/** TROPUSR.DAT magic, from TROPUSR.cpp's TROPUSR_MAGIC. */
|
||||
private const val TROPUSR_MAGIC = 0x818F54AD.toInt()
|
||||
|
||||
/**
|
||||
* Microseconds from 0001-01-01 to 1970-01-01 (719162 days).
|
||||
*
|
||||
* A trophy timestamp is a CellRtcTick — sceNpTrophyUnlockTrophy stores
|
||||
* cellRtcGetCurrentTick's value straight into the entry — and cellRtc counts
|
||||
* microseconds from year 1 UTC (see tick_to_date_time in cellRtc.cpp). Subtracting
|
||||
* this turns it into a Unix epoch.
|
||||
*/
|
||||
private const val RTC_EPOCH_US = 62135596800L * 1_000_000L
|
||||
|
||||
enum class Grade { Unknown, Platinum, Gold, Silver, Bronze }
|
||||
|
||||
data class Trophy(
|
||||
val id: Int,
|
||||
/** Real name from TROPCONF.SFM. Masked by the UI while a hidden trophy is locked. */
|
||||
val name: String,
|
||||
val description: String,
|
||||
val grade: Grade,
|
||||
val hidden: Boolean,
|
||||
val unlocked: Boolean,
|
||||
/** Unix millis, or null when the file carries no timestamp (never unlocked, or an
|
||||
* unlock written by something that did not stamp it). */
|
||||
val unlockedAt: Long?,
|
||||
/** TROP%03d.PNG for this trophy, or null when the icon is missing. */
|
||||
val icon: File?,
|
||||
)
|
||||
|
||||
data class Game(
|
||||
/** The trophy folder name, e.g. NPWR05636_00. The only stable id here — a trophy
|
||||
* set is keyed by comm id, not by the game's title id. */
|
||||
val commId: String,
|
||||
val title: String,
|
||||
val detail: String,
|
||||
val icon: File?,
|
||||
val trophies: List<Trophy>,
|
||||
) {
|
||||
val total: Int get() = trophies.size
|
||||
val unlocked: Int get() = trophies.count { it.unlocked }
|
||||
val percent: Int get() = if (total > 0) 100 * unlocked / total else 0
|
||||
}
|
||||
|
||||
/** Root of the emulator's HDD, i.e. what RPCS3 mounts as /dev_hdd0. */
|
||||
private fun hdd0(): File = File(RPCSX.getHdd0Dir())
|
||||
|
||||
/**
|
||||
* The trophy directories to scan.
|
||||
*
|
||||
* Prefers the logged-in user (Rpcs3Bridge logs in "00000001"), but falls back to
|
||||
* whichever user folder actually holds a trophy dir: getUser() reaches through JNI
|
||||
* into the core, which returns null when the core is not open yet, and a browser that
|
||||
* showed nothing until a game had booted would look broken.
|
||||
*/
|
||||
private fun trophyRoots(): List<File> {
|
||||
val home = File(hdd0(), "home")
|
||||
val users = home.listFiles().orEmpty().filter { it.isDirectory }
|
||||
val preferred = runCatching { RPCSX.instance.getUser() }.getOrNull()?.takeIf { it.isNotBlank() }
|
||||
val ordered = if (preferred != null) {
|
||||
users.sortedBy { it.name != preferred }
|
||||
} else {
|
||||
users
|
||||
}
|
||||
val roots = ordered.map { File(it, "trophy") }.filter { it.isDirectory }
|
||||
// One user is the norm; only that user's sets are shown. Scanning every user would
|
||||
// merge two people's progress into one list.
|
||||
return roots.take(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every trophy set on disk, newest-played first is NOT assumed — sorted by title so the
|
||||
* list is stable across sessions.
|
||||
*
|
||||
* Blocking disk work: call from Dispatchers.IO.
|
||||
*/
|
||||
fun load(): List<Game> {
|
||||
val dirs = trophyRoots().flatMap { it.listFiles().orEmpty().filter { d -> d.isDirectory } }
|
||||
return dirs.mapNotNull { readGame(it) }.sortedBy { it.title.lowercase() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The RUNNING game's trophy set, or null when it has none (or none can be identified).
|
||||
*
|
||||
* Blocking disk work: call from Dispatchers.IO.
|
||||
*
|
||||
* IDENTIFYING THE SET is the whole difficulty here, because trophy folders are named by
|
||||
* NPWR comm id and nothing on the folder says which title it belongs to. Two sources,
|
||||
* in order:
|
||||
*
|
||||
* 1. The core's own `current_trophy_name`, via [RPCSX.getCurrentTrophyName]. This is
|
||||
* exactly what RPCS3's home menu uses to pick the set for its native overlay list,
|
||||
* written by sceNpTrophyCreateContext. Authoritative, and works for disc and
|
||||
* installed titles alike.
|
||||
* 2. The title's TROPDIR, whose subfolders ARE the NPWR ids the title ships. Used only
|
||||
* when (1) is empty, which happens for a real reason: a game creates its trophy
|
||||
* context lazily, often not until you reach a menu, so early in a boot the core
|
||||
* genuinely does not know yet. This covers INSTALLED titles only — a disc game's
|
||||
* TROPDIR is inside the ISO and never lands on the HDD.
|
||||
*/
|
||||
fun loadCurrentGame(): Game? {
|
||||
val roots = trophyRoots()
|
||||
if (roots.isEmpty()) return null
|
||||
|
||||
for (commId in currentGameCommIds()) {
|
||||
val dir = roots.asSequence().map { File(it, commId) }.firstOrNull { it.isDirectory }
|
||||
?: continue
|
||||
readGame(dir)?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate trophy folder names for the running game, best guess first. Empty when
|
||||
* nothing identifies it.
|
||||
*/
|
||||
private fun currentGameCommIds(): List<String> {
|
||||
val fromCore = runCatching { RPCSX.instance.getCurrentTrophyName() }
|
||||
.getOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (fromCore != null) return listOf(fromCore)
|
||||
|
||||
val titleId = runCatching { RPCSX.instance.getTitleId() }
|
||||
.getOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return emptyList()
|
||||
// TROPDIR/<NPWRxxxxx_00>/TROPHY.TRP — the folder names are the comm ids.
|
||||
return File(hdd0(), "game/$titleId/TROPDIR").listFiles().orEmpty()
|
||||
.filter { it.isDirectory }
|
||||
.map { it.name }
|
||||
}
|
||||
|
||||
private fun readGame(dir: File): Game? {
|
||||
val conf = File(dir, "TROPCONF.SFM")
|
||||
if (!conf.isFile) {
|
||||
Log.i(TAG, "skipping ${dir.name}: no TROPCONF.SFM")
|
||||
return null
|
||||
}
|
||||
val parsed = runCatching { readTropConf(conf) }.getOrElse {
|
||||
Log.w(TAG, "failed to parse ${conf.absolutePath}", it)
|
||||
return null
|
||||
}
|
||||
if (parsed.trophies.isEmpty()) return null
|
||||
|
||||
// Unlock state is optional: TROPUSR.DAT only exists once the game has registered its
|
||||
// trophy context. Without it every trophy simply reads as locked, which is correct.
|
||||
val state = runCatching { readTropUsr(File(dir, "TROPUSR.DAT")) }.getOrElse {
|
||||
Log.w(TAG, "failed to parse TROPUSR.DAT in ${dir.name}", it)
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
val trophies = parsed.trophies.map { def ->
|
||||
val entry = state[def.id]
|
||||
Trophy(
|
||||
id = def.id,
|
||||
name = def.name,
|
||||
description = def.detail,
|
||||
// ttype from the XML is what the native overlay uses; the grade duplicated in
|
||||
// TROPUSR table 4 is the fallback for a set with a missing/odd ttype.
|
||||
grade = def.grade.takeIf { it != Grade.Unknown } ?: entry?.grade ?: Grade.Unknown,
|
||||
hidden = def.hidden,
|
||||
unlocked = entry?.unlocked == true,
|
||||
unlockedAt = entry?.takeIf { it.unlocked }?.timestamp?.let(::tickToUnixMillis),
|
||||
// Locale.ROOT: the default locale would render the digits in its own numeral
|
||||
// system for e.g. Arabic, and the file name is ASCII.
|
||||
icon = File(dir, String.format(java.util.Locale.ROOT, "TROP%03d.PNG", def.id))
|
||||
.takeIf { it.isFile },
|
||||
)
|
||||
}
|
||||
|
||||
return Game(
|
||||
commId = dir.name,
|
||||
title = parsed.title.ifBlank { dir.name },
|
||||
detail = parsed.detail,
|
||||
icon = File(dir, "ICON0.PNG").takeIf { it.isFile },
|
||||
trophies = trophies,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A CellRtcTick to Unix millis, or null when it is absent or implausible.
|
||||
*
|
||||
* Range-checked rather than trusted: a tick of 0 means "no timestamp", and a corrupt
|
||||
* entry would otherwise render as a date in the year 1 or 30000.
|
||||
*/
|
||||
private fun tickToUnixMillis(tick: Long): Long? {
|
||||
if (tick <= RTC_EPOCH_US) return null
|
||||
val millis = (tick - RTC_EPOCH_US) / 1000L
|
||||
// 1980-01-01 .. 2100-01-01. The PS3 itself did not exist before the lower bound.
|
||||
return millis.takeIf { it in 315_532_800_000L..4_102_444_800_000L }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TROPCONF.SFM (definitions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private data class TrophyDef(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val detail: String,
|
||||
val grade: Grade,
|
||||
val hidden: Boolean,
|
||||
)
|
||||
|
||||
private data class TropConf(
|
||||
val title: String,
|
||||
val detail: String,
|
||||
val trophies: List<TrophyDef>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parse the definitions.
|
||||
*
|
||||
* The file is plain XML with a signature COMMENT before the root, and no XML
|
||||
* declaration — XmlPullParser handles both. Attribute names and the 'y' test on
|
||||
* `hidden` follow the native overlay's reload() exactly.
|
||||
*/
|
||||
private fun readTropConf(file: File): TropConf {
|
||||
val parser = XmlPullParserFactory.newInstance().apply { isNamespaceAware = false }
|
||||
.newPullParser()
|
||||
var title = ""
|
||||
var titleDetail = ""
|
||||
val trophies = ArrayList<TrophyDef>()
|
||||
|
||||
file.inputStream().use { stream ->
|
||||
parser.setInput(stream, null)
|
||||
// Fields of the <trophy> currently being read; null id = not inside one.
|
||||
var id: Int? = null
|
||||
var hidden = false
|
||||
var grade = Grade.Unknown
|
||||
var name = ""
|
||||
var detail = ""
|
||||
// Which leaf we are collecting text into. <name>/<detail> appear both at
|
||||
// trophyconf level (title-name/title-detail are separate tags) and inside a
|
||||
// <trophy>, so the text handler has to know where it is.
|
||||
var leaf = ""
|
||||
|
||||
var event = parser.eventType
|
||||
while (event != XmlPullParser.END_DOCUMENT) {
|
||||
when (event) {
|
||||
XmlPullParser.START_TAG -> when (val tag = parser.name) {
|
||||
"trophy" -> {
|
||||
id = parser.getAttributeValue(null, "id")?.trim()?.toIntOrNull()
|
||||
hidden = parser.getAttributeValue(null, "hidden")
|
||||
?.firstOrNull()?.lowercaseChar() == 'y'
|
||||
grade = gradeOf(parser.getAttributeValue(null, "ttype"))
|
||||
name = ""
|
||||
detail = ""
|
||||
leaf = ""
|
||||
}
|
||||
else -> leaf = tag
|
||||
}
|
||||
XmlPullParser.TEXT -> {
|
||||
val text = parser.text ?: ""
|
||||
// Appended unconditionally, not skipped when blank: a parser is free to
|
||||
// split a run of text at an entity reference, and dropping the blank
|
||||
// pieces would silently glue "a & b" into "a&b". leaf is cleared on
|
||||
// every END_TAG, so inter-element whitespace is never collected.
|
||||
when {
|
||||
leaf == "title-name" && id == null -> title += text
|
||||
leaf == "title-detail" && id == null -> titleDetail += text
|
||||
leaf == "name" && id != null -> name += text
|
||||
leaf == "detail" && id != null -> detail += text
|
||||
}
|
||||
}
|
||||
XmlPullParser.END_TAG -> {
|
||||
if (parser.name == "trophy") {
|
||||
id?.let {
|
||||
trophies += TrophyDef(
|
||||
id = it,
|
||||
name = name.trim(),
|
||||
detail = detail.trim(),
|
||||
grade = grade,
|
||||
hidden = hidden,
|
||||
)
|
||||
}
|
||||
id = null
|
||||
}
|
||||
leaf = ""
|
||||
}
|
||||
}
|
||||
event = parser.next()
|
||||
}
|
||||
}
|
||||
|
||||
return TropConf(title.trim(), titleDetail.trim(), trophies.sortedBy { it.id })
|
||||
}
|
||||
|
||||
private fun gradeOf(ttype: String?): Grade = when (ttype?.firstOrNull()?.uppercaseChar()) {
|
||||
'B' -> Grade.Bronze
|
||||
'S' -> Grade.Silver
|
||||
'G' -> Grade.Gold
|
||||
'P' -> Grade.Platinum
|
||||
else -> Grade.Unknown
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TROPUSR.DAT (unlock state)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private data class UsrEntry(val unlocked: Boolean, val timestamp: Long, val grade: Grade)
|
||||
|
||||
/**
|
||||
* Parse the unlock state, keyed by trophy id.
|
||||
*
|
||||
* Layout, from TROPUSR.h:
|
||||
* 0x00 u32 magic, u32 unk1, u32 tables_count, u32 unk2, char reserved[32]
|
||||
* 0x30 tables_count * { u32 type, u32 entries_size, u32 unk1, u32 entries_count,
|
||||
* u64 offset, u64 reserved } (32 bytes each)
|
||||
* then, per table, entries_count records at `offset`.
|
||||
*
|
||||
* All big-endian.
|
||||
*
|
||||
* THE STRIDE IS NOT entries_size. entries_size is the size of an entry's PAYLOAD after
|
||||
* its 16-byte header (type/size/id/unk1), so a record is 16 + entries_size bytes:
|
||||
* table 4 reports 0x50 and its records are 96 bytes, table 6 reports 0x60 and its
|
||||
* records are 112 — which is exactly sizeof(TROPUSREntry4/6), the stride RPCS3 gets for
|
||||
* free by reading the structs directly. Using entries_size as the stride parses
|
||||
* garbage that still looks superficially plausible (verified against a real file: it
|
||||
* yielded 5 entries out of 29 and grade "unknown" for all of them).
|
||||
*
|
||||
* Table 4 carries the grade; table 6 the unlock flag and timestamps.
|
||||
*/
|
||||
private fun readTropUsr(file: File): Map<Int, UsrEntry> {
|
||||
if (!file.isFile) return emptyMap()
|
||||
val bytes = file.readBytes()
|
||||
if (bytes.size < 0x30) return emptyMap()
|
||||
|
||||
val buf = java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.BIG_ENDIAN)
|
||||
if (buf.getInt(0) != TROPUSR_MAGIC) {
|
||||
Log.w(TAG, "${file.name}: bad magic")
|
||||
return emptyMap()
|
||||
}
|
||||
val tableCount = buf.getInt(8)
|
||||
if (tableCount <= 0 || tableCount > 32) return emptyMap()
|
||||
|
||||
val grades = HashMap<Int, Grade>()
|
||||
val states = HashMap<Int, Pair<Boolean, Long>>()
|
||||
|
||||
for (t in 0 until tableCount) {
|
||||
val head = 0x30 + t * 32
|
||||
if (head + 32 > bytes.size) break
|
||||
val type = buf.getInt(head)
|
||||
val entrySize = buf.getInt(head + 4)
|
||||
val entryCount = buf.getInt(head + 12)
|
||||
val offset = buf.getLong(head + 16)
|
||||
if (entrySize <= 0 || entryCount <= 0 || offset < 0) continue
|
||||
val stride = 16 + entrySize
|
||||
// Longest field this reads is table 6's timestamp2, at body+24..body+31.
|
||||
val needed = 16 + 32
|
||||
for (i in 0 until entryCount) {
|
||||
val base = offset + i.toLong() * stride
|
||||
// Bounds-check the bytes actually read, not just the nominal record: a bogus
|
||||
// entries_size would otherwise let the last record's fields run off the end.
|
||||
if (base < 0 || base + maxOf(stride, needed) > bytes.size) break
|
||||
val body = (base + 16).toInt()
|
||||
when (type) {
|
||||
4 -> {
|
||||
val id = buf.getInt(body)
|
||||
grades[id] = usrGradeOf(buf.getInt(body + 4))
|
||||
}
|
||||
6 -> {
|
||||
val id = buf.getInt(body)
|
||||
val unlocked = buf.getInt(body + 4) == 1
|
||||
// timestamp1 at body+16, timestamp2 at body+24. RPCS3's
|
||||
// GetTrophyTimestamp returns timestamp2; UnlockTrophy writes the same
|
||||
// tick to both, so they agree in practice.
|
||||
val timestamp = buf.getLong(body + 24)
|
||||
states[id] = unlocked to timestamp
|
||||
}
|
||||
// Other tables are unused here, as in RPCS3.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return states.mapValues { (id, state) ->
|
||||
UsrEntry(unlocked = state.first, timestamp = state.second, grade = grades[id] ?: Grade.Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
/** TROPUSRLoader::trophy_grade — note it is NOT the same numbering as ttype. */
|
||||
private fun usrGradeOf(value: Int): Grade = when (value) {
|
||||
1 -> Grade.Platinum
|
||||
2 -> Grade.Gold
|
||||
3 -> Grade.Silver
|
||||
4 -> Grade.Bronze
|
||||
else -> Grade.Unknown
|
||||
}
|
||||
}
|
||||
@@ -383,6 +383,14 @@ val EN: Map<String, String> = mapOf(
|
||||
"action.allSettings" to "All Settings",
|
||||
"action.back" to "Back",
|
||||
"action.cancel" to "Cancel",
|
||||
"action.remove" to "Remove",
|
||||
"overlay.pauseTapReveal.label" to "Tap to reveal pause button",
|
||||
"overlay.pauseTapReveal.desc" to "Keeps the pause glyph hidden until you tap the top-right corner. That corner always opens this menu either way.",
|
||||
"packages.licence.remove" to "Remove",
|
||||
"packages.licence.remove.title" to "Remove this licence?",
|
||||
"packages.licence.remove.body" to "%s will be deleted. Content it unlocks will stop working until the licence is installed again.",
|
||||
"packages.licence.removed" to "Licence removed.",
|
||||
"packages.licence.remove.failed" to "Could not remove the licence file.",
|
||||
"action.ok" to "OK",
|
||||
"action.save" to "Save",
|
||||
"core.settings.title" to "All Core Settings",
|
||||
@@ -390,6 +398,31 @@ val EN: Map<String, String> = mapOf(
|
||||
"core.settings.unavailable" to "The emulator core is not loaded, so its settings cannot be read.",
|
||||
"core.settings.scope.game" to "Changes are remembered for this game only",
|
||||
"core.settings.scope.global" to "Changes are remembered for every game",
|
||||
// PS3 trophies (TrophiesScreen). Numbered placeholders (%1/%2/%3) rather than %d, because
|
||||
// several of these take more than one number and a translator has to be able to reorder them.
|
||||
"trophies.title" to "Trophies",
|
||||
"trophies.loading" to "Reading trophy data…",
|
||||
"trophies.overall" to "%1 of %2 earned across %3 game(s)",
|
||||
"trophies.progress" to "%1 / %2 earned (%3%)",
|
||||
"trophies.earned" to "Earned",
|
||||
"trophies.notEarned" to "Not earned",
|
||||
"trophies.earnedOn" to "Earned %s",
|
||||
"trophies.grade.bronze" to "Bronze",
|
||||
"trophies.grade.silver" to "Silver",
|
||||
"trophies.grade.gold" to "Gold",
|
||||
"trophies.grade.platinum" to "Platinum",
|
||||
"trophies.hidden.name" to "Hidden trophy",
|
||||
"trophies.hidden.desc" to "This trophy is hidden",
|
||||
"trophies.showHidden" to "Show hidden trophies",
|
||||
"trophies.showHidden.desc" to "Some games hide a trophy until you earn it, usually because its name gives away a twist. This lists them, still without their real names.",
|
||||
"trophies.allHidden" to "Every trophy in this set is hidden and not yet earned.",
|
||||
"trophies.empty.title" to "No trophies yet",
|
||||
"trophies.empty.body" to "Trophies appear here once you play a game that has them — the game installs its trophy set the first time it runs, and this reads the same data the console would. A game with no trophy set never adds one.",
|
||||
// In-game (pause menu) trophies tab. Separate from empty.* above: "this game has none" and
|
||||
// "you have none at all" are different facts and must not share a string.
|
||||
"trophies.viewTrophies" to "View trophies",
|
||||
"trophies.none.title" to "No trophies for this game",
|
||||
"trophies.none.body" to "This game either has no trophy set, or has not opened it yet — many games only do that once you reach a menu or start playing. Check again later in the session.",
|
||||
"packages.title" to "Install Package",
|
||||
"packages.description" to "Install a .pkg game, update or DLC, or a .rap licence file. Some games need both: the .pkg holds the content and the .rap unlocks it. Installed titles are added to your library automatically, and updates and DLC need the base game installed first.",
|
||||
"packages.select.title" to "Select a .pkg or .rap file",
|
||||
@@ -407,6 +440,8 @@ val EN: Map<String, String> = mapOf(
|
||||
"packages.uninstall.alsoCache" to "Also remove cached shaders and compiled code (%s)",
|
||||
"packages.installed.header" to "Installed titles",
|
||||
"packages.licences.header" to "Installed licences",
|
||||
"packages.licences.count" to "%d licence(s)",
|
||||
"packages.licences.unattributed" to "Unattributed",
|
||||
"packages.uninstall" to "Uninstall",
|
||||
"packages.uninstall.confirmTitle" to "Uninstall this title?",
|
||||
"packages.uninstall.confirmBody" to "This deletes %s and everything installed with it. Save data stored separately is not touched. This cannot be undone.",
|
||||
@@ -433,6 +468,8 @@ val EN: Map<String, String> = mapOf(
|
||||
"audio.synchronization.label" to "Time Stretching",
|
||||
"audio.synchronization.description" to "Keeps audio pitch correct when the emulator runs below full speed, instead of letting sound slow and drop in pitch along with it. Costs a little CPU; turn it off if audio stutters more with it on.",
|
||||
"audio.buffer.label" to "Audio Buffer",
|
||||
"audio.cubebBackend.label" to "Audio Backend (advanced)",
|
||||
"audio.cubebBackend.description" to "Which system audio path the emulator uses. Auto picks AAudio, which has the lowest latency but the smallest buffers. OpenSL adds latency and is much harder to starve — try it if audio crackles or stutters on a slower device.",
|
||||
"audio.buffer.description" to "Bigger buffer = fewer crackles/dropouts but more latency. Raise this if audio stutters on low-end devices.",
|
||||
// --- Recompiler (JIT) tab ---
|
||||
"jit.recompiler.warning" to "Disabling a recompiler drops that CPU/COP onto its interpreter — much slower, for debugging only. Changes apply to the running game.",
|
||||
@@ -490,6 +527,8 @@ val EN: Map<String, String> = mapOf(
|
||||
"adv.ppuRsvPriority.description" to "Gives the main CPU priority over the SPUs when competing for the same memory. Can help games that stall waiting on the PPU.",
|
||||
"adv.spuVerification.label" to "SPU Verification",
|
||||
"adv.spuVerification.description" to "Verifies compiled SPU code against the original. Catches miscompiles; turning it off is faster but makes bad codegen silent.",
|
||||
"adv.preciseSpuVerification.label" to "Precise SPU Verification",
|
||||
"adv.preciseSpuVerification.description" to "Compares cached SPU code byte for byte instead of by checksum. Slower, but games stream different SPU jobs through the same memory, so a checksum collision can run one job's code on another's data. Try this for physics or logic that goes wrong without crashing, such as falling through the ground.",
|
||||
"adv.section.fpu" to "Floating Point",
|
||||
"adv.section.fpu.help" to "PowerPC floating-point corner cases. Defaults are correct for almost every game \u2014 these exist for the handful that depend on exact NaN or rounding behaviour.",
|
||||
"adv.ppuNan.label" to "PPU Vector NaN Handling",
|
||||
@@ -561,19 +600,27 @@ val EN: Map<String, String> = mapOf(
|
||||
"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",
|
||||
"perf.silenceLogs.label" to "Silence all logs",
|
||||
"perf.silenceLogs.description" to "\u26a0 We cannot help you with a bug if logs are disabled \u2014 a report without a log is usually unfixable. That said, a few games log so heavily that this is worth several FPS, so it can be the difference for a game sitting just under playable. Turn it back on before reporting anything.",
|
||||
"renderer.gpuTurbo.label" to "GPU Turbo (Adreno)",
|
||||
"renderer.gpuTurbo.description" to "Pin the GPU to its maximum clocks instead of letting it scale on demand. Removes the stutter when a scene suddenly gets heavy, at the cost of noticeably more heat and battery. Adreno only \u2014 ignored on other GPUs.",
|
||||
"renderer.upscale.label" to "Internal Resolution",
|
||||
"audio.renderer.label" to "Audio Backend",
|
||||
"audio.renderer.description" to "Cubeb is the only working backend on Android. Off disables audio entirely, which can claw back a little CPU on a struggling device.",
|
||||
"audio.renderer.description" to "Cubeb is the general-purpose backend. Oboe is Android-specific and carries per-device workarounds plus automatic recovery when the audio route changes, so try it if you get crackling or dropouts. Off disables audio entirely, which claws back a little CPU on a struggling device.",
|
||||
"patches.ps3.header" to "Community patches and graphics mods from RPCS3. These can unlock frame rates, change resolution or LOD, add widescreen, and fix game bugs. Download once, then enable per game.",
|
||||
"patches.ps3.download" to "Download / update patch database",
|
||||
"patches.ps3.search" to "Search patches",
|
||||
"patches.ps3.showing" to "%1 games, %2 patches \u2014 tap a game to see its patches, or search.",
|
||||
"patches.ps3.downloading" to "Downloading patches\u2026",
|
||||
"patches.ps3.imported" to "patches imported",
|
||||
"patches.ps3.importFile" to "Import a patch file",
|
||||
"patches.ps3.importFailed" to "Could not read that file. Pick a patch.yml exported from RPCS3.",
|
||||
"patches.ps3.downloadFailed" to "Could not download the patch database. Check your connection.",
|
||||
"patches.ps3.serverError" to "RPCS3's patch server returned an error.",
|
||||
"patches.ps3.parseFailed" to "The patch database downloaded but could not be read. It may be for a newer patch format than this build supports.",
|
||||
"patches.ps3.toggleFailed" to "Could not change that patch.",
|
||||
"patches.ps3.checksumFailed" to "The patch database did not match its checksum, so nothing was imported. Try again.",
|
||||
"patches.ps3.restartNeeded" to "Patches are applied while the game loads. A change here takes effect the next time you launch it, not in a game that is already running.",
|
||||
"patches.ps3.emptyAll" to "No patches yet. Download the database above to get started.",
|
||||
"patches.ps3.emptyGame" to "No patches available for this game. Try downloading the database above.",
|
||||
"common.off" to "Off",
|
||||
@@ -1100,7 +1147,6 @@ val EN: Map<String, String> = mapOf(
|
||||
"renderer.shaderPack.presets" to "presets",
|
||||
"renderer.shaderPack.starting" to "Starting…",
|
||||
"renderer.upscale.description" to "Internal resolution. Higher values are sharper but can expose game-specific bloom or alignment artifacts.",
|
||||
"renderer.upscale.label" to "Upscale",
|
||||
"savestate.autoLoadOnBoot" to "Auto-load last state on boot",
|
||||
"savestate.autoSaveInterval.description" to "Save automatically while you play, so a crash or a flat battery costs at most this much progress. It writes the same auto-save slot as the option above, so your numbered slots stay yours. Saving a PS3 state stops and reloads the game, which takes several seconds each time — keep the interval long, 15 minutes or more.",
|
||||
"savestate.autoSaveInterval.every" to "Every %d min",
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.armsx2.ui.home.HomeScreen
|
||||
import com.armsx2.ui.language.LanguageScreen
|
||||
import com.armsx2.ui.saves.SaveManagerScreen
|
||||
import com.armsx2.ui.textures.TextureManagerScreen
|
||||
import com.armsx2.ui.trophies.TrophiesScreen
|
||||
import com.armsx2.ui.settingshub.SettingsScreen
|
||||
|
||||
@Composable
|
||||
@@ -106,6 +107,7 @@ fun AppNavigation() {
|
||||
AppRoute.ControllerManager -> ControllerManagerScreen(onBack = UiNavigator::home)
|
||||
AppRoute.TextureManager -> TextureManagerScreen(onBack = UiNavigator::home)
|
||||
AppRoute.Achievements -> AchievementsScreen(onBack = UiNavigator::home)
|
||||
AppRoute.Trophies -> TrophiesScreen(onBack = UiNavigator::home)
|
||||
AppRoute.Language -> LanguageScreen(
|
||||
onBack = { UiNavigator.navigate(AppRoute.Settings(SettingsCategory.General)) },
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user