mirror of
https://github.com/ARMSX2/ARMSX3.git
synced 2026-08-24 16:58:52 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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
|
||||
--------
|
||||
|
||||
@@ -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 = 10
|
||||
versionName = "0.6"
|
||||
|
||||
// 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,10 @@ struct RPCSXApi {
|
||||
int (*getState)();
|
||||
void (*kill)();
|
||||
void (*resume)();
|
||||
void (*pause)();
|
||||
void (*openHomeMenu)();
|
||||
std::string (*getTitleId)();
|
||||
std::string (*getCurrentTrophyName)();
|
||||
bool (*surfaceEvent)(JNIEnv *env, jobject surface, jint event);
|
||||
void (*surfaceSizeChanged)(int width, int height);
|
||||
bool (*usbDeviceEvent)(int fd, int vendorId, int productId, int event);
|
||||
@@ -52,6 +59,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 +69,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 +113,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 +124,10 @@ struct RPCSXLibrary : RPCSXApi {
|
||||
result.getState = reinterpret_cast<decltype(getState)>(dlsym(handle, "_rpcsx_getState"));
|
||||
result.kill = reinterpret_cast<decltype(kill)>(dlsym(handle, "_rpcsx_kill"));
|
||||
result.resume = reinterpret_cast<decltype(resume)>(dlsym(handle, "_rpcsx_resume"));
|
||||
result.pause = reinterpret_cast<decltype(pause)>(dlsym(handle, "_rpcsx_pause"));
|
||||
result.openHomeMenu = reinterpret_cast<decltype(openHomeMenu)>(dlsym(handle, "_rpcsx_openHomeMenu"));
|
||||
result.getTitleId = reinterpret_cast<decltype(getTitleId)>(dlsym(handle, "_rpcsx_getTitleId"));
|
||||
result.getCurrentTrophyName = reinterpret_cast<decltype(getCurrentTrophyName)>(dlsym(handle, "_rpcsx_getCurrentTrophyName"));
|
||||
result.surfaceEvent = reinterpret_cast<decltype(surfaceEvent)>(dlsym(handle, "_rpcsx_surfaceEvent"));
|
||||
result.surfaceSizeChanged = reinterpret_cast<decltype(surfaceSizeChanged)>(dlsym(handle, "_rpcsx_surfaceSizeChanged"));
|
||||
result.usbDeviceEvent = reinterpret_cast<decltype(usbDeviceEvent)>(dlsym(handle, "_rpcsx_usbDeviceEvent"));
|
||||
@@ -135,12 +147,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 +215,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 +359,18 @@ extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_resume(JNIEnv *env,
|
||||
return rpcsxLib.resume();
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_pause(JNIEnv *env,
|
||||
jobject) {
|
||||
// Same null guard as resume: the core is dlopen()ed separately and may not be
|
||||
// up yet. A missing symbol also means an older core, so an app built against
|
||||
// this cannot assume the export is there.
|
||||
if (rpcsxLib.pause == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
return rpcsxLib.pause();
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_openHomeMenu(JNIEnv *env,
|
||||
jobject) {
|
||||
// The core is dlopen()ed separately and may not be up yet -- during
|
||||
@@ -340,6 +395,21 @@ Java_net_rpcsx_RPCSX_getTitleId(JNIEnv *env, jobject) {
|
||||
return wrap(env, rpcsxLib.getTitleId());
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jstring JNICALL
|
||||
Java_net_rpcsx_RPCSX_getCurrentTrophyName(JNIEnv *env, jobject) {
|
||||
// The core is dlopen()ed separately and may not be up yet -- during
|
||||
// onboarding, or if it failed to load. Calling through a null pointer
|
||||
// is an instant SIGSEGV, so fail the call instead.
|
||||
//
|
||||
// Also null on an OLDER core that predates this export, since it is resolved
|
||||
// by dlsym: the frontend must treat null as "unknown", not as "no trophies".
|
||||
if (rpcsxLib.getCurrentTrophyName == nullptr) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
return wrap(env, rpcsxLib.getCurrentTrophyName());
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_surfaceEvent(
|
||||
JNIEnv *env, jobject, jobject surface, jint event) {
|
||||
// The core is dlopen()ed separately and may not be up yet -- during
|
||||
@@ -556,6 +626,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 +782,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 +815,27 @@ Java_net_rpcsx_RPCSX_setCustomDriver(JNIEnv *env, jobject, jstring jpath,
|
||||
}
|
||||
}
|
||||
|
||||
auto prevLoader = rpcsxLib.setCustomDriver(loader);
|
||||
if (prevLoader != nullptr) {
|
||||
::dlclose(prevLoader);
|
||||
}
|
||||
// Deliberately NOT dlclose()ing the previous handle.
|
||||
//
|
||||
// A Vulkan driver cannot be unloaded while anything resolved out of it is still reachable, and
|
||||
// from here there is no way to know that. VMA caches vkGetPhysicalDeviceMemoryProperties2 in the
|
||||
// allocator at creation time, so the address lives inside the driver library for as long as the
|
||||
// renderer does.
|
||||
//
|
||||
// Restart is the one flow where a start races a teardown that has not finished:
|
||||
// applyRendererPrefs() re-applies the driver on EVERY start, so it dlopen'd a new handle and
|
||||
// closed the old one while the previous VKGSRender was still unwinding. Its destructor then
|
||||
// freed its data heaps, VMA went to refresh its budget, and called through a pointer into a
|
||||
// library that was no longer mapped -- "Segfault executing location <addr> at <addr>", inside
|
||||
// VmaAllocator_T::UpdateVulkanBudget. The give-away was the fault address landing on the same
|
||||
// offset every time with a different base: a live function in an unmapped library, not a
|
||||
// corrupted pointer. That is the "Restart crashes the app" report, and the same for
|
||||
// apply-and-restart after picking a driver.
|
||||
//
|
||||
// Leaking one handle per driver SWITCH is the cheap side of this trade: it is bounded by how
|
||||
// many times a user changes driver in a session, the mapping is shared, and dlclose on an ICD
|
||||
// is not something the loader promises to honour anyway.
|
||||
rpcsxLib.setCustomDriver(loader);
|
||||
|
||||
return true;
|
||||
#else
|
||||
@@ -663,6 +909,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) {
|
||||
|
||||
@@ -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,40 @@ 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
|
||||
}
|
||||
|
||||
/** 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.
|
||||
//
|
||||
|
||||
@@ -1294,10 +1294,18 @@ data class Settings(
|
||||
// A deliberate change in All Core Settings still wins, because CoreSettingOverrides
|
||||
// replays immediately below this.
|
||||
runCatching { net.rpcsx.RPCSX.instance.settingsSet("Video@@Vblank Rate", "60") }
|
||||
// And the cap itself. Frame limit Auto resolves to the vblank rate, so with the line
|
||||
// above it would already be 60; setting it explicitly means the cap does not depend
|
||||
// on the vblank path holding, which it did not. Enum node, so the value is quoted.
|
||||
runCatching { net.rpcsx.RPCSX.instance.settingsSet("Video@@Frame limit", "\"60\"") }
|
||||
// Frame limit is deliberately NOT forced here any more.
|
||||
//
|
||||
// It used to be written to "60" on every push as a belt-and-braces way to reach a 60Hz cap
|
||||
// alongside the Vblank Rate above. But this is the same node the Display FPS Cap row writes,
|
||||
// and this push runs after it, so choosing 30 wrote the enum and then this overwrote it --
|
||||
// the cap did nothing for every preset value while 20 and 45 worked, because those take the
|
||||
// free-form Second Frame Limit path instead. The core reported frame_limit=_60 on every flip
|
||||
// no matter what the UI had just been told.
|
||||
//
|
||||
// The 60Hz intent survives without it: Frame limit Auto resolves to the vblank rate, which
|
||||
// the line above pins to 60. ConfigStore also clears the stale core override that pinned
|
||||
// this node, since that replayed even later than this did.
|
||||
// Left at upstream's 100: busy-wait on a reservation rather than sleeping.
|
||||
//
|
||||
// This was dropped to 20 while the emulator was starved for cores, on the reasoning that
|
||||
|
||||
+43
-2
@@ -191,9 +191,36 @@ class GameLibraryRepository(private val context: Context) {
|
||||
// Everything the probe produces is already durable: the serial and title are in the
|
||||
// library cache, and the icon is on disk under disc-icons. So a disc we have seen
|
||||
// before never needs mounting again.
|
||||
//
|
||||
// That last clause is only true if the extraction actually succeeded once. The serial
|
||||
// and title are durable by construction -- this loop is reading them -- but the icon
|
||||
// is a separate file that may never have been written: probeDiscInfo answers "{}"
|
||||
// whenever a game is loaded, and the serial then comes from the FILENAME instead,
|
||||
// which for dev_hdd0/game/<title id> is indistinguishable from one the SFO gave us.
|
||||
// Seeding on the serial alone made that miss permanent, because every later rescan
|
||||
// skipped the one thing that would repair it. Reported as a PKG-installed title
|
||||
// showing a text placeholder for good, with its ICON0.PNG sitting unread in the game
|
||||
// folder and its path already recorded in games.json.
|
||||
//
|
||||
// So gate the skip on the icon as well, for the games that have one. Re-probing costs
|
||||
// one mount, once, and only for a game actually missing it; a game whose icon is on
|
||||
// disk still never mounts again, which is what the reasoning above is protecting.
|
||||
loadCached().games.forEach { game ->
|
||||
val serial = game.serial?.takeIf { it.isNotBlank() } ?: return@forEach
|
||||
val path = runCatching { game.uri.path }.getOrNull() ?: return@forEach
|
||||
// Folders only, and that is not a convenience: re-probing an ISO means load_iso ->
|
||||
// vfs::mount, which is the process-wide mount this whole seeding exists to avoid, and
|
||||
// it has crashed the app for real -- twice in one day, faulting in
|
||||
// manual_typemap::init<vfs_manager> from this very thread while a boot was starting.
|
||||
// A directory is read straight off disk by read_sfo_game_info with no mount at all,
|
||||
// so it carries none of that risk. The game this was reported for was a PKG install
|
||||
// (folder form) whose ICON0.PNG was sitting there unread, which is exactly the case
|
||||
// that stays covered.
|
||||
val isFolder = game.extension.equals("folder", ignoreCase = true)
|
||||
if (isFolder && !DiscIcons.has(serial)) {
|
||||
android.util.Log.i(ScanTag, "re-probing folder '$serial': no usable disc icon on disk")
|
||||
return@forEach
|
||||
}
|
||||
discInfoCache.putIfAbsent(path, DiscInfo(serial, game.title))
|
||||
}
|
||||
|
||||
@@ -582,8 +609,22 @@ class GameLibraryRepository(private val context: Context) {
|
||||
// title ID until it has already parsed the SFO.
|
||||
if (o.optBoolean("icon")) {
|
||||
val staged = DiscIcons.fileFor(PendingIcon)
|
||||
if (staged.isFile) {
|
||||
staged.renameTo(DiscIcons.fileFor(id))
|
||||
val target = DiscIcons.fileFor(id)
|
||||
// renameTo answers false instead of throwing when the target already exists,
|
||||
// and the answer was discarded: a re-extraction for a title that already had
|
||||
// an icon silently kept the old file and left the staging one behind. Clear
|
||||
// the target first, and say so if it still fails -- a stale or empty icon must
|
||||
// not outlive the probe that was meant to replace it, because every reader
|
||||
// downstream treats "a file is there" as "the cover is good".
|
||||
if (staged.length() > 0L) {
|
||||
target.delete()
|
||||
if (!staged.renameTo(target)) {
|
||||
android.util.Log.w(ScanTag, " could not place disc icon for $id")
|
||||
staged.delete()
|
||||
}
|
||||
} else {
|
||||
android.util.Log.w(ScanTag, " probe claimed an icon for $id, staged 0 bytes")
|
||||
staged.delete()
|
||||
}
|
||||
}
|
||||
DiscInfo(id, o.optString("title"))
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
package com.armsx2.data.trophies
|
||||
|
||||
import android.util.Log
|
||||
import java.io.File
|
||||
import net.rpcsx.RPCSX
|
||||
import org.xmlpull.v1.XmlPullParser
|
||||
import org.xmlpull.v1.XmlPullParserFactory
|
||||
|
||||
/**
|
||||
* Reader for RPCS3's NATIVE PS3 trophy data — the real thing the games write, not
|
||||
* RetroAchievements (which has no PS3 support at all, which is why the RA screen is
|
||||
* hidden in ARMSX3).
|
||||
*
|
||||
* Where the data lives, and why this reads it from disk rather than through JNI:
|
||||
*
|
||||
* config/dev_hdd0/home/<user>/trophy/<NPWRxxxxx_00>/
|
||||
* TROPCONF.SFM — the trophy DEFINITIONS, installed from the game's TROPHY.TRP.
|
||||
* Plain XML: <title-name>, then one <trophy id hidden ttype pid>
|
||||
* per trophy with <name> and <detail> children.
|
||||
* TROPUSR.DAT — the user's UNLOCK STATE. Big-endian binary, written by
|
||||
* sceNpTrophyUnlockTrophy via TROPUSRLoader::Save.
|
||||
* ICON0.PNG — the game's icon; TROP000.PNG… the per-trophy icons.
|
||||
*
|
||||
* The emulator's own loaders (rpcs3/Loader/TROPUSR.cpp, and the overlay's
|
||||
* load_trophies in Emu/RSX/Overlays/Trophies/overlay_trophy_list_dialog.cpp) sit behind
|
||||
* vfs::get, so reaching them needs the core dlopen()ed AND its VFS mounted — neither is
|
||||
* guaranteed in the library, which is exactly where this screen is used. These files are
|
||||
* inside the app's own external files dir, so plain java.io reads them with no core at
|
||||
* all: no JNI, no native rebuild, and the browser works before a game has ever booted.
|
||||
*
|
||||
* The parse mirrors TROPUSR.h/.cpp field for field; see [readTropUsr] for the one
|
||||
* non-obvious part (the entry stride).
|
||||
*/
|
||||
object TrophyRepository {
|
||||
|
||||
private const val TAG = "Trophies"
|
||||
|
||||
/** TROPUSR.DAT magic, from TROPUSR.cpp's TROPUSR_MAGIC. */
|
||||
private const val TROPUSR_MAGIC = 0x818F54AD.toInt()
|
||||
|
||||
/**
|
||||
* Microseconds from 0001-01-01 to 1970-01-01 (719162 days).
|
||||
*
|
||||
* A trophy timestamp is a CellRtcTick — sceNpTrophyUnlockTrophy stores
|
||||
* cellRtcGetCurrentTick's value straight into the entry — and cellRtc counts
|
||||
* microseconds from year 1 UTC (see tick_to_date_time in cellRtc.cpp). Subtracting
|
||||
* this turns it into a Unix epoch.
|
||||
*/
|
||||
private const val RTC_EPOCH_US = 62135596800L * 1_000_000L
|
||||
|
||||
enum class Grade { Unknown, Platinum, Gold, Silver, Bronze }
|
||||
|
||||
data class Trophy(
|
||||
val id: Int,
|
||||
/** Real name from TROPCONF.SFM. Masked by the UI while a hidden trophy is locked. */
|
||||
val name: String,
|
||||
val description: String,
|
||||
val grade: Grade,
|
||||
val hidden: Boolean,
|
||||
val unlocked: Boolean,
|
||||
/** Unix millis, or null when the file carries no timestamp (never unlocked, or an
|
||||
* unlock written by something that did not stamp it). */
|
||||
val unlockedAt: Long?,
|
||||
/** TROP%03d.PNG for this trophy, or null when the icon is missing. */
|
||||
val icon: File?,
|
||||
)
|
||||
|
||||
data class Game(
|
||||
/** The trophy folder name, e.g. NPWR05636_00. The only stable id here — a trophy
|
||||
* set is keyed by comm id, not by the game's title id. */
|
||||
val commId: String,
|
||||
val title: String,
|
||||
val detail: String,
|
||||
val icon: File?,
|
||||
val trophies: List<Trophy>,
|
||||
) {
|
||||
val total: Int get() = trophies.size
|
||||
val unlocked: Int get() = trophies.count { it.unlocked }
|
||||
val percent: Int get() = if (total > 0) 100 * unlocked / total else 0
|
||||
}
|
||||
|
||||
/** Root of the emulator's HDD, i.e. what RPCS3 mounts as /dev_hdd0. */
|
||||
private fun hdd0(): File = File(RPCSX.getHdd0Dir())
|
||||
|
||||
/**
|
||||
* The trophy directories to scan.
|
||||
*
|
||||
* Prefers the logged-in user (Rpcs3Bridge logs in "00000001"), but falls back to
|
||||
* whichever user folder actually holds a trophy dir: getUser() reaches through JNI
|
||||
* into the core, which returns null when the core is not open yet, and a browser that
|
||||
* showed nothing until a game had booted would look broken.
|
||||
*/
|
||||
private fun trophyRoots(): List<File> {
|
||||
val home = File(hdd0(), "home")
|
||||
val users = home.listFiles().orEmpty().filter { it.isDirectory }
|
||||
val preferred = runCatching { RPCSX.instance.getUser() }.getOrNull()?.takeIf { it.isNotBlank() }
|
||||
val ordered = if (preferred != null) {
|
||||
users.sortedBy { it.name != preferred }
|
||||
} else {
|
||||
users
|
||||
}
|
||||
val roots = ordered.map { File(it, "trophy") }.filter { it.isDirectory }
|
||||
// One user is the norm; only that user's sets are shown. Scanning every user would
|
||||
// merge two people's progress into one list.
|
||||
return roots.take(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every trophy set on disk, newest-played first is NOT assumed — sorted by title so the
|
||||
* list is stable across sessions.
|
||||
*
|
||||
* Blocking disk work: call from Dispatchers.IO.
|
||||
*/
|
||||
fun load(): List<Game> {
|
||||
val dirs = trophyRoots().flatMap { it.listFiles().orEmpty().filter { d -> d.isDirectory } }
|
||||
return dirs.mapNotNull { readGame(it) }.sortedBy { it.title.lowercase() }
|
||||
}
|
||||
|
||||
/**
|
||||
* The RUNNING game's trophy set, or null when it has none (or none can be identified).
|
||||
*
|
||||
* Blocking disk work: call from Dispatchers.IO.
|
||||
*
|
||||
* IDENTIFYING THE SET is the whole difficulty here, because trophy folders are named by
|
||||
* NPWR comm id and nothing on the folder says which title it belongs to. Two sources,
|
||||
* in order:
|
||||
*
|
||||
* 1. The core's own `current_trophy_name`, via [RPCSX.getCurrentTrophyName]. This is
|
||||
* exactly what RPCS3's home menu uses to pick the set for its native overlay list,
|
||||
* written by sceNpTrophyCreateContext. Authoritative, and works for disc and
|
||||
* installed titles alike.
|
||||
* 2. The title's TROPDIR, whose subfolders ARE the NPWR ids the title ships. Used only
|
||||
* when (1) is empty, which happens for a real reason: a game creates its trophy
|
||||
* context lazily, often not until you reach a menu, so early in a boot the core
|
||||
* genuinely does not know yet. This covers INSTALLED titles only — a disc game's
|
||||
* TROPDIR is inside the ISO and never lands on the HDD.
|
||||
*/
|
||||
fun loadCurrentGame(): Game? {
|
||||
val roots = trophyRoots()
|
||||
if (roots.isEmpty()) return null
|
||||
|
||||
for (commId in currentGameCommIds()) {
|
||||
val dir = roots.asSequence().map { File(it, commId) }.firstOrNull { it.isDirectory }
|
||||
?: continue
|
||||
readGame(dir)?.let { return it }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Candidate trophy folder names for the running game, best guess first. Empty when
|
||||
* nothing identifies it.
|
||||
*/
|
||||
private fun currentGameCommIds(): List<String> {
|
||||
val fromCore = runCatching { RPCSX.instance.getCurrentTrophyName() }
|
||||
.getOrNull()?.trim()?.takeIf { it.isNotEmpty() }
|
||||
if (fromCore != null) return listOf(fromCore)
|
||||
|
||||
val titleId = runCatching { RPCSX.instance.getTitleId() }
|
||||
.getOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return emptyList()
|
||||
// TROPDIR/<NPWRxxxxx_00>/TROPHY.TRP — the folder names are the comm ids.
|
||||
return File(hdd0(), "game/$titleId/TROPDIR").listFiles().orEmpty()
|
||||
.filter { it.isDirectory }
|
||||
.map { it.name }
|
||||
}
|
||||
|
||||
private fun readGame(dir: File): Game? {
|
||||
val conf = File(dir, "TROPCONF.SFM")
|
||||
if (!conf.isFile) {
|
||||
Log.i(TAG, "skipping ${dir.name}: no TROPCONF.SFM")
|
||||
return null
|
||||
}
|
||||
val parsed = runCatching { readTropConf(conf) }.getOrElse {
|
||||
Log.w(TAG, "failed to parse ${conf.absolutePath}", it)
|
||||
return null
|
||||
}
|
||||
if (parsed.trophies.isEmpty()) return null
|
||||
|
||||
// Unlock state is optional: TROPUSR.DAT only exists once the game has registered its
|
||||
// trophy context. Without it every trophy simply reads as locked, which is correct.
|
||||
val state = runCatching { readTropUsr(File(dir, "TROPUSR.DAT")) }.getOrElse {
|
||||
Log.w(TAG, "failed to parse TROPUSR.DAT in ${dir.name}", it)
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
val trophies = parsed.trophies.map { def ->
|
||||
val entry = state[def.id]
|
||||
Trophy(
|
||||
id = def.id,
|
||||
name = def.name,
|
||||
description = def.detail,
|
||||
// ttype from the XML is what the native overlay uses; the grade duplicated in
|
||||
// TROPUSR table 4 is the fallback for a set with a missing/odd ttype.
|
||||
grade = def.grade.takeIf { it != Grade.Unknown } ?: entry?.grade ?: Grade.Unknown,
|
||||
hidden = def.hidden,
|
||||
unlocked = entry?.unlocked == true,
|
||||
unlockedAt = entry?.takeIf { it.unlocked }?.timestamp?.let(::tickToUnixMillis),
|
||||
// Locale.ROOT: the default locale would render the digits in its own numeral
|
||||
// system for e.g. Arabic, and the file name is ASCII.
|
||||
icon = File(dir, String.format(java.util.Locale.ROOT, "TROP%03d.PNG", def.id))
|
||||
.takeIf { it.isFile },
|
||||
)
|
||||
}
|
||||
|
||||
return Game(
|
||||
commId = dir.name,
|
||||
title = parsed.title.ifBlank { dir.name },
|
||||
detail = parsed.detail,
|
||||
icon = File(dir, "ICON0.PNG").takeIf { it.isFile },
|
||||
trophies = trophies,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A CellRtcTick to Unix millis, or null when it is absent or implausible.
|
||||
*
|
||||
* Range-checked rather than trusted: a tick of 0 means "no timestamp", and a corrupt
|
||||
* entry would otherwise render as a date in the year 1 or 30000.
|
||||
*/
|
||||
private fun tickToUnixMillis(tick: Long): Long? {
|
||||
if (tick <= RTC_EPOCH_US) return null
|
||||
val millis = (tick - RTC_EPOCH_US) / 1000L
|
||||
// 1980-01-01 .. 2100-01-01. The PS3 itself did not exist before the lower bound.
|
||||
return millis.takeIf { it in 315_532_800_000L..4_102_444_800_000L }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TROPCONF.SFM (definitions)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private data class TrophyDef(
|
||||
val id: Int,
|
||||
val name: String,
|
||||
val detail: String,
|
||||
val grade: Grade,
|
||||
val hidden: Boolean,
|
||||
)
|
||||
|
||||
private data class TropConf(
|
||||
val title: String,
|
||||
val detail: String,
|
||||
val trophies: List<TrophyDef>,
|
||||
)
|
||||
|
||||
/**
|
||||
* Parse the definitions.
|
||||
*
|
||||
* The file is plain XML with a signature COMMENT before the root, and no XML
|
||||
* declaration — XmlPullParser handles both. Attribute names and the 'y' test on
|
||||
* `hidden` follow the native overlay's reload() exactly.
|
||||
*/
|
||||
private fun readTropConf(file: File): TropConf {
|
||||
val parser = XmlPullParserFactory.newInstance().apply { isNamespaceAware = false }
|
||||
.newPullParser()
|
||||
var title = ""
|
||||
var titleDetail = ""
|
||||
val trophies = ArrayList<TrophyDef>()
|
||||
|
||||
file.inputStream().use { stream ->
|
||||
parser.setInput(stream, null)
|
||||
// Fields of the <trophy> currently being read; null id = not inside one.
|
||||
var id: Int? = null
|
||||
var hidden = false
|
||||
var grade = Grade.Unknown
|
||||
var name = ""
|
||||
var detail = ""
|
||||
// Which leaf we are collecting text into. <name>/<detail> appear both at
|
||||
// trophyconf level (title-name/title-detail are separate tags) and inside a
|
||||
// <trophy>, so the text handler has to know where it is.
|
||||
var leaf = ""
|
||||
|
||||
var event = parser.eventType
|
||||
while (event != XmlPullParser.END_DOCUMENT) {
|
||||
when (event) {
|
||||
XmlPullParser.START_TAG -> when (val tag = parser.name) {
|
||||
"trophy" -> {
|
||||
id = parser.getAttributeValue(null, "id")?.trim()?.toIntOrNull()
|
||||
hidden = parser.getAttributeValue(null, "hidden")
|
||||
?.firstOrNull()?.lowercaseChar() == 'y'
|
||||
grade = gradeOf(parser.getAttributeValue(null, "ttype"))
|
||||
name = ""
|
||||
detail = ""
|
||||
leaf = ""
|
||||
}
|
||||
else -> leaf = tag
|
||||
}
|
||||
XmlPullParser.TEXT -> {
|
||||
val text = parser.text ?: ""
|
||||
// Appended unconditionally, not skipped when blank: a parser is free to
|
||||
// split a run of text at an entity reference, and dropping the blank
|
||||
// pieces would silently glue "a & b" into "a&b". leaf is cleared on
|
||||
// every END_TAG, so inter-element whitespace is never collected.
|
||||
when {
|
||||
leaf == "title-name" && id == null -> title += text
|
||||
leaf == "title-detail" && id == null -> titleDetail += text
|
||||
leaf == "name" && id != null -> name += text
|
||||
leaf == "detail" && id != null -> detail += text
|
||||
}
|
||||
}
|
||||
XmlPullParser.END_TAG -> {
|
||||
if (parser.name == "trophy") {
|
||||
id?.let {
|
||||
trophies += TrophyDef(
|
||||
id = it,
|
||||
name = name.trim(),
|
||||
detail = detail.trim(),
|
||||
grade = grade,
|
||||
hidden = hidden,
|
||||
)
|
||||
}
|
||||
id = null
|
||||
}
|
||||
leaf = ""
|
||||
}
|
||||
}
|
||||
event = parser.next()
|
||||
}
|
||||
}
|
||||
|
||||
return TropConf(title.trim(), titleDetail.trim(), trophies.sortedBy { it.id })
|
||||
}
|
||||
|
||||
private fun gradeOf(ttype: String?): Grade = when (ttype?.firstOrNull()?.uppercaseChar()) {
|
||||
'B' -> Grade.Bronze
|
||||
'S' -> Grade.Silver
|
||||
'G' -> Grade.Gold
|
||||
'P' -> Grade.Platinum
|
||||
else -> Grade.Unknown
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// TROPUSR.DAT (unlock state)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private data class UsrEntry(val unlocked: Boolean, val timestamp: Long, val grade: Grade)
|
||||
|
||||
/**
|
||||
* Parse the unlock state, keyed by trophy id.
|
||||
*
|
||||
* Layout, from TROPUSR.h:
|
||||
* 0x00 u32 magic, u32 unk1, u32 tables_count, u32 unk2, char reserved[32]
|
||||
* 0x30 tables_count * { u32 type, u32 entries_size, u32 unk1, u32 entries_count,
|
||||
* u64 offset, u64 reserved } (32 bytes each)
|
||||
* then, per table, entries_count records at `offset`.
|
||||
*
|
||||
* All big-endian.
|
||||
*
|
||||
* THE STRIDE IS NOT entries_size. entries_size is the size of an entry's PAYLOAD after
|
||||
* its 16-byte header (type/size/id/unk1), so a record is 16 + entries_size bytes:
|
||||
* table 4 reports 0x50 and its records are 96 bytes, table 6 reports 0x60 and its
|
||||
* records are 112 — which is exactly sizeof(TROPUSREntry4/6), the stride RPCS3 gets for
|
||||
* free by reading the structs directly. Using entries_size as the stride parses
|
||||
* garbage that still looks superficially plausible (verified against a real file: it
|
||||
* yielded 5 entries out of 29 and grade "unknown" for all of them).
|
||||
*
|
||||
* Table 4 carries the grade; table 6 the unlock flag and timestamps.
|
||||
*/
|
||||
private fun readTropUsr(file: File): Map<Int, UsrEntry> {
|
||||
if (!file.isFile) return emptyMap()
|
||||
val bytes = file.readBytes()
|
||||
if (bytes.size < 0x30) return emptyMap()
|
||||
|
||||
val buf = java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.BIG_ENDIAN)
|
||||
if (buf.getInt(0) != TROPUSR_MAGIC) {
|
||||
Log.w(TAG, "${file.name}: bad magic")
|
||||
return emptyMap()
|
||||
}
|
||||
val tableCount = buf.getInt(8)
|
||||
if (tableCount <= 0 || tableCount > 32) return emptyMap()
|
||||
|
||||
val grades = HashMap<Int, Grade>()
|
||||
val states = HashMap<Int, Pair<Boolean, Long>>()
|
||||
|
||||
for (t in 0 until tableCount) {
|
||||
val head = 0x30 + t * 32
|
||||
if (head + 32 > bytes.size) break
|
||||
val type = buf.getInt(head)
|
||||
val entrySize = buf.getInt(head + 4)
|
||||
val entryCount = buf.getInt(head + 12)
|
||||
val offset = buf.getLong(head + 16)
|
||||
if (entrySize <= 0 || entryCount <= 0 || offset < 0) continue
|
||||
val stride = 16 + entrySize
|
||||
// Longest field this reads is table 6's timestamp2, at body+24..body+31.
|
||||
val needed = 16 + 32
|
||||
for (i in 0 until entryCount) {
|
||||
val base = offset + i.toLong() * stride
|
||||
// Bounds-check the bytes actually read, not just the nominal record: a bogus
|
||||
// entries_size would otherwise let the last record's fields run off the end.
|
||||
if (base < 0 || base + maxOf(stride, needed) > bytes.size) break
|
||||
val body = (base + 16).toInt()
|
||||
when (type) {
|
||||
4 -> {
|
||||
val id = buf.getInt(body)
|
||||
grades[id] = usrGradeOf(buf.getInt(body + 4))
|
||||
}
|
||||
6 -> {
|
||||
val id = buf.getInt(body)
|
||||
val unlocked = buf.getInt(body + 4) == 1
|
||||
// timestamp1 at body+16, timestamp2 at body+24. RPCS3's
|
||||
// GetTrophyTimestamp returns timestamp2; UnlockTrophy writes the same
|
||||
// tick to both, so they agree in practice.
|
||||
val timestamp = buf.getLong(body + 24)
|
||||
states[id] = unlocked to timestamp
|
||||
}
|
||||
// Other tables are unused here, as in RPCS3.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return states.mapValues { (id, state) ->
|
||||
UsrEntry(unlocked = state.first, timestamp = state.second, grade = grades[id] ?: Grade.Unknown)
|
||||
}
|
||||
}
|
||||
|
||||
/** TROPUSRLoader::trophy_grade — note it is NOT the same numbering as ttype. */
|
||||
private fun usrGradeOf(value: Int): Grade = when (value) {
|
||||
1 -> Grade.Platinum
|
||||
2 -> Grade.Gold
|
||||
3 -> Grade.Silver
|
||||
4 -> Grade.Bronze
|
||||
else -> Grade.Unknown
|
||||
}
|
||||
}
|
||||
@@ -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.",
|
||||
@@ -574,6 +609,8 @@ val EN: Map<String, String> = mapOf(
|
||||
"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 +1137,6 @@ val EN: Map<String, String> = mapOf(
|
||||
"renderer.shaderPack.presets" to "presets",
|
||||
"renderer.shaderPack.starting" to "Starting…",
|
||||
"renderer.upscale.description" to "Internal resolution. Higher values are sharper but can expose game-specific bloom or alignment artifacts.",
|
||||
"renderer.upscale.label" to "Upscale",
|
||||
"savestate.autoLoadOnBoot" to "Auto-load last state on boot",
|
||||
"savestate.autoSaveInterval.description" to "Save automatically while you play, so a crash or a flat battery costs at most this much progress. It writes the same auto-save slot as the option above, so your numbered slots stay yours. Saving a PS3 state stops and reloads the game, which takes several seconds each time — keep the interval long, 15 minutes or more.",
|
||||
"savestate.autoSaveInterval.every" to "Every %d min",
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.armsx2.ui.home.HomeScreen
|
||||
import com.armsx2.ui.language.LanguageScreen
|
||||
import com.armsx2.ui.saves.SaveManagerScreen
|
||||
import com.armsx2.ui.textures.TextureManagerScreen
|
||||
import com.armsx2.ui.trophies.TrophiesScreen
|
||||
import com.armsx2.ui.settingshub.SettingsScreen
|
||||
|
||||
@Composable
|
||||
@@ -106,6 +107,7 @@ fun AppNavigation() {
|
||||
AppRoute.ControllerManager -> ControllerManagerScreen(onBack = UiNavigator::home)
|
||||
AppRoute.TextureManager -> TextureManagerScreen(onBack = UiNavigator::home)
|
||||
AppRoute.Achievements -> AchievementsScreen(onBack = UiNavigator::home)
|
||||
AppRoute.Trophies -> TrophiesScreen(onBack = UiNavigator::home)
|
||||
AppRoute.Language -> LanguageScreen(
|
||||
onBack = { UiNavigator.navigate(AppRoute.Settings(SettingsCategory.General)) },
|
||||
)
|
||||
|
||||
@@ -19,6 +19,9 @@ sealed interface AppRoute {
|
||||
data object ControllerManager : AppRoute
|
||||
data object TextureManager : AppRoute
|
||||
data object Achievements : AppRoute
|
||||
// PS3 trophies, read from the emulator's own dev_hdd0 trophy folders. Distinct from
|
||||
// Achievements above, which is the (hidden) RetroAchievements screen.
|
||||
data object Trophies : AppRoute
|
||||
data object Language : AppRoute
|
||||
data object News : AppRoute
|
||||
data object Friends : AppRoute
|
||||
|
||||
@@ -199,7 +199,12 @@ private fun DrawerContent(selected: AppRoute, onNavigate: (AppRoute) -> Unit, on
|
||||
// below, which only points the emulator at your BIOS file.
|
||||
DrawerItem("bios.boot.title", "▶️", onAction = { MainActivityRuntime.startBios(); onDismiss() }),
|
||||
// ARMSX3: RetroAchievements removed - RA has no PS3 support at all, so
|
||||
// the screen could only ever be empty.
|
||||
// the screen could only ever be empty. PS3 TROPHIES take its slot: RPCS3
|
||||
// tracks the real ones the games unlock, and its own list is reachable only
|
||||
// from inside a running game (the home menu's Trophies item, which shows
|
||||
// that game's set alone). This is the across-titles browser, which was
|
||||
// Qt-only upstream and so had no Android entry point at all.
|
||||
DrawerItem("trophies.title", "🏆", AppRoute.Trophies),
|
||||
DrawerItem("action.settings", "⚙️", AppRoute.Settings()),
|
||||
// Everything the core exposes, generated from its config tree rather than
|
||||
// hand-written. The curated tabs above stay small on purpose; this is the
|
||||
@@ -369,6 +374,7 @@ private fun sameDestination(current: AppRoute, target: AppRoute): Boolean = when
|
||||
AppRoute.ControllerManager -> current is AppRoute.ControllerManager
|
||||
AppRoute.TextureManager -> current is AppRoute.TextureManager
|
||||
AppRoute.Achievements -> current is AppRoute.Achievements
|
||||
AppRoute.Trophies -> current is AppRoute.Trophies
|
||||
AppRoute.Language -> current is AppRoute.Language
|
||||
AppRoute.News -> current is AppRoute.News
|
||||
AppRoute.Friends -> current is AppRoute.Friends
|
||||
|
||||
@@ -710,7 +710,21 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
if (restartNow) {
|
||||
start()
|
||||
// Queued on vmStopControl rather than called here, because "the run loop has
|
||||
// exited" is NOT "the stop has finished". stop() enqueues NativeApp.shutdown()
|
||||
// on that same single-thread executor, and shutdown() sets stopRequested and
|
||||
// calls kill(). This finally block runs as soon as boot() returns -- which
|
||||
// stopRequested is exactly what causes -- so calling start() straight from
|
||||
// here raced ahead of the shutdown that released it, and the kill then landed
|
||||
// on the VM that had just started, killing it too. Traced on a Restart press:
|
||||
// START_VM 6ms after the stop began, then a SECOND START_VM 15s later once
|
||||
// the freshly-killed VM unwound, which is the "Restart kicks you back to the
|
||||
// library" report.
|
||||
//
|
||||
// vmStopControl is single-threaded, so this cannot begin until the pending
|
||||
// shutdown has returned. execute() and not submit().get(): waiting here would
|
||||
// block the run-loop thread that kill() may itself be waiting on.
|
||||
vmStopControl.execute { start() }
|
||||
} else {
|
||||
WindowImpl.toolbarVisible.value = true
|
||||
WindowImpl.showLibrary.value = false
|
||||
@@ -1039,7 +1053,8 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
if (restartNow) {
|
||||
start()
|
||||
// Same ordering as the game path above: queued behind any pending shutdown.
|
||||
vmStopControl.execute { start() }
|
||||
} else {
|
||||
// BIOS exit had no cleanup at all — it relied entirely on stop()'s racy
|
||||
// branch, so quitting the BIOS also left the launcher stuck in its rotation.
|
||||
@@ -4703,7 +4718,12 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
// the target stick's own (resting) ANALOG writer in the same event.
|
||||
accumAnalog(target, out)
|
||||
} else {
|
||||
NativeApp.setPadButtonForPort(port, target, (out * 32767).toInt(), out > 0f)
|
||||
// coerceAtLeast(1) because range 0 is the input layer's "full press"
|
||||
// convention: the lightest real squeeze floors to 0 here, which would
|
||||
// otherwise be delivered as a FULL press -- the exact opposite of the
|
||||
// half-press these games want.
|
||||
val range = if (out > 0f) (out * 32767).toInt().coerceAtLeast(1) else 0
|
||||
NativeApp.setPadButtonForPort(port, target, range, out > 0f)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ import kotlinx.coroutines.flow.first
|
||||
|
||||
/** A full manager screen shown as an overlay over the paused game (in-game menu). */
|
||||
enum class InGameScreen {
|
||||
Settings, CoreSettings, Achievements, Controls, Skins, Textures, SaveState, LoadState
|
||||
Settings, CoreSettings, Achievements, Controls, Skins, Textures, SaveState, LoadState,
|
||||
// PS3 trophies for the RUNNING title (the library's Trophies screen, scoped).
|
||||
Trophies,
|
||||
}
|
||||
|
||||
object WindowImpl {
|
||||
@@ -193,6 +195,17 @@ object WindowImpl {
|
||||
InGameScreen.LoadState -> com.armsx2.ui.saves.SaveStatePickerScreen(
|
||||
mode = com.armsx2.ui.saves.SaveMode.Load, onBack = dismiss,
|
||||
)
|
||||
// The library's Trophies screen, scoped to the running title. Same
|
||||
// screen, not a second implementation. Keyed to the same ViewModel the
|
||||
// pause menu's Trophies pane uses, so the set it already scanned is
|
||||
// reused instead of being read off disk again.
|
||||
InGameScreen.Trophies -> com.armsx2.ui.trophies.TrophiesScreen(
|
||||
onBack = dismiss,
|
||||
currentGameOnly = true,
|
||||
viewModel = androidx.lifecycle.viewmodel.compose.viewModel(
|
||||
key = com.armsx2.ui.emulation.InGameTrophiesVmKey,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,42 @@ import coil.request.ImageRequest
|
||||
import com.armsx2.CustomCovers
|
||||
import com.armsx2.GameInfo
|
||||
|
||||
/**
|
||||
* Try each cover source in turn, falling through to [placeholder] when none load.
|
||||
*
|
||||
* There were two hand-rolled copies of this, one here and one in the library grid, and they
|
||||
* disagreed: this one ended its chain at the extracted ICON0.PNG, the grid's ended one step
|
||||
* earlier at the remote cover. So the in-game menu showed a PS3 game's own artwork while the
|
||||
* library showed a text placeholder for the same game -- reported for a European PSN title,
|
||||
* because the art repo's COV set is keyed by USA title IDs and has no entry for it.
|
||||
*
|
||||
* Both were also only ONE retry deep, which hid the divergence: the retry slot was spent on
|
||||
* the regional cover, and whether the local icon ever got a turn depended on whether that URL
|
||||
* happened to differ from the first one. A chain has no such limit, and one chain cannot
|
||||
* disagree with itself.
|
||||
*/
|
||||
@Composable
|
||||
fun CoverFallbackChain(
|
||||
models: List<Any>,
|
||||
contentDescription: String,
|
||||
contentScale: ContentScale,
|
||||
placeholder: @Composable () -> Unit,
|
||||
) {
|
||||
val head = models.firstOrNull()
|
||||
if (head == null) {
|
||||
placeholder()
|
||||
return
|
||||
}
|
||||
SubcomposeAsyncImage(
|
||||
model = head,
|
||||
contentDescription = contentDescription,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = contentScale,
|
||||
loading = { placeholder() },
|
||||
error = { CoverFallbackChain(models.drop(1), contentDescription, contentScale, placeholder) },
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GameCoverArt(game: GameInfo, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
@@ -43,26 +79,18 @@ fun GameCoverArt(game: GameInfo, modifier: Modifier = Modifier) {
|
||||
error = {
|
||||
// Cover Region can point at a release the art repo has no cover for; falling straight
|
||||
// to the placeholder would BLANK a cover the user already had (reported for the in-game
|
||||
// menu, which uses this component). Retry with this disc's own serial first.
|
||||
// aldostools does not have art for every title. Rather than drop
|
||||
// straight to a text placeholder, fall back to the ICON0.PNG we
|
||||
// extracted from the disc itself -- wrong shape, but it is the real
|
||||
// game's art and beats nothing.
|
||||
val discUrl: Any? = if (customCover == null) {
|
||||
game.discCoverUrl?.takeIf { it != game.coverUrl } ?: game.discIconFile
|
||||
} else null
|
||||
if (discUrl != null) {
|
||||
SubcomposeAsyncImage(
|
||||
model = discUrl,
|
||||
contentDescription = game.title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
loading = { GameCoverPlaceholder(game.title, game.serial) },
|
||||
error = { GameCoverPlaceholder(game.title, game.serial) },
|
||||
)
|
||||
} else {
|
||||
GameCoverPlaceholder(game.title, game.serial)
|
||||
}
|
||||
// menu, which uses this component). Retry with this disc's own serial first, then with
|
||||
// the ICON0.PNG extracted from the disc itself -- wrong shape, but it is the real
|
||||
// game's art and beats nothing, and aldostools does not have art for every title.
|
||||
CoverFallbackChain(
|
||||
models = if (customCover != null) emptyList() else listOfNotNull(
|
||||
game.discCoverUrl?.takeIf { it != game.coverUrl },
|
||||
game.discIconFile,
|
||||
),
|
||||
contentDescription = game.title,
|
||||
contentScale = ContentScale.Crop,
|
||||
placeholder = { GameCoverPlaceholder(game.title, game.serial) },
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -373,6 +373,7 @@ private fun MenuPage(
|
||||
EmulationMenuTab.Controls -> ControlsPane(state, viewModel)
|
||||
EmulationMenuTab.Options -> OptionsPane(state, viewModel)
|
||||
EmulationMenuTab.Achievements -> AchievementsPane(state, viewModel)
|
||||
EmulationMenuTab.Trophies -> TrophiesPane(viewModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -513,6 +514,9 @@ private fun tabGlyph(tab: EmulationMenuTab): String = when (tab) {
|
||||
EmulationMenuTab.Controls -> "🎮"
|
||||
EmulationMenuTab.Options -> "⚙"
|
||||
EmulationMenuTab.Achievements -> "🏆"
|
||||
// The trophy cup, same glyph the library drawer's Trophies row uses. It does not collide
|
||||
// with the RA tab above because that one is filtered out of the rail on ARMSX3.
|
||||
EmulationMenuTab.Trophies -> "🏆"
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -655,6 +659,17 @@ private fun SessionPane(state: EmulationMenuUiState, viewModel: EmulationMenuVie
|
||||
// "tap to reveal", NOT show/hide: on = the glyph stays hidden until you tap its top-right
|
||||
// corner, which surfaces it. Either way that corner always opens this menu, so unlike the
|
||||
// old on/off toggle there's no setting here that can lock you out of it.
|
||||
//
|
||||
// The comment above outlived its control: the row was lost somewhere in the port and the
|
||||
// setting was left with no writer at all, so the glyph could not be hidden or brought back
|
||||
// by anyone. Reported as the option missing from this menu, which is exactly what it was.
|
||||
MenuSwitchRow(
|
||||
str("overlay.pauseTapReveal.label"),
|
||||
com.armsx2.ui.touch.TouchControls.pauseTapToReveal.value,
|
||||
description = str("overlay.pauseTapReveal.desc"),
|
||||
) { v ->
|
||||
com.armsx2.ui.touch.TouchControls.setPauseTapToReveal(v)
|
||||
}
|
||||
// Removed: PS2 per-primitive filtering; use Anisotropic Filtering.
|
||||
Spacer(Modifier.height(6.dp))
|
||||
// OSD mode selector — one control (Full / Minimal / Custom / Off) in place of the old
|
||||
@@ -956,7 +971,12 @@ private fun PerformancePane(state: EmulationMenuUiState, viewModel: EmulationMen
|
||||
}
|
||||
HorizontalOptions(
|
||||
title = str("perf.displayFpsCap.label"),
|
||||
options = listOf(0, 20, 30, 45, 60, 90, 120).map {
|
||||
// 90 and 120 removed: neither can take effect. RPCS3 caps the presented rate at the
|
||||
// Frame limit enum, which tops out at 60 for anything the PS3 outputs, so a Second Frame
|
||||
// Limit above that loses the min() at RSXThread.cpp:3676 and the rate stays 60. Offering
|
||||
// them just invited "the cap does nothing" reports for the two values where that is true
|
||||
// by construction. (Measured: second=90.00 -> limit=60.00.)
|
||||
options = listOf(0, 20, 30, 45, 60).map {
|
||||
it to if (it == 0) str("setup.toggle.off") else "$it FPS"
|
||||
},
|
||||
selected = settings.fpsLimit,
|
||||
@@ -1282,6 +1302,79 @@ private fun AchievementsPane(state: EmulationMenuUiState, viewModel: EmulationMe
|
||||
state.achievements.forEach { item -> InGameAchievementRow(item) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The running game's PS3 trophies. RPCS3's own data, not RetroAchievements.
|
||||
*
|
||||
* The rows are [com.armsx2.ui.trophies.TrophyRow] — the SAME composable the library's Trophies
|
||||
* screen uses, not a copy — so the two lists cannot drift apart. Scoping is
|
||||
* TrophyRepository.loadCurrentGame(), which asks the core for its `current_trophy_name`.
|
||||
*
|
||||
* Its ViewModel is keyed apart from the library screen's so the two do not fight over one
|
||||
* instance: this pane and the full in-game screen deliberately SHARE that keyed instance, so
|
||||
* opening the full list reuses what the pane already loaded instead of rescanning.
|
||||
*/
|
||||
@Composable
|
||||
private fun TrophiesPane(viewModel: EmulationMenuViewModel) {
|
||||
val trophies: com.armsx2.ui.trophies.TrophiesViewModel =
|
||||
androidx.lifecycle.viewmodel.compose.viewModel(key = InGameTrophiesVmKey)
|
||||
val state = trophies.state.value
|
||||
// Re-read on every entry to the tab: a trophy can unlock while the game is running, and the
|
||||
// set itself only appears once the game creates its trophy context.
|
||||
LaunchedEffect(Unit) { trophies.refresh(currentGameOnly = true) }
|
||||
|
||||
val game = state.games.firstOrNull()
|
||||
|
||||
CompactAction(
|
||||
str("trophies.viewTrophies"),
|
||||
"🏆",
|
||||
Modifier.fillMaxWidth(),
|
||||
viewModel::openTrophies,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
SectionCard(str("trophies.title")) {
|
||||
when {
|
||||
state.loading && game == null -> Text(
|
||||
str("trophies.loading"),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
// Most PS3 games have no trophy set at all, and plenty of those that do only
|
||||
// register it once you reach a menu — so this is an ordinary state, not an error.
|
||||
game == null -> Text(
|
||||
str("trophies.none.body"),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> {
|
||||
Text(
|
||||
game.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Text(
|
||||
com.armsx2.i18n.I18n.get("trophies.progress")
|
||||
.replace("%1", game.unlocked.toString())
|
||||
.replace("%2", game.total.toString())
|
||||
.replace("%3", game.percent.toString()),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inline list, mirroring the RA pane: the gateway above is still there for the full screen
|
||||
// (with the show-hidden toggle), but the common case is a glance at what is left.
|
||||
game?.let { set ->
|
||||
trophies.visibleTrophies(set).forEach { com.armsx2.ui.trophies.TrophyRow(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/** Shared ViewModel key for the two in-game trophy surfaces (this pane and the full screen). */
|
||||
internal const val InGameTrophiesVmKey = "trophies-ingame"
|
||||
|
||||
@Composable
|
||||
private fun InGameAchievementRow(item: AchievementItem) {
|
||||
Surface(
|
||||
|
||||
@@ -19,6 +19,9 @@ enum class EmulationMenuTab(val titleKey: String) {
|
||||
Controls("tab.controls"),
|
||||
Options("action.settings"),
|
||||
Achievements("ra.title"),
|
||||
// ARMSX3's answer to the (hidden) Achievements tab: the RUNNING game's real PS3
|
||||
// trophies, read from RPCS3's own dev_hdd0 trophy data.
|
||||
Trophies("trophies.title"),
|
||||
// No Friends tab. It lived at the end of a rail that scrolls, so reaching it meant knowing it
|
||||
// was there and then hunting for it — it is a header button with its own overlay instead.
|
||||
;
|
||||
@@ -169,6 +172,9 @@ class EmulationMenuViewModel(application: Application) : AndroidViewModel(applic
|
||||
0 -> requestToggleHardcore()
|
||||
1 -> openAchievements()
|
||||
}
|
||||
EmulationMenuTab.Trophies -> when (state.value.selectedAction) {
|
||||
0 -> openTrophies()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,6 +366,9 @@ class EmulationMenuViewModel(application: Application) : AndroidViewModel(applic
|
||||
/** Open the full RetroAchievements screen (list + options) over the paused game. */
|
||||
fun openAchievements() = com.armsx2.ui.WindowImpl.openInGameScreen(com.armsx2.ui.InGameScreen.Achievements)
|
||||
|
||||
/** Open the full trophy list over the paused game, scoped to the running title. */
|
||||
fun openTrophies() = com.armsx2.ui.WindowImpl.openInGameScreen(com.armsx2.ui.InGameScreen.Trophies)
|
||||
|
||||
fun updateSettings(transform: (Settings) -> Settings) {
|
||||
// ★ Transform the LIVE shared settings, not this screen's snapshot. state.value.settings is
|
||||
// only refreshed in load(), so every write here shipped the whole Settings object as it
|
||||
@@ -381,6 +390,9 @@ class EmulationMenuViewModel(application: Application) : AndroidViewModel(applic
|
||||
EmulationMenuTab.Controls -> 2
|
||||
EmulationMenuTab.Options -> 5
|
||||
EmulationMenuTab.Achievements -> 2
|
||||
// Just the "view trophies" gateway. The rows below it are read-only, and the
|
||||
// show-hidden toggle lives on the full screen the gateway opens.
|
||||
EmulationMenuTab.Trophies -> 1
|
||||
}
|
||||
|
||||
private fun Int.floorMod(modulus: Int): Int = ((this % modulus) + modulus) % modulus
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user