It did its job -- it is what identified res - rtime == 128 as the constant behind
the Assassin's Creed hang -- and what it costs now is an atomic increment on a
shared static every time any conditional store fails, on every PPU thread. That is
cross-core traffic on the hot path of every guest atomic, for a question that has
been answered.
The measurement it produced is kept in the comment above the fix, which is the part
worth having.
Three fixes found while testing Assassin's Creed on a Turnip device.
fp16 was off on every Turnip install regardless of driver age. The gate that
re-enables it compares driverVersion against 512.676.53, which is QUALCOMM'S
numbering; is_ADRENO() is true for Turnip as well, and Turnip reports Mesa's
scheme -- 25.99.99 on an 8 Gen 2 -- which packs to a far smaller integer and can
never pass. So the log said "All float16_t arithmetic will be emulated with
float32_t" on the configuration these handhelds actually ship in.
It should not have been gated for Turnip at all. The failure being worked around
is Qualcomm's shader compiler rejecting SPIR-V with float16_t in it; Mesa's is a
different compiler. The version check stays for the proprietary driver, where it
was measured, and Turnip passes on its own account.
The on-screen keyboard raised its bar without raising a keyboard.
showSoftInput(SHOW_IMPLICIT) is a hint the system may decline, and it does for a
fullscreen immersive window like the game surface -- but visible was set either
way, so the extra-keys bar appeared over nothing. It now also drives the IME
through WindowInsetsControllerCompat, which is the supported route once
setDecorFitsSystemWindows(false) is in effect, and it already is. Both are used:
the old call still works on odd IMEs and asking twice costs nothing. hide()
mirrors it.
Space and Enter join the bar as wide keys. They are on the IME too, but the IME
is not reliably what comes up, and Space is the key that opens the debug menu
this was built for -- it should not depend on another keyboard appearing. Enter
was already there as a glyph and read as decoration; it says Enter now.
The emulated keyboard reaches games now, and the first thing it was used for was
a game's debug menu -- which opened on Space and then could not be navigated,
because a soft keyboard is built for typing text and has no arrows, no Escape and
no function row. For that job those are not optional extras, they are the whole
interaction.
A row of them floats above the IME whenever the keyboard is up: Esc, Tab, the
four arrows, Enter, and an Fn toggle for F1-F12. It scrolls sideways so nothing
is cut off on a narrow screen, and it appears and disappears with the keyboard,
so there is nothing to place in the touch layout and nothing to find.
This adds to the IME rather than replacing it. Prediction, swipe and non-Latin
input still come from whichever keyboard the user chose; only the keys that
keyboard cannot express come from here.
Taps go through SoftKeyboard.tap, the same paced queue the IME's own keys use, so
a press is held long enough for the guest to sample it. The keys use tap gesture
detection rather than clickable(): this sits beside a focused IME sink, and
anything focusable here can take focus off it and drop the keyboard mid-use.
The arrows are KEYCODE_DPAD_*, which the handler already maps to Qt's arrow
codes, so nothing was needed on the native side.
Four probes, all sampled or already on a timer, added while chasing the
Assassin's Creed loading hang. Each one existed because a fact that turned out to
be decisive was unreachable from outside.
sys_event: EVENTSPIN counts sends and receives and prints one line per million of
each. The syscall usage stats already said two calls dominated everything; they
could not say which port, which queue, or whether the receive blocked. It was one
thread posting a zero-data event to its own queue and taking it straight back,
2.2 million times a second -- a yield loop, not a deadlock, which is a different
thing to go looking for.
The SPU half of the stall dump gains res_now/res_moved and the first bytes of the
line an SPU is parked on. The wait loop wakes on either the counter moving or the
data changing, so a sleeping SPU proves the line is static rather than that a
notification was lost. Those are different bugs and the dump could not tell them
apart.
It also gains one kernel's registers and the local store around its pc, matching
what the PPU half has always printed. The SPU's decision to sleep is guest code;
the registers hold what it tested and the local store holds the test.
The PPU half gains the memory behind registers of a spinning thread. dump_all
prints eight bytes, enough to recognise a pointer and not enough to read the
struct behind it -- the field that settled this one was 0x74 bytes past a value
in r5.
One SPU and six pointers, deduplicated and capped: a dozen parked threads each
dragging a hex dump per register buries the report that explains the hang.
ldarx has a fast path: re-reserving the same 128-byte line the thread's last
successful stdcx wrote skips reloading ppu.rtime, on the theory that the hardware
caches the reservation across a chain of stores. The branch is empty, so rtime
keeps the value it had before that store -- and the store left the line's counter
128 higher. Every conditional store after the first one on a line therefore fails
its rtime != (res & -128) test by exactly 128, and keeps failing, because a guest
that retries re-enters the same fast path with the same stale value.
Assassin's Creed never gets past its loading screen because of it. libsre's
cellSpursAddUrgentCommand walks four urgent-command slots inside a single
reservation loop: it stores slot 0 back unchanged to release it, then can never
claim slot 1, and its failure path restarts the whole scan. Measured at 490
million failures on 0x102ed6b8, res-rtime == 128 on every one, data unchanged.
Downstream of that, everything else looked like the bug and was not. The job
chain is valid (workloadId 4), three of its four urgent slots stay empty, no
workload is ever marked ready, all six SPURS SPU kernels sleep correctly on a
control block nothing writes, and the main thread burns 2.2 million syscalls a
second in a yield loop waiting for a load that cannot finish. One missing
increment, five symptoms.
047f71b43 added this fast path for MGS4 along with a "rtime -= 128" here and a
"rtime += 128" in the store; the two cancel, and both were later dropped rather
than corrected, which left the fast path reading a stale value. Advancing rtime
at the store is what makes the empty branch correct: it is already current.
A raw override recorded in All Core Settings re-pushes at the tail of applyTo,
after the curated store has written the same node. So the recorded value wins
every time and the normal settings screen becomes decorative: it shows the
choice, saves the choice, and the choice is overwritten a moment later with
nothing on screen to say so. config.yml disagrees with the UI and there is no
way in the app to see why, or to undo it -- CoreSettingOverrides.clear() existed
and nothing called it.
That is not hypothetical. A test device carried
Core@@PPU Decoder = "Recompiler (LLVM)"
which silently defeated every attempt to boot a game on the interpreter,
including a run made specifically to find out whether a hang was a codegen bug.
The run happened, the setting was chosen, and the log recorded the recompiler.
All Core Settings now reads the store alongside the tree: it says how many paths
this scope remembers, marks each row that carries one, and offers Forget per row
and for the scope. Forgetting also restores the node -- the core's default first,
then a curated re-apply on top -- because dropping only the record would leave
the value it wrote still live, which looks like the button doing nothing.
Reset is two taps rather than an AlertDialog: dialogs swallow gamepad keys here,
and this screen opens from the in-game menu with only a controller in hand.
The migration clears the paths a curated screen also writes. Three others --
RSX Profiler, PPU Calling History, Disable SPU GETLLAR Spin Optimization -- have
no curated writer, so they are actively written back to their upstream default
rather than merely forgotten; forgetting alone would remove the record and keep
the effect. All three are instrumentation or debug levers that were never meant
to ship on.
Accurate ZCULL stats is deliberately left alone: no curated writer and no
debugging history, so a value there is most likely a deliberate per-game choice.
It is visible and clearable now, which is the point.
Two earlier migrations already purged diagnostics by name, and both had run on
the device that still had RSX Profiler recorded. Hence a screen rather than a
third list.
The keyboard setting, the on-screen keyboard hotkey, the touch button and the
IME plumbing all worked. Nothing behind them did, in three separate places:
- init_kb_handler installed NullKeyboardHandler unconditionally, so cellKb --
the API games actually read a keyboard through -- reported none attached no
matter what the UI said.
- NativeApp.usbKeyboardKey and usbSetKeyboardEnabled were Unsupported.note()
stubs. Every keystroke went into a no-op that returned false.
- The setting wrote [USB1] Type = hidkbd, which is PCSX2's emulated USB HID
keyboard. There is no such device in this core -- "hidkbd" appears nowhere
in it -- so that write only ever reached Unsupported.note("USB1/Type").
All three are inherited from the UI port, which is why the feature looked whole.
The desktop handler is a QObject that installs an event filter on a QWindow, so
none of it survives the port. It does not need to: everything that turns a key
into cellKb data already lives in KeyboardHandlerBase::HandleKey, and a concrete
handler owes it exactly one thing, a populated qt_code -> CELL_KEYC map.
virtual_keyboard_handler builds that map with the Qt key codes as literals, and
translates Android keycodes onto it -- including left/right modifiers, which have
to come back in the native_key encoding get_out_key_code compares against or
every modifier reads as the right-hand one.
The setting now writes Input/Output/Keyboard, which is what the core reads. That
happens once, in Emulator::Load, so it applies on the next boot rather than to a
game already running; the description says so now, because the old comment
claimed a live attach that never existed.
KeyEvent.getUnicodeChar() is carried through as well. cellKb derives its own
character from the raw code plus the live modifier state and does not need it,
but the emulator's own overlay text entry matches on the string.
__FILE__ expands to whatever path the compiler was handed, and RPCS3 prints source locations in
ensure() failures, fmt::throw_exception and assertions. Every one of those lines therefore carried
the full build directory into EVERY USER'S LOG -- on a developer machine that is a home directory,
and the shipped core contained 2500 copies of one username. It surfaced in a bug report where a
user's log showed somebody else's paths, which is a reasonable thing to be alarmed by.
-ffile-prefix-map rewrites the prefix at compile time, covering both __FILE__ and debug info, so
paths become relative-looking (./rpcs3/Emu/...) -- which is what a log wants to show anyway. No
runtime cost.
Applied before any add_subdirectory so third-party targets built in-tree are covered too: they
account for 32k of the roughly 52k embedded paths. Guarded with check_cxx_compiler_flag so a
toolchain without it still builds.
Two bugs, both mine, both in the new importer. Reported as "import failed, could not open
the zip".
The archive path could never succeed. These stage functions answer null to mean "no problem,
carry on", and importArchive folded the open into the same expression:
contentResolver.openInputStream(uri)?.use { stageArchive(...) }
?: Outcome(false, "Could not open the selected file")
so a null from stageArchive -- the SUCCESS return -- selected the elvis branch. Every archive
import reported a file it had in fact opened and read as unopenable, and returned before
discover() ever looked at what had been staged. The open is now checked on its own, so the
two cases cannot be confused again; the message was pointing at the wrong thing, which is
what sent the diagnosis into the zip handling.
Separately the file carried six NUL bytes where space character literals were meant:
trimEnd('\0'), it == '\0'. They compile -- '\0' is a valid Char -- so nothing complained, and
grep silently reports nothing for a file it decides is binary, which is why searching for
these came back empty on a file that plainly contained them.
Fixing them corrects the rule rather than restoring the intent, because the intent was wrong.
Spaces are ordinary in a downloaded folder name: "All Pro Football 2K8 roster/" is exactly
what a file manager produces. Stripping them silently renamed the user's folders, and the SAF
walk skipped any entry containing one, so a wrapper folder made its contents invisible. Source
names now reject only separators and control characters.
The destination name is no longer narrowed to a character set either. It comes from the game's
own SAVEDATA_DIRECTORY, and refusing one for holding an unanticipated character would produce
"no save data found" -- the same silent-looking failure this class exists to prevent. Only
separators, control characters and a leading dot are rejected, since those are what can
redirect a write or hide the result.
Android 11 stopped third-party file managers from writing into Android/data, so a user who
downloads a save cannot put it where the emulator reads from -- ZArchiver reports EACCES and
no file manager can do better. Reported against an All Pro Football 2K8 roster on an Ayn Thor
Pro. The platform rule is not ours to fix, but we are the only process that can still write
there and until now offered no way to ask us to, so there was no route in at all.
The destination folder name comes from the save's own PARAM.SFO rather than from what the
user's folder or archive is called. Games enumerate saves by matching dirNamePrefix against
the directory name, so a save under the wrong name produces no error the user ever sees --
the game just reports no save data and offers to start fresh, which reads as "the import did
nothing". The core writes SAVEDATA_DIRECTORY into every PARAM.SFO it saves and reads it back
to populate dirName, so the name that works travels inside the save; a renamed download still
lands correctly. The folder's own name is the fallback, and both go through the same
sanitiser because a value read out of a file is untrusted wherever it came from.
Staging follows TexturePackInstaller: everything is copied to a scratch directory beside the
destination, checked there, and only then renamed in, so a cancelled or failed import cannot
leave a half-written save and cannot destroy one the user already had. Archive entries are
rebuilt from sanitised components and any entry containing a '..' fails the whole archive --
this writes into app-private storage, which includes the native library directory, so a
zip-slip here is code execution rather than untidiness. Extracted sizes are counted while
writing rather than trusted from the entry header.
Two rows because the two pickers are different intents: an archive straight from a download,
or an already-unzipped folder. Both accept being pointed at either the save itself or a
parent holding several, since a user has no reason to know which they picked.
stageArchive/stageTree/commit are deliberately not savedata-specific -- the frame generation
plugin installer needs the same component (pick a file, verify it, atomically place it
somewhere the app owns) and should lift these rather than grow a second copy.
They are the one entry in that directory tracked in git rather than staged build output,
which is why .gitignore un-ignores them by name and verifyAngleLibs fails the build without
them.
0.9 shipped seven permanently-connected pads. Reported against LittleBigPlanet 2, which
reacts to the connected count and behaved as though 4+ controllers were plugged in at all
times.
Claiming all seven ports for the virtual handler is what lets a second controller work at
all -- that was the 0.9 fix and it stays -- but initVirtualPad called Init with
CELL_PAD_STATUS_CONNECTED unconditionally, and "this port exists" and "a controller is
plugged into this port" are not the same statement. cellPad derives now_connect by counting
ports whose status carries the CONNECTED bit, so every game asking how many pads were
attached was told seven.
Port 0 therefore starts CONNECTED and ports 1-6 start 0, with the bit set on a port the
first time real input arrives for it in _rpcsx_overlayPadData. No new event plumbing is
needed: pad_thread::update_pad_states already polls is_connected() against its cached
m_pads_connected -- value-initialised to false, so a port that starts disconnected fires
nothing at boot -- and calls pad_state_notify_state_change on a change, which is what
propagates m_port_status into cellPad's reported_info. Setting the bit is enough for the
existing path to publish it.
m_player_id is a const set at construction, not by Init, so reading it to pick the initial
status is safe at this point.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
From johnpetersa19. Fixes renderer.shaderChain.pass, which read "passar" -- the
verb "to pass" rather than a rendering pass -- and its plural, translates a
label left in English, and adds the packages.* strings added after the original
translation. Also drops five strings that were stored truncated mid-sentence;
English is better than half a sentence.
A release now carries four APKs rather than one, so the updater has to choose
the asset built for the device it is running on instead of taking the first it
finds. Matches on the variant suffix in the asset name and falls through to the
next release rather than giving up when one has no usable asset.
Adds the import row for Lossless.dll, the multiplier, Performance shaders and
Motion detail, plus the strings for all of it.
Frame Generation was not reaching the emulator. Rpcs3Bridge.setSetting is a
translation table keyed by (section, key) and anything absent is silently
dropped, so the toggle looked like it worked and did nothing. Enums also have
to cross as NAMES rather than indices -- sending "1" would have been wrong even
with the entry present. Found by an unconditional probe in the present path,
after being wrong about the cause twice; the probe printed mode=0 while the UI
held 1, which was the whole answer.
Performance shaders default ON. It selects framegen's 3.1p shader family
instead of 3.1, which is materially cheaper, and on a mobile GPU the
full-quality path costs more than the frames it buys. Both families are
extracted from the user's DLL already, so this switches between shaders that
are both sitting in the cache.
Motion detail is the optical-flow resolution, stored as a percentage rather
than upstream's divisor so the slider reads the right way round. Both take
effect when frame generation next starts, since the shader family and the flow
scale are baked into framegen's device and pipelines at initialize; the
descriptions say so.
The description also warns about the two things testers will otherwise report
as bugs: on-screen text shimmers because the overlay and the game's own menus
are interpolated along with everything else, and toggling mid-game pauses for
a few seconds while a second device and the pipelines are built.
The "OSD Color" row -- on the Overlay tab and cycled from the in-game menu --
wrote `osdColor`, which is PCSX2's EmuCore/GS/OsdColor plus a
NativeApp.osdSetColor() that is an Unsupported.note() stub here. Both dead, so
the control had never done anything and the overlay sat on whatever RPCS3
defaulted to, while the real picker sat a hundred lines further down the same
tab. Both rows now drive ps3.overlayBodyColor.
The defaults were also wrong in a way that made this worse: they held RPCS3's
RGBA hex verbatim in fields that argbToRgba reads as ARGB, so every channel was
rotated one byte and #FFE138FF orange rendered as #E138FFFF. That is the pink
the overlay has always drawn in, and it applied to picked colours too, so
nothing ever matched what the user chose.
The preset row shows no selection when the colour came from the RGBA sliders,
and the in-game row reads "Custom", rather than naming a preset that is not
active. Overlay position is now cycled from the in-game menu as well -- it was
only in All Settings, unreachable at the one moment it matters, when the stats
are sitting on top of something you are trying to see.
Also carries the two frame generation settings fields, which live in the same
Ps3 settings class.
The one-shot PPU state dump now follows its summary with what each PPU can
report about itself -- registers, the guest call stack, and the recent guest and
HLE/LV2 calls when PPU Calling History is on. Diagnosing the Saint Seiya stall
meant reconstructing that by hand from a log that only named the thread; cia
under the recompiler is written at block boundaries, so it names where a thread
has BEEN, not where it is, and the call history is only populated by the
interpreter.
cellSysutil's parameter query drops from warning to trace. Eternal Sonata
(BLJS10017) asks for ID_ENTER_BUTTON_ASSIGN twice every 33 ms and never stops,
which is about sixty lines a second for an entire session. Games polling this
is normal behaviour, not something to warn about, and the log volume alone is
enough to slow the emulator down.
Interpolates frames between the ones the game draws, at x2/x3/x4. The shaders
come from the user's own Lossless.dll; nothing is bundled or downloaded.
framegen runs on its OWN VkDevice and statically links volk, which defines 655
globals named vkCreateImage, vkQueueSubmit and so on -- including all 124 our
loader declares. Linked into the core those either fail to link or, worse,
merge, and framegen's volkLoadDevice() then repoints the whole RSX renderer at
framegen's device. So it lives in libarmsx3_lsfg.so, reached only by dlopen
with RTLD_LOCAL, behind a C ABI and a version script that exports eleven
symbols and nothing else. Verify with llvm-nm --dynamic --defined-only: only
armsx3_lsfg_* may appear.
Two devices with no shared semaphore means images cross as AHardwareBuffer --
Adreno and Mali both refuse vkGetMemoryFdKHR(OPAQUE_FD) on AHB-imported memory,
so upstream's FD path does not work on this hardware. Capture costs 0.007
ms/frame CPU, measured; the cost is the synchronisation, not the copies.
Notes for anyone reading this later:
* The shader loader's user pointer must outlive initialize(). framegen copies
the callback into ShaderPool::source and resolves shaders lazily while
BUILDING THE CONTEXT, so a stack local there is read back from a dead frame
-- a segfault executing at a mapped, non-executable address.
* The "device UUID" is not one. framegen matches (vendorID << 32) | deviceID.
Zero matches nothing.
* Imported shaders are cached to disk. They used to live only in the library's
map, so every restart silently had none and generate() returned 0 before
doing any work.
* Capture takes the COMPOSITED swapchain image, after overlays. Capturing the
game image put the perf overlay on real frames only, so it blinked at half
the display rate.
* generate() runs only on a frame the game actually drew, or the PPU/SPU
compilation screen gets interpolated too.
The pipelined path that would take waitIdle off the critical path is present but
disabled behind k_framegen_pipelining_enabled: holding a frame back conflicts
with frame-context recycling, and at least one reclaim path has not been found.
The serialised path is what works. Frame generation costs some real framerate
and wants a steady one -- interpolating an unstable rate reads as judder -- so
it is labelled experimental in the UI.
Cull mode, front face, depth test/write/compare and primitive topology move out
of pipeline identity and into per-draw state where VK_EXT_extended_dynamic_state
is available. Fewer pipeline objects to compile and cache is worth a lot on
Adreno and Mali, where first-run compilation is a visible source of stutter.
Topology only collapses within its class -- triangle list/strip/fan share one
pipeline, lines share one, points stand alone. vkCmdSetPrimitiveTopology cannot
cross classes without dynamicPrimitiveTopologyUnrestricted, which comes from
extended_dynamic_state3 and is not something mobile drivers report. The class
representative is restart-aware: primitive restart on a *_LIST topology is
illegal without primitiveTopologyListRestart, so a restarting draw is
represented by the strip form or pipelines that build today start failing
validation.
Gated on the feature bit, not the extension string, and enabled at device
creation; without it the props keep their real values and the command stream is
byte-identical to before. Entry points go through the existing VKProcTable
wrangler, so vk_android_loader needs no regeneration.
pipeline_props keeps its shape: the disk cache stores it as a raw struct, so
the VALUES are normalized before it is used as a key rather than teaching
operator== about the extension. The shader cache directory becomes v1.96-eds
against v1.96 -- the suffix matters because support depends on the DEVICE, and
a driver can be swapped in through adrenotools between two runs of the same
game. Reading a normalized entry back without the extension would silently
build pipelines with culling off and depth compare NEVER.
Depth bounds, stencil, and the EDS2/EDS3 states stay static: depth bounds is
constant per device and never differentiated anything, and stencil is already
all-zero for the overwhelming majority of draws.
Leaving the app during a game aborted the process outright:
Assertion Failed! Vulkan API call failed with unrecoverable error:
Surface lost (VK_ERROR_SURFACE_LOST) swapchain.cpp, swapchain_WSI::init()
Losing the surface is routine on Android -- the ANativeWindow is destroyed
every time the app leaves the foreground -- and the renderer already treats it
as recoverable everywhere else, setting m_surface_lost in both the acquire and
the present paths. Only swapchain init went through die_with_error.
All three surface queries in init() now return false instead of aborting, and
record which kind of failure it was. The caller needs that distinction: "the
window is minimized, retry later" and "the VkSurfaceKHR is dead" both surface
as a false return, but retrying against a dead surface queries the same dead
handle forever. Only the second recreates the surface first.
That also removes the memory corruption behind it. The fatal error killed the
RSX thread mid-operation and the Main Callbacks thread then destroyed its
objects, so tearing down ZCULL state freed a container that was still being
written -- scudo reportInvalidChunkState inside ~ZCULL_control. No fatal
teardown, no corrupted teardown.
~ZCULL_control is tightened regardless: it now drains page refs and resets prot
the way unlock_pages does, rather than freeing pages that still hold references
and leaving m_critical_reports_in_flight unbalanced -- harmless at process exit,
wrong on a restart within the same process, which is every restart here. Note
its m_pages_mutex is the only place that lock is taken; every real writer is
externally synchronized and locks nothing, so holding it must not be mistaken
for protection against a writer that is still running.
Opening a game the instant the app started left a black game area forever,
while rotating the device "fixed" it. SurfaceHolder.Callback::surfaceChanged is
a one-shot -- Android delivers it when the surface is created or resized and
never repeats -- and getNativeWindow() blocks until that single delivery
arrives, in a 100 ms sleep loop with no timeout. One missed delivery therefore
parks the RSX thread for the rest of the session. A rotation only helped
because a configuration change forces a fresh surfaceChanged.
EmulationSurface now re-delivers holder.surface on attach and on window
visibility changes. It is idempotent: the native side compares the incoming
ANativeWindow against the one it holds and no-ops on a match, so this costs
nothing when the first delivery already arrived. It has to be post()ed, since
onAttachedToWindow runs before layout and a 0x0 report is explicitly ignored.
The wait loop also logs now, every three seconds, because the failure was
otherwise completely silent: the emulator log stopped dead just after Vulkan
device creation, the perf sensor read 0.0% CPU, and nothing said why. Diagnosis
took a screenshot and dumpsys SurfaceFlinger to establish the surface existed.
Adds GSFrameBase::display_epoch, bumped when the native window is replaced. The
swapchain is rebuilt on a size mismatch and nothing else, so a replacement
window at identical dimensions was invisible; platforms that cannot swap a
window under a live swapchain keep the default and are unaffected.
Three faults that showed up in tester logs, all of which made the emulator
look broken in ways the log then hid.
Eternal Sonata flooded with SPU "Invalid code" errors: when the analyser
produced no data the recompiler had an empty branch with a TODO where the
fallback belonged, so the block was neither compiled nor marked, and the same
address was retried forever. It now marks the block failed and lets the
interpreter take it -- 6320 errors in one session down to none.
The unknown-instruction and halt messages are rate-limited, per opcode and per
address rather than globally, so a repeating fault reports once instead of
every execution. One tester's log went from 600 MB to 2.0 MB; the log volume
itself had been slowing the emulator, so this is not only a readability fix.
ARM64 fault classification in Thread.cpp preferred a heuristic comparing
si_addr against the PC, which misreads a genuine data fault as an instruction
fetch. It now decodes ESR first and only falls back to the heuristic, and an
SPU halt at the 0xffdead00 sentinel is reported as a guest assertion rather
than a host segfault. BLEACH crashed here, and the misclassification gated
every recovery path behind it.
Saint Seiya: Sanctuary Battle (BLES01421) stalled partway through PPU
compilation and booted to a black screen. The failure was in LLVM, not here:
on AArch64 the register scavenger ran out of registers under the GHC calling
convention, which pins most of the GPRs to guest state, and
AArch64FrameLowering::determineCalleeSaves returns early for GHC before it can
create the emergency spill slot the scavenger falls back on. The scavenger then
aborts, and because that takes down the whole MODULE rather than one function,
every function in it drops to the interpreter -- the boot never finishes, or
the game runs at interpreter speed with nothing in the log to explain it.
Fix creates the spill slot for GHC frames that actually need stack. 231/231
modules compile for Saint Seiya, and Sonic Unleashed's FMVs work for the same
reason. Because it is a codegen fix rather than a per-game workaround, any
title that hit this benefits.
The change itself lives in the LLVM submodule, whose remote is upstream
llvm/llvm-project, so it cannot travel in this repository. It is preserved
here as 3rdparty/llvm/armsx3-aarch64-ghc-emergency-spill.patch, applied
against the pinned submodule commit; a build without it applied will exhibit
the original stall.
Also bumps the ARM64 codegen cache version so caches produced before the fix
are not reused, and carries the PPUTranslator changes the same work needed.
Splits the Android release into legacy / a11 / a13 / a15 so a device can take a
build matched to its CPU and OS instead of one binary suiting everything.
android/build-variants.sh drives all four from a single table of
(ndk, api, -march, apk suffix), and ConfigureCompiler.cmake takes -march per
variant rather than hardcoding one.
The legacy variant had never been compiled before: every release up to 0.7.2
was built at the gradle default of minSdk 33, so nothing had ever targeted a
lower API. Doing so turned up std::aligned_alloc, which is API 28+ -- below
that <cstdlib> does not declare it at all and the using-declaration fails to
resolve. posix_memalign is the older spelling and its result frees with plain
free(), so the rest of the header is unaffected. Kept even though legacy now
targets API 30, because it costs nothing and the next person to try a lower
floor should not rediscover it.
legacy targets armv8.1-a, which is the floor this codebase compiles at rather
than a preference: util/simd.hpp uses SQRDMLAH (v8.1 RDMA) and util/asm.hpp
has inline LSE atomics, so armv8-a does not build. Its value is cores that are
ARMv8.2 without the OPTIONAL fp16 and dotprod extensions the other three
variants require. Cortex-A53/A72/A73 class parts stay out of reach until those
two paths gain fallbacks.
Leisure Suit Larry: Box Office Bust (BLUS30331) copies its disc asset tree into
an on-HDD cache during a short boot window and abandons the copy when emulated
I/O is slower than a console, then crashes at "New Game" on the missing packages
(upstream RPCS3 #14402). Finish that copy once, at boot, before the guest runs.
complete_ue3_hd_cache() runs in Emulator::Load after the bdvd+hdd0 mounts and
before Run(). It is gated to a verified title-ID allowlist ({BLUS30331}):
PS3TOC.txt is a generic UE3 marker, so keying on it alone would act on other UE3
discs and build the write root from an unvalidated PARAM.SFO TITLE_ID. It parses
the disc PS3TOC.txt manifest, confines each entry textually (rejecting
traversal/drive/UNC/reserved names), copies each not-yet-complete asset
atomically via fs::pending_file, and stamps a 0-byte <file>__time sidecar to the
disc source mtime, mirroring the guest's own completeness convention.
Completeness is keyed on the sidecar AND the dest byte size, so a guest-truncated
payload is re-copied rather than skipped. On any parse/stat/space/copy failure it
returns install_failed after Kill(false), like the sibling post-ready error
exits, so the boot aborts cleanly instead of handing the guest a half-install.
For any other title the function returns after a single title-ID comparison,
before any filesystem access.
Validated on-device (Odin 3, Adreno 830): cold cache -> 800 files / 1847 MiB
copied in ~29s -> New Game reaches the Prologue, 0 access violations; 2nd boot
does no work (idempotent); a forced install_failed tears down cleanly with no
crash; Lollipop Chainsaw and Mirror's Edge boot unaffected (completer inert).
A blind re-review of the previous commit (six lenses, fresh reviewers)
found real gaps in the hardening itself. Addressed here:
- The EVTSTRM gate failed open to the spin: with the event stream absent
on a core whose armed WFE does not park, disabling the fallback
reinstated the original full-rate spin. The paced tier now degrades to
a 100 us scheduler sleep instead, which also keeps the timeout and
service polls running at a bounded cadence.
- Gate the FIFO-idle wait_for_event() the same way (three reviewers
independently flagged the contradiction between asm.hpp's new
precondition and this ungated sibling). Without the stream it yields,
which is that path's pre-WFE behavior.
- Non-Linux ARM64 now defaults to the previous commit's behavior instead
of silently disabling the fallback: the false default was a regression
against 002a9b274 on the Apple Silicon and Windows-on-ARM targets, and
no HWCAP equivalent exists there to probe.
- The loop's snapshot is now read through the existing atomic reference
(relaxed observe()) instead of a plain reference: the previous form was
a formal data race whose correct codegen depended on an unrelated
virtual call staying opaque to the optimizer.
- Guard unaligned semaphore addresses on the acquire path: exclusive
loads fault on unaligned addresses, semaphore_release already rejects
them, and acquire did not. Unaligned waits now use the paced tier only,
with a warning.
- Surface the probe in the startup capability string (EVTSTRM-on/off) so
every log records which wait shape was selected; previously the three
possible states were indistinguishable in any output.
- Log the first-observed semaphore value in the recovery-timeout message
as well; the previous message could not distinguish a value that
changed during the wait from one that never moved.
- Comment corrections: the post-budget wake-on-write claim now states the
pacing-period bound honestly; the x86 note names the yield fallback on
CPUs without waitpkg/mwaitx; the event-stream period is stated as a
kernel-dependent range. Note the previous commit's claim that x86 was
unaffected was wrong: the snapshot change lets the x86 early-out fire
where it previously compared a value against itself; the direction is
an earlier return when the semaphore changed during the prologue.
Device check (Odin 3, ME menu, 30 s): 168.0G instructions, and the new
capability line reads EVTSTRM-on, proving the paced branch was live in
the measured run. Known residuals (ledgered, out of scope): HWCAP is a
boot-time global while the stream enable is per-CPU (migration edge);
no parking-core device has been measured; no automated test covers the
path.
Findings addressed (blind review, 8 lenses, see PR discussion):
- Gate the fallback on HWCAP_EVTSTRM (new utils::has_wfe_event_stream()).
The park's wake bound is the kernel's architected timer event stream; on
a kernel that does not enable it, a monitor-less WFE parks until the next
unrelated interrupt. Such devices now keep the pre-existing armed-spin
behavior instead.
- Fall through from the event-stream park to the armed one-shot instead of
else-ing around it. On cores where the armed WFE parks, this re-arms the
exclusive monitor every iteration, so wake-on-write is preserved even
after the spin budget is spent; on Oryon the extra call returns
immediately and costs nothing measurable. This also shrinks the window
in which a written-then-overwritten semaphore value could go unobserved.
- Fix the spin's early-out: the call passed a freshly re-read value as
old_value, which the compiler sank to immediately before the ldaxr,
making the compare a self-comparison that never fired (verified by
disassembly). The loop now snapshots its top-of-iteration read and
passes that, so an already-changed value returns without waiting on
every core class.
- Move spin_budget under ARCH_ARM64 (silences -Wunused-variable on x86).
- Log awaited and observed values in the driver-recovery timeout message,
so a timeout caused by a transient value is distinguishable in reports.
- Rewrite the stale comments in place: spin_on_cacheline_once's event-
stream rationale is core-class dependent (measured non-parking on
Oryon); wait_for_event's usage rule now covers the sustained-idle
fallback shape and names the HWCAP_EVTSTRM precondition.
Device check after hardening (Odin 3, ME menu, 30 s): 164.1G instructions
vs 179.5G for the previous commit and 402.7G pre-fix - the win holds.
The one-shot cacheline wait (ldaxr-armed WFE) used in semaphore_acquire
does not park on every core. Measured on Snapdragon 8 Elite class (Oryon,
Odin 3): WFE returns immediately while the exclusive monitor is armed
(~28.8M wakes/s in a standalone microbenchmark, vs ~20-30k/s for bare WFE
and sevl+wfe), so the acquire loop ran at ~57M iterations/s through waits
averaging 33 ms - about 99% of the RSX thread's wall time at a menu, with
each iteration also paying the driver-recovery get_system_time() check.
Keep the armed one-shot for the first 500 iterations of a wait - on cores
where it parks it keeps its instant wake-on-write, and where it does not
it acts as a short spin that still catches quick signals - then fall back
to wait_for_event(), which parks on both classes and bounds wake latency
at the architected event-stream period (~50 us measured).
Measured on device (Mirror's Edge, MT RSX on, state-verified windows):
menu instructions -55% (402.7G -> 179.5G per 30 s), played-gameplay
instructions -29% (391.6G -> 279.7G), loop iterations down ~1,400x, wait
counts/durations unchanged, 30 fps frame pacing unchanged (max frametime
34.2 ms). Note: cpu-cycles PMU counts at full clock during WFE park on
this SoC, so cycle-based profiles cannot see this change; measure with
instructions retired.
Per-section Reset did nothing on most tabs. The field lists describe the tabs
as they were before the PS3 rewrite, so Reset was clearing settings the tabs no
longer show while missing most of what they do: Performance listed 22 of 47,
Graphics 45 of 57, Audio 10 of 16 -- audioRenderer, audioFormat, audioChannels
and audioCubebBackend were absent, so changing the audio backend and pressing
Reset was a no-op. Regenerated from what each tab actually writes, mapping
ps3.foo to its ps3Foo key and validating every entry against the serialiser.
Five keys also moved off Graphics because another tab owns them, which was a
cross-tab clobber waiting to happen.
Full Diagonal Range, per stick, on by default. A full diagonal was capped to
the unit circle at ~0.707 per axis, which is what a circular-gated DualShock
really sends -- but games that deadzone each axis separately then ignore
diagonals, and Oblivion's camera crawled diagonally while the cardinals were
fine. Off restores the hardware curve.
Oboe is the default audio backend on Android, with a migration for anyone still
on the old Cubeb default; a deliberate choice of another backend is kept.
Enter Button Assignment (circle/cross) is exposed. The core has always had it
and Android never showed it.
Reset all settings, in General. Per-game overrides and controller binds are
deliberately left alone -- they are invisible from that page.
Oblivion's water did not draw on Vulkan and did draw on OpenGL. The only
Vulkan-only shader workaround in play is the blanket disable of native float16
on every mobile GPU, which emulates it with fp32; its own comment claimed that
"renders correctly", and it does not.
The disable exists for a real failure -- Qualcomm's compiler rejected SPIR-V
containing float16_t and every pipeline came back VK_ERROR_UNKNOWN, which
presents as a black screen with working audio and a working compile overlay,
so it reads as a renderer bug rather than a shader one. That is not worth
reintroducing blind, so this is a version gate rather than a removal:
Adreno on driver 512.676.53 or newer -> native fp16 (verified)
older Adreno -> unchanged
Mali, PowerVR, Xclipse, the rest -> unchanged, untested either way
Found by switching the renderer to OpenGL, which isolated it to the Vulkan path
in one run after the settings-level suspects had all come back empty.
sys_fs_unlink handled notdir and noent but not readonly, so it fell through to
fmt::throw_exception and killed the PPU main thread inside the syscall. The
emulator then sat with nothing to run: the game froze with the CPU at 1% and
nothing in the log but a stalled RSX. On Android /app_home is the mounted ISO,
which is read-only, so any game deleting a file in its own directory hit it --
Oblivion removes warnings.txt at startup and never got past it. Returns
CELL_EROFS now, which is already what sys_fs_write and friends do. sys_fs_mkdir
and sys_fs_rmdir carried the identical block and are fixed with it.
Three log floods, all of which stall the emulator outright because writing them
is not free on Android:
- sys_fs_utime logged two warning lines per call and rides a polling loop.
Oblivion's FileCaching thread hit it 7274 times in ten seconds on one .BSA,
~22k lines, and the frame loop stopped for over twenty seconds. Now trace.
- vm::lock_sudo reported a failed mlock on every mapping. Android never grants
RLIMIT_MEMLOCK to apps, so it fails forever while advising the user to raise
a limit they cannot raise -- 6470 lines in ten seconds here, and ~1200 in
every other game log looked at. Reported once per session now.
- sys_mmapper's map/unmap pair, 12431 lines over the same window. Now trace.
None of them lose information: raise the channel to Trace to get them back.
Testers consistently report the best performance on the build with the 0.6
renderer, so 0.7's graphics work goes back out. The Arkham City measurement
behind it (62.8 -> 51.2 ms) was one game on one device and did not survive
contact with a wider set of hardware.
Two files are kept from 0.7 because neither is render pass work and both are
measured wins on their own: RSXFIFO's idle spin plus WFE park, which took ~11%
of total CPU off sched_yield, and RSXThread's ADPF feed, without which the
performance-hint setting reports nothing and does nothing.
Everything else under Emu/RSX is byte-identical to 0.6. The removed work is not
lost -- it is in c4b45eee2 and can come back a piece at a time with testing
behind each one, which is how it should have gone in the first place.
The previous commit reverted all of Emu/RSX to 0.6, which was more than the
bug required. Bisecting had already shown the render pass work was not
responsible -- reverting it alone changed nothing, while removing retention
with the render pass work in place fixed both reported games.
So only retention goes. It reused vertex cache entries across frames, and on
0.6 the attribute ring was too small for it to engage; raising the ring to
192M switched an existing path on in every game at once and handed draws
stale geometry. Back to purging every frame, as 0.6 did.
This restores what the wider revert had taken out for no reason: the render
pass reduction, the RSX FIFO idle fix, ADPF frame timing, the ZCULL and
occlusion query fixes, the swapchain and surface lifetime ports, and VRAM
budgeting.
Sonic Unleashed still does not render FMV cutscenes. That reproduces with
the 0.6 renderer too, so it is unrelated and still open.
0.7 introduced corruption in several games that were fine on 0.6 -- flashing
and flickering in Sonic Unleashed and Dragon Ball among others. Emu/RSX is
returned to its 0.6 state in full; everything outside the renderer is kept.
The main cause was vertex cache retention. On 0.6 the attribute ring was too
small for retention to engage, so raising the ring to 192M did not add a code
path, it switched an existing one on in every game at once, and reusing stale
vertex data is what the flashing was.
Bisecting also showed the render pass work was not responsible: reverting it
alone changed nothing. It can come back, but on its own and with testing
behind it rather than as part of a batch.
Kept from 0.7: the ARM64 PPU float to integer fix, the PPU cache build
identity, the SPU checksum and block state fixes, Oboe, ADPF, and the crash
and stability ports.
Sonic Unleashed does not render FMV cutscenes. That reproduces with the 0.6
renderer as well, so it is not from any of this and is still open.
SPU: the ARM64 block checksum folded two thirds of every block through
absolute difference, which is not injective, so adding the same value to
two words left the checksum unchanged and similar job binaries hashed
alike. Plain summation now. This is what Precise SPU Verification was
working around, and that setting is exposed properly instead of only being
reachable by hand editing the config.
SPU: a block is no longer marked permanently failed when the trampoline
rebuild fails. The compiled function was live, the state was not
recoverable for the rest of the session, and the claim could never be
retaken.
RSX: render pass churn cut in heavy scenes, roughly 113 to 85 passes per
frame. On a tile based GPU every pass boundary is a full tile store and
reload. Two Vulkan specification violations fixed, and a read/write hazard
on the render pass path.
RSX: the FIFO no longer burns a core on sched_yield while idle.
Android: ADPF is implemented rather than an inert setting, logcat no longer
allocates and makes an IPC call per line, and Silence All Logs is available
for playable titles.
Audio: Oboe backend, for the per device quirks database and stream recovery
on disconnect and route change.
Ported from ouroboros420/rpcsx: GPU Turbo, power and thermal handling, the
crash and freeze fixes, savestate and WSI surface lifetime, honest RAM VRAM
budgeting, the persistent SPU object cache design, occlusion query and RSX
fixes, frame pacing and tiler tuning.
Ported from rfandango/rpcsx: the Turnip ZCULL deadlock fix and ARM64 SPU
checksum handling.
Individual commits are credited in comments at each site.
FCTIW, FCTIWZ, FCTID and FCTIDZ carried a saturation correction that only
makes sense on x86. cvtsd2si returns 0x80000000 for any value it cannot
represent, so the result is XORed back into 0x7fffffff on overflow.
FCVTNS and FCVTZS already saturate on their own, so the same XOR turned a
correct result into its opposite: every overflowing conversion produced
INT_MIN where it should have produced INT_MAX. The mask is a no-op when
there is no overflow, so it never did anything except break that case.
Armored Core: For Answer put the player under the floor in the tutorial
because a coordinate that should have clamped high arrived clamped low.
The cache needed a build identity as well. Its key is the executable's
SHA-1 plus a settings bitset and nothing more, so the first attempt at this
fix silently reused objects compiled by the previous build and looked like
it had done nothing. Every earlier PPU codegen change had the same problem
for anyone with a warm cache.
Module loading also no longer abandons the remaining modules after one
object fails to load.
Pause reached the core for the first time. Rpcs3Bridge.pause() set a bool and
returned, on the belief that RPCS3 has no explicit pause entry point -- Emu.Pause()
exists and _rpcsx_surfaceEvent has always called it on surface loss, which is why
backgrounding the app was the only thing that paused. Exported as _rpcsx_pause
through all four layers; resume already reached the core, so the pair was asymmetric.
Restart no longer crashes: setCustomDriver dlclose'd the previous driver handle, and
applyRendererPrefs re-applies the driver on every start, so restart unloaded the
library VMA had resolved vkGetPhysicalDeviceMemoryProperties2 out of. ~VKGSRender then
freed its heaps and UpdateVulkanBudget called into an unmapped mapping. An ICD cannot
be unloaded while anything resolved from it is reachable, so it is no longer closed.
Restart no longer returns to the library either: shutdown() set stopRequested, called
kill() and returned with the VM still live, so the run loop's finally started the
replacement and the in-flight teardown killed it -- two BootGame calls, then Unloading
ISO, by which point the restart flag was spent. shutdown() now waits (bounded) for the
core to report Stopped, and the restart is queued on vmStopControl behind it.
FPS cap applies at every value. ConfigStore recorded a persistent core override of
Video@@Frame limit=60 and Settings rewrote it on every push, both from a migration
escaping a stored 120 -- but that node is the cap control, and overrides replay last,
so presets were pinned at 60 while 20 and 45 worked through Second Frame Limit. The
Vblank Rate force stays, since Frame limit Auto resolves to it. Stale overrides are
cleared once. 90 and 120 dropped from the row: the min() in the pacer discards them.
Cover art for PKG installs: the library grid's fallback chain stopped one leg short of
the extracted ICON0.PNG while the in-game menu's did not. Both now share one chain, so
they cannot diverge again. has()/discIconFile require bytes rather than existence, and
the staging rename is checked instead of discarded.
Licences are grouped per game and collapsed instead of a flat list of content ids.
Trophies: a library-wide browser and an in-game tab for the running title, reading
TROPCONF.SFM and TROPUSR.DAT directly -- no account, no network. The in-game set is
identified from the core's own current_trophy_name (try_get, since get<> would
construct it outside emulation and hand back an empty name), falling back to TROPDIR
on disk because a game registers its context lazily. Note the entry stride there is
16 + entries_size, not entries_size.
Also: renderer.upscale.label was defined twice, so Internal Resolution was dead.
PPU: a module that fails codegen no longer takes the boot with it. ppu_initialize2
called the fatal jit.add(); run_recoverable_llvm and the try_* pair already existed
in this tree but were used only by the SPU recompiler, so LLVM's fatal handler threw
on a thread with no recovery context and killed the worker. That is not one lost
module: g_progr_pdone is incremented in the compile loop's INCREMENT, so the module
the dead worker held was never accounted for, g_progr_ptotal could never reach zero,
and the boot waited on it forever. Saint Seiya: The Sanctuary (BLES01421, issue #25)
stopped at 133 of 134 on 'Cannot scavenge register without an emergency spill slot'.
Now routed through try_add on ARCH_ARM64, mirroring the SPU branch, with
ppu_initialize2 returning bool so the caller stops logging a dead module as compiled.
Losing the worker also halved the rate for everything left.
VK: a data_heap block no longer frees through an allocator that is not the current
one. Borrowed pointer, cached at construction with nothing tying it to the
allocator's lifetime; declining the free costs nothing the device teardown does not
already release.
Android: g_strings held 180 of localized_string_id's 323 entries and the callbacks
ignored their args entirely, so every string carrying a name, date, size or error
code lost it -- including CELL_SAVEDATA_LOAD, which is why the save prompt was Yes
and No over an empty message (open_msg_dialog logged msgString=""). All 322 the Qt
switch provides are present, in enum order, with QString::arg's %0 substitution
reproduced and utf8_to_u32string on the u32 path so trophy names survive. A
static_assert on the table size fails the build when upstream adds an id.
probeDiscInfo: set g_fxo up before mounting. vfs::mount lazily constructs vfs_manager
through manual_typemap::init<T>(), which writes *m_order++, and clear() nulls that
when a game stops -- so scanning a new disc image after playing anything wrote
through null. Emu.IsStopped() cannot guard it, because stopped is the cleared state.
libEGL_angle.so and libGLESv2_angle.so lived in armsx3-app, which stopped being the
built module, so selecting ANGLE for the OpenGL renderer silently fell back to the
system driver with nothing in any log to contradict it. Moved into armsx3-ui beside
the core, with the jniLibs .gitignore negations that keep them tracked.
verifyAngleLibs comes with them and now runs on the release graph ahead of
mergeReleaseJniLibFolders, so packaging an APK that offers ANGLE without shipping it
fails the build. The copy left behind in armsx3-app could never have protected
anything from there, and did not even compile -- its GradleException message escaped
'$' as if the file were a template, and the quotes inside the escaped interpolation
closed the string early, so the project failed to configure. Deleted rather than
fixed, with a comment pointing at the live one.
Version to 0.6 (versionCode 10).
Its comment was still there, above the OSD selector, describing a control that no
longer existed -- the row was lost in the port and the setting left with no writer,
so nobody could hide the glyph or bring it back. Reported as the option missing from
the menu, which is what it was.
Goes where the comment says rather than in the touch editor toolbar, which is where
I first put it: this is a pause-button behaviour toggle and belongs with the overlay
controls it was written for.
The setting has existed since the pause button moved to the top right, and is seeded
once from the old show/hide pref so anyone who had the button hidden keeps it hidden.
Nothing ever wrote it afterwards. A user whose button was visible had no way to hide
it and a user migrated into hidden had no way back, which is how it was reported:
the option is not in the in-game menu.
Sits with multi-touch, gliding and floating stick in the editor toolbar, since those
are the other whole-overlay behaviour toggles and setPauseTapToReveal already
existed to be called.
The freeze-with-audio in Ratchet & Clank is the RSX thread dying in the allocator,
and the heap growth log says why. The index buffer went 16M to 64M to 128M to 192M
to 256M inside 290ms, on requests of 2K, 4K, 5K and 3K; the attrib buffer did the
same and died growing to 192M. Kilobyte allocations cannot need a quarter gigabyte.
The rings were never wrapping, they were only ever growing.
frame_context_cleanup is what returns a frame's ring memory, and check_present_status
is what calls it. I removed that call from flush_command_queue in 0.5 because the
drain poked the oldest queued frame's fence and on Adreno vkGetFenceStatus blocks
until signalled instead of answering -- 14.6ms a frame, second only to the FIFO decode
loop. The reasoning was that the flip path retires frames anyway. It does, enough to
keep presenting, but not often enough to keep the rings bounded, and nothing else
reclaims them.
Restoring it costs nothing now. poke() no longer asks with vkGetFenceStatus: it uses
vkWaitForFences with a zero timeout, which is specified to return VK_TIMEOUT without
waiting. The measurement that motivated the removal was of the old implementation, so
the speedup stays and the reclaim comes back.
Keeps the heap growth log that found this. The allocator reports only a size and a
pool number, and pool 1 covers every data_heap, so three fixes were aimed at a target
that could not be seen. One line naming the heap settled it.
Ratchet & Clank freezes with audio still playing, which is the RSX thread dying:
'Failed to allocate 131072K of video memory (pool=1, pool total=561M, heap cap=
2048M)'. Pool 1 is VMM_ALLOCATION_POOL_SYSTEM, and 131072K is a data_heap taking
its second growth step, 64M to 128M.
The device is not out of memory. It holds 561M against a 2048M cap and cannot place
128M in one piece, which is a different failure from being full and has a different
fix. The heap grew by aligning up to 64M, so every growth demands a single
contiguous block of at least that size, and each step doubles what the allocator has
to find unbroken. A heap that fails to grow has nowhere to degrade to, so the
renderer ends there.
Android now grows in 16M steps and stops at 256M. The smaller granularity asks for a
quarter as much contiguous memory per step and lets the heap settle near the size
actually wanted rather than overshooting to the next 64M boundary. The ceiling comes
down to match: a 1GiB upload ring would exhaust the device long before it was
reached, so as written it was a limit only reachable by dying. Desktop keeps 64M and
1GiB.
Does not touch the separate pool-0 case fixed in the previous commit, where recovery
does run and the last-ditch eviction now gets a turn before the thread is killed.
Ratchet & Clank freezes with audio still playing, which is the RSX thread dying on
VK_ERROR_OUT_OF_DEVICE_MEMORY while the rest of the process lives. Caught on an
Adreno 740: 'Failed to allocate 86016K of video memory (pool=0, pool total=472M,
heap cap=2048M)'. One 84MB request refused while we held 472MB of a 2048MB cap, so
the heap was not full -- a single large allocation could not be placed.
The allocator already retries once after asking for pressure relief, and it did.
The relief is what fell short. on_vram_exhausted refuses the hard sync whenever the
RSX is uninterruptible, and clamps the request below fatal, so the eviction that
drops everything unlocked was unreachable from here. That refusal is right while
there is still a way out: eviction touches resources the driver may still be
reading. It is wrong on the last attempt, where the alternative is not a glitch but
the renderer ending.
So the final attempt is now exempt, through a thread-local set only for the width of
that call. Everything else keeps the existing behaviour, and a caller that opted out
of recovery is not handed it here by the back door. Recovery logs at error level and
says a visual glitch is the expected outcome, since a silent recovery that costs
texture quality reads as a new bug otherwise.
Measured against this failure the eviction ran once, six microseconds before the
allocation failed, and never in the five minutes before it -- so nothing was
reclaimed while it still would have been cheap. That part is not addressed here: the
budget-based ladder cannot see mobile unified memory, where the driver reports one
large shared heap and 472MB against it never crosses a threshold. This makes the
failure survivable rather than preventing it.
A heap profile of Ratchet & Clank on an Adreno 740 put the only real growth during
play on vk::descriptor_set: 56 sets created in half a session, 49MB, through
simple_array::reserve from descriptor_set::operator=. Nothing else grew that was
not one-time JIT or shader compilation.
Each set reserves m_pool_size entries in three pools the first time it is used --
16448 image infos, 16448 buffer infos, 16448 buffer views, about 920KB -- and there
is one set per shader program. simple_array::clear() only resets the size, so that
memory is held for the object's whole life, and the total climbs for as long as new
pipelines keep appearing. Ratchet compiles a lot of them.
It is also why this was invisible from the Vulkan side: these are plain malloc, not
device memory, so the VMM never sees them and no amount of texture eviction reclaims
them. The tester's log shows the shape exactly -- our pool at 516MB while the process
walked from 4448MB to 5626MB without ever dropping, then died on a 32MB allocation.
The reservation is a correctness requirement, not a tuning knob: push_*() hands
Vulkan the address of a pool entry and it has to stay valid until flush(), so the
pools must not reallocate while writes are pending. What makes it safe to shrink is
that max_cache_size is also the flush threshold, in both on_bind() and
storage_cache_pressure(), so the queue can never outrun the reservation. Moving it
takes the guard with it and leaves the same 64 entries of headroom.
1024 on Android: about 57KB a set instead of 920KB, for one extra
vkUpdateDescriptorSets per 1024 writes. Desktop keeps 16384.
The vector body stores vorrq(v, eq) into restart lanes, which is all-ones
regardless of what index_limit() returns -- it only matches the scalar
tail's index_limit store because index_limit is all bits set. Assert that
beside the splats so a change to index_limit fails the ARM64 build instead
of silently diverging the vector body from its own tail. No codegen change
(emitted assembly is identical).
Explain why the non-restart loop stays scalar (clang already
auto-vectorizes it) and spell out which allocations each caller passes,
so the no-overlap contract of upload_untouched_neon is checkable.
The primitive-restart variant of upload_untouched had no SIMD path on
ARM64: the asmjit builder is x86-only, and clang cannot auto-vectorize
the scalar loop (-Rpass-analysis: "value that could not be identified
as reduction is used outside the loop") because the min/max updates are
conditional on the restart compare -- while the non-restart loop next to
it does auto-vectorize. Net effect: 16 scalar instructions per index on
a path some titles saturate. Measured on a Snapdragon 8 Elite (Odin 3),
Virtua Tennis 4 routes its entire indexed-draw traffic through this
loop: 2.81 billion indices in a 9-minute match session, median 159k
indices per frame.
Port the x86 lane algebra to NEON, 8x u16 / 4x u32 per iteration: the
restart-equal mask ORs the lane to all-ones for the min accumulator and
the store (all-ones is index_limit, exactly what the scalar loop
writes) and BICs it to zero for the max accumulator, so restart lanes
can never win either reduction; UMINV/UMAXV reduce once at the end and
the tail stays scalar. Baseline v8.0 AdvSIMD only.
Supporting results, all on the Odin 3 with the system driver:
- Correctness: 216-case differential (scalar vs NEON vs the dispatched
path; every tail residue mod 8 and mod 4; restart index absent,
present, 0, index_limit, all-restart, and index_limit present while
not the restart value; u16 and u32) ran on device at RSX init in all
four A/B runs: 0 mismatches. An independent 65,000-case host-side
model of the same lane algebra also matched the scalar loop, and a
blind review of the diff could not construct a diverging input.
- Performance A/B (cntvct_el0 around the dispatch, null-region
calibration subtracted, per-window medians over 120-flip windows with
>10k restart indices/flip, runs interleaved scalar/NEON/NEON/scalar):
scalar: 0.02504 and 0.02464 ticks/index (1.30 ns/index)
NEON: 0.00346 and 0.00381 ticks/index (0.19 ns/index)
~6.8x faster per index; scalar-scalar repeatability 1.6%. Worst
single-frame cost in this loop fell from 3.64 ms to 0.99 ms. FPS
stayed 60/60 in all runs on this device; the win is RSX-thread
occupancy and worst-frame cost, and would be frame time where the
RSX thread is the bottleneck.
Titles that never enable primitive restart are unaffected: they route
through the untouched path, which clang already vectorizes.
vmm_determine_memory_load_severity is a set of thresholds on get_memory_usage,
which is usage/budget straight out of vmaGetHeapBudgets. VK_EXT_memory_budget was
never enabled, so VMA had no budget from the driver and used the heap size -- and
where pHeapSizeLimit is set, that limit, which is our own vram_allocation_limit.
An Adreno 740 log shows the consequence: 516MB against a 2048MB cap is 25%, below
even the 50% mark, so the allocator kept its fastest flags, severity stayed 'low',
and the 75/90/95 eviction ladder never fired. The first allocation the driver
refused was also the first sign of trouble, and that one is fatal.
Enabling the extension gives VMA the driver's own estimate. VMA takes the smaller
of it and pHeapSizeLimit, so the cap still caps -- it just stops being mistaken for
headroom that exists. Gated on support and logged when absent.
NOT a fix for the Ratchet & Clank crash this was found in, and it should not be
credited as one. That log leaks about 60MB per sample, 4448MB to 5626MB with no
drop, while our own pool sits at 516MB -- so nearly all of it is outside anything
VMA can see or evict, and a truthful budget only makes us give up our own memory
sooner. The tester reports 0.4 unaffected, which makes it a 0.5 regression still
to be found; see the Adreno per-vkCmdEndRenderPass allocation already recorded
against this codebase.
Also: licences can be removed. Installing one was one-way -- the row existed only
to prove the install had happened -- so a wrong or duplicate .rap could only be
cleared through a file manager, which on a scoped-storage device most people
cannot do at all. Confirmed before deleting, like uninstalling a title, because
content stops working without it.
Every pressure-capable button was fully digital. _rpcsx_overlayPadData ended with
btn.m_value = m_pressed ? 255 : 0, and that value is what cellPad copies into the
press byte a game reads for an analog button, so no half-press could ever reach
one. Rpcs3Bridge.setPadButton threw the magnitude away before that, using `range`
only for stick directions and calling applyButton -- pressed or not -- for
everything else.
Two features were silently dead as a result. A physical L2/R2 went 0 to 100 like
a digital button, reported on Iron Man, whose level-two hover tutorial cannot be
passed without a half-press; the trigger axis was read and scaled correctly all
the way to the JNI boundary and discarded there, which is why remapping and
recalibrating changed nothing. The touch overlay's pressure modifier had the same
end: it computes a range through pressureRangeFor and hands it to the same call.
Pressure now travels as its own export rather than widening overlayPadData, whose
signature is frozen -- the core is dlopen()ed and updated independently of the JNI
glue, so a wider existing export would have older glue passing a garbage argument.
Glue or core predating _rpcsx_overlayPadPressure keeps the old digital behaviour.
0 means "nothing analog drives this button", which is a safe sentinel rather than
a lost level: an unpressed button already reports 0, so a pressed button at 0
cannot occur, and the zero-initialised array is exactly the previous behaviour.
Pushed only when it changes, so an all-digital pad adds no JNI call per event.
All twelve buttons the PS3 pad reports pressure for, not just the triggers, since
the offsets are contiguous and cellPad already routes each one. sendTrigger also
floors to at least 1: the lightest real squeeze truncated to 0, which is the input
layer's "full press" convention and would have delivered the opposite of a
half-press.
Rebased by the author onto 0.5, so the occlusion-query bound we shipped stays as
it is and this only adds diagnostics on top of it: the fatal throw that ended the
session is gone, and with it the Web of Shadows regression that kept both PRs out
of 0.5. Also leaves the wait on shutdown, so a driver that never answers cannot
wedge the exit.
README.md is deliberately not taken from the PR -- it removed the whole status
and differences-from-upstream section.
adrenotools swallows this failure. When its dlopen of the custom driver fails it
logs to logcat and hands back the system driver, so the load looks successful
from here, the reason never reaches the emulator log, and the user runs a driver
they did not choose while believing otherwise. The existing dlerror() report
cannot fire, because the pointer that comes back is not null.
That cost real time. Mr Purple T29 fails on an Android 15 device with "cannot
locate symbol pthread_getaffinity_np", falls back, and every log looked exactly
like a successful custom-driver session -- I recorded it as passing a driver
comparison it had never taken. The only hint was its reported driver version
matching the system driver's, which took three saved logs side by side to spot.
The requirement is stated in the file. DT_VERNEED lists the libc versions a
binary needs, and T29 needs LIBC_36, meaning API 36, on a device that provides
35. Reading that before the attempt turns "failed to load" into the reason, and
covers the whole class of community drivers built against a newer NDK than the
device runs -- likely the most common way these packages fail.
Metadata cannot answer this: T29's own meta.json declares minApi 30. That field
is author-declared and unverified, so only the binary is trustworthy.
Reported through the emulator log as well as logcat. The UI glue can only reach
logcat, which is not the file anyone attaches to an issue -- the reason would
exist and no report would ever contain it. It lands beside the driver identity
that it explains.
Advisory on purpose. The load is still attempted and nothing is rejected, so a
wrong answer here costs one log line and never a working driver. It stays quiet
unless it positively finds a LIBC_<n> requirement above the running API, and
declines to answer at all when section headers are absent.
Verified on device both ways: T29 reports needing LIBC_36 against API 35 in
RPCSX.log, and stevenmxz v33, which loads correctly, produces nothing.
Upstream now bounds this wait itself: warn at one second, abandon at
three and use whatever the query holds. That replaces the fatal timeout
this commit previously carried, and it is the better answer -- the throw
could end a session over a driver that was merely slow, at worst wrong
culling for a frame was the actual cost. What remains here is the part
the bound does not cover:
- On abandonment, ask the driver once more directly with
VK_QUERY_RESULT_WITH_AVAILABILITY_BIT and log which way it is
stalling: VK_NOT_READY, or VK_SUCCESS with the availability word still
clear. The two are indistinguishable through poke_query and need
different conversations with whoever maintains the driver.
- Leave the loop when emulation is aborting. A driver that never answers
must not also wedge the exit path, and the value is irrelevant once
the session is going away.
Found chasing a Skate 3 freeze on an Adreno 830, where stevenmxz's gen8
driver builds accept occlusion queries and never complete them; the
system driver completes them in microseconds.
The startup log named the GPU and a driver version, and on Android neither
identifies the driver. adrenotools' hook falls back to the system driver when
its dlopen of the custom one fails, and reports that only to logcat, so a
session that silently ran the system driver logged exactly the same thing as
one that ran the custom driver it was asked for.
That is not hypothetical. Chasing a Skate 3 freeze on an Adreno 830 I recorded
a custom driver as passing a test it never took: it had failed to load with
"cannot locate symbol pthread_getaffinity_np", fallen back, and the log still
said the custom driver was bound. The only tell was that its reported version
matched the system driver's exactly, which needed three saved logs side by side
to notice.
Logs the driver identity Vulkan already reports -- name, driverID, info and
conformance version, all of which were being fetched and thrown away -- and
falls back to saying the identity is name-derived when VK_KHR_driver_properties
is missing, which is common on the older Android devices this matters most on.
Where a custom driver was requested and Qualcomm's own driver answered, that is
a silent fallback, since adrenotools installs Mesa/Turnip builds. It now says
so, and points at the logcat line carrying the actual reason.
The loader's own message no longer claims more than it knows: the handle it
binds is the one it was handed, and whether the driver behind it is the
intended one is not something it can see.
Raw core overrides re-push after the curated settings, so a stale one silently
beats the UI with nothing on screen to explain it: the settings screen read SPU
Block Size = Safe for hours while config.yml read Mega.
Mega is the one that mattered. It produces very large compilation units, and those
are what fail AArch64 register allocation with "Cannot scavenge register without
an emergency spill slot" -- which is what put SPU threads on the interpreter
fallback at all. With it cleared, no block fails to compile and the fallback never
engages. Every "cannot be compiled" chased in these sessions traces back to it.
Cleared in every scope, because a title can pin a key the global also pins: Arkham
City carried Accurate SPU Reservations true as a raw per-title override against
false globally, so clearing one scope did nothing and the two readings looked
contradictory.
Per-title Accurate SPU Reservations values go too, except Web of Shadows, which is
the title it was measured on. Off is off-spec -- it forces the SPURS scheduler to
HLE and bypasses the reservation lock -- and a title left that way desyncs until
its SPU threads execute whatever they land on, which is how Arkham City ended up
dying with "Unknown STOP code: 0x0".
Adds CoreSettingOverrides.forgetEverywhere for the all-scopes case.
3072 was set to get the God of War 3 demo past an allocation failure, but that
failure was measured before the uninterruptible reclaim fix landed, and the cap is
not coordinated with the texture cache, which budgets itself up to 2560MB on
Android. Raising one without the other let the total grow with it: Batman: Arkham
City reached 5596MB resident against a 6246MB peak on a 7.2GB device and stalled
after a while, with no allocation failure to point at.
2048 is the value that shipped before, and it is where the sum of the two sat when
that game worked. Budgeting the cap and the cache together is the actual fix and
is not attempted here.
The RSX profiler was still recorded as a raw core override from the debugging
work, so config.yml read "RSX Profiler: true" while nothing in the UI said so --
the same divergence as the relaxed-ZCULL one, since overrides re-push at the tail
of applyTo. It writes a bucket report every 300 frames and keeps per-scope timers
on the RSX thread, which is not something to ship enabled. The first purge had
already marked itself done, so this takes a new key.
VRAM allocation limit is applied as VMA's pHeapSizeLimit, which makes it a hard
ceiling rather than an eviction threshold: once total allocations reach it VMA
returns OUT_OF_DEVICE_MEMORY however much the device has free. Lowering it does
not make the cache release earlier, it makes allocation fail earlier. The God of
War 3 demo was measured failing a routine 24MB request at 1024 while the process
held 1.6GB resident and 280MB in that pool, and failing at 2048 one screen later.
3072 leaves the caches room while keeping the bound that stops an unbounded quota
driving the process to 4.3GB and getting it killed.
The allocation failure now names the request size and the cap alongside it, since
"Out of video memory" alone cannot separate a full device from an artificial
ceiling, and those need opposite fixes.
Refusing outright skipped the allocator's own recovery. That path is "if
OUT_OF_DEVICE_MEMORY and vmm_handle_memory_pressure(...) succeeds, retry the
allocation", so returning false meant the retry never ran and the allocation died
having freed nothing: God of War 3 reached it with zero reclaim attempts and zero
recoveries logged.
Only the fatal branch needs the queue idle, which is what the flush inside it is
for. The rest is reachable while uninterruptible: the texture cache purges its
unreleased pool, and at severe it also drops unlocked sections. RPCS3 already runs
exactly that with no flush whenever pressure is non-fatal, so this is the existing
contract rather than a new risk. Severity is clamped below fatal so the
flush-dependent path stays unreachable.
Measured after: eviction runs and reports releasing resources, and the allocator
retries. God of War 3 still fails, but now for the honest reason -- the device is
out of memory, with 123MB free of 7.2GB and the emulator resident at 4.3GB -- and
not because nothing was ever given the chance to run.
on_vram_exhausted asserted that the renderer was interruptible. Eviction really
cannot run in that state, since it would touch resources the driver may still be
reading, but that is a reason to refuse rather than to kill the thread -- and
refusing is already the supported answer: the OOM path in VKDraw treats false as
using placeholder textures, which it notes can cause graphics glitches but
should not crash otherwise.
God of War 3 hit it by skipping the intro screens, which pushes a burst of surface
and texture allocation through a point where the renderer is uninterruptible. The
RSX thread died there, audio kept playing, and it presented as a hang. With the
refusal in place the same run reports the real problem instead:
VK_ERROR_OUT_OF_DEVICE_MEMORY from the allocator.
Which it genuinely is. VRAM allocation limit was also lowered from 2048 to 1024:
the first value was still above what the device could give us -- 5355MB resident,
99MB free of 7.2GB -- so the budget was never reached before the system ran dry,
which defeats its only purpose. It has to sit below what allocation can actually
satisfy, so eviction starts while there is still room to allocate.
Three things, all found by measurement after the interpreter fallback started
being used in anger.
Marking only the entry point made the interpreter release the thread after one
instruction, whereupon the recompiler tried the next address, failed the same way
and marked that too. 111 consecutive entries were recorded walking two blocks four
bytes at a time, each step paying a full failed LLVM compile. The failed set now
holds ranges, so a thread stays interpreted for the whole block and leaves when
execution genuinely moves past it: 111 markings became 1.
The range test then ran per interpreted instruction and took a reader lock each
time, which put shared_mutex::imp_lock_shared at 28% of the whole process against
23% for the interpreter itself. The extent is now cached on the thread when the
fallback engages, so the loop compares two integers.
The switch was also logged once per thread, but the flag is cleared on every exit,
so the guard fired on every re-entry: God of War 3 wrote thousands of lines a
second ping-ponging between two addresses. Removed; the block is still recorded
once when it is marked.
Separately, VRAM allocation limit was left at upstream's 65536 MB, which means no
limit and assumes a discrete card. Here the GPU shares system memory with the OS
and our own host allocations, so the texture cache is never asked to evict and
grows until allocation fails -- and failing is fatal: God of War 3 dies in
on_vram_exhausted on ensure(!vk::is_uninterruptible() && ...), because VRAM ran
out where the renderer cannot safely evict. Measured at the crash: 5355MB
resident, 99MB free of 7.2GB. 2048 leaves room for the guest's own memory, the
host caches and the OS.
The fallback flag was set once and never cleared, so a thread that met a single
block it could not compile interpreted everything it ran from then on. Correct,
but these are SPURS kernels doing real work, and Sonic Unleashed reached its
loading screen that way and then crawled through it.
The failed set holds entry points, so this keeps interpreting while pc sits on the
bad entry -- which is where a branch-to-self idle loop stays -- and releases the
thread as soon as execution moves past it. Only the block that cannot be compiled
is interpreted; the rest of the thread runs recompiled.
Leaving is safe at any instruction boundary, since all SPU state lives in
spu_thread, which is the assumption the JIT dispatch already makes. Re-entering
the bad block sets the flag again.
A block that fails to compile switches its thread to the interpreter. On ARM64
that fallback called spu_runtime::g_interpreter, which with a recompiler selected
is the LLVM-built interpreter, and calling it there executes nothing: measured a
million consecutive calls on Sonic Unleashed's stuck SPURS kernel without pc
moving once. The thread then spins in that loop forever at a fixed pc with no
flags set, which reads as a busy SPU and hangs the title with no diagnostic at
all. Any block that fails to compile landed there, so this was not one game.
old_interpreter is what the static decoder ultimately runs, through
tr_interpreter, and it is self-contained -- opcode table, thread, local store. Its
static-decoder-only check rejected exactly the case that needs it, so it now also
accepts a thread already marked for fallback.
Getting there also needed the give-up paths fixed: the TBL2/TBX2 retry could
return null with an empty error and fall through every branch unmarked and
unlogged, so nothing recorded that a block had been abandoned.
The stall dump now carries SPU event, MFC and interrupt state, which is what made
this findable: parked kernels showed pending=0 (no lost wakeup), intr_en was 0 on
healthy threads too (not interrupts), mfc_q was 0 everywhere (no stuck transfer),
and interp_fb=1 on the frozen thread pointed at the fallback itself.
Both waits in writer_lock are unbounded and silent. The acquire loop spins until
every range lock bit clears, and the range_lock path then spins until every
registered PPU thread reaches cpu_flag::wait. A thread that never gets there hangs
every other thread that takes a reservation, and leaves nothing behind: from
outside it reads as a clean guest deadlock with everything in a legitimate wait.
Both now log once, far past any plausible contention, naming the held range locks
or the PPU thread being waited on.
They paid for themselves immediately on Sonic Unleashed, which deadlocks at the
SEGA logo. Both stayed silent across several boots, which ruled out the VM lock
entirely -- worth having, since main_thread was pinned in cellSpursRemoveWorkload
carrying cpu_flag::memory without cpu_flag::wait, which looks exactly like this
bug and is not. The game hangs in a different state on different boots, so it is a
race elsewhere in SPURS.
Issue #16, both halves.
Installing a .pkg or .rap off a USB-OTG drive already went through the system
picker, and the descriptor it returns is handed to the native installer as a raw
fd, so a 40 GB package costs no copy. That holds only while the provider is
backed by real storage. The third-party USB-OTG and cloud apps people reach for
when the platform will not mount their drive return a PIPE, and every install
entry point seeks -- getFileType sniffs the magic and rewinds, package_reader
jumps around the archive -- so lseek failed with ESPIPE and a perfectly good
package was reported as unsupported or broken. Those descriptors are now
detected with the same lseek the core will make, and only those are copied to
real storage first, onto whichever of the emulator's own storage and the app
cache has more room. The copy is checked against the size the provider reported:
a short copy does not throw, it produces a truncated package that fails much
later as "broken", which reads as a bug report about the package.
Split releases picked through the system picker arrived in the order the user
tapped them, and installSplitPkg takes the order given as the part order, so
picking part 2 first extracted into a broken install rather than failing. Both
pick paths now sort the parts, digit runs numerically, since plain string order
puts part 10 between part 1 and part 2.
Installed titles were listed by title id alone -- NPUB90434, BLES01807 -- next
to an Uninstall button, which is where it hurt most: choosing which of two demos
to reclaim space from meant looking the ids up elsewhere. TITLE now comes out of
the install's own PARAM.SFO, read off disk rather than through the library cache
so a title the scanner has not seen yet is still named. The id stays on a second
line because patches, cheats and compatibility lists are keyed by it. Licence
files carry the same id inside their content id, so a .rap can name the game it
unlocks instead of being one of a row of indistinguishable hex strings.
The SFO field reader is the scanner's CATEGORY reader generalised rather than a
second copy of the 16-byte index-entry layout.
Accurate SPU Reservations off is worth a large amount in Spider-Man: Web of
Shadows and is not safe globally, so it goes in that title's own override rather
than the default.
Its SPURS reservation traffic serialises behind the global exclusive
vm::writer_lock that every reservation_op takes, which no amount of CPU can help:
all six SPU threads and several PPUs were measured yielding at the same rate with
18.8% of total CPU in sched_yield. Off, SPURS takes the lock-free path and
vm::writer_lock fell from 8.06% to 0.96%.
Kept per-title because it is off-spec -- upstream defaults it on, and Sonic
Unleashed fails EARLIER with it off, reaching neither the loading icon nor the
logo, which is consistent with the bypass being the SPURS area itself.
The seed writes only fields a title does not already carry, so a deliberate
change is never overwritten, and it runs once. Other regions of the same game
need their own entry.
Two things, both about the RSX waiting rather than working.
flush_command_queue ended by draining the present queue in case a queued frame
still held a ref to the command buffer just taken. It cannot: next() hands them
out from a 512 entry ring and the queued list is bounded at flip to
m_max_async_frames - 1, so the buffer being reused is hundreds of frames retired.
The guard was unreachable and the cost was not -- check_present_status pokes the
oldest queued frame's swap command buffer, and on Adreno vkGetFenceStatus blocks
until signalled rather than returning VK_NOT_READY, so a poll written to be cheap
became a full GPU sync. 1.32 times a frame at about 11ms: Fence poll 14.6ms ->
0.033ms, frame 44.5ms -> 36.6ms. Same fault as the two sites removed earlier;
this one sat inside flush_command_queue rather than on the present path. Ruled
out first: identical frame time at quarter resolution, and forcing the swapchain
pre-transform to match the surface left it unchanged.
The empty-ring yield is now charged to idle. It sits inside fifo_decode, which is
the enclosing scope of the whole run loop, so waiting on an empty ring was
reported as decode work -- Idle 0.003ms against FIFO decode 20.4ms, while a
native profile of the same thread put 34% of its cycles in sched_yield. The
bucket report and the profiler disagreed and the bucket report was wrong, which
has now produced two wrong conclusions in one session.
The 50us backoff in the FIFO_EMPTY path was added when the RSX thread was
measured spending 66% of its cycles in sched_yield on a machine starved for
cores: the affinity mask confined six SPU threads to four cores, and the
reservation path serialised everything behind a global lock, so a spinning RSX
took a core from threads that needed it.
Neither holds now, and the trade inverted with them. Measured after both were
fixed: 34% of eight cores busy, two to four threads runnable, five idle. Nothing
wants the core the sleep gives back, and the RSX sits on the frame's dependency
chain, so sleeping only delays it noticing the guest has produced work.
Also log the surface transform at swapchain creation. 30% of the frame is now in
check_present_status waiting on acquire_next_swapchain_image, which is not GPU
work -- a quarter-resolution run measured the same frame rate. Declaring IDENTITY
while the surface is rotated hands the rotation to the compositor, which can hold
images longer before releasing them for acquire. Logged rather than changed:
matching currentTransform means applying the rotation ourselves across the blit
and the overlay pass, and that is only worth doing if the two actually differ.
Dropped to 20 while the emulator was starved for cores, reasoning that a spinning
SPU steals a core from threads doing real work. Two things have changed
underneath that: the affinity mask no longer confines six SPU threads to four
cores, and the reservation path no longer serialises everything behind a global
lock. Measured after both, in game: 34% of eight cores busy, two to four threads
runnable, five idle, and no thread near saturation.
Tested at 100 and at 20 with no difference, which fits -- the wait is no longer on
the critical path, so how it waits does not matter. Upstream's value stands
rather than carrying a divergence that buys nothing.
The affinity migration turned the scheduler on so the big.LITTLE mask would
apply, keeping SPU and RSX off the A510s that run at roughly 27% of prime-core
capacity. That reasoning holds for one thread per core and breaks down at six.
Measured in game on a Snapdragon 8 Gen 2, reading the masks the threads actually
carry:
app cpuset (top-app) 0-7 Android grants every core
SPU[0..5] 3-6 six threads, four cores
rsx::thread 3-7
Six SPU threads sharing four cores get about two thirds of a core each, which is
worse than one thread owning an A510 outright, and it caps the emulator: the
device sat at 60% busy with cores 0-2 idle while frames were slow. Spider-Man:
Web of Shadows is visibly better at OS.
Worth being clear that the mask is ours and not Android's -- the app is in
top-app with all eight cores granted -- which is also why these devices are
reported to run better under native Linux, where no such policy is applied.
The other modes stay selectable for anyone whose device disagrees.
A DualShock 3 sends pressure bytes in every packet. The press setting governs how
much of the buffer the game is told is valid, not whether the pad produced the
values, so clearing the area diverges from the hardware: a game that reads a
pressure byte without having asked for press mode gets 0 where it would see a
press on a console.
Spider-Man: Web of Shadows does exactly that for R2. Tracing both entry points
showed it never calls cellPadInfoPressMode or cellPadSetPressMode, so the setting
stays at 0, yet it reads the R2 pressure byte to decide whether the trigger is
held. R2 did nothing in that game while every other button worked, from the
controller and from the touch overlay and after remapping to a different physical
button, because the digital bit was delivered correctly the whole time and was
never what the game looked at.
len is unchanged, so a game that honours it sees what it saw before.
The out-buffer check answers 'unlikely to be a loop', and that answer is not
free. It resets the spin count and leaves the busy-waiting switch at umax, so the
caller skips busy_wait, skips the sleep path, and returns immediately: the SPU
re-executes GETLLAR at full rate with no backoff. The spin count never reaches 4,
so the spin optimisation is never evaluated, and the 400ms fallback that would
force a sleep is never reached either. One SPU in that state holds a core flat
out, and the setting meant to control this has nothing to act on.
Spider-Man: Web of Shadows sits in that case. Its GETLLAR sites use an LSA in the
top 64K of local store, which is what the check looks for, and process_mfc_cmd
measured 55% of all CPU across the process while the game ran at 10-15fps.
Re-entering the same site with the same stack 32 times is itself the evidence
that it is a loop, whatever the LSA looks like. After that the verdict is dropped
and the normal spin detection decides between busy-waiting and sleeping. Any real
change of site or stack resets the count, so a genuine OUT buffer still gets the
original treatment.
SPU GETLLAR Busy Waiting Percentage defaults to 100 upstream, meaning always
busy-wait. That suits a desktop, where the SPU threads have cores of their own
and spinning costs nothing else. Here six of them share eight cores with the PPUs
and the RSX, so a spinning SPU takes a core from the threads doing the work.
Measured on Spider-Man: Web of Shadows: process_mfc_cmd accounted for 55% of all
CPU across the process, and making its inner loop cheaper did not move the frame
rate -- the loop just ran more iterations in the same wall clock. That is what
identified it as a spin rather than as work, after two rounds of optimising the
iteration itself.
20 still favours a short busy-wait, so a reservation that frees quickly is caught
without a scheduler round trip, and only a wait that history says is long goes to
sleep. A deliberate change in All Core Settings still wins, since core overrides
replay after this.
Gating the check on getllar_spin_count was not enough. That counter is reset from
several other paths, so it is frequently zero and the callstack was still rebuilt
constantly: measured 19.6% of all CPU inclusive in dump_callstack_list, the
largest single item after process_mfc_cmd itself.
Key on the values the answer actually depends on instead -- pc, the stack pointer
and the link register -- and recompute only when one of them moves. Only the
innermost frame is ever used, so that is all the memo keeps.
A stale answer across an unrelated LS write is acceptable here. This decides only
whether the address looks like a caller's OUT buffer, on a heuristic whose own
comment calls it 'unlikely to be a loop'.
The out-buffer check in the GETLLAR spin detector rebuilt the callstack on every
iteration of a busy-wait loop. dump_callstack_list walks the stack and calls
is_exec_code for each candidate, which allocates a vector<bool> and scans for
branch targets, so the cost is large next to what it decides.
On a whole-process profile of Spider-Man: Web of Shadows those three came to
about 14% of all CPU -- more than the RSX thread spent on the frame -- because
the game's SPU code spins on GETLLAR with an LSA in the top 64K of local store,
which is exactly the case the check looks at.
Once per sequence is enough. pc, ch_mfc_cmd.lsa, gpr[1] and addr are all compared
against the previous iteration a few lines above and any change resets the
sequence, so the callstack cannot move underneath a spin.
sched_yield does not idle a core. With every core already busy it returns almost
immediately and the RSX thread takes it again, running flat out producing
nothing. A native profile of Web of Shadows put 66% of this thread's cycles in
sched_yield and its kernel path against 9% in run_FIFO, which the bucket profiler
reports as a busy RSX because the yield happens inside the fifo_decode scope.
That is not free even with an empty ring. The RSX affinity mask covers the whole
fast cluster while the SPU mask is that cluster minus the prime core, so any of
this that lands off the prime core is taken from the SPU threads, and those are
what the frame is actually waiting on at 61% of all CPU.
The spin still runs 64 times before sleeping, so a producer that is merely slow
is met without a scheduler round trip. Only a ring that has genuinely gone quiet
reaches the 50us sleep, which is far below the frame times where this matters.
Android only.
A query that begins inside a render pass instance has to end inside that same
instance. Ending the pass underneath an open one leaves it permanently
unavailable, and on Turnip it takes the device with it, reported later against
poke_query because that is the first call that waits on a result.
Queries do begin inside render passes here. VKDraw only lifts them out when
use_strict_query_scopes() is set, and that is wired to Strict Rendering Mode, a
user performance setting rather than a driver quirk, so it is off for almost
everyone.
Twenty-one call sites end a render pass and only one, in VKDraw, ever paired
itself with a cleanup. change_image_layout alone ends 41 passes a frame in Web of
Shadows, and any of them can land while a query is open, which is why fixing the
two sites in the query pool moved the device loss from one minute to nearly three
rather than removing it. Holding the invariant in end_renderpass covers all of
them, including any added later.
The VKDraw site now cleans up before the pass ends rather than after, which is
the order the spec asks for; its own call becomes a no-op.
The migration that turned relaxed ZCULL on recorded it twice: once in the curated
store and once as a raw core override. The migration that turned it back off only
corrected the curated field, so the two stores disagreed, and the override is the
one that reaches the core -- overrides re-push at the tail of applyTo, after the
curated store has written the setting.
The toggle therefore read OFF while config.yml read 'Relaxed ZCULL Sync: true' on
every boot, with no way to change it from the UI. That is not cosmetic: relaxed
sync is what allows queries to be read while still pending, which is the path
behind the 'Dubious query data pushed to cond render' warnings, and it also
selects emulated predication in the VK backend.
Emulated conditional rendering exists for hardware that never had the extension.
Turning the extension off as a driver workaround enabled it by accident, because
both are selected by the same test, and the two halves do not fit together:
begin_conditional_rendering returns early without building m_cond_render_buffer,
while the vertex shader still reads that buffer at offset 0. It gets a zeroed
scratch buffer, predicates every draw away, and the game renders black with audio
and overlays still running. The all-ones word that disables predication sits at
offset 4 and is never reached, since the fallback leaves hw_cond_active set.
Off on these drivers means occlusion results stop culling draws, which is the
trade the workaround already documents.
The vendor comes off the GPU rather than from get_driver_vendor(), whose cached
value is not assigned until later in the same function and would still hold the
previous device's.
The existing gate covered only the proprietary driver and said Turnip was left
alone until there was evidence about it. There is now.
Web of Shadows loses the Vulkan device about a minute into gameplay on Turnip 26
/ Adreno 740. The assertion names poke_query, which is the first call that reads
a result rather than the one at fault. Conditional rendering is the only place we
record vkCmdCopyQueryPoolResults with VK_QUERY_RESULT_WAIT_BIT, and that form
makes the GPU block until the query resolves, so a query that never resolves
hangs the device instead of the caller and the watchdog ends the session. The
same run logged 169 'Dubious query data pushed to cond render' warnings, which is
this code being handed queries that are still pending.
It is also the churn: the aggregation barriers closed 42 of the 91 render passes
in a measured frame, and ending a pass on a tiler costs a tile store and reload.
Both drivers now fall back to thread::begin_conditional_rendering, the path
desktop already takes wherever the extension is absent.
Fixing the device loss by ending the render pass before vkCmdCopyQueryPoolResults
introduced a hang in its place. A query that begins inside a render pass instance
has to end inside that same instance; ending the pass underneath an open one
leaves it permanently unavailable, so get_query_result spins on poke_query with
no way out and the RSX thread stops.
Nothing reported it. The submit-time ensure() only checks that the query was
closed, and end_occlusion_query closes it a moment later, so the assert passes
while the result never arrives. The stall detector runs from do_local_task in the
FIFO loop, which the spin has already left, so the profiler charged the wait to
FIFO decode and the frame read as CPU-bound -- 93% in a bucket that was really
the thread sitting in sched_yield. Web of Shadows locked up this way after
reaching gameplay, audio and vblank still running.
Both sites that end a pass from the query path now close an open query first,
which keeps begin and end within one pass. The query is cut short, as it is
anywhere do_query_cleanup is used.
The wait itself is now bounded as well. It warns at one second and abandons at
three, using whatever the query holds: wrong culling for a frame is a better
failure than a thread that never returns, and the log names the cause.
The drain fix made every path that can idle or block publish GET immediately,
which is required for correctness: a producer waiting on ring space needs to see
the progress we made before we stopped consuming.
It publishes far more often than that requires. The empty and busy cases return
straight to the run loop, so a ring that has gone quiet re-enters them once per
iteration with GET unmoved. Web of Shadows measured 137000 loop iterations per
frame against 46000 method dispatches; the remaining 91000 were republishing a
value the guest already had.
GET shares a 64-byte line with put, which the guest PPU writes from another
cluster, so each of those is a coherence miss taken against the thread feeding
the ring. The cost lands on the producer rather than on the RSX, which is why it
presented as a freeze with sound still playing: the PPU stalls on the contended
line while threads that never touch it keep running.
GET is ours to write, so tracking the last published value and skipping an
unchanged store keeps the guarantee -- progress is still announced exactly once
after the last advance -- without the repeats.
vkCmdCopyQueryPoolResults must be recorded outside a render pass instance. This
recorded it inside one, with a comment saying we are technically supposed to stop
the pass first but that it does not matter on IMR hardware. It is not a
technicality -- inside a pass it is undefined behaviour, and a desktop GPU
tolerating it says nothing about a tiler.
This device lost the Vulkan device over it. The fault surfaced later, in
poke_query, because that is the first call that waits on a GPU result, so it read
as the query READ being at fault when the damage was done at record time. Only
the RSX thread died, so the process kept running with audio and vblank alive and
it presented as a hard freeze rather than a crash. Verified gone: zero device
losses on a run that previously died within a minute.
The pass is ended only when one is actually open, on a path that already stalls
for a GPU result, so the flush the upstream comment worried about is paid where
we were blocking anyway -- and disabling occlusion queries is not the
alternative, measured here at 80ms frames with broken visuals.
The two by-pass tables were joined on counters that reset at different points.
tick_frame runs from on_frame_end, before flip; the GPU timer rotates its slot at
the top of flip and then drops every non-frame region on the fresh slot, which is
flip's own overlay and calibration passes -- and those still incremented the CPU
counter. So the CPU ordinal ran ahead by the number of present-path passes and
the two tables described different passes. A whole anomaly came out of that: a
pass whose GPU cost was joined to a neighbour's workload read as 36x the per-draw
cost of its peers. The comment claiming both reset on the same boundary was
wrong. Reset where the GPU slot actually rotates instead.
Also adds a Storage Access Framework route to the package installer. The in-app
browser walks java.io.File, which only reaches storage this process can open by
path, so a .pkg on a USB-OTG drive or an SD card was unreachable and had to be
copied to internal storage first. Packages are handed over as the descriptor SAF
already returned -- the native side takes a raw fd, so nothing is copied and a
4 GB package costs no extra space; licences are 16 bytes and their installer
wants a real file, so those alone are staged.
The low-memory serialisation added three days ago holds a std::mutex across the
LLVM compile itself. A worker that hits LLVM's fatal handler leaves through
pthread_exit, and bionic unwinds nothing on that path, so the mutex stays locked
by a thread that no longer exists and every remaining worker waits on it for the
rest of the session. Memory only falls as modules accumulate, so the tight-memory
branch is likeliest late in a run -- which is why it reads as the PPU cache
getting stuck at the very end, and why dropping to the interpreter avoids it.
Reported as Saint Seiya: The Sanctuary never finishing its module cache. A claim
taken by compare-and-swap and waited on with a timeout costs a stranded claim a
wait rather than the session; the memory back-pressure either side of it is
unchanged. Third time this fork has been bitten by an unbounded wait around a
thread bionic can kill without unwinding.
overlay_audio.cpp already accounts for a platform with no video source; the same
ensure() was left in overlay_video.cpp. Android's make_video_source returns
nullptr, and overlay_save_dialog builds a video_view for EVERY entry on all three
of its paths, so opening a save list aborted as soon as there was one save to
draw. It presents as the save menu never opening -- reported against Ratchet &
Clank: Tools of Destruction and Devil May Cry 4, and against Web of Shadows,
which stalls only once a save exists to be listed. Bundling the overlay icons
was necessary but not sufficient: the dialog still could not survive drawing.
The still image is what an entry needs; the animated ICON1.PAM is the part no
backend here can supply. Also dumps SPU thread pc and block hash alongside the
PPU dump when frames stop, which is what named the SPURS kernels as idle rather
than spinning in guest code.
overlay_controls.cpp loads a fixed set of PNGs -- button glyphs, save.png,
new.png, spinner -- through fs::get_config_dir() + Icons/ui/. Desktop ships them
beside the binary; nothing put them on Android, so every load failed and the log
said so on each boot. The visible cost was cellSaveData's list: it is a native
overlay that draws its rows with save.png/new.png, so the load-save menu a game
opens never appeared. Reported against Ratchet and Clank: Tools of Destruction
and Devil May Cry 4, both fine on emulators that ship the icons.
Bundled from bin/Icons/ui and staged into config/Icons/ui once, revision-guarded,
before the core can draw its first overlay.
The nineteen translation files were ARMSX2-era: about nine hundred of their keys
still existed and showed the old PS2 wording -- worse than the English fallback,
which is at least right -- and roughly eight hundred current keys had no
translation at all. Regenerated all nineteen from the 1041-key map, batch plus
per-line retry, with every %s/%d checked against the source so no broken format
string ships. A string that would not translate is omitted and falls back to
English rather than shipping wrong.
The memory-card and PNACH patch screens were PS2 concepts with no PS3 counterpart
and no caller left -- the drawer had already been cleaned, so they were dead code
holding dead strings. The PNACH downloader went with its only consumer, and the
PCSX2-Android.ini seed could never exist under this package. The session log now
announces ARMSX3_INIT instead of PCSX2_INIT, which had every bug report opening
with the name of a different emulator.
The English map drops 46 PS2 strings and 619 orphans nothing references (1705 ->
1041 keys), rewords the four live strings that still said memory card, and renames
about.pcsx2.* to about.rpcs3.* to match what they already said.
Three things in the patch download path, all of them things desktop
RPCS3 already does.
The download URL had the patch schema version written into it as 1.2.
That is right today, but patch_engine::load rejects any file whose
Version header doesn't match the core's patch_engine_version, so the day
upstream bumps that constant every download starts failing to parse. The
core now hands the version out through patchEngineVersion() and the URL
is built from it. I also check the version the server echoes back, which
turns a several megabyte download into an early error instead of a
parser complaint.
Nothing verified the sha256 the server sends alongside the patch text.
Desktop checks it before it writes anything (patch_manager_dialog::
handle_json). Patches are writes into the guest executable, and
move_file/hide_file patches reach the emulator's own filesystem, so I'd
rather not import bytes that aren't what the server hashed. Mismatches
get their own message rather than being reported as a parse failure.
Last, the wildcard serial. patch_key::all is spelled "All", and
patchSetEnabled compared against a lowercase "all", so a patch carrying
a wildcard entry never had that entry written.
I first "fixed" the same typo in patchesList and let per-game lists match
the wildcard too. Ooops. Turns out that is not a typo doing nothing, it
is a typo doing the right thing by accident: wildcard patches are keyed
by SPU or PPU hash and leave the serial as "All" because the hash is the
filter, so they belong to no single game. There are 17 in the database,
and on device Skate 3 cheerfully offered me a pile of LittleBigPlanet
MLAA patches, where toggling one writes the shared entry and changes
every other game as well. Desktop shows them once under an "All titles"
node, so per-game lists stay serial-only here and the global list is the
equivalent. Only patchSetEnabled and the enabled-state read get the
spelling fix.
Two things in the patch list reported something the game wasn't getting.
First, patchesList marked a patch as enabled if any entry under its hash
was enabled, whichever serial that entry belonged to. patchSetEnabled
writes per serial, so a patch I switched on from one game's list showed
as on in every other game that patch covers. It now scans only the
requested serial, plus RPCS3's "all" wildcard, which really does apply to
the game being listed.
Second, the patch engine builds its map once while the game loads, then
writes the patches into each module as that module loads. Toggling a
patch only rewrites patch_config.yml, so nothing happens in a game that
is already running. The in-game tab never said so, which makes the switch
look broken. It says so now, above the list.
2026-08-10 05:01:54 -05:00
236 changed files with 34378 additions and 19205 deletions
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.