mirror of
https://github.com/ARMSX2/ARMSX3.git
synced 2026-08-24 16:58:52 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
93110e899f | ||
|
|
4b27b585f5 | ||
|
|
bbf44c684c | ||
|
|
f823f6fd66 | ||
|
|
9a12b5e958 | ||
|
|
d094ecd491 | ||
|
|
fda4cc3b50 | ||
|
|
00ce69a315 | ||
|
|
422d831eff | ||
|
|
55a54c924e | ||
|
|
19d23eb691 | ||
|
|
884cb47dde | ||
|
|
9b33316982 | ||
|
|
55a35b5e1d | ||
|
|
8caacc8231 | ||
|
|
0ded153216 | ||
|
|
4baefed106 | ||
|
|
424514fde6 | ||
|
|
37a5d118be | ||
|
|
301f45a2cb | ||
|
|
aa25da4ce2 | ||
|
|
e35bd463cf | ||
|
|
968b892e29 | ||
|
|
89c6d08ed3 | ||
|
|
7811cffeed | ||
|
|
20c854aeb3 | ||
|
|
7952244052 | ||
|
|
4179f23e20 | ||
|
|
4b2b8438be | ||
|
|
7b49e1fcea | ||
|
|
d9957c56ae | ||
|
|
62d8208c71 | ||
|
|
cab4f2507c | ||
|
|
27e9d11b80 | ||
|
|
6e731093c4 | ||
|
|
89b2128679 |
+350
-9
@@ -2,6 +2,7 @@
|
||||
#include "Emu/System.h"
|
||||
#include "Emu/Cell/SPUThread.h"
|
||||
#include "Emu/Cell/PPUThread.h"
|
||||
#include "Emu/Cell/PPUDisAsm.h"
|
||||
#include "Emu/Cell/lv2/sys_mmapper.h"
|
||||
#include "Emu/Cell/lv2/sys_event.h"
|
||||
#include "Emu/Cell/lv2/sys_process.h"
|
||||
@@ -63,6 +64,12 @@ DYNAMIC_IMPORT_RENAME("Kernel32.dll", SetThreadDescriptionImport, "SetThreadDesc
|
||||
#include <sys/timerfd.h>
|
||||
#include <unistd.h>
|
||||
#endif
|
||||
#ifdef __ANDROID__
|
||||
// For the allocation-free breadcrumb the fault handler writes before it risks anything else, and
|
||||
// for reaching libsigchain's registration entry point without linking against the ART apex.
|
||||
#include <android/log.h>
|
||||
#include <dlfcn.h>
|
||||
#endif
|
||||
|
||||
#if defined(__APPLE__) || defined(__DragonFly__) || defined(__FreeBSD__) || defined(__NetBSD__) || defined(__OpenBSD__)
|
||||
# include <sys/sysctl.h>
|
||||
@@ -2216,6 +2223,63 @@ bool handle_access_violation(u32 addr, bool is_writing, bool is_exec, ucontext_t
|
||||
else
|
||||
{
|
||||
vm_log.always()("[%s] Access violation %s location 0x%x (%s)", cpu->get_name(), is_writing ? "writing" : "reading", addr, (is_writing && vm::check_addr(addr)) ? "read-only memory" : "unmapped memory");
|
||||
|
||||
// The guest code at the fault AND at its callers.
|
||||
//
|
||||
// Registers and a call stack come free from dump_useful_thread_info() above,
|
||||
// and for a bad pointer they are only half the answer: they say WHAT the
|
||||
// address was, never what computed it. When the faulting function turns out to
|
||||
// be something generic -- Borderlands 2 faults inside a memcpy, handed
|
||||
// dest=0x93aef33d and length=0xc3aaf87d, both garbage -- the routine itself is
|
||||
// blameless and the whole question is which caller filled those arguments.
|
||||
//
|
||||
// So: a window at cia, then one at each of the first few return addresses. Only
|
||||
// a few, because a PPU call stack here runs fourteen frames deep and the answer
|
||||
// is almost always in the immediate caller.
|
||||
//
|
||||
// Every address is checked before it is read: cia and the stack are taken from
|
||||
// a thread that just faulted, so both can be garbage, and faulting inside the
|
||||
// diagnostic that explains a fault would be the worst possible trade.
|
||||
if (cpu->get_class() == thread_class::ppu)
|
||||
{
|
||||
PPUDisAsm dis_asm(cpu_disasm_mode::dump, vm::g_sudo_addr);
|
||||
std::string code;
|
||||
|
||||
const auto window = [&](const char* what, u32 pc, u32 back, u32 span)
|
||||
{
|
||||
fmt::append(code, "\n%s 0x%08x:\n", what, pc);
|
||||
|
||||
for (u32 at = pc >= back ? pc - back : 0; at <= pc + span; at += 4)
|
||||
{
|
||||
if (!vm::check_addr(at))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
dis_asm.disasm(at);
|
||||
code += (at == pc ? " >>" : " ");
|
||||
code += dis_asm.last_opcode;
|
||||
}
|
||||
};
|
||||
|
||||
window("Code at the faulting pc", static_cast<ppu_thread*>(cpu)->cia, 0x40, 0x40);
|
||||
|
||||
u32 shown = 0;
|
||||
|
||||
for (auto&& [ret, sp] : cpu->dump_callstack_list())
|
||||
{
|
||||
if (shown++ >= 3)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// Back further than forward: the call is BEHIND the return address,
|
||||
// and what fills the arguments sits behind that.
|
||||
window("Code at caller", ret, 0x60, 0x10);
|
||||
}
|
||||
|
||||
vm_log.always()("Guest code around the fault:%s", code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2549,10 +2613,165 @@ const bool s_exception_handler_set = []() -> bool
|
||||
|
||||
#else
|
||||
|
||||
static void signal_handler(int /*sig*/, siginfo_t* info, void* uct) noexcept
|
||||
#ifdef __ANDROID__
|
||||
// The handlers that were installed before ours -- libsigchain's, which fronts ART and debuggerd.
|
||||
// Kept so that faults which are not the emulator's can be forwarded to them.
|
||||
static struct ::sigaction s_prev_fault_action[NSIG]{};
|
||||
|
||||
// True when this fault is one the emulator's own memory model is responsible for.
|
||||
//
|
||||
// The ranges are exactly the ones the handler can act on: guest memory (try_get_addr spans 8GiB
|
||||
// from g_base_addr, so the sudo mirror is included), the executable map, and the segment map.
|
||||
// Everything else is somebody else's fault, in both senses.
|
||||
static bool is_emulator_fault(void* addr)
|
||||
{
|
||||
const u64 exec64 = (reinterpret_cast<u64>(addr) - reinterpret_cast<u64>(vm::g_exec_addr)) / 2;
|
||||
const u64 seg_off = (reinterpret_cast<u64>(addr) - reinterpret_cast<u64>(vm::g_exec_addr)) - vm::g_exec_addr_seg_offset;
|
||||
|
||||
return vm::try_get_addr(addr).second || exec64 < 0x100000000ull || seg_off < 0x80000000ull;
|
||||
}
|
||||
|
||||
// Bionic's own sigaction, reached past libsigchain's interposition.
|
||||
//
|
||||
// libsigchain exports sigaction() and is loaded with global visibility, so an ordinary call
|
||||
// registers us INSIDE ART's chain -- behind its FaultManager, which is the entire problem. Looking
|
||||
// the symbol up in libc's own handle gets the real one, letting us install at the kernel level and
|
||||
// genuinely go first. RTLD_NOLOAD because libc is obviously already here; this must never load
|
||||
// anything. Returns null if bionic ever stops exporting it, and the caller then keeps the ordinary
|
||||
// registration rather than starting with no handler at all.
|
||||
using armsx3_sigaction_fn = int (*)(int, const struct ::sigaction*, struct ::sigaction*);
|
||||
|
||||
static armsx3_sigaction_fn real_sigaction()
|
||||
{
|
||||
void* const libc = ::dlopen("libc.so", RTLD_NOLOAD | RTLD_LOCAL);
|
||||
|
||||
return libc ? reinterpret_cast<armsx3_sigaction_fn>(::dlsym(libc, "sigaction")) : nullptr;
|
||||
}
|
||||
|
||||
// Installs a fault handler, remembering what it replaced.
|
||||
static int install_fault_handler(int sig, const struct ::sigaction& sa)
|
||||
{
|
||||
return ::sigaction(sig, &sa, sig > 0 && sig < NSIG ? &s_prev_fault_action[sig] : nullptr);
|
||||
}
|
||||
#else
|
||||
static int install_fault_handler(int sig, const struct ::sigaction& sa)
|
||||
{
|
||||
return ::sigaction(sig, &sa, nullptr);
|
||||
}
|
||||
#endif
|
||||
|
||||
// Installs a fault handler ahead of the Android runtime, not inside its chain.
|
||||
//
|
||||
// Two registrations were a mistake worth recording. Registering through the interposed sigaction()
|
||||
// AS WELL as at kernel level puts this handler in libsigchain's chain, so forwarding a fault that
|
||||
// is not ours goes to libsigchain, which walks its chain straight back to here, which forwards
|
||||
// again -- recursing until the alternate stack is gone. The process died of that with no
|
||||
// breadcrumb, no tombstone and no ART frames: quieter than the bug it was meant to fix.
|
||||
//
|
||||
// So: capture the handler the kernel currently calls (libsigchain's, which fronts ART), then
|
||||
// replace it, and never register through the interposed entry point for this signal. The chain we
|
||||
// forward into then does not contain us.
|
||||
static bool install_fault_handler_first(int sig, const struct ::sigaction& sa)
|
||||
{
|
||||
#ifdef __ANDROID__
|
||||
if (const armsx3_sigaction_fn real_sa = real_sigaction())
|
||||
{
|
||||
if (real_sa(sig, nullptr, &s_prev_fault_action[sig]) != -1 && real_sa(sig, &sa, nullptr) != -1)
|
||||
{
|
||||
char line[96];
|
||||
|
||||
if (::snprintf(line, sizeof(line), "sigchain: installed ahead of the runtime for signal %d", sig) > 0)
|
||||
{
|
||||
__android_log_write(ANDROID_LOG_INFO, "ARMSX3", line);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
__android_log_write(ANDROID_LOG_WARN, "ARMSX3", "sigchain: could not get ahead of the runtime; it will see faults first");
|
||||
#endif
|
||||
|
||||
// No bionic entry point, or it refused: fall back to the ordinary registration. The runtime
|
||||
// then sees faults first, which is how this behaved before, crash included.
|
||||
return install_fault_handler(sig, sa) != -1;
|
||||
}
|
||||
|
||||
|
||||
// What a SIGBUS was actually about. si_code is the only thing that tells an unbacked page apart
|
||||
// from a misaligned operand, and those point at completely different bugs.
|
||||
static const char* bus_error_kind(int code) noexcept
|
||||
{
|
||||
switch (code)
|
||||
{
|
||||
case BUS_ADRALN: return "misaligned operand";
|
||||
case BUS_ADRERR: return "mapped page has no backing";
|
||||
case BUS_OBJERR: return "hardware error on the mapped object";
|
||||
default: return "unrecognised si_code";
|
||||
}
|
||||
}
|
||||
|
||||
static void signal_handler(int sig, siginfo_t* info, void* uct) noexcept
|
||||
{
|
||||
ucontext_t* context = static_cast<ucontext_t*>(uct);
|
||||
|
||||
#ifdef __ANDROID__
|
||||
// Not our fault: hand it to whoever we displaced.
|
||||
//
|
||||
// We install ahead of libsigchain deliberately (see the registration site), which means ART's
|
||||
// FaultManager no longer sees the emulator's own faults -- it was reading guest registers as
|
||||
// ArtMethod* and dying. But ART still needs its own faults: implicit null checks in JIT'd Java
|
||||
// code arrive as SIGSEGV and are how a NullPointerException gets thrown. This forward is what
|
||||
// keeps that working, and keeps ordinary tombstones for crashes that are genuinely elsewhere.
|
||||
if (!is_emulator_fault(info->si_addr))
|
||||
{
|
||||
// Forward once and once only. If whatever we forward to comes back here -- which it did
|
||||
// while this handler was also registered inside libsigchain's chain -- looping would burn
|
||||
// the alternate stack and kill the process silently. Second time through, stand down: put
|
||||
// the default action back and return, so the instruction faults again and the platform
|
||||
// produces an honest tombstone instead of a recursion.
|
||||
static thread_local bool s_forwarding = false;
|
||||
|
||||
if (s_forwarding)
|
||||
{
|
||||
struct ::sigaction dfl{};
|
||||
dfl.sa_handler = SIG_DFL;
|
||||
sigemptyset(&dfl.sa_mask);
|
||||
::sigaction(sig, &dfl, nullptr);
|
||||
return;
|
||||
}
|
||||
|
||||
const struct ::sigaction& prev = s_prev_fault_action[sig];
|
||||
s_forwarding = true;
|
||||
|
||||
if ((prev.sa_flags & SA_SIGINFO) && prev.sa_sigaction)
|
||||
{
|
||||
prev.sa_sigaction(sig, info, uct);
|
||||
s_forwarding = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (prev.sa_handler && prev.sa_handler != SIG_DFL && prev.sa_handler != SIG_IGN)
|
||||
{
|
||||
prev.sa_handler(sig);
|
||||
s_forwarding = false;
|
||||
return;
|
||||
}
|
||||
|
||||
s_forwarding = false;
|
||||
}
|
||||
#endif
|
||||
|
||||
|
||||
// SIGBUS arrives here too now (see the sigaction block below), and never takes a recovery
|
||||
// path. The recovery below is for pages this process protected itself, and a write to an
|
||||
// mprotect'd page raises SIGSEGV/SEGV_ACCERR, never SIGBUS. A bus error means the page behind
|
||||
// an otherwise valid address could not be produced at all -- unbacked, past the backing size,
|
||||
// or an operand the instruction cannot address at that alignment. Nothing here changes any of
|
||||
// those, so handling one and returning would re-execute the same instruction and fault again
|
||||
// immediately: a livelock in place of a crash report.
|
||||
const bool is_bus_error = sig == SIGBUS;
|
||||
|
||||
#if defined(ARCH_X64)
|
||||
#ifdef __APPLE__
|
||||
const u64 err = context->uc_mcontext->__es.__err;
|
||||
@@ -2635,7 +2854,10 @@ static void signal_handler(int /*sig*/, siginfo_t* info, void* uct) noexcept
|
||||
const u64 seg_off = (reinterpret_cast<u64>(info->si_addr) - reinterpret_cast<u64>(vm::g_exec_addr)) - vm::g_exec_addr_seg_offset;
|
||||
const auto cause = is_executing ? "executing" : is_writing ? "writing" : "reading";
|
||||
|
||||
if (auto [addr, ok] = vm::try_get_addr(info->si_addr); ok && !is_executing)
|
||||
// Gated on more than "not an instruction fetch" now: see is_bus_error above.
|
||||
const bool try_recovery = !is_executing && !is_bus_error;
|
||||
|
||||
if (auto [addr, ok] = vm::try_get_addr(info->si_addr); ok && try_recovery)
|
||||
{
|
||||
// Try to process access violation
|
||||
if (thread_ctrl::get_current() && handle_access_violation(addr, is_writing, false, context))
|
||||
@@ -2644,14 +2866,14 @@ static void signal_handler(int /*sig*/, siginfo_t* info, void* uct) noexcept
|
||||
}
|
||||
}
|
||||
|
||||
if (exec64 < 0x100000000ull && !is_executing)
|
||||
if (exec64 < 0x100000000ull && try_recovery)
|
||||
{
|
||||
if (thread_ctrl::get_current() && handle_access_violation(static_cast<u32>(exec64), is_writing, true, context))
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (seg_off < 0x80000000ull && !is_executing)
|
||||
else if (seg_off < 0x80000000ull && try_recovery)
|
||||
{
|
||||
if (thread_ctrl::get_current() && handle_access_violation(static_cast<u32>(seg_off * 2), is_writing, true, context))
|
||||
{
|
||||
@@ -2659,7 +2881,92 @@ static void signal_handler(int /*sig*/, siginfo_t* info, void* uct) noexcept
|
||||
}
|
||||
}
|
||||
|
||||
std::string msg = fmt::format("Segfault %s location %p at %p.\n", cause, info->si_addr, RIP(context));
|
||||
#ifdef __ANDROID__
|
||||
// Raw state, before anything that can fault.
|
||||
//
|
||||
// Placed here deliberately: every recovery path above has already declined, so this only runs
|
||||
// for faults that are actually fatal -- the write-protection faults the RSX relies on come
|
||||
// through here hundreds of times a second and must not be logged at all.
|
||||
//
|
||||
// Everything below this point formats strings, allocates, takes the logger's locks and walks
|
||||
// thread and guest state, and on a process sick enough to be here any of those can fault
|
||||
// again. A second fault while this signal is blocked is force-delivered with the default
|
||||
// action, killing the process instantly with the fatal message still sitting unflushed in the
|
||||
// async log -- Borderlands 2 died that way three times, handler reached, nothing written.
|
||||
//
|
||||
// So: fixed stack buffers and liblog writes. No allocation, no locks, no ordering with the
|
||||
// async log. Registers and the faulting instruction are what a wild address needs anyway --
|
||||
// they say which operand went bad, which the formatted report never does.
|
||||
{
|
||||
char line[256];
|
||||
const u64 pc = RIP(context);
|
||||
|
||||
if (::snprintf(line, sizeof(line), "fatal signal %d (si_code %d) at %p, pc 0x%llx, tid %d",
|
||||
sig, info->si_code, info->si_addr, static_cast<unsigned long long>(pc),
|
||||
static_cast<int>(::syscall(__NR_gettid))) > 0)
|
||||
{
|
||||
__android_log_write(ANDROID_LOG_FATAL, "ARMSX3", line);
|
||||
}
|
||||
|
||||
#if defined(ARCH_ARM64)
|
||||
// Only when the fault was a data access: an instruction-fetch fault means pc itself is
|
||||
// what could not be read, so reading it here would fault a second time.
|
||||
if (!is_executing && ::snprintf(line, sizeof(line), " insn 0x%08x", *reinterpret_cast<const u32*>(pc)) > 0)
|
||||
{
|
||||
__android_log_write(ANDROID_LOG_FATAL, "ARMSX3", line);
|
||||
}
|
||||
|
||||
for (int i = 0; i < 31; i += 4)
|
||||
{
|
||||
char* p = line;
|
||||
int rem = static_cast<int>(sizeof(line));
|
||||
|
||||
for (int j = i; j < i + 4 && j < 31; ++j)
|
||||
{
|
||||
const int w = ::snprintf(p, rem, " x%d=0x%llx", j, static_cast<unsigned long long>(GPR(context, j)));
|
||||
|
||||
if (w <= 0 || w >= rem)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
p += w;
|
||||
rem -= w;
|
||||
}
|
||||
|
||||
__android_log_write(ANDROID_LOG_FATAL, "ARMSX3", line);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
// A fault outside guest memory is handed straight back to the platform's crash handler.
|
||||
//
|
||||
// Installing this handler displaced debuggerd's, which is why none of these crashes ever
|
||||
// produced a tombstone: emergency_exit() takes the process down itself, throwing away the one
|
||||
// artifact carrying a symbolised backtrace of every thread. For a guest access violation that
|
||||
// is the right trade -- the emulator reports those far better than a tombstone would. For a
|
||||
// fault at an address that is not guest memory, the backtrace IS the diagnosis: it names
|
||||
// whoever handed out the corrupt pointer, which nothing here can work out by itself.
|
||||
//
|
||||
// Before the formatting below, not after, and this is the whole point: the report allocates,
|
||||
// and on a process whose heap is already corrupt the allocation faults again. That second
|
||||
// fault killed the process every time, so a chain placed after the report never ran.
|
||||
//
|
||||
// Restoring the previous handler and returning rather than re-raising: the faulting
|
||||
// instruction executes again and faults again, so debuggerd sees the original pc, address and
|
||||
// registers instead of this handler's frame. Ours is no longer installed, so there is no loop.
|
||||
if (!vm::try_get_addr(info->si_addr).second && s_prev_fault_action[sig].sa_sigaction)
|
||||
{
|
||||
::sigaction(sig, &s_prev_fault_action[sig], nullptr);
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
|
||||
// Named for what it was: a bus error reported as "Segfault" sends whoever reads the log
|
||||
// looking for a bad pointer, when the address is usually fine and the mapping behind it is not.
|
||||
std::string msg = sig == SIGBUS
|
||||
? fmt::format("Bus error (%s) %s location %p at %p.\n", bus_error_kind(info->si_code), cause, info->si_addr, RIP(context))
|
||||
: fmt::format("Segfault %s location %p at %p.\n", cause, info->si_addr, RIP(context));
|
||||
|
||||
if (vm::try_get_addr(info->si_addr).second)
|
||||
{
|
||||
@@ -2700,6 +3007,13 @@ static void signal_handler(int /*sig*/, siginfo_t* info, void* uct) noexcept
|
||||
#endif
|
||||
|
||||
sys_log.fatal("\n%s", msg);
|
||||
|
||||
// Flushed here rather than only after the dump. dump_useful_thread_info() walks thread state
|
||||
// and guest memory, so it is the single most likely thing in this handler to fault again, and
|
||||
// a fault there loses the fatal message with it -- it is still sitting in the async log's
|
||||
// buffer at this point. The message is the part worth keeping; the dump is a bonus.
|
||||
logs::listener::sync_all();
|
||||
|
||||
sys_log.notice("\n%s", dump_useful_thread_info());
|
||||
logs::listener::sync_all();
|
||||
|
||||
@@ -2758,14 +3072,24 @@ const bool s_exception_handler_set = []() -> bool
|
||||
sigemptyset(&sa.sa_mask);
|
||||
sa.sa_sigaction = signal_handler;
|
||||
|
||||
if (::sigaction(SIGSEGV, &sa, NULL) == -1)
|
||||
if (!install_fault_handler_first(SIGSEGV, sa))
|
||||
{
|
||||
std::fprintf(stderr, "sigaction(SIGSEGV) failed (%d).\n", errno);
|
||||
std::abort();
|
||||
}
|
||||
|
||||
#ifdef __APPLE__
|
||||
if (::sigaction(SIGBUS, &sa, NULL) == -1)
|
||||
#if defined(__APPLE__) || defined(__ANDROID__)
|
||||
// Android too, and not for tidiness: with no handler, SIGBUS takes the default action and
|
||||
// the process dies having written nothing at all -- no line from this handler, no tombstone,
|
||||
// and an RPCSX.log that simply stops mid-sentence. The only record of Borderlands 2 dying
|
||||
// this way was one Zygote line, "exited due to signal 7 (Bus error)".
|
||||
//
|
||||
// It is a fault class this emulator can genuinely hit. Guest memory is a MAP_SHARED mapping
|
||||
// of a memfd, and a shared file mapping raises SIGBUS rather than SIGSEGV whenever the page
|
||||
// behind an otherwise valid address cannot be produced -- past the backing size, or with
|
||||
// nothing left to back it. None of that is recoverable here, but all of it is diagnosable,
|
||||
// and none of it was.
|
||||
if (!install_fault_handler_first(SIGBUS, sa))
|
||||
{
|
||||
std::fprintf(stderr, "sigaction(SIGBUS) failed (%d).\n", errno);
|
||||
std::abort();
|
||||
@@ -2773,7 +3097,7 @@ const bool s_exception_handler_set = []() -> bool
|
||||
#endif
|
||||
|
||||
sa.sa_sigaction = sigill_handler;
|
||||
if (::sigaction(SIGILL, &sa, NULL) == -1)
|
||||
if (install_fault_handler(SIGILL, sa) == -1)
|
||||
{
|
||||
std::fprintf(stderr, "sigaction(SIGILL) failed (%d).\n", errno);
|
||||
std::abort();
|
||||
@@ -2855,8 +3179,25 @@ void thread_base::start()
|
||||
ensure(pthread_create(&thread_id, &attrs, entry_point, this) == 0);
|
||||
#else
|
||||
pthread_t thread_id{};
|
||||
|
||||
#ifdef __ANDROID__
|
||||
// Give Android threads the stack desktop Linux already gives them.
|
||||
//
|
||||
// bionic's default is 1 MB; glibc's is 8 MB. Passing null attributes here meant every emulator
|
||||
// thread on Android ran on an eighth of the stack the same code gets everywhere else, and
|
||||
// nothing said so -- an SPU thread's stack mapping measured 0xfc000.
|
||||
//
|
||||
// Address space only; stack pages are committed on first use.
|
||||
pthread_attr_t attrs;
|
||||
pthread_attr_init(&attrs);
|
||||
pthread_attr_setstacksize(&attrs, 0x800000);
|
||||
const int rc = pthread_create(&thread_id, &attrs, entry_point, this);
|
||||
pthread_attr_destroy(&attrs);
|
||||
ensure(rc == 0);
|
||||
#else
|
||||
ensure(pthread_create(&thread_id, nullptr, entry_point, this) == 0);
|
||||
#endif
|
||||
#endif
|
||||
|
||||
#ifndef _WIN32
|
||||
// Update m_thread atomically
|
||||
|
||||
+29
-9
@@ -1803,13 +1803,6 @@ static void append_patches(patch_engine::patch_map& existing_patches, const patc
|
||||
|
||||
bool patch_engine::save_patches(const patch_map& patches, const std::string& path, std::stringstream* log_messages)
|
||||
{
|
||||
fs::file file(path, fs::rewrite);
|
||||
if (!file)
|
||||
{
|
||||
append_log_message(log_messages, fmt::format("Failed to open patch file %s (%s)", path, fs::g_tls_error), &patch_log.fatal);
|
||||
return false;
|
||||
}
|
||||
|
||||
YAML::Emitter out;
|
||||
out << YAML::BeginMap;
|
||||
out << patch_key::version << patch_engine_version;
|
||||
@@ -1904,7 +1897,24 @@ bool patch_engine::save_patches(const patch_map& patches, const std::string& pat
|
||||
out << YAML::Flow;
|
||||
out << YAML::BeginSeq;
|
||||
out << fmt::format("%s", data.type);
|
||||
out << fmt::format("0x%.8x", data.offset);
|
||||
|
||||
// move_file and hide_file carry a VFS path in the address element instead of a
|
||||
// number. load() keeps that text in original_offset and skips the u32 validation for
|
||||
// them, so formatting it numerically here would write out 0x00000000 and the loader
|
||||
// would accept it back as a patch that silently never matches anything.
|
||||
//
|
||||
// The numeric branch deliberately uses offset rather than original_offset: an
|
||||
// address modifier is folded into offset at load time, and the flat form emitted
|
||||
// here has nowhere to put it.
|
||||
if (patch_type_uses_hex_offset(data.type))
|
||||
{
|
||||
out << fmt::format("0x%.8x", data.offset);
|
||||
}
|
||||
else
|
||||
{
|
||||
out << data.original_offset;
|
||||
}
|
||||
|
||||
out << data.original_value;
|
||||
out << YAML::EndSeq;
|
||||
}
|
||||
@@ -1918,7 +1928,17 @@ bool patch_engine::save_patches(const patch_map& patches, const std::string& pat
|
||||
|
||||
out << YAML::EndMap;
|
||||
|
||||
file.write(out.c_str(), out.size());
|
||||
// Write through a temporary and rename on success, as save_config already does. A truncating
|
||||
// in-place write that fails part way (out of space, process killed) leaves a half-written file,
|
||||
// and load() rejects the whole file on a parse error -- so a failure here costs the user every
|
||||
// patch they had, with no way to rebuild it from inside the app.
|
||||
fs::pending_file file(path);
|
||||
|
||||
if (!file.file || file.file.write(out.c_str(), out.size()) < out.size() || !file.commit())
|
||||
{
|
||||
append_log_message(log_messages, fmt::format("Failed to write patch file %s (%s)", path, fs::g_tls_error), &patch_log.fatal);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -32,8 +32,8 @@ android {
|
||||
// agree -- an APK that installs below its core's target is a dlopen failure at boot.
|
||||
minSdk = (project.findProperty("armsx3.minSdk") as String?)?.toInt() ?: 33
|
||||
targetSdk = 37
|
||||
versionCode = 14
|
||||
versionName = "0.8"
|
||||
versionCode = 15
|
||||
versionName = "0.9"
|
||||
|
||||
// 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.
|
||||
|
||||
@@ -50,3 +50,44 @@ PPU-4b46d0161ca657ab16b0a779d9062810ea5ea2dd:
|
||||
- [ jumpf, 0x00000000, "RPCS3_HLE_LIBRARY:WaitForSPUsToEmptySNRs" ] # Args: (SPU ID, 3)
|
||||
- [ be32, 0x00000000, 0x38800000 ] # li r4, 0
|
||||
- [ be32, 0x00000000, 0x44000002 ] # sc
|
||||
|
||||
# Tom Clancy's H.A.W.X. 2 (BLES00928) -- boot hang at the first intro video.
|
||||
#
|
||||
# The SPU dies with "Access violation reading location 0x20" in CellSpursKernel0 and
|
||||
# is parked forever (dbg_pause, which nothing in the Android build can clear), so the
|
||||
# emulator looks healthy at a locked 30 fps while the guest is dead. Upstream RPCS3
|
||||
# lists the title as Loadable with no fix but "delete data/movies".
|
||||
#
|
||||
# The title looks up a section named '.reload' in this SPU module. The module is
|
||||
# stripped -- e_shnum = 0 -- so the lookup can never succeed, on hardware either, and
|
||||
# the game is built to cope: the failure path writes 0 to the work descriptor's +0x10
|
||||
# field, and this very module tests that field to skip the overlay load:
|
||||
#
|
||||
# 03224 lqr r8,0x1b810 ; r8 = desc[+0x10]
|
||||
# 0322c brz r8,0x32cc ; == 0 -> skip
|
||||
#
|
||||
# A bump allocator on the PPU side then runs over the field unconditionally --
|
||||
# (0 - 0x10) & ~0xF = 0xfffffff0 -- destroying the sentinel. The guard no longer
|
||||
# fires, so the SPU issues GET lsa=0 ea=0 size=0x4000, a transfer that would have
|
||||
# overwritten the running SPURS kernel had it succeeded.
|
||||
#
|
||||
# This makes the overlay routine at LS 0x3208 return immediately, which is what the
|
||||
# surviving guard would have caused anyway. Safe because the section it needs cannot
|
||||
# exist in a stripped module. Suppressing the DMA instead does NOT work: the guest
|
||||
# loop waits on data that never arrives and runs away.
|
||||
#
|
||||
# Ps3PatchRepo.BUNDLED must list this, or it is imported but never enabled.
|
||||
|
||||
SPU-42bae8e5d6a9304068ba1c6bbfdc18d656e287a1:
|
||||
Bink overlay skip:
|
||||
Games:
|
||||
"Tom Clancy's H.A.W.X. 2":
|
||||
BLES00928:
|
||||
- "All"
|
||||
Author: Zulux91
|
||||
Patch Version: 1.0
|
||||
Notes: Fixes the boot hang at the first intro video.
|
||||
Patch:
|
||||
# LS 0x3208 is the first instruction of the overlay routine (il r5,0).
|
||||
# Offsets are LS addresses: apply_modification subtracts p_vaddr (0x3000).
|
||||
- [ be32, 0x3208, 0x35000000 ] # bi lr -- return immediately
|
||||
|
||||
@@ -44,6 +44,8 @@ struct RPCSXApi {
|
||||
std::string (*getCurrentTrophyName)();
|
||||
bool (*surfaceEvent)(JNIEnv *env, jobject surface, jint event);
|
||||
void (*surfaceSizeChanged)(int width, int height);
|
||||
void (*setPadSensor)(int port, int x, int y, int z, int g);
|
||||
int (*getPadRumble)(int port);
|
||||
bool (*usbDeviceEvent)(int fd, int vendorId, int productId, int event);
|
||||
bool (*installFw)(JNIEnv *env, int fd, long progressId);
|
||||
bool (*isInstallableFile)(jint fd);
|
||||
@@ -139,6 +141,8 @@ struct RPCSXLibrary : RPCSXApi {
|
||||
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.setPadSensor = reinterpret_cast<decltype(setPadSensor)>(dlsym(handle, "_rpcsx_setPadSensor"));
|
||||
result.getPadRumble = reinterpret_cast<decltype(getPadRumble)>(dlsym(handle, "_rpcsx_getPadRumble"));
|
||||
result.usbDeviceEvent = reinterpret_cast<decltype(usbDeviceEvent)>(dlsym(handle, "_rpcsx_usbDeviceEvent"));
|
||||
result.installFw = reinterpret_cast<decltype(installFw)>(dlsym(handle, "_rpcsx_installFw"));
|
||||
result.isInstallableFile = reinterpret_cast<decltype(isInstallableFile)>(dlsym(handle, "_rpcsx_isInstallableFile"));
|
||||
@@ -437,6 +441,24 @@ extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_surfaceEvent(
|
||||
return rpcsxLib.surfaceEvent(env, surface, event);
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_setPadSensor(
|
||||
JNIEnv *, jobject, jint port, jint x, jint y, jint z, jint g) {
|
||||
if (rpcsxLib.setPadSensor == nullptr) {
|
||||
return;
|
||||
}
|
||||
|
||||
rpcsxLib.setPadSensor(port, x, y, z, g);
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jint JNICALL Java_net_rpcsx_RPCSX_getPadRumble(
|
||||
JNIEnv *, jobject, jint port) {
|
||||
if (rpcsxLib.getPadRumble == nullptr) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return rpcsxLib.getPadRumble(port);
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_surfaceSizeChanged(
|
||||
JNIEnv *, jobject, jint width, jint height) {
|
||||
if (rpcsxLib.surfaceSizeChanged == nullptr) {
|
||||
|
||||
@@ -469,6 +469,21 @@ object CustomCovers {
|
||||
(target.isFile && target.length() > 0L).also { if (it) version.value++ }
|
||||
}.getOrDefault(false)
|
||||
|
||||
/**
|
||||
* Follow a game's custom cover across an identity correction.
|
||||
*
|
||||
* The file is named after the serial, so a game whose id is corrected stops matching its
|
||||
* own cover -- and because [remove] resolves through the same name, the orphan cannot be
|
||||
* deleted from the app either. Skips when a cover already exists under the new id, so a
|
||||
* deliberate choice is never overwritten by a stale one.
|
||||
*/
|
||||
fun renameSerial(context: Context, old: String, new: String): Boolean = runCatching {
|
||||
val from = File(dir(context), sanitize(old) + ".png")
|
||||
val to = File(dir(context), sanitize(new) + ".png")
|
||||
if (!from.isFile || to.exists()) return@runCatching false
|
||||
from.renameTo(to).also { if (it) version.value++ }
|
||||
}.getOrDefault(false)
|
||||
|
||||
fun remove(context: Context, game: GameInfo): Boolean {
|
||||
val f = fileFor(context, game) ?: return false
|
||||
return f.delete().also { if (it) version.value++ }
|
||||
|
||||
@@ -171,12 +171,18 @@ object Ps3PatchRepo {
|
||||
*
|
||||
* appVersion is carried for symmetry with [Patch]; the native side matches on
|
||||
* serial and ignores it.
|
||||
*
|
||||
* sinceRevision is the [BUNDLED_REVISION] this entry first shipped in. It is what
|
||||
* keeps a bump from touching the patches that were already here: an install whose
|
||||
* stored revision is at or above it has been offered this patch once already, and
|
||||
* whatever the user did with the toggle afterwards is their answer.
|
||||
*/
|
||||
private data class Bundled(
|
||||
val hash: String,
|
||||
val name: String,
|
||||
val serial: String,
|
||||
val appVersion: String,
|
||||
val sinceRevision: Int,
|
||||
)
|
||||
|
||||
private val BUNDLED = listOf(
|
||||
@@ -187,6 +193,16 @@ object Ps3PatchRepo {
|
||||
name = "Graphics Fix",
|
||||
serial = "BLUS30008",
|
||||
appVersion = "01.01",
|
||||
sinceRevision = 1,
|
||||
),
|
||||
// Tom Clancy's H.A.W.X. 2, BLES00928 -- without this the game hangs forever at
|
||||
// the first intro video with a dead SPU. See canary_patches.yml.
|
||||
Bundled(
|
||||
hash = "SPU-42bae8e5d6a9304068ba1c6bbfdc18d656e287a1",
|
||||
name = "Bink overlay skip",
|
||||
serial = "BLES00928",
|
||||
appVersion = "All",
|
||||
sinceRevision = 2,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -197,7 +213,7 @@ object Ps3PatchRepo {
|
||||
* install re-imports and enables the new ones. Not a timestamp: it has to be
|
||||
* something a diff of this file makes obvious.
|
||||
*/
|
||||
private const val BUNDLED_REVISION = 1
|
||||
private const val BUNDLED_REVISION = 2
|
||||
|
||||
private const val PREFS_NAME = "ARMSX2"
|
||||
private const val KEY_BUNDLED_REVISION = "ps3_bundled_patch_revision"
|
||||
@@ -210,9 +226,10 @@ object Ps3PatchRepo {
|
||||
* tick a box before Sonic '06 renders has already concluded the emulator is
|
||||
* broken.
|
||||
*
|
||||
* Guarded by a stored revision rather than run every boot, so turning one OFF
|
||||
* sticks. Re-enabling on every launch would make the toggle look broken, which
|
||||
* is the same class of bug as not having the patch at all.
|
||||
* Only patches newer than the stored revision are touched, so turning one OFF
|
||||
* sticks -- including across a later bump made for some other game. Re-enabling
|
||||
* on every launch, or on every bump, would make the toggle look broken, which is
|
||||
* the same class of bug as not having the patch at all.
|
||||
*
|
||||
* Safe to call on every boot: it is a preference read once the revision matches,
|
||||
* and the import itself merges rather than replaces, so a downloaded database
|
||||
@@ -220,7 +237,21 @@ object Ps3PatchRepo {
|
||||
*/
|
||||
fun ensureBundledPatches(context: Context) {
|
||||
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
if (prefs.getInt(KEY_BUNDLED_REVISION, 0) >= BUNDLED_REVISION) return
|
||||
val storedRevision = prefs.getInt(KEY_BUNDLED_REVISION, 0)
|
||||
if (storedRevision >= BUNDLED_REVISION) return
|
||||
|
||||
// Anything at or below the stored revision has had its one chance to be turned
|
||||
// on. Re-enabling it here would silently undo a user's OFF, and patch_config.yml
|
||||
// stores "disabled" as an absent entry, so there is nothing to read back that
|
||||
// would tell us the difference between "opted out" and "never seen".
|
||||
val pending = BUNDLED.filter { it.sinceRevision > storedRevision }
|
||||
|
||||
if (pending.isEmpty()) {
|
||||
// Nothing new to enable, so skip the import entirely rather than rewriting
|
||||
// patches/patch.yml for no reason.
|
||||
prefs.edit().putInt(KEY_BUNDLED_REVISION, BUNDLED_REVISION).apply()
|
||||
return
|
||||
}
|
||||
|
||||
val yaml = runCatching {
|
||||
context.assets.open(BUNDLED_ASSET).bufferedReader().use { it.readText() }
|
||||
@@ -241,8 +272,10 @@ object Ps3PatchRepo {
|
||||
|
||||
// Only mark the revision done if every patch actually turned on. A failure
|
||||
// here means the hash or name drifted from the YAML, and retrying next boot
|
||||
// is better than silently shipping a game that does not render.
|
||||
val allEnabled = BUNDLED.all { b ->
|
||||
// is better than silently shipping a game that does not render. The retry
|
||||
// covers only `pending`, so a patch that is stuck failing cannot drag the
|
||||
// already-settled ones back on every boot with it.
|
||||
val allEnabled = pending.all { b ->
|
||||
val ok = runCatching {
|
||||
RPCSX.instance.patchSetEnabled(b.hash, b.name, b.serial, b.appVersion, true)
|
||||
}.getOrDefault(false)
|
||||
@@ -254,7 +287,7 @@ object Ps3PatchRepo {
|
||||
|
||||
if (allEnabled) {
|
||||
prefs.edit().putInt(KEY_BUNDLED_REVISION, BUNDLED_REVISION).apply()
|
||||
android.util.Log.i("ARMSX3", "canary patches: imported $imported, enabled ${BUNDLED.size}")
|
||||
android.util.Log.i("ARMSX3", "canary patches: imported $imported, enabled ${pending.size}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,6 +77,13 @@ object ConfigStore {
|
||||
private const val KEY_TUNING_OVERRIDES_PURGED = "config.migrated.tuningOverridesPurged"
|
||||
// Per-title Accurate SPU Reservations values left behind by the same debugging.
|
||||
private const val KEY_PERGAME_RSV_CLEARED = "config.migrated.perGameRsvCleared"
|
||||
// The GLOBAL Accurate SPU Reservations value left off by the same debugging. The per-title
|
||||
// clear above never touched it, so installs carried an off-spec global for releases.
|
||||
private const val KEY_GLOBAL_RSV_ON = "config.migrated.globalSpuRsvOn"
|
||||
// "Save LLVM logs", left on while chasing the Saint Seiya register scavenger. Bumped: the
|
||||
// first pass only un-pinned the override, which does nothing for a key no code writes -- the
|
||||
// value already in config.yml is reloaded and saved again on every boot.
|
||||
private const val KEY_LLVM_LOGS_OFF_2 = "config.migrated.llvmLogsOff2"
|
||||
private const val KEY_RELAXED_ZCULL_ON = "config.migrated.relaxedZcullOn"
|
||||
private const val KEY_RELAXED_ZCULL_OFF = "config.migrated.relaxedZcullOff"
|
||||
// The relaxed-ZCULL default was recorded as a raw core override as well, and the OFF
|
||||
@@ -109,7 +116,20 @@ object ConfigStore {
|
||||
private const val BACKUP_FILENAME = "armsx2-settings.json"
|
||||
private fun keyForGame(serial: String) = "config.game.$serial"
|
||||
|
||||
// Memoized result of loadGlobal(). The function below is a JSON parse plus every
|
||||
// migration block in this file -- 24,555 dex instructions by ART's count, over its
|
||||
// JIT ceiling, so it runs interpreted every single call. That would be fine if it
|
||||
// were called rarely, but EmulationSurface's frame-rate monitor re-resolves the
|
||||
// config every 5 seconds of gameplay (measured: one ART bailout log line per 5.00s
|
||||
// for entire sessions), all to read one boolean. The migrations are one-shot by
|
||||
// their own prefs flags, so caching the parsed result is behavior-identical; the
|
||||
// cache is refreshed by saveGlobal (the only writer of KEY_GLOBAL after boot) and
|
||||
// dropped by reconcileReusedFolder, whose restore writes the pref directly.
|
||||
@Volatile
|
||||
private var cachedGlobal: Settings? = null
|
||||
|
||||
fun loadGlobal(): Settings {
|
||||
cachedGlobal?.let { return it }
|
||||
val raw = MainActivityRuntime.prefs.getString(KEY_GLOBAL, null)
|
||||
var parsed = if (raw != null) {
|
||||
try { Settings.fromJson(JSONObject(raw)) } catch (_: Exception) { Settings() }
|
||||
@@ -369,6 +389,55 @@ object ConfigStore {
|
||||
MainActivityRuntime.prefs.edit { putBoolean(KEY_ATOMIC_DMA_OFF, true) }
|
||||
}
|
||||
|
||||
// Put the GLOBAL Accurate SPU Reservations back on, which is upstream's default and this
|
||||
// app's default too.
|
||||
//
|
||||
// Off is not a slower-but-correct trade, it is off-spec: it forces the SPURS scheduler to
|
||||
// HLE and bypasses the reservation lock, so SPU threads desync and end up executing
|
||||
// whatever they land on. See KEY_PERGAME_RSV_CLEARED below, which cleared the PER-TITLE
|
||||
// values this same debugging left behind -- but never the global, so an install kept
|
||||
// running off-spec no matter what any title said.
|
||||
//
|
||||
// Found via Borderlands 2 hanging after its logo with one SPURS SPU and the RSX pinned
|
||||
// while every PPU sat in a legitimate wait. The same desync is the likeliest source of the
|
||||
// wild guest register values that were crashing the process before that.
|
||||
if (!MainActivityRuntime.prefs.getBoolean(KEY_GLOBAL_RSV_ON, false)) {
|
||||
if (raw != null && !parsed.ps3.accurateSpuRsv) {
|
||||
parsed = parsed.copy(ps3 = parsed.ps3.copy(accurateSpuRsv = true))
|
||||
dirty = true
|
||||
}
|
||||
|
||||
// Both stores, because either alone is not enough: a raw core override is re-pushed
|
||||
// after the settings themselves, so one left recorded would put false straight back
|
||||
// over the line above. KEY_TUNING_OVERRIDES_PURGED cleared these once already, but it
|
||||
// marks itself done, so anything recorded afterwards survived it.
|
||||
//
|
||||
// Global scope ONLY, deliberately. This is correcting the baseline everyone inherited,
|
||||
// not overruling a per-title decision -- Web of Shadows (BLUS30218) is kept off on
|
||||
// purpose, and forgetEverywhere() would take that with it.
|
||||
runCatching {
|
||||
CoreSettingOverrides.forget(SettingsScope.Global, null, "Core@@Accurate SPU Reservations")
|
||||
}
|
||||
MainActivityRuntime.prefs.edit { putBoolean(KEY_GLOBAL_RSV_ON, true) }
|
||||
}
|
||||
|
||||
// Drop "Save LLVM logs", left pinned as a raw override while chasing the Saint Seiya
|
||||
// register-scavenger failure.
|
||||
//
|
||||
// Upstream defaults it off and this app has no field or UI for it, so nothing would ever
|
||||
// turn it back off again -- it writes the IR for every compiled module to disk on every
|
||||
// boot, which costs compile time and a lot of storage for output nobody is reading.
|
||||
// Recorded as false rather than merely un-pinned, and that distinction is the whole fix:
|
||||
// forgetting an override only stops us re-pushing a value, and nothing in this app writes
|
||||
// this key at all, so whatever is already in config.yml is simply reloaded and saved again
|
||||
// forever. It has to be actively written off, the way the Vblank migration writes 60.
|
||||
if (!MainActivityRuntime.prefs.getBoolean(KEY_LLVM_LOGS_OFF_2, false)) {
|
||||
runCatching {
|
||||
CoreSettingOverrides.record(SettingsScope.Global, null, "Core@@Save LLVM logs", "false")
|
||||
}
|
||||
MainActivityRuntime.prefs.edit { putBoolean(KEY_LLVM_LOGS_OFF_2, true) }
|
||||
}
|
||||
|
||||
// Put Vblank Rate back to 60, which is both upstream's default and what a PS3
|
||||
// actually runs at.
|
||||
//
|
||||
@@ -567,6 +636,7 @@ object ConfigStore {
|
||||
}
|
||||
|
||||
if (dirty) saveGlobal(parsed)
|
||||
cachedGlobal = parsed
|
||||
return parsed
|
||||
}
|
||||
|
||||
@@ -586,6 +656,7 @@ object ConfigStore {
|
||||
|
||||
fun saveGlobal(s: Settings) {
|
||||
MainActivityRuntime.prefs.edit { putString(KEY_GLOBAL, s.toJson().toString()) }
|
||||
cachedGlobal = s
|
||||
writeBackupMirror()
|
||||
}
|
||||
|
||||
@@ -770,6 +841,10 @@ object ConfigStore {
|
||||
// Hard guard: an existing new-UI user (has config.global) is off-limits.
|
||||
if (MainActivityRuntime.prefs.getString(KEY_GLOBAL, null) != null) return
|
||||
|
||||
// The restore below writes KEY_GLOBAL behind loadGlobal's back; drop any
|
||||
// default Settings() a pre-restore call may have pinned in the cache.
|
||||
cachedGlobal = null
|
||||
|
||||
// (1) Lossless restore from the in-folder mirror (written by a prior new-UI install).
|
||||
val mirror = backupFile()
|
||||
if (mirror != null && mirror.exists() && mirror.length() > 0L) {
|
||||
|
||||
@@ -212,6 +212,22 @@ data class Ps3Settings(
|
||||
* the world uses cross, which is why RPCS3 exposes it rather than deriving it from region.
|
||||
*/
|
||||
val enterButtonAssign: Int = 1,
|
||||
/**
|
||||
* The rest of the console's identity, as cellSysutil reports it to games: language, region,
|
||||
* keyboard layout and clock formats.
|
||||
*
|
||||
* All five are an INDEX into the tables in Rpcs3Settings, not the core's enum value, and the
|
||||
* bridge turns them into the enum NAME the config expects. Defaults match upstream --
|
||||
* English (US), SCEA, US keyboard, ddmmyyyy, clock24 -- so an existing install is unchanged.
|
||||
*
|
||||
* A game reads these: the language decides which text a multi-language disc shows, and the
|
||||
* region is what makes a title behave as its NTSC or PAL self.
|
||||
*/
|
||||
val consoleLanguage: Int = 1,
|
||||
val consoleRegion: Int = 1,
|
||||
val keyboardType: Int = 0,
|
||||
val dateFormat: Int = 1,
|
||||
val timeFormat: Int = 1,
|
||||
val spuXFloat: Int = 1,
|
||||
val accurateSpuRsv: Boolean = true,
|
||||
/**
|
||||
@@ -1090,6 +1106,11 @@ data class Settings(
|
||||
put("PS3/Net", "PSN status", "enum", ps3.psnStatus.toString())
|
||||
put("PS3/Net", "UPNP Enabled", "bool", ps3.upnpEnabled.toString())
|
||||
put("PS3/System", "Enter button assignment", "enum", ps3.enterButtonAssign.toString())
|
||||
put("PS3/System", "Language", "enum", ps3.consoleLanguage.toString())
|
||||
put("PS3/System", "License Area", "enum", ps3.consoleRegion.toString())
|
||||
put("PS3/System", "Keyboard Type", "enum", ps3.keyboardType.toString())
|
||||
put("PS3/System", "Date Format", "enum", ps3.dateFormat.toString())
|
||||
put("PS3/System", "Time Format", "enum", ps3.timeFormat.toString())
|
||||
put("PS3/Core", "SPU XFloat Accuracy", "enum", ps3.spuXFloat.toString())
|
||||
put("PS3/Core", "Accurate SPU Reservations", "bool", ps3.accurateSpuRsv.toString())
|
||||
put("PS3/Core", "Accurate Cache Line Stores", "bool", ps3.accurateCacheLine.toString())
|
||||
@@ -2062,6 +2083,11 @@ data class Settings(
|
||||
put("ps3PsnStatus", ps3.psnStatus)
|
||||
put("ps3UpnpEnabled", ps3.upnpEnabled)
|
||||
put("ps3EnterButtonAssign", ps3.enterButtonAssign)
|
||||
put("ps3ConsoleLanguage", ps3.consoleLanguage)
|
||||
put("ps3ConsoleRegion", ps3.consoleRegion)
|
||||
put("ps3KeyboardType", ps3.keyboardType)
|
||||
put("ps3DateFormat", ps3.dateFormat)
|
||||
put("ps3TimeFormat", ps3.timeFormat)
|
||||
put("ps3SpuXFloat", ps3.spuXFloat)
|
||||
put("ps3AccurateSpuRsv", ps3.accurateSpuRsv)
|
||||
put("ps3AccurateCacheLine", ps3.accurateCacheLine)
|
||||
@@ -2404,6 +2430,11 @@ data class Settings(
|
||||
psnStatus = json.optBoolean("ps3PsnStatus", def.ps3.psnStatus),
|
||||
upnpEnabled = json.optBoolean("ps3UpnpEnabled", def.ps3.upnpEnabled),
|
||||
enterButtonAssign = json.optInt("ps3EnterButtonAssign", def.ps3.enterButtonAssign),
|
||||
consoleLanguage = json.optInt("ps3ConsoleLanguage", def.ps3.consoleLanguage),
|
||||
consoleRegion = json.optInt("ps3ConsoleRegion", def.ps3.consoleRegion),
|
||||
keyboardType = json.optInt("ps3KeyboardType", def.ps3.keyboardType),
|
||||
dateFormat = json.optInt("ps3DateFormat", def.ps3.dateFormat),
|
||||
timeFormat = json.optInt("ps3TimeFormat", def.ps3.timeFormat),
|
||||
spuXFloat = json.optInt("ps3SpuXFloat", def.ps3.spuXFloat),
|
||||
accurateSpuRsv = json.optBoolean("ps3AccurateSpuRsv", def.ps3.accurateSpuRsv),
|
||||
accurateCacheLine = json.optBoolean("ps3AccurateCacheLine", def.ps3.accurateCacheLine),
|
||||
@@ -2726,6 +2757,11 @@ data class Settings(
|
||||
if (current.ps3.psnStatus != base.ps3.psnStatus) j.put("ps3PsnStatus", current.ps3.psnStatus)
|
||||
if (current.ps3.upnpEnabled != base.ps3.upnpEnabled) j.put("ps3UpnpEnabled", current.ps3.upnpEnabled)
|
||||
if (current.ps3.enterButtonAssign != base.ps3.enterButtonAssign) j.put("ps3EnterButtonAssign", current.ps3.enterButtonAssign)
|
||||
if (current.ps3.consoleLanguage != base.ps3.consoleLanguage) j.put("ps3ConsoleLanguage", current.ps3.consoleLanguage)
|
||||
if (current.ps3.consoleRegion != base.ps3.consoleRegion) j.put("ps3ConsoleRegion", current.ps3.consoleRegion)
|
||||
if (current.ps3.keyboardType != base.ps3.keyboardType) j.put("ps3KeyboardType", current.ps3.keyboardType)
|
||||
if (current.ps3.dateFormat != base.ps3.dateFormat) j.put("ps3DateFormat", current.ps3.dateFormat)
|
||||
if (current.ps3.timeFormat != base.ps3.timeFormat) j.put("ps3TimeFormat", current.ps3.timeFormat)
|
||||
if (current.ps3.spuXFloat != base.ps3.spuXFloat) j.put("ps3SpuXFloat", current.ps3.spuXFloat)
|
||||
if (current.ps3.accurateSpuRsv != base.ps3.accurateSpuRsv) j.put("ps3AccurateSpuRsv", current.ps3.accurateSpuRsv)
|
||||
if (current.ps3.accurateCacheLine != base.ps3.accurateCacheLine) j.put("ps3AccurateCacheLine", current.ps3.accurateCacheLine)
|
||||
@@ -3029,6 +3065,11 @@ data class Settings(
|
||||
psnStatus = if (overrides.has("ps3PsnStatus")) overrides.getBoolean("ps3PsnStatus") else base.ps3.psnStatus,
|
||||
upnpEnabled = if (overrides.has("ps3UpnpEnabled")) overrides.getBoolean("ps3UpnpEnabled") else base.ps3.upnpEnabled,
|
||||
enterButtonAssign = if (overrides.has("ps3EnterButtonAssign")) overrides.getInt("ps3EnterButtonAssign") else base.ps3.enterButtonAssign,
|
||||
consoleLanguage = if (overrides.has("ps3ConsoleLanguage")) overrides.getInt("ps3ConsoleLanguage") else base.ps3.consoleLanguage,
|
||||
consoleRegion = if (overrides.has("ps3ConsoleRegion")) overrides.getInt("ps3ConsoleRegion") else base.ps3.consoleRegion,
|
||||
keyboardType = if (overrides.has("ps3KeyboardType")) overrides.getInt("ps3KeyboardType") else base.ps3.keyboardType,
|
||||
dateFormat = if (overrides.has("ps3DateFormat")) overrides.getInt("ps3DateFormat") else base.ps3.dateFormat,
|
||||
timeFormat = if (overrides.has("ps3TimeFormat")) overrides.getInt("ps3TimeFormat") else base.ps3.timeFormat,
|
||||
spuXFloat = if (overrides.has("ps3SpuXFloat")) overrides.getInt("ps3SpuXFloat") else base.ps3.spuXFloat,
|
||||
accurateSpuRsv = if (overrides.has("ps3AccurateSpuRsv")) overrides.getBoolean("ps3AccurateSpuRsv") else base.ps3.accurateSpuRsv,
|
||||
accurateCacheLine = if (overrides.has("ps3AccurateCacheLine")) overrides.getBoolean("ps3AccurateCacheLine") else base.ps3.accurateCacheLine,
|
||||
|
||||
+93
-4
@@ -16,6 +16,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import com.armsx2.CustomCovers
|
||||
import com.armsx2.DiscIcons
|
||||
import com.armsx3.NativeApp
|
||||
import net.rpcsx.GameFlag
|
||||
@@ -507,6 +508,73 @@ class GameLibraryRepository(private val context: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the separator, but only from something that actually looks like a title ID.
|
||||
*
|
||||
* The check is not about which console the game is for -- this emulator runs PS3 titles
|
||||
* and nothing else. It is about not manufacturing an identity out of a guess.
|
||||
* FilenameParser takes the first four-letters + five-digits token it finds ANYWHERE in
|
||||
* the name and reconstructs it in the PS2 dump shape it was written for, so what arrives
|
||||
* here may be a real id (BLUS-30917), a token out of a release tag, or an id belonging to
|
||||
* a different game entirely.
|
||||
*
|
||||
* Left hyphenated, a bad guess matches nothing: no cover, no disc icon, no config, and
|
||||
* the card shows a placeholder. That is a visible failure and it is the safe one.
|
||||
* Stripped, the same guess becomes a WELL-FORMED title id and quietly resolves whatever
|
||||
* is filed under it -- another game's cover and curated name, and its config_db entry,
|
||||
* which the core applies at boot. So normalise only what carries a real PS3 prefix:
|
||||
* B for disc releases (BLUS/BLES/BCUS...), N for PSN (NPUB/NPEB...).
|
||||
*
|
||||
* Deliberately NOT gated on [GamePlatform]: that enum comes from the same probe that
|
||||
* produced the serial, so in the one case this function exists for -- the probe failed
|
||||
* and the name came off the filename -- it carries no information at all.
|
||||
*/
|
||||
private fun normalizeSerial(raw: String): String {
|
||||
val stripped = raw.replace("-", "")
|
||||
return if (ps3SerialRegex.matches(stripped)) stripped else raw
|
||||
}
|
||||
|
||||
/**
|
||||
* Carry a game's per-serial data across an identity correction.
|
||||
*
|
||||
* The serial is not just the cover key: it keys config.game.<serial>, per-game core
|
||||
* overrides, touch layouts and profiles, pad bindings, play time, the pinned name and
|
||||
* the custom cover file. Renaming the game without moving those silently resets every
|
||||
* one of them, and nothing prunes the old keys afterwards, so they become unreachable
|
||||
* rather than merely unused.
|
||||
*
|
||||
* A same-named key already under the new id is overwritten. It can only have come from
|
||||
* an earlier scan that probed the disc successfully, before this entry regressed to a
|
||||
* filename-derived id; the hyphenated one is what the game has actually been running
|
||||
* with since, so it is the live value and the older one is stale.
|
||||
*/
|
||||
private fun migrateSerialKeys(old: String, new: String) {
|
||||
runCatching {
|
||||
val prefs = MainActivityRuntime.prefs
|
||||
val snapshot = prefs.all
|
||||
val moved = snapshot.keys.filter { it.contains(old) }
|
||||
if (moved.isNotEmpty()) {
|
||||
prefs.edit().apply {
|
||||
moved.forEach { key ->
|
||||
when (val value = snapshot[key]) {
|
||||
is String -> putString(key.replace(old, new), value)
|
||||
is Int -> putInt(key.replace(old, new), value)
|
||||
is Long -> putLong(key.replace(old, new), value)
|
||||
is Boolean -> putBoolean(key.replace(old, new), value)
|
||||
is Float -> putFloat(key.replace(old, new), value)
|
||||
is Set<*> -> @Suppress("UNCHECKED_CAST")
|
||||
putStringSet(key.replace(old, new), value as Set<String>)
|
||||
else -> return@forEach
|
||||
}
|
||||
remove(key)
|
||||
}
|
||||
}.apply()
|
||||
}
|
||||
CustomCovers.renameSerial(context, old, new)
|
||||
android.util.Log.i(ScanTag, "serial '$old' -> '$new' (${moved.size} pref key(s) moved)")
|
||||
}
|
||||
}
|
||||
|
||||
private fun createGame(
|
||||
uri: Uri,
|
||||
name: String,
|
||||
@@ -516,9 +584,22 @@ class GameLibraryRepository(private val context: Context) {
|
||||
): GameInfo {
|
||||
val (probeSerial, probePlatform) = parseProbe(rawProbe)
|
||||
val (fileTitle, fileSerial) = FilenameParser.parse(name)
|
||||
val platform = if (disc != null) GamePlatform.PS3 else probePlatform ?: GamePlatform.PS3
|
||||
// The disc's own PARAM.SFO wins: it is the authoritative title ID, where
|
||||
// a filename-derived one is a guess off a dump's naming convention.
|
||||
val serial = disc?.titleId ?: probeSerial ?: fileSerial
|
||||
//
|
||||
// Normalise the result: FilenameParser reconstructs every serial in the PS2 dump
|
||||
// shape (SLUS-20312) because that is the convention its regex was written for, so a
|
||||
// PS3 game whose serial came off the filename is recorded as BLUS-30917 and matches
|
||||
// nothing -- not the cover repo (keyed by the exact PARAM.SFO id), not disc-icons,
|
||||
// not config.game.<serial>. Doing it here rather than in FilenameParser is what
|
||||
// repairs the entries already cached: they are re-seeded into discInfoCache on every
|
||||
// scan and arrive back here as disc.titleId, which has top priority.
|
||||
val rawSerial = disc?.titleId ?: probeSerial ?: fileSerial
|
||||
val serial = rawSerial?.let(::normalizeSerial)
|
||||
if (rawSerial != null && serial != null && rawSerial != serial) {
|
||||
migrateSerialKeys(rawSerial, serial)
|
||||
}
|
||||
val compatibility = serial
|
||||
?.let { runCatching { NativeApp.getCompatibilityForSerial(it) }.getOrDefault(0) }
|
||||
?.minus(1)
|
||||
@@ -537,7 +618,7 @@ class GameLibraryRepository(private val context: Context) {
|
||||
serial = serial,
|
||||
compatibility = compatibility,
|
||||
extension = extension.uppercase(),
|
||||
platform = if (disc != null) GamePlatform.PS3 else probePlatform ?: GamePlatform.PS3,
|
||||
platform = platform,
|
||||
// Only meaningful alongside a DB title; a filename-derived one has no sort key
|
||||
// and is not a translation of anything.
|
||||
titleSort = db?.sort.orEmpty(),
|
||||
@@ -669,8 +750,16 @@ class GameLibraryRepository(private val context: Context) {
|
||||
/** v2: PS3 title ID + title + ICON0.PNG read from the disc's PARAM.SFO.
|
||||
* v5: folder-format games (JB folder / installed game folder).
|
||||
* v6: PARAM.SFO CATEGORY read, to drop game-data installs.
|
||||
* v7: licence-locked state, asked of the core per installed title. */
|
||||
const val ScanSchemaVersion = 7
|
||||
* v7: licence-locked state, asked of the core per installed title.
|
||||
* v8: PS3 serials normalised (BLUS-30917 -> BLUS30917). The scanner does not
|
||||
* extract a NEW field here, it changes the VALUE of one it already stored, which
|
||||
* has the same staleness signature: without a bump an existing install keeps
|
||||
* serving the cached hyphenated ids and never rescans, so the repair never
|
||||
* reaches the libraries that need it. */
|
||||
/** PS3 disc ids are B***, PSN ids N***, both four letters and five digits. */
|
||||
val ps3SerialRegex = Regex("^[BN][A-Z]{3}[0-9]{5}$")
|
||||
|
||||
const val ScanSchemaVersion = 8
|
||||
const val ScanTag = "ARMSX3-Scan"
|
||||
/** Staging name for an extracted icon, renamed once the title ID is known. */
|
||||
const val PendingIcon = "__pending"
|
||||
|
||||
@@ -546,6 +546,20 @@ val EN: Map<String, String> = mapOf(
|
||||
"adv.dazFtz.label" to "Denormals As Zero / Flush To Zero",
|
||||
"adv.dazFtz.description" to "Treats extremely small floating-point values as zero. Faster on some hardware, but incorrect for games that rely on denormals.",
|
||||
"adv.section.system" to "System",
|
||||
"adv.section.console" to "Console",
|
||||
"adv.consoleLanguage.label" to "Console Language",
|
||||
"adv.consoleLanguage.description" to "The language the console reports to games. A disc with several languages on it picks its text from this, not from the app's language.",
|
||||
"adv.consoleRegion.label" to "Console Region",
|
||||
"adv.consoleRegion.description" to "Which region the console claims to be from: SCEA America, SCEE Europe, SCEJ Japan, SCEH Asia, SCEK Korea, SCH China. Some titles change behaviour to match.",
|
||||
"adv.keyboardType.label" to "Keyboard Type",
|
||||
"adv.keyboardType.description" to "Layout reported for a USB keyboard, which decides where the symbol keys land.",
|
||||
"adv.dateFormat.label" to "Date Format",
|
||||
"adv.dateFormat.ymd" to "Year/Month/Day",
|
||||
"adv.dateFormat.dmy" to "Day/Month/Year",
|
||||
"adv.dateFormat.mdy" to "Month/Day/Year",
|
||||
"adv.timeFormat.label" to "Time Format",
|
||||
"adv.timeFormat.clock12" to "12 hour",
|
||||
"adv.timeFormat.clock24" to "24 hour",
|
||||
"adv.sleepTimers.label" to "Sleep Timers Accuracy",
|
||||
"adv.sleepTimers.asHost" to "As Host",
|
||||
"adv.sleepTimers.usleep" to "Usleep Only",
|
||||
@@ -695,7 +709,9 @@ val EN: Map<String, String> = mapOf(
|
||||
"memcard.slot1" to "Slot 1",
|
||||
"memcard.status.coreStarting" to "Core settings are still starting up.",
|
||||
"network.address" to "Address",
|
||||
"touch.stateAction.keyboard" to "KBD",
|
||||
"network.emulateUsbKeyboard" to "Emulate USB Keyboard",
|
||||
"net.usbKeyboard.description" to "Report a USB keyboard to the game. Needed by titles that require one \u2014 EverQuest Online Adventures, Konami-keyboard games \u2014 and for typing in online chat. A physical or Bluetooth keyboard works once this is on, and the \"On-Screen Keyboard (toggle)\" hotkey raises the Android keyboard over the game without pausing.",
|
||||
"network.ethernetDevice" to "Ethernet Device",
|
||||
"network.hddImage.dialogHint" to "File name (kept in the data folder) or a full path to an existing image.",
|
||||
"network.hddImage.fieldLabel" to "HDD image",
|
||||
|
||||
@@ -4132,6 +4132,9 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
* button and hold-button-then-push-direction both bind combos, and a push
|
||||
* released with nothing else still binds the plain single direction. */
|
||||
private val captureHeldSynth = HashSet<Int>()
|
||||
/** Trigger pull that counts as a press while binding. Half, so resting drift cannot bind. */
|
||||
private val CAPTURE_TRIGGER_ON = 0.5f
|
||||
|
||||
private fun handleCaptureMotion(ev: MotionEvent): Boolean {
|
||||
// Desired engaged-direction set for this event: at most one per HAT axis
|
||||
// pair and one per stick (dominant direction), so sweeping through a
|
||||
@@ -4146,6 +4149,23 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
// here is why its directions could never be bound.
|
||||
val (capRightX, capRightY) = rightStickAxes(ev.deviceId)
|
||||
captureStickCode(ev, capRightX, capRightY, false).takeIf { it != 0 }?.let { want.add(it) }
|
||||
// Analog triggers. On a pad that reports L2/R2 as AXES rather than buttons they arrive
|
||||
// here and never as a key, so the binder -- which lives in Compose's onPreviewKeyEvent --
|
||||
// could not see them and L2/R2 simply would not bind. Everything else on the same pad
|
||||
// binds, which is what makes it look player-specific rather than trigger-specific: it
|
||||
// depends on the controller model, so a second pad of a different make fails where the
|
||||
// first one worked. Reported on Player 2.
|
||||
//
|
||||
// Same axis pairs the gameplay path uses (sendTrigger), including the per-device third
|
||||
// axis some pads put the right trigger on, so a trigger that works in game can be bound.
|
||||
// Threshold is a deliberate half-pull: resting drift on a worn trigger must not self-bind.
|
||||
val capLt = maxOf(ev.getAxisValue(MotionEvent.AXIS_LTRIGGER), ev.getAxisValue(MotionEvent.AXIS_BRAKE))
|
||||
var capRt = maxOf(ev.getAxisValue(MotionEvent.AXIS_RTRIGGER), ev.getAxisValue(MotionEvent.AXIS_GAS))
|
||||
rightTriggerExtraAxis(ev.deviceId).takeIf { it != 0 }?.let { extra ->
|
||||
capRt = maxOf(capRt, ev.getAxisValue(extra))
|
||||
}
|
||||
if (capLt >= CAPTURE_TRIGGER_ON) want.add(KeyEvent.KEYCODE_BUTTON_L2)
|
||||
if (capRt >= CAPTURE_TRIGGER_ON) want.add(KeyEvent.KEYCODE_BUTTON_R2)
|
||||
captureHatX = dx
|
||||
captureHatY = dy
|
||||
val now = SystemClock.uptimeMillis()
|
||||
@@ -4896,7 +4916,13 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
// worked (.iso/.bin/.chd) can never be made worse by this.
|
||||
val uri = resolveCueToTrack(raw) ?: raw
|
||||
currentGame.value = null
|
||||
pendingExternalLaunch.value = uri.toString()
|
||||
// A file:// URI has to be reduced to its path before the core sees it. Handed the string
|
||||
// form, the core takes "file:///sdcard/x/y.elf" as a filesystem path: it mounts /app_home
|
||||
// at "/file:/sdcard/x/" and then reports "Failed to open executable". content:// is passed
|
||||
// through untouched, since the core opens those by fd. This is the same conversion
|
||||
// launchCurrentGameFromSaveSlot already does, and it was simply missing on the external
|
||||
// path -- so anything launching us with file:// (a file manager, a front-end, adb) failed.
|
||||
pendingExternalLaunch.value = if (uri.scheme == "file") (uri.path ?: uri.toString()) else uri.toString()
|
||||
launchPendingExternalGameIfReady()
|
||||
}
|
||||
|
||||
|
||||
@@ -67,30 +67,49 @@ fun BiosManagerScreen(onBack: () -> Unit, game: com.armsx2.GameInfo? = null) {
|
||||
// a successful install.
|
||||
LaunchedEffect(Unit) { runCatching { FirmwareRepository.load() } }
|
||||
|
||||
// Install takes a URI, not a File.
|
||||
//
|
||||
// The core is handed a file descriptor either way, and openAssetFileDescriptor resolves a
|
||||
// content:// URI exactly as happily as a file://. Routing both paths through one function is
|
||||
// what lets the SAF picker below share it -- the in-app browser cannot reach a MicroSD on
|
||||
// Android 11 and later, because storageRoots() enumerates /storage by POSIX and a removable
|
||||
// volume is not listable that way. Reported on an Odin 3 Max: the picker showed internal
|
||||
// storage only. The package installer already had this second route; firmware never did.
|
||||
val installFirmware: (android.net.Uri) -> Unit = { uri ->
|
||||
busy = true
|
||||
message = null
|
||||
MainActivityRuntime.invoke {
|
||||
val ok = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val id = ProgressRepository.create(context, "Installing firmware")
|
||||
context.contentResolver
|
||||
.openAssetFileDescriptor(uri, "r")
|
||||
.use { afd ->
|
||||
val fd = afd?.parcelFileDescriptor?.fd
|
||||
?: return@runCatching false
|
||||
RPCSX.instance.installFw(fd, id)
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
busy = false
|
||||
if (!ok) message = I18n.get("bios.firmware.failed")
|
||||
}
|
||||
}
|
||||
|
||||
// */* rather than a PUP MIME type: Android has no type for a PS3 firmware update, and every
|
||||
// provider reports something different for it -- octet-stream, nothing at all, or the type of
|
||||
// whatever extension it guesses. A filtered picker would grey the file out on some devices.
|
||||
val safPicker = androidx.activity.compose.rememberLauncherForActivityResult(
|
||||
androidx.activity.result.contract.ActivityResultContracts.OpenDocument(),
|
||||
) { uri -> uri?.let(installFirmware) }
|
||||
|
||||
if (showBrowser) {
|
||||
FileBrowserDialog(
|
||||
title = str("setup.bios.selectTitle"),
|
||||
extensions = setOf("pup"),
|
||||
onPick = { file ->
|
||||
showBrowser = false
|
||||
busy = true
|
||||
message = null
|
||||
MainActivityRuntime.invoke {
|
||||
val ok = withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
val id = ProgressRepository.create(context, "Installing firmware")
|
||||
context.contentResolver
|
||||
.openAssetFileDescriptor(android.net.Uri.fromFile(file), "r")
|
||||
.use { afd ->
|
||||
val fd = afd?.parcelFileDescriptor?.fd
|
||||
?: return@runCatching false
|
||||
RPCSX.instance.installFw(fd, id)
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
busy = false
|
||||
if (!ok) message = I18n.get("bios.firmware.failed")
|
||||
}
|
||||
installFirmware(android.net.Uri.fromFile(file))
|
||||
},
|
||||
onDismiss = { showBrowser = false },
|
||||
)
|
||||
@@ -184,6 +203,24 @@ fun BiosManagerScreen(onBack: () -> Unit, game: com.armsx2.GameInfo? = null) {
|
||||
else str("bios.firmware.reinstall"),
|
||||
)
|
||||
}
|
||||
|
||||
// Reaches storage the in-app browser cannot open by path: USB-OTG, and MicroSD on
|
||||
// devices that only expose it through SAF. Always offered, not just when canBrowse()
|
||||
// fails -- a device can have all-files access AND still hide its card from a POSIX
|
||||
// walk of /storage, which is exactly the case that was reported.
|
||||
OutlinedButton(
|
||||
onClick = { safPicker.launch(arrayOf("*/*")) },
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.controllerFocusable(
|
||||
"firmware.install.external",
|
||||
RoundedCornerShape(13.dp),
|
||||
onConfirm = { safPicker.launch(arrayOf("*/*")) },
|
||||
),
|
||||
) {
|
||||
Text(str("packages.select.external"))
|
||||
}
|
||||
}
|
||||
|
||||
OutlinedButton(
|
||||
|
||||
@@ -539,7 +539,7 @@ private fun MenuHeader(
|
||||
Column(Modifier.weight(1f)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
game?.title ?: "PlayStation 2",
|
||||
game?.title ?: "PlayStation 3",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
maxLines = 1,
|
||||
|
||||
@@ -153,6 +153,61 @@ fun FixesTab(state: MutableState<Settings>) {
|
||||
) { apply(s.copy(ps3 = s.ps3.copy(debugConsoleMode = it))) }
|
||||
}
|
||||
|
||||
// What the emulated console tells games about itself.
|
||||
//
|
||||
// These are not app preferences: a multi-language disc picks its text from the console
|
||||
// language, and region is what makes a title behave as its NTSC or PAL self. Option
|
||||
// labels come from the core's own enum names (the same strings the All Core Settings
|
||||
// screen shows) rather than 60 new translation keys -- they are proper nouns like
|
||||
// "SCEA" and keyboard layout names, which are not usefully translated.
|
||||
CollapsibleSection(str("adv.section.console")) {
|
||||
SegmentedGridRow(
|
||||
label = str("adv.consoleLanguage.label"),
|
||||
options = com.armsx3.Rpcs3Settings.consoleLanguageNames(),
|
||||
selectedIndex = s.ps3.consoleLanguage.coerceIn(0, com.armsx3.Rpcs3Settings.consoleLanguageNames().lastIndex),
|
||||
columns = 2,
|
||||
description = str("adv.consoleLanguage.description"),
|
||||
onChange = { apply(s.copy(ps3 = s.ps3.copy(consoleLanguage = it))) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SegmentedGridRow(
|
||||
label = str("adv.consoleRegion.label"),
|
||||
options = com.armsx3.Rpcs3Settings.consoleRegionNames(),
|
||||
selectedIndex = s.ps3.consoleRegion.coerceIn(0, com.armsx3.Rpcs3Settings.consoleRegionNames().lastIndex),
|
||||
columns = 3,
|
||||
description = str("adv.consoleRegion.description"),
|
||||
onChange = { apply(s.copy(ps3 = s.ps3.copy(consoleRegion = it))) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SegmentedGridRow(
|
||||
label = str("adv.keyboardType.label"),
|
||||
options = com.armsx3.Rpcs3Settings.keyboardTypeNames(),
|
||||
selectedIndex = s.ps3.keyboardType.coerceIn(0, com.armsx3.Rpcs3Settings.keyboardTypeNames().lastIndex),
|
||||
columns = 1,
|
||||
description = str("adv.keyboardType.description"),
|
||||
onChange = { apply(s.copy(ps3 = s.ps3.copy(keyboardType = it))) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SegmentedGridRow(
|
||||
label = str("adv.dateFormat.label"),
|
||||
options = listOf(
|
||||
str("adv.dateFormat.ymd"),
|
||||
str("adv.dateFormat.dmy"),
|
||||
str("adv.dateFormat.mdy"),
|
||||
),
|
||||
selectedIndex = s.ps3.dateFormat.coerceIn(0, 2),
|
||||
columns = 3,
|
||||
onChange = { apply(s.copy(ps3 = s.ps3.copy(dateFormat = it))) },
|
||||
)
|
||||
SettingsDivider()
|
||||
SegmentedRow(
|
||||
label = str("adv.timeFormat.label"),
|
||||
options = listOf(str("adv.timeFormat.clock12"), str("adv.timeFormat.clock24")),
|
||||
selectedIndex = s.ps3.timeFormat.coerceIn(0, 1),
|
||||
onChange = { apply(s.copy(ps3 = s.ps3.copy(timeFormat = it))) },
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,16 @@ fun NetworkTab(state: MutableState<Settings>) {
|
||||
s.ps3.upnpEnabled,
|
||||
description = str("net.upnp.description"),
|
||||
) { apply(s.copy(ps3 = s.ps3.copy(upnpEnabled = it))) }
|
||||
SettingsDivider()
|
||||
// Emulate USB Keyboard. Previously reachable ONLY from the in-game pause menu, which made
|
||||
// the "On-Screen Keyboard (toggle)" hotkey's own message a dead end: it says to turn this
|
||||
// on in Network settings, and there was nothing here to turn on. Same field, so the two
|
||||
// rows stay in sync.
|
||||
ToggleRow(
|
||||
str("network.emulateUsbKeyboard"),
|
||||
s.usbKeyboard,
|
||||
description = str("net.usbKeyboard.description"),
|
||||
) { apply(s.copy(usbKeyboard = it)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -455,8 +455,9 @@ fun PerformanceTab(state: MutableState<Settings>) {
|
||||
* Root of RPCS3's compiled-code cache: `<files>/cache/cache/`.
|
||||
*
|
||||
* Holds `ppu-<hash>-<name>` directories for firmware modules at the top level, plus
|
||||
* `<TITLEID>/ppu-<hash>-EBOOT.BIN` per game. The SPU cache is a `spu-*.dat` file INSIDE
|
||||
* those directories, which is why clearing PPU necessarily clears SPU with it.
|
||||
* `<TITLEID>/ppu-<hash>-EBOOT.BIN` per game. The SPU caches live INSIDE those directories -- a
|
||||
* `spu-*.dat` file and a `spuobj-v<n>-<key>/` directory of compiled objects -- which is why
|
||||
* clearing PPU necessarily clears SPU with it.
|
||||
*/
|
||||
private fun recompilerCacheRoot(context: Context): File =
|
||||
File(MainActivityRuntime.assetCopyRoot(context), "cache/cache")
|
||||
@@ -493,16 +494,36 @@ private fun clearRecompilerCache(root: File, spuOnly: Boolean): Pair<Int, Long>
|
||||
var bytes = 0L
|
||||
|
||||
if (spuOnly) {
|
||||
root.walkTopDown()
|
||||
.filter { it.isFile && it.name.startsWith("spu-") && it.extension == "dat" }
|
||||
// Two things live here, not one. `spu-*.dat` is the original SPU cache; the persistent SPU
|
||||
// LLVM object cache sits beside it in `spuobj-v<n>-<key>/` directories and is by far the
|
||||
// larger of the two. Clearing only the .dat files handed the recompiler straight back the
|
||||
// objects the user pressed this button to be rid of -- which is exactly what someone
|
||||
// clearing an SPU cache to escape stale compiled code needs not to happen.
|
||||
val objDirs = root.walkTopDown()
|
||||
.filter { it.isDirectory && it.name.startsWith("spuobj-") }
|
||||
.toList() // materialise before deleting, so the walk is not mutated under itself
|
||||
.forEach { file ->
|
||||
val size = file.length()
|
||||
if (runCatching { file.delete() }.getOrDefault(false)) {
|
||||
count++
|
||||
bytes += size
|
||||
}
|
||||
val datFiles = root.walkTopDown()
|
||||
.filter { it.isFile && it.name.startsWith("spu-") && it.extension == "dat" }
|
||||
.toList()
|
||||
|
||||
objDirs.forEach { dir ->
|
||||
val size = dir.sizeRecursive()
|
||||
if (runCatching { dir.deleteRecursively() }.getOrDefault(false)) {
|
||||
count++
|
||||
bytes += size
|
||||
}
|
||||
}
|
||||
|
||||
datFiles.forEach { file ->
|
||||
// exists() because a .dat inside one of the directories above is already gone.
|
||||
if (!file.exists()) return@forEach
|
||||
val size = file.length()
|
||||
if (runCatching { file.delete() }.getOrDefault(false)) {
|
||||
count++
|
||||
bytes += size
|
||||
}
|
||||
}
|
||||
|
||||
return count to bytes
|
||||
}
|
||||
|
||||
|
||||
@@ -146,6 +146,7 @@ internal val SETTINGS_SEARCH_INDEX: List<SettingsSearchEntry> = listOf(
|
||||
SettingsSearchEntry("net.internet.label", true, SettingsCategory.Network),
|
||||
SettingsSearchEntry("net.psn.label", true, SettingsCategory.Network),
|
||||
SettingsSearchEntry("net.upnp.label", true, SettingsCategory.Network),
|
||||
SettingsSearchEntry("network.emulateUsbKeyboard", true, SettingsCategory.Network),
|
||||
SettingsSearchEntry("overlay.osdSize.label", true, SettingsCategory.OnScreen),
|
||||
SettingsSearchEntry("overlay.osdColor.label", true, SettingsCategory.OnScreen),
|
||||
SettingsSearchEntry("overlay.uiSize.label", true, SettingsCategory.OnScreen),
|
||||
|
||||
@@ -1325,7 +1325,14 @@ enum class TouchButtonId(val label: String, val keycode: Int, val kind: Kind) {
|
||||
// additionally hit-tests this widget's own circle so a thumb that glides up off
|
||||
// the stick latches it without lifting; see StickWidget. Its keycode is chosen in
|
||||
// Pad settings (analogExtraKeycode), so the enum entry carries none.
|
||||
ANALOG_EXTRA("Extra", 0, Kind.ANALOGEXTRA);
|
||||
ANALOG_EXTRA("Extra", 0, Kind.ANALOGEXTRA),
|
||||
|
||||
// On-screen keyboard toggle. Emits no PS3 keycode; tapping raises or drops the Android
|
||||
// keyboard via MainActivityRuntime.toggleSoftKeyboard(), the same action as the
|
||||
// "On-Screen Keyboard (toggle)" hotkey -- so a device with no spare pad button, or no
|
||||
// controller at all, can still reach it. Requires Emulate USB Keyboard (Network settings);
|
||||
// the action says so itself when it is off. Opt-in: absent from the default layout.
|
||||
KEYBOARD("KBD", 0, Kind.STATEACTION);
|
||||
|
||||
enum class Kind { FACE, SHOULDER, MENU, DPAD, STICK, PAUSE, PRESSURE, FASTFORWARD, MACRO, STATEACTION, ANALOGEXTRA }
|
||||
}
|
||||
@@ -1495,6 +1502,12 @@ data class TouchLayout(val buttons: List<TouchButtonCfg>) {
|
||||
TouchButtonCfg(TouchButtonId.SAVE_STATE, 0.30f, 0.54f, 44f, enabled = false),
|
||||
TouchButtonCfg(TouchButtonId.LOAD_STATE, 0.38f, 0.54f, 44f, enabled = false),
|
||||
TouchButtonCfg(TouchButtonId.SCREENSHOT, 0.46f, 0.54f, 44f, enabled = false),
|
||||
// On-screen keyboard toggle -- OPT-IN, same row. Being listed here is what makes it
|
||||
// reachable at all: the editor offers what the layout CONTAINS, not what the enum
|
||||
// declares, so adding the TouchButtonId alone left it invisible. Existing layouts
|
||||
// pick it up because fromJson splices in any default button they lack, and
|
||||
// defaultPortrait splices from this same table.
|
||||
TouchButtonCfg(TouchButtonId.KEYBOARD, 0.54f, 0.54f, 44f, enabled = false),
|
||||
// Extra analog button, parked directly above the D-PAD (0.10, 0.55) — not above the
|
||||
// left stick, which is where it first went. The button is for sprint/jump held while
|
||||
// you keep moving, so it belongs above whichever control the thumb is already on,
|
||||
|
||||
@@ -569,6 +569,7 @@ private fun drawableFor(id: TouchButtonId, pressed: Boolean): Int = when (id) {
|
||||
TouchButtonId.PAUSE, TouchButtonId.PRESSURE, TouchButtonId.FAST_FORWARD,
|
||||
TouchButtonId.MACRO1, TouchButtonId.MACRO2, TouchButtonId.MACRO3, TouchButtonId.MACRO4,
|
||||
TouchButtonId.SAVE_STATE, TouchButtonId.LOAD_STATE, TouchButtonId.SCREENSHOT,
|
||||
TouchButtonId.KEYBOARD,
|
||||
TouchButtonId.ANALOG_EXTRA -> R.drawable.pad_cross
|
||||
}
|
||||
|
||||
@@ -1037,6 +1038,7 @@ private fun StateActionWidget(cfg: TouchButtonCfg, edit: Boolean) {
|
||||
val label = when (cfg.id) {
|
||||
TouchButtonId.SAVE_STATE -> str("touch.stateAction.save")
|
||||
TouchButtonId.LOAD_STATE -> str("touch.stateAction.load")
|
||||
TouchButtonId.KEYBOARD -> str("touch.stateAction.keyboard")
|
||||
else -> str("touch.stateAction.screenshot")
|
||||
}
|
||||
if (edit) {
|
||||
@@ -1062,6 +1064,7 @@ private fun StateActionWidget(cfg: TouchButtonCfg, edit: Boolean) {
|
||||
when (cfg.id) {
|
||||
TouchButtonId.SAVE_STATE -> MainActivityRuntime.instance?.saveState()
|
||||
TouchButtonId.LOAD_STATE -> MainActivityRuntime.instance?.loadState()
|
||||
TouchButtonId.KEYBOARD -> MainActivityRuntime.toggleSoftKeyboard()
|
||||
else -> MainActivityRuntime.instance?.applicationContext?.let {
|
||||
com.armsx2.Screenshots.capture(it)
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user