36 Commits
Author SHA1 Message Date
jpolo1224 93110e899f Release: 0.9 (versionCode 15) 2026-08-18 15:09:55 -04:00
jpolo1224 4b27b585f5 Input: let analog triggers bind in the pad-button capture
On a pad that reports L2/R2 as AXES rather than buttons they arrive in
dispatchGenericMotionEvent and never as a key, so the binder -- which lives in Compose's
onPreviewKeyEvent -- could not see them, and L2/R2 would not bind while every other button on
the same pad did. That depends on the controller MODEL, not the port, which is what makes it
present as a Player 2 problem: a second pad of a different make fails where the first worked.
Reported against Player 2 with everything else binding.

handleCaptureMotion already existed for exactly this shape -- the D-pad HAT arrives as an axis
on several handhelds and is synthesised into a key during capture -- so the triggers just join
its  set and inherit its press/release tracking, which debounces the motion stream for
free.

Uses the same axis pairs as the gameplay path (sendTrigger), including the per-device third axis
some pads put the right trigger on, so a trigger that works in game can also be bound.
Threshold is a deliberate half-pull so resting drift on a worn trigger cannot self-bind.
2026-08-18 14:31:22 -04:00
jpolo1224 bbf44c684c Input: pace digital transitions so a short press cannot fall between guest polls
The guest polls cellPad at its own rate -- 33ms at 30fps -- and a press plus its release are
two separate snapshot pushes with nothing between them. A press shorter than one poll interval
lands entirely between polls and the game never sees it. Steady presses always span a poll,
which is why a button works everywhere except during a rapid mash, and why a held button
(crouch) keeps working while everything tapped alongside it does not.

GestureLayer already knew this and worked around it locally with a 40ms hold in pulse();
ordinary touch taps and physical controller presses had no equivalent. Reported on Iron Man's
quick-time event, where circle must be pressed repeatedly and does not register, and again as
'only the crouch button works' with both on-screen controls and a PS5 pad.

Delaying a too-short release would NOT fix it and would look identical: in a mash, press N's
deferred release collides with press N+1 and the game sees one long press instead of several,
while a QTE counts presses. Each transition therefore gets its own slot -- press visible for
40ms, release visible for 40ms, then the next press -- so a mash arrives as distinct presses
rather than a hold.

A press with nothing queued ahead of it still goes through immediately, so normal input takes
no added latency; only the release of a tap shorter than 40ms is deferred, and only up to 40ms.
Queue depth is capped so a mash cannot accumulate while the guest is not consuming.

40ms matches the gesture path and clears one 60Hz sample. NEEDS FIGHTING-GAME TESTING: it also
paces the d-pad, and 40ms is about 2.4 frames at 60fps, so frame-tight directional input is the
case most likely to feel different.
2026-08-18 14:23:15 -04:00
jpolo1224 f823f6fd66 Touch: list the keyboard button in the default layout so the editor offers it
Adding the TouchButtonId was not enough. The editor offers what the LAYOUT contains, not what
the enum declares, so the button existed and did nothing visible -- reported straight away.

Listed with enabled = false alongside the save/load/screenshot buttons, which is the same
opt-in shape. Existing layouts pick it up without being disturbed: TouchLayout.fromJson
splices in any default button a saved layout lacks, and defaultPortrait splices from this
same table, so one entry covers both orientations and everyone's current layouts.
2026-08-18 13:56:34 -04:00
jpolo1224 9a12b5e958 Pads 2-7 at startup, and an on-screen keyboard button
Ports 2-7 were left as Null pad handlers unless a USB device happened to be plugged in. The
loop that claims them for the virtual handler existed, but only inside _rpcsx_usbDeviceEvent,
so it ran on a USB plug/unplug and nowhere else. A second controller on a phone is normally
BLUETOOTH, which never produces that event, so those ports stayed Null and a second pad did
not exist in the core at all. Per-player button mapping therefore looked correct -- the UI
stores those bindings regardless -- while the second controller did nothing in game. Reported
against Tekken 6. Now claimed at startup, next to player 1.

Also adds a KEYBOARD touch button (Kind.STATEACTION, so it emits no pad code and calls
MainActivityRuntime.toggleSoftKeyboard) for the same reason the hotkey exists: to reach the
keyboard without pausing. The hotkey needs a spare pad button, which a touch-only player does
not have. Opt-in, absent from the default layout, like the save/load/screenshot buttons.

Appending to TouchButtonId is safe: touch layouts serialise the id by NAME
(TouchButtonId.valueOf), unlike SysHotkey which is persisted by ordinal.
2026-08-18 13:43:32 -04:00
jpolo1224 d094ecd491 Settings: put Emulate USB Keyboard in the Network tab
It existed only in the in-game pause menu, which made the On-Screen Keyboard hotkey's own
message a dead end: it tells you to turn this on in Network settings, and there was nothing
in Network settings to turn on. Reported from Discord after exactly that.

Same Settings.usbKeyboard field as the in-game row, so the two stay in sync, and indexed for
settings search.
2026-08-18 13:29:42 -04:00
jpolo1224 fda4cc3b50 Revert the added keyboard work: it duplicated an existing feature and shifted hotkey ordinals
Reverts 422d831ef and 00ce69a31.

ARMSX3 already had all of this. Settings.usbKeyboard writes USB1/Type = hidkbd and
NativeApp.usbSetKeyboardEnabled, the TOGGLE_KEYBOARD hotkey raises the Android IME through
SoftKeyboard.toggle, and dispatchKeyEvent already forwards keys via forwardKeyToUsbKeyboard.
The toast users see -- "Turn on Emulate USB Keyboard (Network settings) first" -- is that
feature correctly reporting that its setting is off, not a missing capability. What I added
was a second, parallel path through cellKb with its own setting and its own hotkey.

The revert is not only for redundancy. SysHotkey is persisted BY ORDINAL, as the comments
around TOGGLE_KEYBOARD and GYRO_RECENTER say in as many words, and both are appended last
for exactly that reason. KEYBOARD_TOGGLE was inserted mid-enum, ahead of GYRO_TOGGLE, which
re-points every binding after it for every existing user.
2026-08-18 13:21:57 -04:00
jpolo1224 00ce69a315 Android: on-screen keyboard over the running game, and a setting to enable it
Uses the Android system IME rather than a drawn key grid, so layouts, languages, prediction
and emoji come for free and it is the keyboard users already know. Bound to a new
KEYBOARD_TOGGLE hotkey, so it can be raised and dismissed mid-game without opening
settings.

The IME only opens for a focused view that accepts input, so a zero-size transparent
EditText owns focus on demand. Its InputConnection does the real work, because an IME
reports typing in two different ways and only one of them is a key event:

  sendKeyEvent            backspace, enter, arrows -- forward the keycode as-is
  commitText              ordinary characters, with NO key event behind them
  deleteSurroundingText   some IMEs delete by range instead of sending backspace

commitText is synthesised with KeyCharacterMap.getEvents, which produces the shift presses
capitals and symbols need rather than guessing a keycode per character. Characters no
keycode can produce -- emoji, CJK picked from a candidate list -- are still delivered with
their unicode and KEYCODE_UNKNOWN, since the guest reads the unicode field and that is the
honest keycode for a character with no key behind it.

Also adds the Emulated Keyboard setting, without which all of this was inert: the core
defaults to keyboard_handler::null, so cellKb told games no keyboard was attached no matter
what was typed. Off by default, matching the core, because a game that sees a keyboard can
behave differently. The hotkey says so in its toast when the guest cannot receive keys,
rather than silently showing an IME that goes nowhere.
2026-08-18 13:09:51 -04:00
jpolo1224 422d831eff Android: give the guest a real keyboard
cellKb reported no keyboard at all, so games that need one were unreachable: NFS Most
Wanted's beta debug menu, and native keyboard support in games like Counter-Strike. The
only handler upstream ships, basic_keyboard_handler, derives from QObject and filters
QKeyEvent off a QWindow, and android/CMakeLists.txt excludes it with the rest of the Qt
input layer -- init_kb_handler was hardcoded to NullKeyboardHandler as a result.

Almost none of that handler is actually Qt-bound. KeyboardHandlerBase::HandleKey already
takes plain u32 codes and keyboard_consumer::ConsumeKey resolves them through
m_keys.find(code), so the code space only has to agree between whatever registers the
buttons and whatever injects them. android_keyboard_handler therefore registers ANDROID
KeyEvent keycodes directly rather than impersonating Qt. The PS3 side uses USB HID usage
IDs and Android's letters and digits are contiguous too, so those map arithmetically and
only the remainder needs a table. Android also distinguishes left from right modifiers,
which Qt cannot, so all eight are wired rather than four.

init_kb_handler now honours the Keyboard setting instead of always reporting none, and
_rpcsx_keyboardKey delivers one key through the usual dlsym bridge, returning false when
no keyboard is active so a caller can tell the difference.

A physical keyboard reaches the guest through dispatchKeyEvent. The test there is
KEYBOARD_TYPE_ALPHABETIC, not the event source: gamepads also report SOURCE_KEYBOARD for
their buttons, so filtering on source alone would send every controller press to the guest
keyboard as well as the pad. The event is consumed only when the native side reports the
key landed, which keeps a physical keyboard usable for UI navigation everywhere else.

Not yet done: the on-screen keyboard overlay, and a UI setting for the handler. The core
default is still keyboard_handler::null, so this is inert until Keyboard is set to Basic.
2026-08-18 12:57:34 -04:00
jpolo1224 55a54c924e SPU/Android: stop generated SPU code running off the end of the thread stack
The ARM64 SPU gateway reserved a shared 8192-byte stack scratchpad. Compiled SPU
functions build no frames of their own on ARM64 -- GHC_frame_preservation_pass runs with
use_stack_frames = false -- so every one of them spills into that single reservation, and
a function needing more simply writes past it. Borderlands 2's 2401-instruction function
at LS 0x25da8 wants ~21 KB: the fault landed at sp+21760, exactly the top of the thread's
stack mapping, on the PROT_NONE guard page above it. x86 reserves 0xc8 in the same place
because LLVM emits ordinary per-function frames there, so this arrangement and this
failure are ARM64-only. Raised to 256 KB.

That is still a fixed bound rather than a scaling fix; a larger function could overflow
it the same way. use_stack_frames = true would scale, at a cost the pass comments call
out and which is not measured here.

Android threads also ran on an eighth of the stack they get elsewhere: the pthread path
passed null attributes, so bionic's 1 MB default applied where glibc gives 8 MB, measured
as a 0xfc000 stack mapping. Not the cause of this bug -- the overrun is off the TOP of the
stack, so size does not affect it, and 1 MB to 64 MB changed nothing -- but a real
discrepancy worth closing.

Both were invisible because of how the fault died. A guard page is not emulator memory,
so is_emulator_fault() correctly declines it, the handler forwards to libsigchain, and
ART's FaultManager reads the guest registers as an ArtMethod* and takes the process down.
No tombstone is produced, the async emulator log never reaches disk, and Android records
only 'SIGNALED status=11'.

Verified with the function compiled and no forced interpretation: zero stalls, zero
guard-page faults, 47 presented frames where the previous best was 18.
2026-08-18 12:19:07 -04:00
jpolo1224 19d23eb691 SPU LLVM: fix ARM64 SHUFB byteswap fold and accurate-xfloat CFLTS
SHUFB: a7fc31f32 made two semantic changes to the ARM64 path, and BOTH have to go. It
widened the byteswap fold from splat-only constants to any constant, byte-reversing
non-splat ones in get_swap_from_const, and it added idx_selects_single, which treats a
mask whose bit 4 is known-constant across all lanes as single-source.

Borderlands 2's SPURS function at LS 0x25da8 is 1446 shufb whose data operand is usually
a non-splat constant -- 0xbf800000 built by ilhu/iohl, or a mask straight out of cbd/cwd.
Compiled, that function spins forever inside a single block: block_counter, loop count
and retreat count are byte-identical across six thread dumps spanning the hang, at 96%
CPU, so it never reaches a block boundary. Interpreted, the game boots.

Reverting the byteswap widening alone is NOT enough -- measured, and the hang came back
with seven stall dumps at 0x25da8. Disabling the whole ARM64 shufb block in favour of the
generic path also fixes it, which is what identifies the fold and the single-source
trigger rather than the tbl/tbx paths themselves. Kept narrow so ARM64 keeps its fast
paths.

CFLTS accurate xfloat: only the high side was guarded. The f32 path is a single saturating
fcvtzs.4s, but this one converts f64[4], and AArch64 has no v4f64->v4i32 form, so it
lowers to fcvtzs.2d twice plus uzp1 -- saturation happens at int64 range and uzp1 then
keeps the low 32 bits. Negative overflow therefore did not produce 0x80000000: -3e9 came
back as +1295786496.
2026-08-18 12:19:06 -04:00
jpolo1224 884cb47dde SPU: fix ARM64 float-to-int conversions in the interpreter
CFLTS and CFLTU both carried x86 corrections that are wrong on AArch64, and the
SSE templates they live in are what spu_interpreter_rt is built from, so they are
live on ARM64 through spu_run_interp_fallback.

CFLTS applied the cvttps2dq fixup: x86 returns the integer-indefinite value
0x80000000 for anything unrepresentable, positive overflow included, so the result
was XORed back. _mm_cvttps_epi32 is sse2neon's vcvtq_s32_f32 (FCVTZS), which
already saturates, so the correction inverted a correct result. Measured: +3e9 gave
0x80000000 instead of 0x7fffffff, and NaN gave 0 instead of 0x80000000.

CFLTU went further and relied on the 0x80000000 return, ORing the remainder back in
to rebuild the u32. On ARM64 the conversion yields 0x7fffffff, and 0x7fffffff | v is
0x7fffffff for every v below 2^31, so the entire upper half of the range collapsed to
one value: 3e9 read back as 0x7fffffff rather than 0xb2d05e00.

This also matters for diagnosis, not just correctness: forcing a block to the
interpreter is the standard test for whether the recompiler emits wrong code, and
until now that test could introduce a fault the recompiler did not have.
2026-08-18 10:40:00 -04:00
jpolo1224 9b33316982 SPU: copy the reservation line 16 bytes at a time on ARM64, and add SPURS dispatch diagnostics
mov_rdata and mov_rdata_nt move the 128-byte reservation line -- the GETLLAR
snapshot, and the fill back into live guest local store. On x86 that is four
16-byte vector moves, so each quarter lands whole and a racing reader sees either
the old or the new 16 bytes. On ARM64 both fell through to std::memcpy, whose
granularity is a libc implementation detail; AArch64 implementations mix transfer
sizes freely, so a reader can observe a line stitched from both versions. Use
eight vld1q_u8/vst1q_u8 pairs to match what x86 gets for free.

This does NOT fix the Borderlands 2 hang -- measured, no change to any observable:
same 4807 SPU blocks, same 0x29b48 ceiling, same stall state. It is committed as a
latent correctness fix rather than a behavioural one: the copy exists to produce a
coherent snapshot and had no atomicity guarantee here at all.

The diagnostics are the instrumentation that traced that hang from symptom to a
single missing DMA: guest thread and thread-group state at an RSX stall, per-SPU
conditional-store counters, the local-store and reservation-vs-memory dumps, code
GET destinations, the SPURS control-block fields, and the register dump at the last
transfer both hosts issue in common. They hang off the existing rate-limited stall
report or are capped by distinct key, because every earlier attempt at this was
capped by volume and got eaten by whichever event happened most often.
2026-08-18 08:10:43 -04:00
jpolo1224 55a35b5e1d Diagnostics: guest-thread stall reporting, SPU reservation counters, autotest harness
Hangs where the RSX idles were only ever visible from the RSX side, so a stall
report now names every guest thread, its state, PC and function, and for SPUs adds
the reservation counters -- conditional store calls, failures, notifications, and
the SPURS heuristic's deliberate non-notifications -- plus where the host thread
last was in cpu_task. block_counter alone cannot separate a thread livelocked
retrying PUTLLC from one that is genuinely idle; both report zero blocks a second.

The SPU code window prints once per process. Unguarded it re-emitted a whole
function on every stall dump, measured at 538 lines a second over 31 dumps with a
690 MiB log left behind, which on Android is itself a stall -- it was degrading the
hang it was meant to describe, and it buried the state lines that answered the
question.

do_local_task counters cover the case the profiler cannot: it reports the thread is
in Local task and has been for 0.00s, which together mean it is not stuck there at
all and the FIFO loop is calling it repeatedly. Which FIFO state, and whether guest
GET equals PUT, separates a starved RSX from a stuck one.

tools/ps3autotests drives ps3autotests on a device over adb and diffs per
instruction against real-hardware output; compare-platforms.py does the three-way
ARM/x86/hardware split that separates shared upstream failures from ARM-only ones.
This is what found the CFLTS and FMS divergences.
2026-08-17 20:50:56 -04:00
jpolo1224 8caacc8231 Android: correct persisted off-spec settings, file:// launches, and RSS reporting
Accurate SPU Reservations was persisted false in the global config, left over from
earlier debugging, where upstream and our own defaults are both true. Turning the
default back on reached nobody who had already run the app, so this migrates the
stored value -- correcting the curated field and forgetting the raw override at
global scope only, since a per-title exception exists on purpose and
forgetEverywhere() would take it with it. Save LLVM logs had the same problem and
needed the value recorded, not just the override un-pinned.

A file:// launch never booted: the intent path was passed through as a URI string
and the loader wants a filesystem path, so only content:// ever worked.

get_memory_usage() reports system-wide totals -- MemTotal minus MemAvailable, every
process on the machine plus page cache -- and was being read as if it were ours.
Add get_process_memory_usage() for this process's resident set, which is the number
Android's low-memory killer actually decides on, and report that instead.
2026-08-17 20:50:43 -04:00
jpolo1224 0ded153216 Settings: add the console System settings, and route PS3/System to the core
Console Language, Keyboard Type, Console Region, Date Format, Time Format and
Enter Button Assignment had no UI, so anything the core read from PS3/System was
whatever the default happened to be.

Enter Button Assignment in particular was already a field but never reached the
core: Rpcs3Bridge.setSetting has no fallthrough, it translates a fixed set of
(section, key) pairs and silently drops the rest, and PS3/System was not among
them. Add the branch, add the five missing fields through the eight sites a
Settings field needs, and give them setters that map an index to the enum name --
these serialise by name, and the enum is neither contiguous nor in formatter order,
so an index cannot be written straight through.
2026-08-17 20:50:26 -04:00
jpolo1224 4baefed106 Android: install fault handlers ahead of the ART runtime, and handle SIGBUS
Calling sigaction() on Android does not make you the first handler for SIGSEGV.
libsigchain intercepts it and runs ART's FaultManager first, which reads the
faulting thread's registers as an ArtMethod* and dies on guest data -- so every
recoverable guest fault in JIT'd code killed the process before our handler ran,
with nothing in the app log to say why. Resolve sigaction from libc directly and
install through that, keeping the runtime's previous action so non-emulator faults
are forwarded on rather than swallowed. Registering through both paths recurses,
so this registers once and guards re-entry.

SIGBUS was only handled on Apple platforms; Android raises it for the same
unmapped-guest-page cases, so it needs the same treatment.

Also make the fault report survive a fault taken while reporting: emit an
allocation-free breadcrumb with the signal, address, PC and the GPRs before the
formatted dump, and chain to the previous handler first so debuggerd still records
a tombstone. The breadcrumb goes after the recovery attempts, not before -- emitting
it on entry logged over a thousand recovered faults per second and was itself a
stall.
2026-08-17 20:50:17 -04:00
jpolo1224 424514fde6 SPU: fix two ARM64 float divergences and make the object cache key cover codegen
CFLTS applied an x86 saturation correction on every host. cvttps2dq returns the
integer-indefinite value 0x80000000 for anything it cannot represent, positive
overflow included, so XOR-ing all the bits when the input is >= 2^31 produces the
0x7fffffff CFLTS wants. AArch64's FCVTZS already saturates that way, so the same
XOR turned a correct saturated-high result into saturated-low, and its NaN-to-0
conversion became 0xffffffff where x86 lands on 0x7fffffff. Same shape as the
FCTIW/FCTIWZ/FCTID split already guarded in PPUTranslator; the SPU one was missed.

FMS expressed a * b - c as fma(a, b, -c). x86 folds that into vfmsub and never
materialises -c, so a NaN addend propagates its own bits; AArch64 cannot take that
shape -- FMLS is Zd - Zn*Zm -- so it emits the FNEG and propagated the negated NaN.
0x7fffffff is not a NaN on a real SPU, just a large number, so the two hosts
disagreed about the sign of a huge result. Negate the addend only when it is not a
NaN pattern, with a known-never-NaN early out to keep it off the common path.

Measured with ps3autotests cpu/spu_fpu against x86 output from an otherwise
identical build: cflts 16 -> 0 differing lines, fms 484 -> 0, and spu_fpu as a
whole 984 -> 0 against a non-AVX512 x86 host. The 484 fma lines that remain
against an AVX-512 host are that host's vfixupimmps path and reproduce on any x86
without AVX-512, so they are not ARM-specific.

The cache key hashed the build stamp of SPUCommonRecompiler.cpp while the code
generator lives in SPULLVMRecompiler.cpp, so editing codegen alone did not move the
key and a rebuilt emulator silently reused objects from the previous binary -- the
first attempt at the CFLTS fix looked like it did nothing for exactly that reason.
Export a stamp from the codegen TU and hash that in as well, and prune stale
spuobj-* siblings so a version bump does not strand old directories.
2026-08-17 20:50:07 -04:00
jpolo1224 37a5d118be Merge PR #64: Library: a filename-derived PS3 serial gets a hyphen and loses its cover 2026-08-17 13:14:01 -04:00
jpolo1224 301f45a2cb Merge PR #63: cellAudio: don't let a silent port reset the untouched baseline every period 2026-08-17 13:14:00 -04:00
jpolo1224 aa25da4ce2 Merge PR #62: Stop two guest polling loops from flooding the log 2026-08-17 13:13:59 -04:00
jpolo1224 e35bd463cf Merge PR #60: Bundle the H.A.W.X. 2 Bink overlay patch and enable it 2026-08-17 13:13:57 -04:00
Zulux91 968b892e29 Library: a filename-derived PS3 serial gets a hyphen and loses its cover
FilenameParser reconstructs every serial in the PS2 dump shape -- four letters,
a hyphen, five digits (SLUS-20312) -- because that is the convention its regex
was written for. A PS3 title ID has no separator, so a game whose serial comes
off the filename rather than the disc is recorded as BLUS-30917.

That serial matches nothing. Cover art is fetched as COV/<TITLE_ID>.JPG, keyed
by exactly the id PARAM.SFO gives us, and the extracted-icon fallback is
disc-icons/<TITLE_ID>.png:

  COV/BLUS30917.JPG  -> HTTP 200
  COV/BLUS-30917.JPG -> HTTP 404

so the card shows a text placeholder. The filename path is taken whenever the
disc was not probed -- probeDiscInfo answers "{}" while a game is loaded. Once
that has happened the entry cannot recover: the cached serial is re-seeded into
discInfoCache at the start of every scan and comes back as disc.titleId, which
has top priority.

Normalise the resolved serial rather than the parser, which is what repairs the
already-cached entries since they arrive through the same expression.

Three things this has to get right beyond the cover itself.

NOT EVERY 4+5 TOKEN IS A TITLE ID. FilenameParser takes the first four-letter
plus five-digit token it finds anywhere in the name, so what arrives may be a
release tag or an id belonging to a different game. Left hyphenated a bad guess
matches nothing and the card shows a placeholder -- visibly wrong, and safe.
Stripped, it would become a WELL-FORMED id and quietly resolve whatever is filed
under it: another game's cover, its 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 and N for PSN. Deliberately not gated on GamePlatform: that
enum comes from the same probe that produced the serial, so in the one case this
exists for -- probe failed, name came off the filename -- it is always null and
the guard would be constant-true.

THE SERIAL IS NOT ONLY THE COVER KEY. It also 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 resets
every one of them silently, and nothing prunes the old keys, so they become
unreachable rather than merely unused -- the custom cover worst of all, since
CustomCovers.remove resolves through the same name and cannot delete the orphan.
migrateSerialKeys moves them, and CustomCovers.renameSerial follows the file.

THE REPAIR HAS TO REACH EXISTING INSTALLS. cacheKey embeds ScanSchemaVersion, and
HomeViewModel only schedules a scan when that key changes. Without a bump an
upgraded install keeps serving the cached hyphenated ids and never rescans, so
the covers stay broken until the user finds the refresh button. Bumped 7 -> 8;
the constant's own contract asks for this whenever a stored field changes, and a
changed VALUE has the same staleness signature as a new field.

Verified on device, 14-game library with three affected ISOs. Seeded the broken
state (hyphenated serial in the cache, a pinned name and play time under the old
id, cached key at v7), then launched WITHOUT touching the UI:

  load(first): cachedKey=v7|...  newKey=v8|...  pending=true
  scan start: 1 dir(s), rawStorage=true
  serial 'BLUS-30917' -> 'BLUS30917' (3 pref key(s) moved)

pending=true is the field that read false before the bump. Afterwards no
hyphenated key or serial remained anywhere in the preferences, the pinned name
was live on the card under the new id, and Lollipop Chainsaw, Ratchet & Clank:
Full Frontal Assault and Virtua Tennis 4 all render their covers.

Not fixed here: those discs still have no extracted ICON0.PNG, so their offline
fallback stays missing, and the re-probe that would create one is folder-only --
re-probing an ISO needs a vfs::mount, which the seeding loop deliberately avoids.
Two library entries that resolve to the same id can also cross-write each other's
per-serial data; that is the intended merge for a genuine duplicate, but nothing
models it.
2026-08-17 07:51:59 -05:00
Zulux91 89c6d08ed3 cellAudio: don't let a silent port reset the untouched baseline every period
A game can leave an audio port started and write nothing but zeros into it.
Those writes still land on the tag slots, overwriting the -0.0f tag with
+0.0f, and count_port_buffer_tags() detects that sign flip as "the buffer was
touched" -- correctly, since it cannot tell silence from data.

The result is a port that reports untouched on most periods and touched on the
few that a write happens to land in. Storing untouched_expected as the
instantaneous count then drops it to 0 on exactly those periods, so on the
next period the same silent port looks like a newly untouched buffer, and the
loop waits out the whole untouched timeout for it. Every time it flickers.

untouched_expected is now a high-water mark, clamped to active_ports so a port
going away lowers it again.

Measured on device, Tom Clancy's H.A.W.X. 2 (BLES00928), main menu, stock
audio settings (time stretching off, buffer 34), with a temporary probe in the
period loop counting branch hits per second. Same scene, same build, only this
change differing:

                       before      after
  wait_untouched         669          0     hits/s (1000us each)
  MIX                     65        188     hits/s
  advance (forced)        37          0     hits/s
  enqueued_buffers         0        5-7
  untouched > expected   743          0     per second
  untouched_expected     0 in 799   1 in 376  of the second's samples

The port itself is unchanged by this: it is still started, still counted as
active, still mixed. A full-block scan of it reads 0 non-zero floats out of
512 on every one of 875 consecutive periods, which is what makes it silent,
and it is the tag flicker rather than the silence that caused the stall.

Audible effect: the audio clock ran at ~55% of real time (103 vs 189 periods
per second) with the ring buffer permanently empty, which is why the whole
title sounded slowed down and stuttering. Note this happens with time
stretching disabled -- the frequency ratio stayed at 1.000 throughout, so the
slowdown is the period rate itself and not resampling.

Not verified: whether any title depends on untouched_expected falling back to
a lower value within a stable port configuration. Nothing in the tree tests
this loop.
2026-08-17 04:26:09 -05:00
Zulux91 7811cffeed Canary patches: a revision bump must not re-enable the older ones
ensureBundledPatches iterated the whole BUNDLED list and forced each entry
enabled, so bumping BUNDLED_REVISION for one game re-enabled every bundled
patch -- including Sonic '06's Graphics Fix, for a user who had deliberately
turned it off and may not own the game the bump was made for. That directly
contradicts the guarantee written above the function ("turning one OFF
sticks").

It cannot be fixed by reading the state back: save_config writes an entry
only when it is enabled, so "disabled" is stored as an absent entry and
patch_config.yml cannot distinguish opted out from never seen.

Each Bundled entry now records the revision it first shipped in, and only
entries newer than the stored revision are touched. An install already at
revision 1 has been offered the Sonic patch once; whatever the user did with
the toggle afterwards is their answer.

Two things fall out of the same change:

- The retry on partial failure now covers only the pending entries, so a
  patch stuck failing can no longer drag the already-settled ones back on
  with it every boot.
- When nothing is pending the import is skipped entirely rather than
  rewriting patches/patch.yml for no reason, which keeps a future bump that
  adds no new patch from touching the file at all.

Verified on device (arm64, Android 15) against the shape this changes, which
is the upgrade in place: stored revision 1, a populated patch.yml without the
new entry, and no patch_config.yml -- the state a user is in after turning
the Sonic patch off, since disabled is stored as absence.

Booting a game logged

  canary patches: imported 1, enabled 1

("enabled 2" is what the previous code would report, since it enabled
BUNDLED.size entries), and the resulting patch_config.yml contained only

  SPU-42bae8e5d6a9304068ba1c6bbfdc18d656e287a1:
    Bink overlay skip:
      Tom Clancy's H.A.W.X. 2:
        BLES00928:
          All:
            Enabled: true

with no Graphics Fix entry, i.e. the opted-out patch stayed off across the
bump. The Sonic entry in patch.yml itself was preserved, and the stored
revision advanced to 2.

Not verified: the two-writer race on patch.yml (a Patches-tab download
overlapping a boot). That is unaffected by this change and still unguarded.
2026-08-17 03:20:59 -05:00
Zulux91 20c854aeb3 patch_engine: stop save_patches from destroying the file it rewrites
Two defects on the same write path, both reachable today from Download
database and from a local patch import.

1. The file was opened with fs::rewrite (write + create + trunc), streamed
   into, and the write result discarded -- save_patches returned true
   unconditionally. A write that fails part way (out of space, process
   killed) therefore leaves a truncated patch.yml behind, and load() rejects
   the whole file on a parse error, so the failure costs the user every patch
   they had. There is no way to rebuild it from inside the app either:
   import_patches refuses to write when load() fails, so both import paths
   return -1 from then on.

   save_config, 70 lines up in the same file, already writes through
   fs::pending_file and checks the result. save_patches now does the same.

2. The address element was always emitted as fmt::format("0x%.8x", offset).
   For move_file and hide_file that element is a VFS path, not a number:
   load() keeps the text in original_offset and skips the u32 validation for
   those two types. So a round trip turned a path into 0x00000000, and the
   loader accepted it back -- the patch still lists and still toggles, it just
   silently stops matching anything. Re-downloading does not repair it,
   because append_patches discards an incoming patch whose Patch Version is
   not strictly greater than the stored one.

   The emit is now gated on patch_type_uses_hex_offset, the predicate that
   already existed for this and was used only on the load side.

   The numeric branch deliberately keeps using 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.

Both predate the Android patch work and apply to upstream RPCS3 unchanged;
they are in this branch because the bundled-patch import adds another caller
of save_patches.

Verified on device (arm64, Android 15). A patch.yml seeded with move_file and
hide_file entries was put through an import that merges a new patch, which is
what forces the rewrite. After it:

  - [move_file, /dev_bdvd/PS3_GAME/USRDIR/probe.bik, /dev_bdvd/PS3_GAME/USRDIR/probe.bik.bak]
  - [hide_file, /dev_bdvd/PS3_GAME/USRDIR/hidden.bik, ""]

Both paths survived; before this change they would read 0x00000000. All three
top-level hashes in the file (the two seeded, plus the merged one) were still
present and parseable afterwards.

Not verified: the failure path in (1). Forcing a short write mid-rewrite
(ENOSPC or a kill inside save_patches) was not exercised, so the atomicity is
argued from fs::pending_file's contract and from parity with save_config, not
from a reproduced failure.
2026-08-17 03:20:43 -05:00
Zulux91 7952244052 Stop two guest polling loops from flooding the log
cellMicOpenEx logged at notice and sys_net_bnet_accept at warning, once
per call. Titles poll both. In H.A.W.X. 2 they are called roughly 100 and
200 times a second respectively for the whole session, and together they
were 46% of the log -- 27305 lines of 58441, about 9 MB per three minutes.

On Android that file is on FUSE-backed storage, where writes are far
slower than the f2fs the emulator's own data sits on, so this is not just
noise in a text file.

Neither call is an error. cellMicOpen and cellMicOpenRaw are thin wrappers
around cellMicOpenEx and were already trace, so the wrappers were quieter
than the function they call. A non-blocking accept() on an idle listening
socket is a normal polling pattern, not a warning.

After: 4 and 1 lines respectively, log down to 2.7 MB over the same span.

Also corrects the heap-flag test in mem_allocator_vma: the loop checks a
VkMemoryHeap::flags value against VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT,
which is a memory-type property rather than a heap flag. Both constants
are 0x1 so behaviour is unchanged; this only puts the right enum on the
test.
2026-08-17 02:20:34 -05:00
Zulux91 4179f23e20 Bundle the H.A.W.X. 2 Bink overlay patch and enable it
Tom Clancy's H.A.W.X. 2 (BLES00928) hangs forever at the first intro
video. The SPU dies with "Access violation reading location 0x20" in
CellSpursKernel0 and is parked with dbg_pause, which nothing in the
Android build can clear, so the emulator sits 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 an SPU module embedded
in its own EBOOT. That module is stripped -- e_shnum is 0 -- so the
lookup cannot succeed on hardware either, and the game copes: the
failure path writes 0 to the work descriptor's +0x10 field, and the same
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 that field
unconditionally -- (0 - 0x10) & ~0xF = 0xfffffff0 -- destroying the
sentinel. The guard stops firing, so the SPU issues GET lsa=0 ea=0
size=0x4000, a transfer that would have overwritten the running SPURS
kernel had it succeeded.

The patch makes the overlay routine at LS 0x3208 return immediately,
which is what the surviving guard would have caused anyway. It is keyed
on the SPU image hash, so it cannot affect another title or a build of
this module that does carry sections.

Suppressing the DMA emulator-side instead does not work: the guest loop
waits on data that never arrives and runs away into a second fault. So
does zero-filling local store, which breaks the SPURS kernel's own HALT
assertion earlier than the fault it was meant to prevent.

Verified on device with every diagnostic reverted and default settings
(PPU/SPU Recompiler (LLVM), Accurate SPU DMA off): the import runs at
boot, patch.yml grows 468 -> 761 bytes with the existing Sonic entry
preserved, patch_config.yml enables both, and the core reports

  PAT: Applied patch (hash='SPU-42bae8e5d6a9304068ba1c6bbfdc18d656e287a1',
       description='Bink overlay skip', ...)
  ppu_loader: SPU executable hash: SPU-42bae8e5d6a9304068ba1c6bbfdc18d656e287a1 (<- 1)

0 access violations, intro cinematic plays, title screen reachable and
the first mission's targeting-pod sequence renders.

BUNDLED_REVISION goes to 2 so existing installs re-import.
2026-08-17 00:31:29 -05:00
jpolo1224 4b2b8438be Merge PR #50: UI: Lingering Playstation 2 naming on the side menu during execution 2026-08-16 10:43:08 -04:00
jpolo1224 7b49e1fcea Merge PR #55: Android CPU time: park the dma_manager::sync() wait, and stop re-parsing the global config every 5s 2026-08-16 10:43:08 -04:00
jpolo1224 d9957c56ae Merge PR #57: SPU: make the ARM64 uncompilable-block fallback safe to enter 2026-08-16 10:43:07 -04:00
jpolo1224 62d8208c71 VK: frame generation's Motion detail slider was inverted
framegen treats flowScale as a DIVISOR -- flowExtent = inputExtent / flowScale in
v3.1_src/shaders/mipmaps.cpp -- which is why upstream's own layer passes
1.0f / conf.flowScale rather than the value itself.

We passed value / 100 from a 25..100 setting, so every position below the default
asked for a LARGER optical-flow pyramid instead of a smaller one:

  100 -> 1.00 -> full resolution          (correct, 1.0 being its own reciprocal)
   64 -> 0.64 -> 1.56x per axis, 2.4x px
   25 -> 0.25 -> 4x per axis,   16x px

So a user turning "Motion detail" down to find speed got sixteen times the flow
cost at the bottom of the range, and the slider got slower the further it was
turned down. Only the default was ever right, which is why this survived testing.

Now 100 / value. The ~10% of real framerate frame generation already costs is not
this: that was measured before the setting existed, when the call site passed a
hardcoded 1.0f. Anything measured since, at a non-default value, was carrying the
inflated cost.
2026-08-16 10:37:46 -04:00
Zulux91 cab4f2507c SPU: make the uncompilable-block fallback safe to enter
The failed-block set is consulted by two lookups that locate a candidate with
upper_bound and step back exactly one entry, so they only ever examine a single
range. That is correct only while no range can hide another, and nothing kept
the set disjoint. The two marking call sites record different extents: one
records a whole analysed program, the other records an entry point alone when
there is no program to describe. An entry-only mark landing inside a
program-sized mark is therefore ordinary, and it always ends first, which leaves
the enclosing range invisible for every address past its end.

mark() now merges on insert, so the invariant the cheap lookup depends on holds
by construction. The set moves into spu_failed_block_set (SPUFailedBlocks.h),
header-only and free of engine dependencies so it can be exercised directly
rather than through a model of it.

A hole was not merely a missed optimisation. dispatch armed the fallback with
whatever the lookup returned, and old_interpreter releases the thread when
(pc < begin || pc >= end), which is unconditionally true for an empty range, so
the interpreter would return having executed nothing while dispatch re-entered
at an unchanged pc. spu_arm_interp_fallback now yields a range that contains pc
and is non-empty, recording the block first when no path had recorded it. It
does that under one critical section rather than lookup, unlock, mark, look up
again: nothing removes ranges concurrently today, so the gap was not live, but
the guarantee rested on who happens to call the reset rather than on structure.

It also recorded only [pc, pc + 4) while dispatch was holding the analysed
program, so the interpreter released the thread after a single instruction and
dispatch re-entered four bytes later to pay another full analyse and another
full failed compile -- the 4-bytes-at-a-time walk documented at the top of this
file. The extent is passed through when the caller has one. That path no longer
logs "cannot be compiled on this backend" either: a null compile with no
diagnostic also covers a poisoned engine, an analyser that produced nothing for
a branch into data, and a lost compile claim, none of which are backend limits.

The interpreter also ran in the wrong place. It was started from
spu_thread::cpu_task after dispatch had escaped, which executes guest code
outside any gateway invocation, while spu_runtime::g_escape resumes through the
gateway epilogue whose address and stack pointer the prologue stored in hv_ctx
-- belonging to a call that has already returned. A guest HALT, an MFC interrupt
or cpu_work escaping from inside the interpreter would restore a stack pointer
into a dead frame. It now runs from dispatch, inside the live gateway call.

allow_interrupts_in_cpu_work is not restored after the old_interpreter call,
because an escape out of the interpreter is a far jump to the gateway epilogue
that abandons every frame in between -- a restore placed there is skipped on
exactly the paths the flag is set for. Both that flag and interp_fallback are
cleared by cpu_task before each gateway entry instead, which is the one point
every escape returns through. interp_fallback was previously left set when
old_interpreter exited through check_state() as well.

spu_interpreter_fallback_available() tested spu_runtime::g_interpreter, the
LLVM-built interpreter used when a recompiler is selected. The fallback actually
run is old_interpreter, which reads the opcode table, the thread and the local
store and nothing else. When the LLVM interpreter failed to build, that check
disabled a fallback which was in fact available and dispatch took the
"Compilation failed" path instead.

The set is now also cleared per emulation session. Its keys are local-store
offsets, which every SPU thread, every image and every title in the process
reuse, so a set that outlived the session let one title's compile failures route
an unrelated title's code at the same offset to the interpreter. The call is
guarded by ARCH_ARM64: the set and its accessors exist only on that backend,
which is the one that can fail to compile a block.

tests/test_spu_failed_blocks.cpp covers both hole shapes, the half-open
boundaries, the merge cases in both orders and the local-store extremes. Its
load-bearing case is MatchesReferenceCoverage, a randomized differential against
an independent bitmap, which constrains the union, the maximality of range_of
and the "coverage grew" return value together for sequences nobody chose by
hand. is_disjoint() has no reachable negative through the public API and is
documented as a witness rather than presented as a check. The file also names
the runtime paths it cannot reach. It is registered in rpcs3_test.vcxproj as
well as the CMake list; the Windows CI job runs the MSVC build, where it would
otherwise have been absent while reporting green.

Executed. The ARM64 core builds clean, no warnings. The interval set passes a
randomized differential run directly on the header (200 trials, 4800 mark
operations, 0 mismatches); the pre-merge algorithm fails the same oracle 1268
times. On device (Snapdragon 8 Elite-class, Android 15), a throwaway build that
forces compile failures drove the fallback end to end for the first time: a
block with no prior mark recorded its whole 680-byte analysed extent in one
mark, and a pre-marked block returned its covering range; both were interpreted
inside the live gateway frame and escaped, with Mirror's Edge holding its title
screen at 30.00 fps and Metal Gear Rising at 457 present frames over 8m44s with
no "Compilation failed".

Not executed. No x86-64 build and no rpcs3_test binary: the header's evidence
comes from a standalone host harness and mutation runs, not from the registered
gtest, and the ARCH_ARM64 guard on the session reset is unverified by
compilation. The dispatch re-entry fast path recorded zero hits in every device
leg, so the exposure from merging ranges -- previously-JIT'd addresses routed to
the interpreter for the rest of the session -- is unmeasured. The same forced
failure applied to the pre-change code did not fail on device either, so these
runs show the new path is correct and free, not that it is necessary; the escape
from a dead gateway frame needs a HALT, an MFC interrupt or cpu_work to fire
while inside the interpreter, which one short interpreted block did not reach.
2026-08-16 06:10:31 -05:00
Zulux91 27e9d11b80 UI: stop re-parsing the whole global config every 5 seconds of gameplay
ConfigStore.loadGlobal() is a JSON parse plus every migration block in the
file -- 24,555 dex instructions by ART's own count, over its JIT ceiling,
so it runs interpreted on every call. EmulationSurface's frame-rate
monitor calls it (via resolveForGame) every 5 seconds for the whole
session, to read a single boolean. Measured: one ART "exceeds compiler
instruction limit" bailout line per 5.00 s of gameplay, entire sessions
long.

Memoize the parsed Settings. The migrations are one-shot behind their own
prefs flags, so caching is behavior-identical; saveGlobal is the only
writer of the pref after boot and refreshes the cache, and
reconcileReusedFolder -- whose restore path writes the pref directly,
before any settings screen exists -- drops it.

Measured after, same scene: one bailout line for the whole session (the
single first-call compile attempt) versus twelve per minute before, and
the boot config dump still carries the user's settings.
2026-08-15 08:17:33 -05:00
Zulux91 6e731093c4 RSX: park the dma_manager::sync() wait instead of spinning
The RSX-thread branch of dma_manager::sync() busy-waited on the offloader
with a pure pause() loop. Measured on Metal Gear Rising gameplay (Odin,
warm shader cache, off-CPU profile): the loop held 26.6% of the RSX
thread's wall time while the RSX Offloader thread itself was parked in a
kernel wait for 99.78% of the same window -- the spin was paying the
offloader's wake-up latency on every small handoff, burning about a
quarter of a core to wait for a mostly-idle thread.

Spin briefly for the short common case, then wait on m_processed_count
with a 100us timeout. The offloader notify_all()s that atomic when its
queue drains; the timeout is load-bearing, not a formality -- an
offloader stopped mid-job by a memory fault cannot notify (it spins in
on_access_violation until this thread's upkeep clears the deadlock
flag), and the upkeep can itself enqueue new jobs from inside the wait,
deferring the equal-counters notify to the next drain.
on_semaphore_acquire_wait() still runs every iteration.

Three refinements from an eight-pass adversarial review of the first
version of this change:

- The wait targets the processed count the loop condition observed, and
  parks only if a re-read after the upkeep call shows no progress. The
  drain-notify is one-shot: parking on a pre-upkeep value absorbs a
  full timeout when the offloader drained during the upkeep, and
  parking on a blind re-read turns any partial progress into an
  immediate return, degrading the park into a hot upkeep loop for the
  whole drain.
- If the offloader thread is not running (config toggled on mid-session
  after booting with it off, aborting, or dead from an unrecoverable
  fault), the drain can never come; keep the visible spin there so the
  pre-existing hang stays attributable in a profiler instead of
  presenting as an idle, healthy-looking app.
- The comment states the timeout's real role; the first version claimed
  nothing else could enqueue during the wait, which is false (the
  upkeep's flush path reaches backend_ctrl) and would have licensed
  removing the timeout.

Measured after (same scene and script, healthy device): sync() falls to
0.10% of the RSX thread's wall time, the thread parks in the kernel for
67.6% of the workload, and fps is unchanged within run noise (52.9 avg
vs 51.4 for the pre-review variant in the same session). The win is a
freed core and its thermal budget, not frame rate. The
non-RSX-thread branch has the same spin shape; it was not measured and
is left untouched.
2026-08-15 06:47:59 -05:00
Diego BM 89b2128679 Update EmulationMenuScreen.kt
Lingering Playstation 2 naming on the side menu during execution
2026-08-15 13:14:33 +02:00
48 changed files with 3462 additions and 177 deletions
+350 -9
View File
@@ -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
View File
@@ -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;
}
+2 -2
View File
@@ -32,8 +32,8 @@ android {
// agree -- an APK that installs below its core's target is a dlopen failure at boot.
minSdk = (project.findProperty("armsx3.minSdk") as String?)?.toInt() ?: 33
targetSdk = 37
versionCode = 14
versionName = "0.8"
versionCode = 15
versionName = "0.9"
// ARMSX2's UI reads these. STORAGE_ALL_FILES gates the all-files storage path in
// onboarding; IN_APP_UPDATER gates the in-app GitHub-release updater.
@@ -50,3 +50,44 @@ PPU-4b46d0161ca657ab16b0a779d9062810ea5ea2dd:
- [ jumpf, 0x00000000, "RPCS3_HLE_LIBRARY:WaitForSPUsToEmptySNRs" ] # Args: (SPU ID, 3)
- [ be32, 0x00000000, 0x38800000 ] # li r4, 0
- [ be32, 0x00000000, 0x44000002 ] # sc
# Tom Clancy's H.A.W.X. 2 (BLES00928) -- boot hang at the first intro video.
#
# The SPU dies with "Access violation reading location 0x20" in CellSpursKernel0 and
# is parked forever (dbg_pause, which nothing in the Android build can clear), so the
# emulator looks healthy at a locked 30 fps while the guest is dead. Upstream RPCS3
# lists the title as Loadable with no fix but "delete data/movies".
#
# The title looks up a section named '.reload' in this SPU module. The module is
# stripped -- e_shnum = 0 -- so the lookup can never succeed, on hardware either, and
# the game is built to cope: the failure path writes 0 to the work descriptor's +0x10
# field, and this very module tests that field to skip the overlay load:
#
# 03224 lqr r8,0x1b810 ; r8 = desc[+0x10]
# 0322c brz r8,0x32cc ; == 0 -> skip
#
# A bump allocator on the PPU side then runs over the field unconditionally --
# (0 - 0x10) & ~0xF = 0xfffffff0 -- destroying the sentinel. The guard no longer
# fires, so the SPU issues GET lsa=0 ea=0 size=0x4000, a transfer that would have
# overwritten the running SPURS kernel had it succeeded.
#
# This makes the overlay routine at LS 0x3208 return immediately, which is what the
# surviving guard would have caused anyway. Safe because the section it needs cannot
# exist in a stripped module. Suppressing the DMA instead does NOT work: the guest
# loop waits on data that never arrives and runs away.
#
# Ps3PatchRepo.BUNDLED must list this, or it is imported but never enabled.
SPU-42bae8e5d6a9304068ba1c6bbfdc18d656e287a1:
Bink overlay skip:
Games:
"Tom Clancy's H.A.W.X. 2":
BLES00928:
- "All"
Author: Zulux91
Patch Version: 1.0
Notes: Fixes the boot hang at the first intro video.
Patch:
# LS 0x3208 is the first instruction of the overlay routine (il r5,0).
# Offsets are LS addresses: apply_modification subtracts p_vaddr (0x3000).
- [ be32, 0x3208, 0x35000000 ] # bi lr -- return immediately
@@ -44,6 +44,8 @@ struct RPCSXApi {
std::string (*getCurrentTrophyName)();
bool (*surfaceEvent)(JNIEnv *env, jobject surface, jint event);
void (*surfaceSizeChanged)(int width, int height);
void (*setPadSensor)(int port, int x, int y, int z, int g);
int (*getPadRumble)(int port);
bool (*usbDeviceEvent)(int fd, int vendorId, int productId, int event);
bool (*installFw)(JNIEnv *env, int fd, long progressId);
bool (*isInstallableFile)(jint fd);
@@ -139,6 +141,8 @@ struct RPCSXLibrary : RPCSXApi {
result.getCurrentTrophyName = reinterpret_cast<decltype(getCurrentTrophyName)>(dlsym(handle, "_rpcsx_getCurrentTrophyName"));
result.surfaceEvent = reinterpret_cast<decltype(surfaceEvent)>(dlsym(handle, "_rpcsx_surfaceEvent"));
result.surfaceSizeChanged = reinterpret_cast<decltype(surfaceSizeChanged)>(dlsym(handle, "_rpcsx_surfaceSizeChanged"));
result.setPadSensor = reinterpret_cast<decltype(setPadSensor)>(dlsym(handle, "_rpcsx_setPadSensor"));
result.getPadRumble = reinterpret_cast<decltype(getPadRumble)>(dlsym(handle, "_rpcsx_getPadRumble"));
result.usbDeviceEvent = reinterpret_cast<decltype(usbDeviceEvent)>(dlsym(handle, "_rpcsx_usbDeviceEvent"));
result.installFw = reinterpret_cast<decltype(installFw)>(dlsym(handle, "_rpcsx_installFw"));
result.isInstallableFile = reinterpret_cast<decltype(isInstallableFile)>(dlsym(handle, "_rpcsx_isInstallableFile"));
@@ -437,6 +441,24 @@ extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_surfaceEvent(
return rpcsxLib.surfaceEvent(env, surface, event);
}
extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_setPadSensor(
JNIEnv *, jobject, jint port, jint x, jint y, jint z, jint g) {
if (rpcsxLib.setPadSensor == nullptr) {
return;
}
rpcsxLib.setPadSensor(port, x, y, z, g);
}
extern "C" JNIEXPORT jint JNICALL Java_net_rpcsx_RPCSX_getPadRumble(
JNIEnv *, jobject, jint port) {
if (rpcsxLib.getPadRumble == nullptr) {
return 0;
}
return rpcsxLib.getPadRumble(port);
}
extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_surfaceSizeChanged(
JNIEnv *, jobject, jint width, jint height) {
if (rpcsxLib.surfaceSizeChanged == nullptr) {
@@ -469,6 +469,21 @@ object CustomCovers {
(target.isFile && target.length() > 0L).also { if (it) version.value++ }
}.getOrDefault(false)
/**
* Follow a game's custom cover across an identity correction.
*
* The file is named after the serial, so a game whose id is corrected stops matching its
* own cover -- and because [remove] resolves through the same name, the orphan cannot be
* deleted from the app either. Skips when a cover already exists under the new id, so a
* deliberate choice is never overwritten by a stale one.
*/
fun renameSerial(context: Context, old: String, new: String): Boolean = runCatching {
val from = File(dir(context), sanitize(old) + ".png")
val to = File(dir(context), sanitize(new) + ".png")
if (!from.isFile || to.exists()) return@runCatching false
from.renameTo(to).also { if (it) version.value++ }
}.getOrDefault(false)
fun remove(context: Context, game: GameInfo): Boolean {
val f = fileFor(context, game) ?: return false
return f.delete().also { if (it) version.value++ }
@@ -171,12 +171,18 @@ object Ps3PatchRepo {
*
* appVersion is carried for symmetry with [Patch]; the native side matches on
* serial and ignores it.
*
* sinceRevision is the [BUNDLED_REVISION] this entry first shipped in. It is what
* keeps a bump from touching the patches that were already here: an install whose
* stored revision is at or above it has been offered this patch once already, and
* whatever the user did with the toggle afterwards is their answer.
*/
private data class Bundled(
val hash: String,
val name: String,
val serial: String,
val appVersion: String,
val sinceRevision: Int,
)
private val BUNDLED = listOf(
@@ -187,6 +193,16 @@ object Ps3PatchRepo {
name = "Graphics Fix",
serial = "BLUS30008",
appVersion = "01.01",
sinceRevision = 1,
),
// Tom Clancy's H.A.W.X. 2, BLES00928 -- without this the game hangs forever at
// the first intro video with a dead SPU. See canary_patches.yml.
Bundled(
hash = "SPU-42bae8e5d6a9304068ba1c6bbfdc18d656e287a1",
name = "Bink overlay skip",
serial = "BLES00928",
appVersion = "All",
sinceRevision = 2,
),
)
@@ -197,7 +213,7 @@ object Ps3PatchRepo {
* install re-imports and enables the new ones. Not a timestamp: it has to be
* something a diff of this file makes obvious.
*/
private const val BUNDLED_REVISION = 1
private const val BUNDLED_REVISION = 2
private const val PREFS_NAME = "ARMSX2"
private const val KEY_BUNDLED_REVISION = "ps3_bundled_patch_revision"
@@ -210,9 +226,10 @@ object Ps3PatchRepo {
* tick a box before Sonic '06 renders has already concluded the emulator is
* broken.
*
* Guarded by a stored revision rather than run every boot, so turning one OFF
* sticks. Re-enabling on every launch would make the toggle look broken, which
* is the same class of bug as not having the patch at all.
* Only patches newer than the stored revision are touched, so turning one OFF
* sticks -- including across a later bump made for some other game. Re-enabling
* on every launch, or on every bump, would make the toggle look broken, which is
* the same class of bug as not having the patch at all.
*
* Safe to call on every boot: it is a preference read once the revision matches,
* and the import itself merges rather than replaces, so a downloaded database
@@ -220,7 +237,21 @@ object Ps3PatchRepo {
*/
fun ensureBundledPatches(context: Context) {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
if (prefs.getInt(KEY_BUNDLED_REVISION, 0) >= BUNDLED_REVISION) return
val storedRevision = prefs.getInt(KEY_BUNDLED_REVISION, 0)
if (storedRevision >= BUNDLED_REVISION) return
// Anything at or below the stored revision has had its one chance to be turned
// on. Re-enabling it here would silently undo a user's OFF, and patch_config.yml
// stores "disabled" as an absent entry, so there is nothing to read back that
// would tell us the difference between "opted out" and "never seen".
val pending = BUNDLED.filter { it.sinceRevision > storedRevision }
if (pending.isEmpty()) {
// Nothing new to enable, so skip the import entirely rather than rewriting
// patches/patch.yml for no reason.
prefs.edit().putInt(KEY_BUNDLED_REVISION, BUNDLED_REVISION).apply()
return
}
val yaml = runCatching {
context.assets.open(BUNDLED_ASSET).bufferedReader().use { it.readText() }
@@ -241,8 +272,10 @@ object Ps3PatchRepo {
// Only mark the revision done if every patch actually turned on. A failure
// here means the hash or name drifted from the YAML, and retrying next boot
// is better than silently shipping a game that does not render.
val allEnabled = BUNDLED.all { b ->
// is better than silently shipping a game that does not render. The retry
// covers only `pending`, so a patch that is stuck failing cannot drag the
// already-settled ones back on every boot with it.
val allEnabled = pending.all { b ->
val ok = runCatching {
RPCSX.instance.patchSetEnabled(b.hash, b.name, b.serial, b.appVersion, true)
}.getOrDefault(false)
@@ -254,7 +287,7 @@ object Ps3PatchRepo {
if (allEnabled) {
prefs.edit().putInt(KEY_BUNDLED_REVISION, BUNDLED_REVISION).apply()
android.util.Log.i("ARMSX3", "canary patches: imported $imported, enabled ${BUNDLED.size}")
android.util.Log.i("ARMSX3", "canary patches: imported $imported, enabled ${pending.size}")
}
}
}
@@ -77,6 +77,13 @@ object ConfigStore {
private const val KEY_TUNING_OVERRIDES_PURGED = "config.migrated.tuningOverridesPurged"
// Per-title Accurate SPU Reservations values left behind by the same debugging.
private const val KEY_PERGAME_RSV_CLEARED = "config.migrated.perGameRsvCleared"
// The GLOBAL Accurate SPU Reservations value left off by the same debugging. The per-title
// clear above never touched it, so installs carried an off-spec global for releases.
private const val KEY_GLOBAL_RSV_ON = "config.migrated.globalSpuRsvOn"
// "Save LLVM logs", left on while chasing the Saint Seiya register scavenger. Bumped: the
// first pass only un-pinned the override, which does nothing for a key no code writes -- the
// value already in config.yml is reloaded and saved again on every boot.
private const val KEY_LLVM_LOGS_OFF_2 = "config.migrated.llvmLogsOff2"
private const val KEY_RELAXED_ZCULL_ON = "config.migrated.relaxedZcullOn"
private const val KEY_RELAXED_ZCULL_OFF = "config.migrated.relaxedZcullOff"
// The relaxed-ZCULL default was recorded as a raw core override as well, and the OFF
@@ -109,7 +116,20 @@ object ConfigStore {
private const val BACKUP_FILENAME = "armsx2-settings.json"
private fun keyForGame(serial: String) = "config.game.$serial"
// Memoized result of loadGlobal(). The function below is a JSON parse plus every
// migration block in this file -- 24,555 dex instructions by ART's count, over its
// JIT ceiling, so it runs interpreted every single call. That would be fine if it
// were called rarely, but EmulationSurface's frame-rate monitor re-resolves the
// config every 5 seconds of gameplay (measured: one ART bailout log line per 5.00s
// for entire sessions), all to read one boolean. The migrations are one-shot by
// their own prefs flags, so caching the parsed result is behavior-identical; the
// cache is refreshed by saveGlobal (the only writer of KEY_GLOBAL after boot) and
// dropped by reconcileReusedFolder, whose restore writes the pref directly.
@Volatile
private var cachedGlobal: Settings? = null
fun loadGlobal(): Settings {
cachedGlobal?.let { return it }
val raw = MainActivityRuntime.prefs.getString(KEY_GLOBAL, null)
var parsed = if (raw != null) {
try { Settings.fromJson(JSONObject(raw)) } catch (_: Exception) { Settings() }
@@ -369,6 +389,55 @@ object ConfigStore {
MainActivityRuntime.prefs.edit { putBoolean(KEY_ATOMIC_DMA_OFF, true) }
}
// Put the GLOBAL Accurate SPU Reservations back on, which is upstream's default and this
// app's default too.
//
// Off is not a slower-but-correct trade, it is off-spec: it forces the SPURS scheduler to
// HLE and bypasses the reservation lock, so SPU threads desync and end up executing
// whatever they land on. See KEY_PERGAME_RSV_CLEARED below, which cleared the PER-TITLE
// values this same debugging left behind -- but never the global, so an install kept
// running off-spec no matter what any title said.
//
// Found via Borderlands 2 hanging after its logo with one SPURS SPU and the RSX pinned
// while every PPU sat in a legitimate wait. The same desync is the likeliest source of the
// wild guest register values that were crashing the process before that.
if (!MainActivityRuntime.prefs.getBoolean(KEY_GLOBAL_RSV_ON, false)) {
if (raw != null && !parsed.ps3.accurateSpuRsv) {
parsed = parsed.copy(ps3 = parsed.ps3.copy(accurateSpuRsv = true))
dirty = true
}
// Both stores, because either alone is not enough: a raw core override is re-pushed
// after the settings themselves, so one left recorded would put false straight back
// over the line above. KEY_TUNING_OVERRIDES_PURGED cleared these once already, but it
// marks itself done, so anything recorded afterwards survived it.
//
// Global scope ONLY, deliberately. This is correcting the baseline everyone inherited,
// not overruling a per-title decision -- Web of Shadows (BLUS30218) is kept off on
// purpose, and forgetEverywhere() would take that with it.
runCatching {
CoreSettingOverrides.forget(SettingsScope.Global, null, "Core@@Accurate SPU Reservations")
}
MainActivityRuntime.prefs.edit { putBoolean(KEY_GLOBAL_RSV_ON, true) }
}
// Drop "Save LLVM logs", left pinned as a raw override while chasing the Saint Seiya
// register-scavenger failure.
//
// Upstream defaults it off and this app has no field or UI for it, so nothing would ever
// turn it back off again -- it writes the IR for every compiled module to disk on every
// boot, which costs compile time and a lot of storage for output nobody is reading.
// Recorded as false rather than merely un-pinned, and that distinction is the whole fix:
// forgetting an override only stops us re-pushing a value, and nothing in this app writes
// this key at all, so whatever is already in config.yml is simply reloaded and saved again
// forever. It has to be actively written off, the way the Vblank migration writes 60.
if (!MainActivityRuntime.prefs.getBoolean(KEY_LLVM_LOGS_OFF_2, false)) {
runCatching {
CoreSettingOverrides.record(SettingsScope.Global, null, "Core@@Save LLVM logs", "false")
}
MainActivityRuntime.prefs.edit { putBoolean(KEY_LLVM_LOGS_OFF_2, true) }
}
// Put Vblank Rate back to 60, which is both upstream's default and what a PS3
// actually runs at.
//
@@ -567,6 +636,7 @@ object ConfigStore {
}
if (dirty) saveGlobal(parsed)
cachedGlobal = parsed
return parsed
}
@@ -586,6 +656,7 @@ object ConfigStore {
fun saveGlobal(s: Settings) {
MainActivityRuntime.prefs.edit { putString(KEY_GLOBAL, s.toJson().toString()) }
cachedGlobal = s
writeBackupMirror()
}
@@ -770,6 +841,10 @@ object ConfigStore {
// Hard guard: an existing new-UI user (has config.global) is off-limits.
if (MainActivityRuntime.prefs.getString(KEY_GLOBAL, null) != null) return
// The restore below writes KEY_GLOBAL behind loadGlobal's back; drop any
// default Settings() a pre-restore call may have pinned in the cache.
cachedGlobal = null
// (1) Lossless restore from the in-folder mirror (written by a prior new-UI install).
val mirror = backupFile()
if (mirror != null && mirror.exists() && mirror.length() > 0L) {
@@ -212,6 +212,22 @@ data class Ps3Settings(
* the world uses cross, which is why RPCS3 exposes it rather than deriving it from region.
*/
val enterButtonAssign: Int = 1,
/**
* The rest of the console's identity, as cellSysutil reports it to games: language, region,
* keyboard layout and clock formats.
*
* All five are an INDEX into the tables in Rpcs3Settings, not the core's enum value, and the
* bridge turns them into the enum NAME the config expects. Defaults match upstream --
* English (US), SCEA, US keyboard, ddmmyyyy, clock24 -- so an existing install is unchanged.
*
* A game reads these: the language decides which text a multi-language disc shows, and the
* region is what makes a title behave as its NTSC or PAL self.
*/
val consoleLanguage: Int = 1,
val consoleRegion: Int = 1,
val keyboardType: Int = 0,
val dateFormat: Int = 1,
val timeFormat: Int = 1,
val spuXFloat: Int = 1,
val accurateSpuRsv: Boolean = true,
/**
@@ -1090,6 +1106,11 @@ data class Settings(
put("PS3/Net", "PSN status", "enum", ps3.psnStatus.toString())
put("PS3/Net", "UPNP Enabled", "bool", ps3.upnpEnabled.toString())
put("PS3/System", "Enter button assignment", "enum", ps3.enterButtonAssign.toString())
put("PS3/System", "Language", "enum", ps3.consoleLanguage.toString())
put("PS3/System", "License Area", "enum", ps3.consoleRegion.toString())
put("PS3/System", "Keyboard Type", "enum", ps3.keyboardType.toString())
put("PS3/System", "Date Format", "enum", ps3.dateFormat.toString())
put("PS3/System", "Time Format", "enum", ps3.timeFormat.toString())
put("PS3/Core", "SPU XFloat Accuracy", "enum", ps3.spuXFloat.toString())
put("PS3/Core", "Accurate SPU Reservations", "bool", ps3.accurateSpuRsv.toString())
put("PS3/Core", "Accurate Cache Line Stores", "bool", ps3.accurateCacheLine.toString())
@@ -2062,6 +2083,11 @@ data class Settings(
put("ps3PsnStatus", ps3.psnStatus)
put("ps3UpnpEnabled", ps3.upnpEnabled)
put("ps3EnterButtonAssign", ps3.enterButtonAssign)
put("ps3ConsoleLanguage", ps3.consoleLanguage)
put("ps3ConsoleRegion", ps3.consoleRegion)
put("ps3KeyboardType", ps3.keyboardType)
put("ps3DateFormat", ps3.dateFormat)
put("ps3TimeFormat", ps3.timeFormat)
put("ps3SpuXFloat", ps3.spuXFloat)
put("ps3AccurateSpuRsv", ps3.accurateSpuRsv)
put("ps3AccurateCacheLine", ps3.accurateCacheLine)
@@ -2404,6 +2430,11 @@ data class Settings(
psnStatus = json.optBoolean("ps3PsnStatus", def.ps3.psnStatus),
upnpEnabled = json.optBoolean("ps3UpnpEnabled", def.ps3.upnpEnabled),
enterButtonAssign = json.optInt("ps3EnterButtonAssign", def.ps3.enterButtonAssign),
consoleLanguage = json.optInt("ps3ConsoleLanguage", def.ps3.consoleLanguage),
consoleRegion = json.optInt("ps3ConsoleRegion", def.ps3.consoleRegion),
keyboardType = json.optInt("ps3KeyboardType", def.ps3.keyboardType),
dateFormat = json.optInt("ps3DateFormat", def.ps3.dateFormat),
timeFormat = json.optInt("ps3TimeFormat", def.ps3.timeFormat),
spuXFloat = json.optInt("ps3SpuXFloat", def.ps3.spuXFloat),
accurateSpuRsv = json.optBoolean("ps3AccurateSpuRsv", def.ps3.accurateSpuRsv),
accurateCacheLine = json.optBoolean("ps3AccurateCacheLine", def.ps3.accurateCacheLine),
@@ -2726,6 +2757,11 @@ data class Settings(
if (current.ps3.psnStatus != base.ps3.psnStatus) j.put("ps3PsnStatus", current.ps3.psnStatus)
if (current.ps3.upnpEnabled != base.ps3.upnpEnabled) j.put("ps3UpnpEnabled", current.ps3.upnpEnabled)
if (current.ps3.enterButtonAssign != base.ps3.enterButtonAssign) j.put("ps3EnterButtonAssign", current.ps3.enterButtonAssign)
if (current.ps3.consoleLanguage != base.ps3.consoleLanguage) j.put("ps3ConsoleLanguage", current.ps3.consoleLanguage)
if (current.ps3.consoleRegion != base.ps3.consoleRegion) j.put("ps3ConsoleRegion", current.ps3.consoleRegion)
if (current.ps3.keyboardType != base.ps3.keyboardType) j.put("ps3KeyboardType", current.ps3.keyboardType)
if (current.ps3.dateFormat != base.ps3.dateFormat) j.put("ps3DateFormat", current.ps3.dateFormat)
if (current.ps3.timeFormat != base.ps3.timeFormat) j.put("ps3TimeFormat", current.ps3.timeFormat)
if (current.ps3.spuXFloat != base.ps3.spuXFloat) j.put("ps3SpuXFloat", current.ps3.spuXFloat)
if (current.ps3.accurateSpuRsv != base.ps3.accurateSpuRsv) j.put("ps3AccurateSpuRsv", current.ps3.accurateSpuRsv)
if (current.ps3.accurateCacheLine != base.ps3.accurateCacheLine) j.put("ps3AccurateCacheLine", current.ps3.accurateCacheLine)
@@ -3029,6 +3065,11 @@ data class Settings(
psnStatus = if (overrides.has("ps3PsnStatus")) overrides.getBoolean("ps3PsnStatus") else base.ps3.psnStatus,
upnpEnabled = if (overrides.has("ps3UpnpEnabled")) overrides.getBoolean("ps3UpnpEnabled") else base.ps3.upnpEnabled,
enterButtonAssign = if (overrides.has("ps3EnterButtonAssign")) overrides.getInt("ps3EnterButtonAssign") else base.ps3.enterButtonAssign,
consoleLanguage = if (overrides.has("ps3ConsoleLanguage")) overrides.getInt("ps3ConsoleLanguage") else base.ps3.consoleLanguage,
consoleRegion = if (overrides.has("ps3ConsoleRegion")) overrides.getInt("ps3ConsoleRegion") else base.ps3.consoleRegion,
keyboardType = if (overrides.has("ps3KeyboardType")) overrides.getInt("ps3KeyboardType") else base.ps3.keyboardType,
dateFormat = if (overrides.has("ps3DateFormat")) overrides.getInt("ps3DateFormat") else base.ps3.dateFormat,
timeFormat = if (overrides.has("ps3TimeFormat")) overrides.getInt("ps3TimeFormat") else base.ps3.timeFormat,
spuXFloat = if (overrides.has("ps3SpuXFloat")) overrides.getInt("ps3SpuXFloat") else base.ps3.spuXFloat,
accurateSpuRsv = if (overrides.has("ps3AccurateSpuRsv")) overrides.getBoolean("ps3AccurateSpuRsv") else base.ps3.accurateSpuRsv,
accurateCacheLine = if (overrides.has("ps3AccurateCacheLine")) overrides.getBoolean("ps3AccurateCacheLine") else base.ps3.accurateCacheLine,
@@ -16,6 +16,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.armsx2.CustomCovers
import com.armsx2.DiscIcons
import com.armsx3.NativeApp
import net.rpcsx.GameFlag
@@ -507,6 +508,73 @@ class GameLibraryRepository(private val context: Context) {
}
}
/**
* Drop the separator, but only from something that actually looks like a title ID.
*
* The check is not about which console the game is for -- this emulator runs PS3 titles
* and nothing else. It is about not manufacturing an identity out of a guess.
* FilenameParser takes the first four-letters + five-digits token it finds ANYWHERE in
* the name and reconstructs it in the PS2 dump shape it was written for, so what arrives
* here may be a real id (BLUS-30917), a token out of a release tag, or an id belonging to
* a different game entirely.
*
* Left hyphenated, a bad guess matches nothing: no cover, no disc icon, no config, and
* the card shows a placeholder. That is a visible failure and it is the safe one.
* Stripped, the same guess becomes a WELL-FORMED title id and quietly resolves whatever
* is filed under it -- another game's cover and curated name, and its config_db entry,
* which the core applies at boot. So normalise only what carries a real PS3 prefix:
* B for disc releases (BLUS/BLES/BCUS...), N for PSN (NPUB/NPEB...).
*
* Deliberately NOT gated on [GamePlatform]: that enum comes from the same probe that
* produced the serial, so in the one case this function exists for -- the probe failed
* and the name came off the filename -- it carries no information at all.
*/
private fun normalizeSerial(raw: String): String {
val stripped = raw.replace("-", "")
return if (ps3SerialRegex.matches(stripped)) stripped else raw
}
/**
* Carry a game's per-serial data across an identity correction.
*
* The serial is not just the cover key: it keys config.game.<serial>, per-game core
* overrides, touch layouts and profiles, pad bindings, play time, the pinned name and
* the custom cover file. Renaming the game without moving those silently resets every
* one of them, and nothing prunes the old keys afterwards, so they become unreachable
* rather than merely unused.
*
* A same-named key already under the new id is overwritten. It can only have come from
* an earlier scan that probed the disc successfully, before this entry regressed to a
* filename-derived id; the hyphenated one is what the game has actually been running
* with since, so it is the live value and the older one is stale.
*/
private fun migrateSerialKeys(old: String, new: String) {
runCatching {
val prefs = MainActivityRuntime.prefs
val snapshot = prefs.all
val moved = snapshot.keys.filter { it.contains(old) }
if (moved.isNotEmpty()) {
prefs.edit().apply {
moved.forEach { key ->
when (val value = snapshot[key]) {
is String -> putString(key.replace(old, new), value)
is Int -> putInt(key.replace(old, new), value)
is Long -> putLong(key.replace(old, new), value)
is Boolean -> putBoolean(key.replace(old, new), value)
is Float -> putFloat(key.replace(old, new), value)
is Set<*> -> @Suppress("UNCHECKED_CAST")
putStringSet(key.replace(old, new), value as Set<String>)
else -> return@forEach
}
remove(key)
}
}.apply()
}
CustomCovers.renameSerial(context, old, new)
android.util.Log.i(ScanTag, "serial '$old' -> '$new' (${moved.size} pref key(s) moved)")
}
}
private fun createGame(
uri: Uri,
name: String,
@@ -516,9 +584,22 @@ class GameLibraryRepository(private val context: Context) {
): GameInfo {
val (probeSerial, probePlatform) = parseProbe(rawProbe)
val (fileTitle, fileSerial) = FilenameParser.parse(name)
val platform = if (disc != null) GamePlatform.PS3 else probePlatform ?: GamePlatform.PS3
// The disc's own PARAM.SFO wins: it is the authoritative title ID, where
// a filename-derived one is a guess off a dump's naming convention.
val serial = disc?.titleId ?: probeSerial ?: fileSerial
//
// Normalise the result: FilenameParser reconstructs every serial in the PS2 dump
// shape (SLUS-20312) because that is the convention its regex was written for, so a
// PS3 game whose serial came off the filename is recorded as BLUS-30917 and matches
// nothing -- not the cover repo (keyed by the exact PARAM.SFO id), not disc-icons,
// not config.game.<serial>. Doing it here rather than in FilenameParser is what
// repairs the entries already cached: they are re-seeded into discInfoCache on every
// scan and arrive back here as disc.titleId, which has top priority.
val rawSerial = disc?.titleId ?: probeSerial ?: fileSerial
val serial = rawSerial?.let(::normalizeSerial)
if (rawSerial != null && serial != null && rawSerial != serial) {
migrateSerialKeys(rawSerial, serial)
}
val compatibility = serial
?.let { runCatching { NativeApp.getCompatibilityForSerial(it) }.getOrDefault(0) }
?.minus(1)
@@ -537,7 +618,7 @@ class GameLibraryRepository(private val context: Context) {
serial = serial,
compatibility = compatibility,
extension = extension.uppercase(),
platform = if (disc != null) GamePlatform.PS3 else probePlatform ?: GamePlatform.PS3,
platform = platform,
// Only meaningful alongside a DB title; a filename-derived one has no sort key
// and is not a translation of anything.
titleSort = db?.sort.orEmpty(),
@@ -669,8 +750,16 @@ class GameLibraryRepository(private val context: Context) {
/** v2: PS3 title ID + title + ICON0.PNG read from the disc's PARAM.SFO.
* v5: folder-format games (JB folder / installed game folder).
* v6: PARAM.SFO CATEGORY read, to drop game-data installs.
* v7: licence-locked state, asked of the core per installed title. */
const val ScanSchemaVersion = 7
* v7: licence-locked state, asked of the core per installed title.
* v8: PS3 serials normalised (BLUS-30917 -> BLUS30917). The scanner does not
* extract a NEW field here, it changes the VALUE of one it already stored, which
* has the same staleness signature: without a bump an existing install keeps
* serving the cached hyphenated ids and never rescans, so the repair never
* reaches the libraries that need it. */
/** PS3 disc ids are B***, PSN ids N***, both four letters and five digits. */
val ps3SerialRegex = Regex("^[BN][A-Z]{3}[0-9]{5}$")
const val ScanSchemaVersion = 8
const val ScanTag = "ARMSX3-Scan"
/** Staging name for an extracted icon, renamed once the title ID is known. */
const val PendingIcon = "__pending"
@@ -546,6 +546,20 @@ val EN: Map<String, String> = mapOf(
"adv.dazFtz.label" to "Denormals As Zero / Flush To Zero",
"adv.dazFtz.description" to "Treats extremely small floating-point values as zero. Faster on some hardware, but incorrect for games that rely on denormals.",
"adv.section.system" to "System",
"adv.section.console" to "Console",
"adv.consoleLanguage.label" to "Console Language",
"adv.consoleLanguage.description" to "The language the console reports to games. A disc with several languages on it picks its text from this, not from the app's language.",
"adv.consoleRegion.label" to "Console Region",
"adv.consoleRegion.description" to "Which region the console claims to be from: SCEA America, SCEE Europe, SCEJ Japan, SCEH Asia, SCEK Korea, SCH China. Some titles change behaviour to match.",
"adv.keyboardType.label" to "Keyboard Type",
"adv.keyboardType.description" to "Layout reported for a USB keyboard, which decides where the symbol keys land.",
"adv.dateFormat.label" to "Date Format",
"adv.dateFormat.ymd" to "Year/Month/Day",
"adv.dateFormat.dmy" to "Day/Month/Year",
"adv.dateFormat.mdy" to "Month/Day/Year",
"adv.timeFormat.label" to "Time Format",
"adv.timeFormat.clock12" to "12 hour",
"adv.timeFormat.clock24" to "24 hour",
"adv.sleepTimers.label" to "Sleep Timers Accuracy",
"adv.sleepTimers.asHost" to "As Host",
"adv.sleepTimers.usleep" to "Usleep Only",
@@ -695,7 +709,9 @@ val EN: Map<String, String> = mapOf(
"memcard.slot1" to "Slot 1",
"memcard.status.coreStarting" to "Core settings are still starting up.",
"network.address" to "Address",
"touch.stateAction.keyboard" to "KBD",
"network.emulateUsbKeyboard" to "Emulate USB Keyboard",
"net.usbKeyboard.description" to "Report a USB keyboard to the game. Needed by titles that require one \u2014 EverQuest Online Adventures, Konami-keyboard games \u2014 and for typing in online chat. A physical or Bluetooth keyboard works once this is on, and the \"On-Screen Keyboard (toggle)\" hotkey raises the Android keyboard over the game without pausing.",
"network.ethernetDevice" to "Ethernet Device",
"network.hddImage.dialogHint" to "File name (kept in the data folder) or a full path to an existing image.",
"network.hddImage.fieldLabel" to "HDD image",
@@ -4132,6 +4132,9 @@ open class MainActivityRuntime : ComponentActivity() {
* button and hold-button-then-push-direction both bind combos, and a push
* released with nothing else still binds the plain single direction. */
private val captureHeldSynth = HashSet<Int>()
/** Trigger pull that counts as a press while binding. Half, so resting drift cannot bind. */
private val CAPTURE_TRIGGER_ON = 0.5f
private fun handleCaptureMotion(ev: MotionEvent): Boolean {
// Desired engaged-direction set for this event: at most one per HAT axis
// pair and one per stick (dominant direction), so sweeping through a
@@ -4146,6 +4149,23 @@ open class MainActivityRuntime : ComponentActivity() {
// here is why its directions could never be bound.
val (capRightX, capRightY) = rightStickAxes(ev.deviceId)
captureStickCode(ev, capRightX, capRightY, false).takeIf { it != 0 }?.let { want.add(it) }
// Analog triggers. On a pad that reports L2/R2 as AXES rather than buttons they arrive
// here and never as a key, so the binder -- which lives in Compose's onPreviewKeyEvent --
// could not see them and L2/R2 simply would not bind. Everything else on the same pad
// binds, which is what makes it look player-specific rather than trigger-specific: it
// depends on the controller model, so a second pad of a different make fails where the
// first one worked. Reported on Player 2.
//
// Same axis pairs the gameplay path uses (sendTrigger), including the per-device third
// axis some pads put the right trigger on, so a trigger that works in game can be bound.
// Threshold is a deliberate half-pull: resting drift on a worn trigger must not self-bind.
val capLt = maxOf(ev.getAxisValue(MotionEvent.AXIS_LTRIGGER), ev.getAxisValue(MotionEvent.AXIS_BRAKE))
var capRt = maxOf(ev.getAxisValue(MotionEvent.AXIS_RTRIGGER), ev.getAxisValue(MotionEvent.AXIS_GAS))
rightTriggerExtraAxis(ev.deviceId).takeIf { it != 0 }?.let { extra ->
capRt = maxOf(capRt, ev.getAxisValue(extra))
}
if (capLt >= CAPTURE_TRIGGER_ON) want.add(KeyEvent.KEYCODE_BUTTON_L2)
if (capRt >= CAPTURE_TRIGGER_ON) want.add(KeyEvent.KEYCODE_BUTTON_R2)
captureHatX = dx
captureHatY = dy
val now = SystemClock.uptimeMillis()
@@ -4896,7 +4916,13 @@ open class MainActivityRuntime : ComponentActivity() {
// worked (.iso/.bin/.chd) can never be made worse by this.
val uri = resolveCueToTrack(raw) ?: raw
currentGame.value = null
pendingExternalLaunch.value = uri.toString()
// A file:// URI has to be reduced to its path before the core sees it. Handed the string
// form, the core takes "file:///sdcard/x/y.elf" as a filesystem path: it mounts /app_home
// at "/file:/sdcard/x/" and then reports "Failed to open executable". content:// is passed
// through untouched, since the core opens those by fd. This is the same conversion
// launchCurrentGameFromSaveSlot already does, and it was simply missing on the external
// path -- so anything launching us with file:// (a file manager, a front-end, adb) failed.
pendingExternalLaunch.value = if (uri.scheme == "file") (uri.path ?: uri.toString()) else uri.toString()
launchPendingExternalGameIfReady()
}
@@ -67,30 +67,49 @@ fun BiosManagerScreen(onBack: () -> Unit, game: com.armsx2.GameInfo? = null) {
// a successful install.
LaunchedEffect(Unit) { runCatching { FirmwareRepository.load() } }
// Install takes a URI, not a File.
//
// The core is handed a file descriptor either way, and openAssetFileDescriptor resolves a
// content:// URI exactly as happily as a file://. Routing both paths through one function is
// what lets the SAF picker below share it -- the in-app browser cannot reach a MicroSD on
// Android 11 and later, because storageRoots() enumerates /storage by POSIX and a removable
// volume is not listable that way. Reported on an Odin 3 Max: the picker showed internal
// storage only. The package installer already had this second route; firmware never did.
val installFirmware: (android.net.Uri) -> Unit = { uri ->
busy = true
message = null
MainActivityRuntime.invoke {
val ok = withContext(Dispatchers.IO) {
runCatching {
val id = ProgressRepository.create(context, "Installing firmware")
context.contentResolver
.openAssetFileDescriptor(uri, "r")
.use { afd ->
val fd = afd?.parcelFileDescriptor?.fd
?: return@runCatching false
RPCSX.instance.installFw(fd, id)
}
}.getOrDefault(false)
}
busy = false
if (!ok) message = I18n.get("bios.firmware.failed")
}
}
// */* rather than a PUP MIME type: Android has no type for a PS3 firmware update, and every
// provider reports something different for it -- octet-stream, nothing at all, or the type of
// whatever extension it guesses. A filtered picker would grey the file out on some devices.
val safPicker = androidx.activity.compose.rememberLauncherForActivityResult(
androidx.activity.result.contract.ActivityResultContracts.OpenDocument(),
) { uri -> uri?.let(installFirmware) }
if (showBrowser) {
FileBrowserDialog(
title = str("setup.bios.selectTitle"),
extensions = setOf("pup"),
onPick = { file ->
showBrowser = false
busy = true
message = null
MainActivityRuntime.invoke {
val ok = withContext(Dispatchers.IO) {
runCatching {
val id = ProgressRepository.create(context, "Installing firmware")
context.contentResolver
.openAssetFileDescriptor(android.net.Uri.fromFile(file), "r")
.use { afd ->
val fd = afd?.parcelFileDescriptor?.fd
?: return@runCatching false
RPCSX.instance.installFw(fd, id)
}
}.getOrDefault(false)
}
busy = false
if (!ok) message = I18n.get("bios.firmware.failed")
}
installFirmware(android.net.Uri.fromFile(file))
},
onDismiss = { showBrowser = false },
)
@@ -184,6 +203,24 @@ fun BiosManagerScreen(onBack: () -> Unit, game: com.armsx2.GameInfo? = null) {
else str("bios.firmware.reinstall"),
)
}
// Reaches storage the in-app browser cannot open by path: USB-OTG, and MicroSD on
// devices that only expose it through SAF. Always offered, not just when canBrowse()
// fails -- a device can have all-files access AND still hide its card from a POSIX
// walk of /storage, which is exactly the case that was reported.
OutlinedButton(
onClick = { safPicker.launch(arrayOf("*/*")) },
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp)
.controllerFocusable(
"firmware.install.external",
RoundedCornerShape(13.dp),
onConfirm = { safPicker.launch(arrayOf("*/*")) },
),
) {
Text(str("packages.select.external"))
}
}
OutlinedButton(
@@ -539,7 +539,7 @@ private fun MenuHeader(
Column(Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
game?.title ?: "PlayStation 2",
game?.title ?: "PlayStation 3",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
@@ -153,6 +153,61 @@ fun FixesTab(state: MutableState<Settings>) {
) { apply(s.copy(ps3 = s.ps3.copy(debugConsoleMode = it))) }
}
// What the emulated console tells games about itself.
//
// These are not app preferences: a multi-language disc picks its text from the console
// language, and region is what makes a title behave as its NTSC or PAL self. Option
// labels come from the core's own enum names (the same strings the All Core Settings
// screen shows) rather than 60 new translation keys -- they are proper nouns like
// "SCEA" and keyboard layout names, which are not usefully translated.
CollapsibleSection(str("adv.section.console")) {
SegmentedGridRow(
label = str("adv.consoleLanguage.label"),
options = com.armsx3.Rpcs3Settings.consoleLanguageNames(),
selectedIndex = s.ps3.consoleLanguage.coerceIn(0, com.armsx3.Rpcs3Settings.consoleLanguageNames().lastIndex),
columns = 2,
description = str("adv.consoleLanguage.description"),
onChange = { apply(s.copy(ps3 = s.ps3.copy(consoleLanguage = it))) },
)
SettingsDivider()
SegmentedGridRow(
label = str("adv.consoleRegion.label"),
options = com.armsx3.Rpcs3Settings.consoleRegionNames(),
selectedIndex = s.ps3.consoleRegion.coerceIn(0, com.armsx3.Rpcs3Settings.consoleRegionNames().lastIndex),
columns = 3,
description = str("adv.consoleRegion.description"),
onChange = { apply(s.copy(ps3 = s.ps3.copy(consoleRegion = it))) },
)
SettingsDivider()
SegmentedGridRow(
label = str("adv.keyboardType.label"),
options = com.armsx3.Rpcs3Settings.keyboardTypeNames(),
selectedIndex = s.ps3.keyboardType.coerceIn(0, com.armsx3.Rpcs3Settings.keyboardTypeNames().lastIndex),
columns = 1,
description = str("adv.keyboardType.description"),
onChange = { apply(s.copy(ps3 = s.ps3.copy(keyboardType = it))) },
)
SettingsDivider()
SegmentedGridRow(
label = str("adv.dateFormat.label"),
options = listOf(
str("adv.dateFormat.ymd"),
str("adv.dateFormat.dmy"),
str("adv.dateFormat.mdy"),
),
selectedIndex = s.ps3.dateFormat.coerceIn(0, 2),
columns = 3,
onChange = { apply(s.copy(ps3 = s.ps3.copy(dateFormat = it))) },
)
SettingsDivider()
SegmentedRow(
label = str("adv.timeFormat.label"),
options = listOf(str("adv.timeFormat.clock12"), str("adv.timeFormat.clock24")),
selectedIndex = s.ps3.timeFormat.coerceIn(0, 1),
onChange = { apply(s.copy(ps3 = s.ps3.copy(timeFormat = it))) },
)
}
Spacer(Modifier.height(12.dp))
}
}
@@ -76,6 +76,16 @@ fun NetworkTab(state: MutableState<Settings>) {
s.ps3.upnpEnabled,
description = str("net.upnp.description"),
) { apply(s.copy(ps3 = s.ps3.copy(upnpEnabled = it))) }
SettingsDivider()
// Emulate USB Keyboard. Previously reachable ONLY from the in-game pause menu, which made
// the "On-Screen Keyboard (toggle)" hotkey's own message a dead end: it says to turn this
// on in Network settings, and there was nothing here to turn on. Same field, so the two
// rows stay in sync.
ToggleRow(
str("network.emulateUsbKeyboard"),
s.usbKeyboard,
description = str("net.usbKeyboard.description"),
) { apply(s.copy(usbKeyboard = it)) }
}
}
@@ -455,8 +455,9 @@ fun PerformanceTab(state: MutableState<Settings>) {
* Root of RPCS3's compiled-code cache: `<files>/cache/cache/`.
*
* Holds `ppu-<hash>-<name>` directories for firmware modules at the top level, plus
* `<TITLEID>/ppu-<hash>-EBOOT.BIN` per game. The SPU cache is a `spu-*.dat` file INSIDE
* those directories, which is why clearing PPU necessarily clears SPU with it.
* `<TITLEID>/ppu-<hash>-EBOOT.BIN` per game. The SPU caches live INSIDE those directories -- a
* `spu-*.dat` file and a `spuobj-v<n>-<key>/` directory of compiled objects -- which is why
* clearing PPU necessarily clears SPU with it.
*/
private fun recompilerCacheRoot(context: Context): File =
File(MainActivityRuntime.assetCopyRoot(context), "cache/cache")
@@ -493,16 +494,36 @@ private fun clearRecompilerCache(root: File, spuOnly: Boolean): Pair<Int, Long>
var bytes = 0L
if (spuOnly) {
root.walkTopDown()
.filter { it.isFile && it.name.startsWith("spu-") && it.extension == "dat" }
// Two things live here, not one. `spu-*.dat` is the original SPU cache; the persistent SPU
// LLVM object cache sits beside it in `spuobj-v<n>-<key>/` directories and is by far the
// larger of the two. Clearing only the .dat files handed the recompiler straight back the
// objects the user pressed this button to be rid of -- which is exactly what someone
// clearing an SPU cache to escape stale compiled code needs not to happen.
val objDirs = root.walkTopDown()
.filter { it.isDirectory && it.name.startsWith("spuobj-") }
.toList() // materialise before deleting, so the walk is not mutated under itself
.forEach { file ->
val size = file.length()
if (runCatching { file.delete() }.getOrDefault(false)) {
count++
bytes += size
}
val datFiles = root.walkTopDown()
.filter { it.isFile && it.name.startsWith("spu-") && it.extension == "dat" }
.toList()
objDirs.forEach { dir ->
val size = dir.sizeRecursive()
if (runCatching { dir.deleteRecursively() }.getOrDefault(false)) {
count++
bytes += size
}
}
datFiles.forEach { file ->
// exists() because a .dat inside one of the directories above is already gone.
if (!file.exists()) return@forEach
val size = file.length()
if (runCatching { file.delete() }.getOrDefault(false)) {
count++
bytes += size
}
}
return count to bytes
}
@@ -146,6 +146,7 @@ internal val SETTINGS_SEARCH_INDEX: List<SettingsSearchEntry> = listOf(
SettingsSearchEntry("net.internet.label", true, SettingsCategory.Network),
SettingsSearchEntry("net.psn.label", true, SettingsCategory.Network),
SettingsSearchEntry("net.upnp.label", true, SettingsCategory.Network),
SettingsSearchEntry("network.emulateUsbKeyboard", true, SettingsCategory.Network),
SettingsSearchEntry("overlay.osdSize.label", true, SettingsCategory.OnScreen),
SettingsSearchEntry("overlay.osdColor.label", true, SettingsCategory.OnScreen),
SettingsSearchEntry("overlay.uiSize.label", true, SettingsCategory.OnScreen),
@@ -1325,7 +1325,14 @@ enum class TouchButtonId(val label: String, val keycode: Int, val kind: Kind) {
// additionally hit-tests this widget's own circle so a thumb that glides up off
// the stick latches it without lifting; see StickWidget. Its keycode is chosen in
// Pad settings (analogExtraKeycode), so the enum entry carries none.
ANALOG_EXTRA("Extra", 0, Kind.ANALOGEXTRA);
ANALOG_EXTRA("Extra", 0, Kind.ANALOGEXTRA),
// On-screen keyboard toggle. Emits no PS3 keycode; tapping raises or drops the Android
// keyboard via MainActivityRuntime.toggleSoftKeyboard(), the same action as the
// "On-Screen Keyboard (toggle)" hotkey -- so a device with no spare pad button, or no
// controller at all, can still reach it. Requires Emulate USB Keyboard (Network settings);
// the action says so itself when it is off. Opt-in: absent from the default layout.
KEYBOARD("KBD", 0, Kind.STATEACTION);
enum class Kind { FACE, SHOULDER, MENU, DPAD, STICK, PAUSE, PRESSURE, FASTFORWARD, MACRO, STATEACTION, ANALOGEXTRA }
}
@@ -1495,6 +1502,12 @@ data class TouchLayout(val buttons: List<TouchButtonCfg>) {
TouchButtonCfg(TouchButtonId.SAVE_STATE, 0.30f, 0.54f, 44f, enabled = false),
TouchButtonCfg(TouchButtonId.LOAD_STATE, 0.38f, 0.54f, 44f, enabled = false),
TouchButtonCfg(TouchButtonId.SCREENSHOT, 0.46f, 0.54f, 44f, enabled = false),
// On-screen keyboard toggle -- OPT-IN, same row. Being listed here is what makes it
// reachable at all: the editor offers what the layout CONTAINS, not what the enum
// declares, so adding the TouchButtonId alone left it invisible. Existing layouts
// pick it up because fromJson splices in any default button they lack, and
// defaultPortrait splices from this same table.
TouchButtonCfg(TouchButtonId.KEYBOARD, 0.54f, 0.54f, 44f, enabled = false),
// Extra analog button, parked directly above the D-PAD (0.10, 0.55) — not above the
// left stick, which is where it first went. The button is for sprint/jump held while
// you keep moving, so it belongs above whichever control the thumb is already on,
@@ -569,6 +569,7 @@ private fun drawableFor(id: TouchButtonId, pressed: Boolean): Int = when (id) {
TouchButtonId.PAUSE, TouchButtonId.PRESSURE, TouchButtonId.FAST_FORWARD,
TouchButtonId.MACRO1, TouchButtonId.MACRO2, TouchButtonId.MACRO3, TouchButtonId.MACRO4,
TouchButtonId.SAVE_STATE, TouchButtonId.LOAD_STATE, TouchButtonId.SCREENSHOT,
TouchButtonId.KEYBOARD,
TouchButtonId.ANALOG_EXTRA -> R.drawable.pad_cross
}
@@ -1037,6 +1038,7 @@ private fun StateActionWidget(cfg: TouchButtonCfg, edit: Boolean) {
val label = when (cfg.id) {
TouchButtonId.SAVE_STATE -> str("touch.stateAction.save")
TouchButtonId.LOAD_STATE -> str("touch.stateAction.load")
TouchButtonId.KEYBOARD -> str("touch.stateAction.keyboard")
else -> str("touch.stateAction.screenshot")
}
if (edit) {
@@ -1062,6 +1064,7 @@ private fun StateActionWidget(cfg: TouchButtonCfg, edit: Boolean) {
when (cfg.id) {
TouchButtonId.SAVE_STATE -> MainActivityRuntime.instance?.saveState()
TouchButtonId.LOAD_STATE -> MainActivityRuntime.instance?.loadState()
TouchButtonId.KEYBOARD -> MainActivityRuntime.toggleSoftKeyboard()
else -> MainActivityRuntime.instance?.applicationContext?.let {
com.armsx2.Screenshots.capture(it)
}

Some files were not shown because too many files have changed in this diff Show More