mirror of
https://github.com/ARMSX2/ARMSX3.git
synced 2026-08-24 16:58:52 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e65c8b212 | ||
|
|
91952ae4c1 | ||
|
|
ef63026354 | ||
|
|
da4169148f | ||
|
|
ca3b755fd1 | ||
|
|
5c810c72c2 | ||
|
|
c1781b95cb | ||
|
|
fb5045d086 | ||
|
|
4c080066cf | ||
|
|
044ba9cb03 | ||
|
|
5dee5bc42e | ||
|
|
1b9ab35ac0 | ||
|
|
26c39e1f55 | ||
|
|
8bc7ca307c | ||
|
|
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 |
@@ -12,6 +12,29 @@ project(rpcs3 LANGUAGES C CXX)
|
||||
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
|
||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||
|
||||
# Keep the builder's absolute paths out of the shipped binary.
|
||||
#
|
||||
# __FILE__ expands to whatever path the compiler was handed, and RPCS3 prints source locations in
|
||||
# ensure() failures, fmt::throw_exception and assertions -- so every one of those lines carried the
|
||||
# full build directory into EVERY USER'S LOG. On a developer's machine that is a home directory:
|
||||
# the shipped core contained 2500 copies of one username. Someone else's crash report is not the
|
||||
# place to publish where we build.
|
||||
#
|
||||
# -ffile-prefix-map rewrites the prefix at compile time, covering both __FILE__ (macro-prefix-map)
|
||||
# and debug info (debug-prefix-map). Paths become relative-looking (./rpcs3/Emu/...), which is what
|
||||
# a log wants to show anyway. Costs nothing at runtime.
|
||||
#
|
||||
# Applied here, before any add_subdirectory, so third-party targets built in-tree are covered too --
|
||||
# they embed the same root.
|
||||
if(NOT MSVC)
|
||||
include(CheckCXXCompilerFlag)
|
||||
check_cxx_compiler_flag("-ffile-prefix-map=${CMAKE_SOURCE_DIR}=." COMPILER_HAS_FILE_PREFIX_MAP)
|
||||
|
||||
if(COMPILER_HAS_FILE_PREFIX_MAP)
|
||||
add_compile_options("$<$<COMPILE_LANGUAGE:C,CXX>:-ffile-prefix-map=${CMAKE_SOURCE_DIR}=.>")
|
||||
endif()
|
||||
endif()
|
||||
|
||||
if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU")
|
||||
if(CMAKE_CXX_COMPILER_VERSION VERSION_LESS 13)
|
||||
message(FATAL_ERROR "RPCS3 requires at least gcc-13.")
|
||||
|
||||
@@ -7,7 +7,7 @@ Uses the latest RPCS3 upstream code (the recent ARM64 improvements included).
|
||||
Building
|
||||
--------
|
||||
|
||||
Only arm64-v8a is supported. You need the Android SDK with NDK r27 or newer,
|
||||
arm64-v8a and armv8.2 is supported. You need the Android SDK with NDK r27 or newer,
|
||||
CMake 3.30 or newer, and a JDK 17. Android Studio ships all of these.
|
||||
|
||||
Clone with submodules, then fetch the two third party checkouts that are not
|
||||
|
||||
+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;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,9 @@ set(ARMSX3_INPUT_SOURCES
|
||||
${CMAKE_SOURCE_DIR}/rpcs3/Input/mouse_gyro_handler.cpp
|
||||
# Ours: on-screen touch controls.
|
||||
${CMAKE_SOURCE_DIR}/rpcs3/Input/virtual_pad_handler.cpp
|
||||
# Ours: cellKb fed from the Android IME / a physical keyboard. The desktop
|
||||
# handler is a QObject and cannot be built here.
|
||||
${CMAKE_SOURCE_DIR}/rpcs3/Input/virtual_keyboard_handler.cpp
|
||||
)
|
||||
|
||||
add_library(rpcsx-android SHARED
|
||||
|
||||
@@ -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 = 17
|
||||
versionName = "0.9.2"
|
||||
|
||||
// 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
|
||||
|
||||
@@ -24,6 +24,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 (*keyboardKey)(int androidKeyCode, int unicode, bool pressed, bool repeat);
|
||||
bool (*initialize)(std::string_view rootDir, std::string_view user);
|
||||
void (*setSocInfo)(std::string_view socInfo);
|
||||
bool (*processCompilationQueue)(JNIEnv *env);
|
||||
@@ -44,6 +45,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);
|
||||
@@ -120,6 +123,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.keyboardKey = reinterpret_cast<decltype(keyboardKey)>(dlsym(handle, "_rpcsx_keyboardKey"));
|
||||
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"));
|
||||
@@ -139,6 +143,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"));
|
||||
@@ -259,6 +265,20 @@ extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_overlayPadPressure(
|
||||
return ok;
|
||||
}
|
||||
|
||||
extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_keyboardKey(
|
||||
JNIEnv *, jobject, jint androidKeyCode, jint unicode, jboolean pressed,
|
||||
jboolean repeat) {
|
||||
// Absent on a core older than this export. Returning false is right either
|
||||
// way: it means "nothing consumed this key", which is also what an emulator
|
||||
// with no keyboard attached reports.
|
||||
if (rpcsxLib.keyboardKey == nullptr) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return rpcsxLib.keyboardKey(androidKeyCode, unicode, pressed == JNI_TRUE,
|
||||
repeat == JNI_TRUE);
|
||||
}
|
||||
|
||||
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
|
||||
@@ -437,6 +457,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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
package com.armsx2
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.armsx2.data.library.ParamSfo
|
||||
import net.rpcsx.RPCSX
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipInputStream
|
||||
|
||||
/**
|
||||
* Imports PS3 save data into `config/dev_hdd0/home/<user>/savedata/` from a SAF-picked folder or
|
||||
* archive.
|
||||
*
|
||||
* This exists because of a platform rule, not a bug of ours. Android 11 blocks third-party file
|
||||
* managers from writing into `Android/data/<pkg>/`, so a user who downloads a roster or a save
|
||||
* cannot put it where the emulator reads from: ZArchiver reports `EACCES (Permission denied)` and
|
||||
* there is no way round it from outside the app. Reported against All Pro Football 2K8 on an Ayn
|
||||
* Thor Pro. We are the only process that can still write there, so the copy has to happen in here.
|
||||
*
|
||||
* The destination folder name comes from the save's own PARAM.SFO, not from what the user's folder
|
||||
* or archive happened to be called. That is the whole reliability argument for this class. Games
|
||||
* enumerate saves by matching `dirNamePrefix` against the directory name (cellSaveData.cpp:543), so
|
||||
* a save placed under the wrong name is not an error the user ever sees -- the game simply reports
|
||||
* no save data and offers to start fresh, which looks like the import silently did nothing. The
|
||||
* core writes SAVEDATA_DIRECTORY into every PARAM.SFO it saves (cellSaveData.cpp:1695) and reads it
|
||||
* back to populate dirName (cellSaveData.cpp:248), so the correct name travels inside the save.
|
||||
*
|
||||
* Follows [TexturePackInstaller] for staging and commit: everything lands in a scratch directory on
|
||||
* the same filesystem, is validated there, and only then is renamed into place. Nothing half-formed
|
||||
* is ever visible under `savedata/`, and a failure part-way cannot destroy a save the user already
|
||||
* had. The pieces here that are not savedata-specific -- [stageArchive], [stageTree], [commit] --
|
||||
* are what the frame-generation plugin installer needs too (pick a file, verify it, atomically
|
||||
* place it somewhere the app owns); they are written to be lifted rather than reimplemented.
|
||||
*/
|
||||
object SaveDataImporter {
|
||||
private const val TAG = "SaveDataImporter"
|
||||
|
||||
/** Guards against a decompression bomb: real save data is kilobytes to a few megabytes. */
|
||||
private const val MAX_ENTRY_BYTES = 256L * 1024 * 1024
|
||||
private const val MAX_TOTAL_BYTES = 1024L * 1024 * 1024
|
||||
private const val MAX_ENTRIES = 20_000
|
||||
|
||||
sealed interface Progress {
|
||||
data object Scanning : Progress
|
||||
data class Copying(val done: Int, val total: Int) : Progress
|
||||
data object Installing : Progress
|
||||
}
|
||||
|
||||
/** One save found in the source, named as it will actually be written. */
|
||||
data class Imported(val dirName: String, val title: String?, val replaced: Boolean)
|
||||
|
||||
data class Outcome(
|
||||
val ok: Boolean,
|
||||
val saves: List<Imported> = emptyList(),
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
// ---- entry points ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Imports from a `.zip` picked with `ActivityResultContracts.OpenDocument`.
|
||||
*
|
||||
* Blocking; call from a background dispatcher.
|
||||
*/
|
||||
fun importArchive(
|
||||
context: Context,
|
||||
uri: Uri,
|
||||
onProgress: (Progress) -> Unit = {},
|
||||
isCancelled: () -> Boolean = { false },
|
||||
): Outcome = runImport(onProgress) { staging ->
|
||||
// Opened separately rather than with `?.use { } ?: openFailed`. These stages answer null
|
||||
// to mean "no problem, carry on", so folding them together made the SUCCESS path -- a null
|
||||
// from stageArchive -- select the elvis branch and report every single archive import as
|
||||
// "could not open the selected file", while the staged files were discarded unread.
|
||||
val input = runCatching { context.contentResolver.openInputStream(uri) }.getOrNull()
|
||||
?: return@runImport Outcome(false, error = "Could not open the selected file")
|
||||
|
||||
input.use { stageArchive(it, staging, onProgress, isCancelled) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Imports from a folder picked with `ActivityResultContracts.OpenDocumentTree`.
|
||||
*
|
||||
* Accepts either the save folder itself or a parent holding several, since a user who
|
||||
* downloaded a pack of rosters has no reason to know which of those they picked.
|
||||
*/
|
||||
fun importFolder(
|
||||
context: Context,
|
||||
treeUri: Uri,
|
||||
onProgress: (Progress) -> Unit = {},
|
||||
isCancelled: () -> Boolean = { false },
|
||||
): Outcome = runImport(onProgress) { staging ->
|
||||
val root = DocumentFile.fromTreeUri(context, treeUri)
|
||||
?: return@runImport Outcome(false, error = "Could not open the selected folder")
|
||||
stageTree(context, root, staging, onProgress, isCancelled)
|
||||
}
|
||||
|
||||
// ---- shared driver --------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Stages, validates, then commits. [stage] does only the copy; it must not touch the live
|
||||
* savedata directory, which is what makes a cancelled or failed import a no-op.
|
||||
*/
|
||||
private fun runImport(
|
||||
onProgress: (Progress) -> Unit,
|
||||
stage: (File) -> Outcome?,
|
||||
): Outcome {
|
||||
val savedataRoot = savedataRoot() ?: return Outcome(
|
||||
false,
|
||||
error = "No user profile yet — boot a game once, then import.",
|
||||
)
|
||||
|
||||
// A sibling of the destination, so the commit below is a rename and not a copy across
|
||||
// filesystems. Leading dot keeps it out of the way of anything that lists savedata/.
|
||||
val staging = File(savedataRoot, ".import-tmp")
|
||||
staging.deleteRecursively()
|
||||
if (!staging.mkdirs()) {
|
||||
return Outcome(false, error = "Could not create a staging folder")
|
||||
}
|
||||
|
||||
try {
|
||||
onProgress(Progress.Scanning)
|
||||
stage(staging)?.let { return it }
|
||||
|
||||
val found = discover(staging)
|
||||
if (found.isEmpty()) {
|
||||
return Outcome(
|
||||
false,
|
||||
error = "No save data found. A save is a folder containing PARAM.SFO.",
|
||||
)
|
||||
}
|
||||
|
||||
onProgress(Progress.Installing)
|
||||
val imported = mutableListOf<Imported>()
|
||||
for ((staged, dirName) in found) {
|
||||
val dest = File(savedataRoot, dirName)
|
||||
val replaced = dest.exists()
|
||||
if (!commit(staged, dest)) {
|
||||
return Outcome(
|
||||
false,
|
||||
imported,
|
||||
"Could not write $dirName into the savedata folder",
|
||||
)
|
||||
}
|
||||
imported += Imported(
|
||||
dirName = dirName,
|
||||
title = ParamSfo.string(File(dest, "PARAM.SFO"), "TITLE"),
|
||||
replaced = replaced,
|
||||
)
|
||||
}
|
||||
return Outcome(true, imported)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "import failed: ${e.message}")
|
||||
return Outcome(false, error = e.message ?: "Import failed")
|
||||
} finally {
|
||||
staging.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- discovery and naming --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Finds every staged directory holding a PARAM.SFO, paired with the name it must be written
|
||||
* under. That is the same test the core uses to decide a directory is a save at all: it loads
|
||||
* `<entry>/PARAM.SFO` per directory when enumerating (cellSaveData.cpp:240).
|
||||
*
|
||||
* Searched recursively because the source shape is not ours to dictate -- a user may hand us
|
||||
* the save, its parent, or an archive that wraps both in a download folder.
|
||||
*/
|
||||
private fun discover(staging: File): List<Pair<File, String>> {
|
||||
val out = mutableListOf<Pair<File, String>>()
|
||||
fun walk(dir: File, depth: Int) {
|
||||
if (depth > 6) return
|
||||
if (File(dir, "PARAM.SFO").isFile) {
|
||||
resolveDirName(dir)?.let { out += dir to it }
|
||||
// A save has no nested saves; stopping also stops a PARAM.SFO in a subfolder from
|
||||
// being imported as a second, bogus save.
|
||||
return
|
||||
}
|
||||
dir.listFiles().orEmpty().filter { it.isDirectory }.forEach { walk(it, depth + 1) }
|
||||
}
|
||||
walk(staging, 0)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The directory name to write this save under: PARAM.SFO's SAVEDATA_DIRECTORY when it has one,
|
||||
* else the folder's own name.
|
||||
*
|
||||
* Preferring the SFO is what makes a renamed download still work. Names look like
|
||||
* `<SERIAL><TAG>` (`BLUS30760SM2011_SAVE`), which is not something a user can be expected to
|
||||
* reconstruct after their file manager or a zip tool has flattened or renamed a folder.
|
||||
*
|
||||
* The fallback is not a formality: a save copied by hand out of another emulator may have had
|
||||
* its SFO rewritten. Both paths go through [sanitizedDirName] because a value read out of a
|
||||
* file is untrusted input no matter which file it came from.
|
||||
*/
|
||||
private fun resolveDirName(dir: File): String? {
|
||||
val fromSfo = ParamSfo.string(File(dir, "PARAM.SFO"), "SAVEDATA_DIRECTORY")
|
||||
return sanitizedDirName(fromSfo) ?: sanitizedDirName(dir.name)
|
||||
}
|
||||
|
||||
/**
|
||||
* A directory name safe to join onto the savedata root.
|
||||
*
|
||||
* Rejects rather than repairs. A name carrying a separator or a `..` is not a name we can
|
||||
* correct into the user's intent, and quietly writing it somewhere else would be worse than
|
||||
* saying so: this is the value that decides where the copy lands.
|
||||
*/
|
||||
private fun sanitizedDirName(raw: String?): String? {
|
||||
val name = raw?.trim().orEmpty()
|
||||
if (name.isEmpty() || name == "." || name == "..") return null
|
||||
if (name.length > 64) return null
|
||||
if (name.any { it == '/' || it == '\\' || it < ' ' }) return null
|
||||
// Deliberately NOT narrowed to a character set. This name comes from the game's own
|
||||
// SAVEDATA_DIRECTORY, and rejecting one for holding a character we did not anticipate
|
||||
// would refuse a good save with "no save data found" -- the silent-looking failure this
|
||||
// whole class exists to avoid. Only separators and control characters can redirect a
|
||||
// write, and a leading dot would make a directory no file browser shows.
|
||||
if (name.startsWith('.')) return null
|
||||
return name
|
||||
}
|
||||
|
||||
// ---- staging: archive ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extracts [input] into [staging].
|
||||
*
|
||||
* Entry paths are rebuilt from sanitized components rather than used as given. A crafted
|
||||
* `../../lib/foo.so` would otherwise be written wherever the app can reach, and the app can
|
||||
* reach its own native library directory -- so this is a code-execution path, not a tidiness
|
||||
* one. Any entry containing a `..` component fails the whole archive: an archive carrying one
|
||||
* is not an archive to half-extract and then trust.
|
||||
*/
|
||||
private fun stageArchive(
|
||||
input: InputStream,
|
||||
staging: File,
|
||||
onProgress: (Progress) -> Unit,
|
||||
isCancelled: () -> Boolean,
|
||||
): Outcome? {
|
||||
val stagingCanonical = staging.canonicalPath + File.separator
|
||||
var entries = 0
|
||||
var totalBytes = 0L
|
||||
var written = 0
|
||||
|
||||
ZipInputStream(input.buffered()).use { zip ->
|
||||
while (true) {
|
||||
if (isCancelled()) return Outcome(false, error = null)
|
||||
val entry: ZipEntry = zip.nextEntry ?: break
|
||||
try {
|
||||
if (++entries > MAX_ENTRIES) {
|
||||
return Outcome(false, error = "Archive has too many files")
|
||||
}
|
||||
if (entry.isDirectory) continue
|
||||
|
||||
val rel = safeRelativePath(entry.name)
|
||||
?: return Outcome(false, error = "Archive contains an unsafe path")
|
||||
if (rel.isEmpty() || isJunk(entry.name)) continue
|
||||
|
||||
val out = File(staging, rel)
|
||||
// Belt and braces. safeRelativePath already dropped every `..`, so reaching
|
||||
// this is a bug in it rather than a crafted archive -- but the cost of the
|
||||
// check is nothing and the cost of being wrong is arbitrary file write.
|
||||
if (!out.canonicalPath.startsWith(stagingCanonical)) {
|
||||
Log.w(TAG, "zip-slip entry rejected: ${entry.name}")
|
||||
return Outcome(false, error = "Archive contains an unsafe path")
|
||||
}
|
||||
out.parentFile?.mkdirs()
|
||||
|
||||
var entryBytes = 0L
|
||||
FileOutputStream(out).use { fos ->
|
||||
val buf = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
if (isCancelled()) return Outcome(false, error = null)
|
||||
val n = zip.read(buf)
|
||||
if (n < 0) break
|
||||
entryBytes += n
|
||||
totalBytes += n
|
||||
// Sizes are checked while writing, not from the entry header: the
|
||||
// header is attacker-controlled and can simply lie.
|
||||
if (entryBytes > MAX_ENTRY_BYTES || totalBytes > MAX_TOTAL_BYTES) {
|
||||
return Outcome(false, error = "Archive is unexpectedly large")
|
||||
}
|
||||
fos.write(buf, 0, n)
|
||||
}
|
||||
}
|
||||
written++
|
||||
if (written % 16 == 0) onProgress(Progress.Copying(written, 0))
|
||||
} finally {
|
||||
zip.closeEntry()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (written == 0) return Outcome(false, error = "Archive was empty")
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds an entry path from its own components, keeping only the basename of each.
|
||||
*
|
||||
* Every component is reduced to its last path-ish token and anything left that is `.` or `..`
|
||||
* is dropped, so no combination of separators, doubled slashes or backslashes can climb out of
|
||||
* the staging directory. Depth is capped because the structure a save needs is at most a
|
||||
* folder and its files.
|
||||
*/
|
||||
private fun safeRelativePath(name: String): String? {
|
||||
val norm = name.replace('\\', '/')
|
||||
val parts = norm.split('/')
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() && it != "." }
|
||||
if (parts.any { it == ".." }) return null
|
||||
if (parts.isEmpty()) return ""
|
||||
// Drop leading wrappers so a "Download/BLUS30760SAVE/PARAM.SFO" still stages usefully;
|
||||
// discover() walks anyway, so this only keeps the tree shallow.
|
||||
// Control characters only. Stripping spaces here silently renamed the user's folders,
|
||||
// and a wrapper like "All Pro Football 2K8 roster/" is a completely ordinary thing for
|
||||
// a file manager to produce.
|
||||
val kept = parts.takeLast(3).map { part -> part.filterNot { c -> c < ' ' } }
|
||||
if (kept.any { it.isEmpty() }) return null
|
||||
return kept.joinToString("/")
|
||||
}
|
||||
|
||||
// ---- staging: folder -------------------------------------------------------------------
|
||||
|
||||
/** Copies a picked SAF tree into [staging], mirroring its structure. */
|
||||
private fun stageTree(
|
||||
context: Context,
|
||||
root: DocumentFile,
|
||||
staging: File,
|
||||
onProgress: (Progress) -> Unit,
|
||||
isCancelled: () -> Boolean,
|
||||
): Outcome? {
|
||||
var copied = 0
|
||||
var totalBytes = 0L
|
||||
|
||||
fun walk(node: DocumentFile, dest: File, depth: Int): Outcome? {
|
||||
if (depth > 6) return null
|
||||
for (child in node.listFiles()) {
|
||||
if (isCancelled()) return Outcome(false, error = null)
|
||||
val rawName = child.name ?: continue
|
||||
// The picker gives us display names, which are not path components; a name with a
|
||||
// separator in it is malformed and is dropped rather than joined.
|
||||
if (rawName.any { it == '/' || it == '\\' || it < ' ' }) continue
|
||||
if (rawName == "." || rawName == "..") continue
|
||||
if (isJunk(rawName)) continue
|
||||
|
||||
if (child.isDirectory) {
|
||||
val sub = File(dest, rawName)
|
||||
if (!sub.exists() && !sub.mkdirs()) continue
|
||||
walk(child, sub, depth + 1)?.let { return it }
|
||||
continue
|
||||
}
|
||||
|
||||
val out = File(dest, rawName)
|
||||
out.parentFile?.mkdirs()
|
||||
context.contentResolver.openInputStream(child.uri)?.use { input ->
|
||||
FileOutputStream(out).use { fos ->
|
||||
val buf = ByteArray(64 * 1024)
|
||||
while (true) {
|
||||
val n = input.read(buf)
|
||||
if (n < 0) break
|
||||
totalBytes += n
|
||||
if (totalBytes > MAX_TOTAL_BYTES) return@use
|
||||
fos.write(buf, 0, n)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (totalBytes > MAX_TOTAL_BYTES) {
|
||||
return Outcome(false, error = "Folder is unexpectedly large")
|
||||
}
|
||||
copied++
|
||||
if (copied % 16 == 0) onProgress(Progress.Copying(copied, 0))
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// The picked folder may itself be the save, so its own name has to survive into staging or
|
||||
// the dirName fallback would see the scratch directory instead.
|
||||
val rootName = root.name?.takeIf { n ->
|
||||
n.none { it == '/' || it == '\\' || it < ' ' } && n != "." && n != ".."
|
||||
}
|
||||
val base = if (rootName != null) File(staging, rootName).also { it.mkdirs() } else staging
|
||||
|
||||
walk(root, base, 0)?.let { return it }
|
||||
if (copied == 0) return Outcome(false, error = "Folder contained no files")
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- commit ----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Moves [staged] to [target], keeping any existing save until the new one is in place.
|
||||
*
|
||||
* Overwriting matters more here than for a texture pack: the thing being replaced is the
|
||||
* user's own progress, and a rename that fails half way must leave what they had rather than
|
||||
* nothing at all.
|
||||
*/
|
||||
private fun commit(staged: File, target: File): Boolean {
|
||||
val backup = File(target.parentFile, "${target.name}.old-import")
|
||||
backup.deleteRecursively()
|
||||
target.parentFile?.mkdirs()
|
||||
|
||||
val hadPrevious = target.exists()
|
||||
if (hadPrevious && !target.renameTo(backup)) {
|
||||
Log.w(TAG, "could not move existing ${target.name} aside")
|
||||
return false
|
||||
}
|
||||
if (!staged.renameTo(target)) {
|
||||
if (hadPrevious) backup.renameTo(target)
|
||||
Log.w(TAG, "could not move staged ${target.name} into place")
|
||||
return false
|
||||
}
|
||||
backup.deleteRecursively()
|
||||
return true
|
||||
}
|
||||
|
||||
// ---- paths -----------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* `config/dev_hdd0/home/<user>/savedata`, created if the user directory already exists.
|
||||
*
|
||||
* Prefers the logged-in user and falls back to whichever home directory is actually there,
|
||||
* matching how the trophy browser resolves the same ambiguity: getUser() reaches through JNI
|
||||
* into the core and answers null before a game has been opened, and refusing to import until
|
||||
* then would be a confusing rule to explain. Answers null only when there is no user directory
|
||||
* at all, which is a genuinely fresh install.
|
||||
*/
|
||||
private fun savedataRoot(): File? {
|
||||
val home = File(RPCSX.getHdd0Dir(), "home")
|
||||
val preferred = runCatching { RPCSX.instance.getUser() }.getOrNull()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
val user = preferred
|
||||
?.let { File(home, it) }
|
||||
?.takeIf { it.isDirectory }
|
||||
?: home.listFiles().orEmpty()
|
||||
.filter { it.isDirectory && it.name.length == 8 && it.name.all(Char::isDigit) }
|
||||
.minByOrNull { it.name }
|
||||
?: return null
|
||||
|
||||
return File(user, "savedata").also { it.mkdirs() }.takeIf { it.isDirectory }
|
||||
}
|
||||
|
||||
private fun isJunk(name: String): Boolean {
|
||||
val lower = name.lowercase()
|
||||
return lower.startsWith("__macosx/") || lower.contains("/__macosx/") ||
|
||||
lower == "__macosx" || lower.endsWith("/.ds_store") || lower == ".ds_store" ||
|
||||
lower.endsWith("thumbs.db")
|
||||
}
|
||||
}
|
||||
@@ -73,10 +73,18 @@ object ConfigStore {
|
||||
// Bumped: the profiler was recorded again during the 0.5 debugging work, after the first
|
||||
// purge had already marked itself done.
|
||||
private const val KEY_DIAG_OVERRIDES_PURGED_2 = "config.migrated.diagOverridesPurged2"
|
||||
private const val KEY_SHADOWING_OVERRIDES_PURGED = "config.migrated.shadowingOverridesPurged"
|
||||
// Core settings left pinned as raw overrides by the 0.5 debugging sessions.
|
||||
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 +117,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 +390,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.
|
||||
//
|
||||
@@ -453,6 +523,61 @@ object ConfigStore {
|
||||
MainActivityRuntime.prefs.edit { putBoolean(KEY_DIAG_OVERRIDES_PURGED_2, true) }
|
||||
}
|
||||
|
||||
// Drop raw overrides on nodes a curated settings screen also writes.
|
||||
//
|
||||
// These two cannot coexist. Overrides replay at the tail of applyTo, after the curated
|
||||
// store has written the same node, so the recorded value wins every time and the normal
|
||||
// screen becomes decorative: it shows the choice, saves the choice, and the choice is
|
||||
// overwritten a moment later with nothing on screen to say so. A test device carried
|
||||
// Core@@PPU Decoder = "Recompiler (LLVM)" this way, which silently defeated every
|
||||
// attempt to boot a game on the interpreter -- including one run specifically to find
|
||||
// out whether a hang was a codegen bug.
|
||||
//
|
||||
// Named rather than derived: the curated set is spread across applyToInner and the
|
||||
// Rpcs3Bridge routing table, and a wrong automatic answer here would delete real user
|
||||
// edits. Every path in the first group is reachable from Settings, so nothing is lost --
|
||||
// the value still applies, it just comes from the screen that shows it.
|
||||
//
|
||||
// Video@@Accurate ZCULL stats is deliberately NOT purged: it has no curated writer and
|
||||
// no debugging history, so a recorded value there is most likely a deliberate per-game
|
||||
// performance choice. It is visible and clearable in All Core Settings now instead.
|
||||
//
|
||||
// The two migrations above purged diagnostics by name and both had already run on the
|
||||
// device that still had RSX Profiler recorded, which is why All Core Settings now shows
|
||||
// and clears overrides directly instead of waiting for the next migration.
|
||||
if (!MainActivityRuntime.prefs.getBoolean(KEY_SHADOWING_OVERRIDES_PURGED, false)) {
|
||||
runCatching {
|
||||
CoreSettingOverrides.forgetEverywhere(
|
||||
"Core@@PPU Decoder",
|
||||
"Core@@SPU Decoder",
|
||||
"Core@@SPU XFloat Accuracy",
|
||||
"Core@@Max SPURS Threads",
|
||||
"Core@@Precise SPU Verification",
|
||||
"Core@@PPU Vector NaN Handling",
|
||||
"Video@@Shader Mode",
|
||||
"Video@@Multithreaded RSX",
|
||||
)
|
||||
|
||||
// These three have no curated writer, so forgetting alone would leave the
|
||||
// recorded value sitting in config.yml with nothing to overwrite it -- the
|
||||
// record would be gone and the effect would remain, which is worse than
|
||||
// leaving it. Write the core's own default off instead, the way the Vblank
|
||||
// migration writes 60 rather than deleting.
|
||||
//
|
||||
// All three are instrumentation or debug levers, off by default upstream:
|
||||
// the RSX profiler keeps per-scope timers on the RSX thread and reports every
|
||||
// 300 frames, PPU calling history records every call, and the GETLLAR spin
|
||||
// optimization being disabled changes how an SPU waiting on a reservation
|
||||
// behaves -- which is not something to ship switched off by accident.
|
||||
CoreSettingOverrides.record(SettingsScope.Global, null, "Video@@RSX Profiler", "false")
|
||||
CoreSettingOverrides.record(SettingsScope.Global, null, "Core@@PPU Calling History", "false")
|
||||
CoreSettingOverrides.record(
|
||||
SettingsScope.Global, null, "Core@@Disable SPU GETLLAR Spin Optimization", "false",
|
||||
)
|
||||
}
|
||||
MainActivityRuntime.prefs.edit { putBoolean(KEY_SHADOWING_OVERRIDES_PURGED, true) }
|
||||
}
|
||||
|
||||
// Move anyone still on the old Approximate xfloat default onto Accurate.
|
||||
// Approximate corrupted SPU float registers badly enough that a job
|
||||
// manager built a DMA command out of one; see Settings.spuXFloat. A
|
||||
@@ -567,6 +692,7 @@ object ConfigStore {
|
||||
}
|
||||
|
||||
if (dirty) saveGlobal(parsed)
|
||||
cachedGlobal = parsed
|
||||
return parsed
|
||||
}
|
||||
|
||||
@@ -586,6 +712,7 @@ object ConfigStore {
|
||||
|
||||
fun saveGlobal(s: Settings) {
|
||||
MainActivityRuntime.prefs.edit { putString(KEY_GLOBAL, s.toJson().toString()) }
|
||||
cachedGlobal = s
|
||||
writeBackupMirror()
|
||||
}
|
||||
|
||||
@@ -770,6 +897,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,
|
||||
/**
|
||||
@@ -720,12 +736,18 @@ data class Settings(
|
||||
val memoryCardSlot2Enabled: Boolean = true,
|
||||
val memoryCardSlot2Filename: String = "mcd002.ps2",
|
||||
|
||||
// ---- USB ----
|
||||
/** USB1/Type = hidkbd — attach an emulated USB HID keyboard on USB port 1.
|
||||
* Needed by games that require a real USB keyboard (EverQuest Online
|
||||
* Adventures, Konami-keyboard titles). A physical/Bluetooth keyboard's key
|
||||
* events are forwarded to it (see MainActivityRuntime.dispatchKeyEvent → NativeApp.usbKeyboardKey).
|
||||
* Default off. */
|
||||
// ---- Keyboard ----
|
||||
/** Input/Output/Keyboard = Basic — serve cellKb from the Android keyboard handler.
|
||||
* Needed by games that want a keyboard (EverQuest Online Adventures, in-game
|
||||
* text chat, the debug menus some titles put behind one). Keys come from a
|
||||
* physical/Bluetooth keyboard (MainActivityRuntime.forwardKeyToUsbKeyboard) or
|
||||
* from the Android IME the On-Screen Keyboard hotkey raises (SoftKeyboard), and
|
||||
* reach the core through NativeApp.usbKeyboardKey.
|
||||
*
|
||||
* The name is ARMSX2's. RPCS3 has no emulated USB HID keyboard device; it has a
|
||||
* keyboard handler, which is what this drives.
|
||||
*
|
||||
* Read once, in Emulator::Load, so it takes effect on the next boot. Default off. */
|
||||
val usbKeyboard: Boolean = false,
|
||||
|
||||
// ---- EmuCore/CPU/Recompiler — recompiler enables ----
|
||||
@@ -1090,6 +1112,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())
|
||||
@@ -1254,12 +1281,10 @@ data class Settings(
|
||||
put("MemoryCards", "Slot1_Filename", "string", memoryCardSlot1Filename.ifEmpty { "mcd001.ps2" })
|
||||
put("MemoryCards", "Slot2_Enable", "bool", memoryCardSlot2Enabled.toString())
|
||||
put("MemoryCards", "Slot2_Filename", "string", memoryCardSlot2Filename.ifEmpty { "mcd002.ps2" })
|
||||
// USB keyboard (#254). Persist [USB1] Type so USBOptions::LoadSave attaches
|
||||
// the emulated HID keyboard on the next boot (or ApplySettings). The live
|
||||
// attach/detach on a running VM is done via NativeApp.usbSetKeyboardEnabled
|
||||
// below (CheckForConfigChanges recreates the device), since a plain
|
||||
// setSetting write doesn't reattach USB devices on its own.
|
||||
put("USB1", "Type", "string", if (usbKeyboard) "hidkbd" else "None")
|
||||
// Keyboard: NOT written here. [USB1] Type = hidkbd is a PCSX2 key -- there is
|
||||
// no such USB device in RPCS3, so that write only ever reached
|
||||
// Unsupported.note("USB1/Type"). The PS3 equivalent is the keyboard handler,
|
||||
// pushed by NativeApp.usbSetKeyboardEnabled below.
|
||||
// Recompiler enables. Picked up by VMManager::ApplySettings →
|
||||
// SysCpuProviderPack rebind. Toggling these on a running VM swaps
|
||||
// the dispatch pointer; existing JIT block caches are flushed by
|
||||
@@ -1315,10 +1340,8 @@ data class Settings(
|
||||
NativeApp.osdShowVersion(osdShowVersion)
|
||||
NativeApp.osdShowSettings(osdShowSettings)
|
||||
NativeApp.osdShowInputs(osdShowInputs)
|
||||
// USB keyboard (#254): live attach/detach on the running VM. A plain
|
||||
// setSetting("USB1","Type",...) write is persisted but doesn't reattach
|
||||
// USB devices, so drive the device (re)creation explicitly. No-op before
|
||||
// the VM exists — the persisted Type above handles the cold boot.
|
||||
// Keyboard handler (#254). Installed by Emulator::Load, so this is a persist,
|
||||
// not a live attach: a game already running keeps whatever it booted with.
|
||||
NativeApp.usbSetKeyboardEnabled(0, usbKeyboard)
|
||||
// Vblank at the PS3's own rate, pushed on every apply rather than left to a
|
||||
// migration.
|
||||
@@ -2062,6 +2085,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 +2432,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 +2759,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 +3067,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"
|
||||
|
||||
@@ -307,6 +307,14 @@ val EN: Map<String, String> = mapOf(
|
||||
"app.backup.exported" to "Backup saved — %s.",
|
||||
"app.backup.imported" to "Restored %s. Restarting…",
|
||||
"app.backup.failed" to "Backup failed: %s",
|
||||
"app.savedata.import" to "Import save data",
|
||||
"app.savedata.import.desc" to "Add a PS3 save or roster from a .zip. Android blocks other apps from writing into the emulator's folder, so files have to be brought in from here.",
|
||||
"app.savedata.importFolder" to "Import save data folder",
|
||||
"app.savedata.importFolder.desc" to "Pick an unzipped save folder — the one containing PARAM.SFO — or a folder holding several.",
|
||||
"app.savedata.working" to "Importing…",
|
||||
"app.savedata.done" to "Imported %s.",
|
||||
"app.savedata.replaced" to "Imported %s, replacing an existing save.",
|
||||
"app.savedata.failed" to "Import failed: %s",
|
||||
"app.clearCache" to "Clear cached data",
|
||||
"app.clearCache.desc" to "Delete compiled shader caches and cover-art thumbnails. They rebuild automatically.",
|
||||
"app.clearCache.done" to "Cleared %s of cached data.",
|
||||
@@ -398,6 +406,11 @@ 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",
|
||||
"core.settings.overrideCount" to "Settings remembered here",
|
||||
"core.settings.overridden" to "Remembered here",
|
||||
"core.settings.clearOne" to "Forget",
|
||||
"core.settings.reset" to "Forget all",
|
||||
"core.settings.resetConfirm" to "Tap again to forget all",
|
||||
// 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",
|
||||
@@ -546,6 +559,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 +722,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 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. Takes effect the next time you start a game.",
|
||||
"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",
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.armsx2.input
|
||||
|
||||
import android.view.KeyEvent
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
|
||||
/**
|
||||
* The keys the Android IME does not have, floated above it.
|
||||
*
|
||||
* The emulated keyboard works, but a soft keyboard is built for typing text and has no
|
||||
* arrows, no Escape and no function row -- so the first thing it was used for, a game's
|
||||
* debug menu, opened and then could not be navigated. Those keys are not optional extras
|
||||
* for that job; they are the whole interaction.
|
||||
*
|
||||
* This does not replace the IME. Prediction, swipe and non-Latin input all still come from
|
||||
* whichever keyboard the user has chosen, and this only adds what that keyboard cannot
|
||||
* express. It appears and disappears with [SoftKeyboard.visible], so there is nothing to
|
||||
* place in the touch layout and nothing to discover.
|
||||
*
|
||||
* Taps go through [SoftKeyboard.tap], the same paced queue the IME's own keys use, so a
|
||||
* press is held long enough for the guest to sample it (see KEY_STEP_MS -- a press and
|
||||
* release issued back to back can land entirely between two guest polls and be missed).
|
||||
*
|
||||
* Gestures rather than clickable(): this sits next to a focused IME sink, and anything
|
||||
* focusable here can take focus off it and drop the keyboard mid-use. detectTapGestures
|
||||
* never touches focus.
|
||||
*/
|
||||
private data class ExtraKey(val label: String, val code: Int, val wide: Boolean = false)
|
||||
|
||||
private val BASE_KEYS = listOf(
|
||||
ExtraKey("Esc", KeyEvent.KEYCODE_ESCAPE),
|
||||
ExtraKey("Tab", KeyEvent.KEYCODE_TAB),
|
||||
ExtraKey("←", KeyEvent.KEYCODE_DPAD_LEFT),
|
||||
ExtraKey("↑", KeyEvent.KEYCODE_DPAD_UP),
|
||||
ExtraKey("↓", KeyEvent.KEYCODE_DPAD_DOWN),
|
||||
ExtraKey("→", KeyEvent.KEYCODE_DPAD_RIGHT),
|
||||
// Space and Enter are on the IME too, but the IME is not always the thing that comes up --
|
||||
// and Space in particular is the key that opens the debug menu this was first used for, so
|
||||
// it should not depend on another keyboard appearing.
|
||||
ExtraKey("Space", KeyEvent.KEYCODE_SPACE, wide = true),
|
||||
ExtraKey("Enter", KeyEvent.KEYCODE_ENTER, wide = true),
|
||||
)
|
||||
|
||||
// KEYCODE_F1..F12 are contiguous, the same way the handler's qt mapping assumes.
|
||||
private val FN_KEYS = (0..11).map { ExtraKey("F${it + 1}", KeyEvent.KEYCODE_F1 + it) }
|
||||
|
||||
@Composable
|
||||
fun BoxScope.KeyboardExtraKeys() {
|
||||
if (!SoftKeyboard.visible.value) return
|
||||
|
||||
var showFn by remember { mutableStateOf(false) }
|
||||
|
||||
Surface(
|
||||
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
|
||||
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.fillMaxWidth()
|
||||
// imePadding lifts it clear of the keyboard; navigationBarsPadding keeps it off
|
||||
// the gesture bar on the frames where the IME is animating out.
|
||||
.imePadding()
|
||||
.navigationBarsPadding(),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 6.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
for (key in if (showFn) FN_KEYS else BASE_KEYS) {
|
||||
KeyCap(key.label, wide = key.wide) { SoftKeyboard.tap(key.code) }
|
||||
}
|
||||
|
||||
KeyCap(if (showFn) "abc" else "Fn", accent = true) { showFn = !showFn }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyCap(label: String, accent: Boolean = false, wide: Boolean = false, onTap: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.sizeIn(minWidth = if (wide) 92.dp else 44.dp)
|
||||
.heightIn(min = 40.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
if (accent) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
.pointerInput(label) { detectTapGestures { onTap() } }
|
||||
.padding(horizontal = 10.dp, vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
label,
|
||||
color = if (accent) MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,27 @@ object SoftKeyboard {
|
||||
activity.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)
|
||||
view.isFocusableInTouchMode = true
|
||||
view.requestFocus()
|
||||
|
||||
// Two ways of asking, because one of them does not work here.
|
||||
//
|
||||
// SHOW_IMPLICIT is a hint, and the system is free to decline it -- which it does for a
|
||||
// fullscreen immersive window like the game surface. The result was the extra-keys bar
|
||||
// appearing (it follows [visible]) with no keyboard under it, because visible was set
|
||||
// whether or not anything came up.
|
||||
//
|
||||
// WindowInsetsControllerCompat drives the IME through the insets animation instead,
|
||||
// which is the supported path once setDecorFitsSystemWindows(false) is in effect --
|
||||
// and it is, set in MainActivityRuntime. Keep showSoftInput as well: it is what works
|
||||
// on older/odd IMEs, and asking twice is harmless.
|
||||
imm(activity)?.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT)
|
||||
|
||||
activity.window?.let { win ->
|
||||
runCatching {
|
||||
androidx.core.view.WindowInsetsControllerCompat(win, view)
|
||||
.show(androidx.core.view.WindowInsetsCompat.Type.ime())
|
||||
}
|
||||
}
|
||||
|
||||
visible.value = true
|
||||
}
|
||||
|
||||
@@ -66,6 +86,15 @@ object SoftKeyboard {
|
||||
val view = sink
|
||||
if (view != null) {
|
||||
imm(activity)?.hideSoftInputFromWindow(view.windowToken, 0)
|
||||
|
||||
// Mirror of show(): whichever route raised it is the one that can lower it.
|
||||
activity.window?.let { win ->
|
||||
runCatching {
|
||||
androidx.core.view.WindowInsetsControllerCompat(win, view)
|
||||
.hide(androidx.core.view.WindowInsetsCompat.Type.ime())
|
||||
}
|
||||
}
|
||||
|
||||
view.clearFocus()
|
||||
}
|
||||
visible.value = false
|
||||
@@ -96,22 +125,27 @@ object SoftKeyboard {
|
||||
*/
|
||||
private const val KEY_STEP_MS = 24L
|
||||
|
||||
private val pending = java.util.concurrent.LinkedBlockingQueue<Pair<Int, Boolean>>()
|
||||
/** keyCode, the character it produced (0 if none), pressed. */
|
||||
private data class KeyStep(val keyCode: Int, val unicode: Int, val pressed: Boolean)
|
||||
|
||||
private val pending = java.util.concurrent.LinkedBlockingQueue<KeyStep>()
|
||||
|
||||
/** Drains [pending] on its own thread: the UI thread must not sleep between key states. */
|
||||
private val worker: Thread by lazy {
|
||||
Thread({
|
||||
while (true) {
|
||||
val (keyCode, pressed) = pending.take()
|
||||
runCatching { NativeApp.usbKeyboardKey(0, keyCode, pressed) }
|
||||
val step = pending.take()
|
||||
runCatching {
|
||||
NativeApp.usbKeyboardKey(0, step.keyCode, step.unicode, step.pressed)
|
||||
}
|
||||
runCatching { Thread.sleep(KEY_STEP_MS) }
|
||||
}
|
||||
}, "usb-kbd-ime").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
private fun enqueue(keyCode: Int, pressed: Boolean) {
|
||||
private fun enqueue(keyCode: Int, unicode: Int, pressed: Boolean) {
|
||||
worker // start on first use
|
||||
pending.put(keyCode to pressed)
|
||||
pending.put(KeyStep(keyCode, unicode, pressed))
|
||||
}
|
||||
|
||||
/** Send one character as the key-down/key-up pair(s) a real keyboard would produce. */
|
||||
@@ -129,12 +163,12 @@ object SoftKeyboard {
|
||||
KeyEvent.ACTION_UP -> false
|
||||
else -> return
|
||||
}
|
||||
enqueue(event.keyCode, pressed)
|
||||
enqueue(event.keyCode, event.unicodeChar, pressed)
|
||||
}
|
||||
|
||||
internal fun tap(keyCode: Int) {
|
||||
enqueue(keyCode, true)
|
||||
enqueue(keyCode, false)
|
||||
enqueue(keyCode, 0, true)
|
||||
enqueue(keyCode, 0, false)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3346,7 +3346,7 @@ open class MainActivityRuntime : ComponentActivity() {
|
||||
else -> return false // MULTIPLE etc. — ignore
|
||||
}
|
||||
return runCatching {
|
||||
NativeApp.usbKeyboardKey(0, kc, pressed)
|
||||
NativeApp.usbKeyboardKey(0, kc, event.unicodeChar, pressed)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.armsx2.ui
|
||||
|
||||
import com.armsx2.input.KeyboardExtraKeys
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -130,6 +131,12 @@ object WindowImpl {
|
||||
com.armsx2.ui.touch.TouchControlsOverlay()
|
||||
}
|
||||
|
||||
// The keys the IME does not have (arrows, Esc, Tab, function row), shown only
|
||||
// while the emulated keyboard is up. Outside the density override above: this
|
||||
// is normal UI and should scale with the UI scale setting, unlike the touch
|
||||
// controls, whose size comes from the user's own layout.
|
||||
KeyboardExtraKeys()
|
||||
|
||||
if (showLibrary.value && MainActivityRuntime.eState.value == EmuState.RUNNING && !overlayVisible.value) {
|
||||
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.56f))) {
|
||||
com.armsx2.navigation.AppNavigation()
|
||||
|
||||
@@ -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(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user