146 Commits
Author SHA1 Message Date
jpolo1224 ab3335b8d6 Make the auto-save options and save-state import do what they say
Auto-save on exit, auto-load on boot and the interval auto-save were ARMSX2 shims
returning false that were never ported, so all three toggles persisted and read
back while doing nothing -- the interval job woke on schedule to call a function
that always failed. They now use a reserved slot above the ten the picker shows,
reusing the numbered-slot path rather than growing a second mechanism.

Import treated getGamePathSlot as a file path, but it answers with the title id:
File(id).exists() was false for every slot, so the first OCCUPIED slot read as
free and the destination resolved against the process working directory. It
copied the file nowhere useful and reported the slot it had not written.
Occupancy now comes from the core and the destination from the real path.

Also says how large a state is before the storage bill arrives, and stops the
interval description promising a pause when a PS3 save is a stop and a reload.
2026-08-10 13:20:38 -04:00
jpolo1224 99b6b47ee1 Look for slot thumbnails where the core actually wrote them
The reader rooted the path at systemDirPosix(), which is null unless a data
folder was explicitly picked, so on a default install it returned before it ever
looked and every slot drew as a blank tile with the thumbnail sitting on disk
beside the state it belongs to. Falls back to getExternalFilesDir, which is what
fs::get_config_dir() resolves to on that install and where the writer put it --
the same fallback inputProfilesDir() makes for the same reason.
2026-08-10 13:03:07 -04:00
jpolo1224 6255ce5840 Run posted main-thread callbacks off the caller's thread
CallFromMainThread without a wake_up is a post: upstream hands the callback to
the GUI thread and returns. This ran it inline instead, under whatever locks the
caller held.

lv2_obj::sleep_unlocked posts one while holding lv2_obj::g_mutex, which is what
the comment on that call site is about. The callback is FinalizeRunRequest, the
wake for a restored savestate, so it took g_mutex against itself and every thread
stopped there: the log reaches Final Thread and goes quiet with the SPUs spinning
and the progress overlay frozen on its last figure. It took out loading a state
and saving one alike, a save being a stop and a restore.

Callers passing wake_up are waiting on completion and still run inline.
2026-08-10 12:52:51 -04:00
jpolo1224 87b0992fdd Revert keying the SPU cache on savestate-compatible mode
The hash it changed is one of several computed over the same function data, and
only this one moved, so the cache rebuild it forced ran through a path whose
other sites disagreed. Loading a state then wedged in Building SPU cache with
nothing compiling.

Reuse of blocks across a change of the setting is still wrong, but it is an
upstream behaviour that predates this and is better addressed by invalidating
the object cache once when the mode changes than by moving one hash out from
under the others.
2026-08-10 12:40:22 -04:00
jpolo1224 2a0c04ade7 Stop treating an already-registered game as a boot failure
already_added reports that the title was already in games.yml, which is the
normal case for anything booted once before, and RPCS3's own front-end passes it
through for that reason. The bridge failed every result that was not NoErrors,
so the boot was abandoned and the user was returned to the library with "Game
failed to start: AlreadyAdded".
2026-08-10 12:38:35 -04:00
jpolo1224 5b8ff01fe2 Route the savestate-compatible setting to the core
The bridge translates each section it knows and returns false for the rest, and
there was no Savestate case, so the write was dropped on the floor. The setting
could not be turned on at all -- not by the default, not from the settings row --
and savestates failed to lock the SPUs while telling the user to enable exactly
the option that was being discarded.
2026-08-10 12:23:55 -04:00
jpolo1224 51d465f193 Key the SPU cache on savestate-compatible mode
The setting changes the code generated for blocking channel reads -- the GPRs
are stored rather than the thread being marked unsavable -- but the cache key
was a hash of the guest code alone, and the compiled object is cached under it.
A block built in one mode was therefore reused unchanged in the other, so
turning the setting on left the old unsavable blocks in place and savestates
went on failing to lock the SPUs with a message telling the user to enable a
setting they had already enabled.

Mixed in only when set, so caches built in the default mode stay valid.
2026-08-10 12:17:53 -04:00
jpolo1224 6cd5b8b986 Turn save states on by default
A save that fails with "missing SPU setting" reads as broken rather than as a
setting waiting to be found, and the setting is not one a player would think to
look for. Upstream defaults it off to protect SPU performance; here the feature
not working at all is the worse trade.

Costs are unchanged and still stated on the switch: it slows the SPUs while it
is on, and a PS3 state runs 500MB to 3GB. Turning it off restores upstream
behaviour and gives the performance back, and that choice is now respected on
every boot rather than overwritten.

Release 0.4.1.
2026-08-10 11:51:09 -04:00
jpolo1224 44f62f310c Let the user turn save states on
Save states could not be taken at all. Saving has to stop every SPU somewhere it
can be serialised from, which is what Compatible Savestate Mode does, and this
port wrote that setting to false on every boot -- so the save failed with
"missing SPU setting" no matter what the user did, and nothing on screen
connected the two.

The reasoning was sound: the mode costs SPU performance, and a PS3 state runs
500MB to 3GB, so a few saves fill a phone. Both of those are costs to disclose,
not reasons to decide for someone. It is now a setting, still off by default, so
nobody pays for a feature they did not ask for and installs from the window when
it was pushed as true are corrected by the same write.

Placed with the SPU rows rather than under a savestate heading, because that is
where the cost lands, and the description says what both costs are before the
switch is touched.
2026-08-10 11:47:41 -04:00
jpolo1224 431b6d0925 Stop a recovered LLVM fatal error from wedging the SPU JIT
run_recoverable_llvm runs code generation on a disposable thread and terminates
it through pthread_exit when LLVM invokes its fatal error handler. bionic does
not force-unwind C++ frames on pthread_exit, so the lock MCJIT holds over the
execution engine is never released and stays held by a thread that no longer
exists.

Every entry point into the engine takes that lock, so the next compile hangs and
so does teardown. One recovered error, and the emulator is finished until it is
killed -- and the error is recovered, which is the point: it is meant to be
survivable.

Their Kotlin log-channel screen is left out; this port has its own.

From MaxsTechReview in PS3Native.
2026-08-10 11:44:11 -04:00
jpolo1224 4a73773cee Bound two unchecked sizes reached from file contents
Two places compute a size from values a file supplies and use it without
checking it is possible.

A SELF or SCE header gives the metadata offset and the header size, and the
buffer between them is sized by subtracting one from the other. Both are
unsigned, so a truncated or malformed dump that puts the offset past the header
end underflows into a near-SIZE_MAX allocation, which fails as an out-of-memory
rather than as the bad file it is. Reject the layout instead.

Texture uploads take the mip levels the guest describes and write them into an
image built from the destination's own dimensions, which can hold fewer. Drop
the levels that do not fit rather than writing past what was allocated.

From MaxsTechReview in PS3Native.
2026-08-10 11:43:05 -04:00
Zulux91 e16f0fcd3d Sample thread CPU time by tid on Android
get_cycles passes the pthread handle to pthread_getcpuclockid, which glibc
answers with an error for a thread that has already exited -- the else branch
below returns the last known value for exactly that case. bionic instead looks
the handle up in its list of live threads and aborts the process when it is not
there.

m_thread is never cleared when a thread ends and the performance overlay samples
every PPU, SPU and RSX thread on a timer, so one finished thread is enough to
take the emulator down with it. Latent here rather than absent: it needs the
overlay on and a thread to have gone.

Record the kernel tid at initialize and build the per-thread clock id from it
the way bionic does once its own lookup succeeds, so clock_gettime simply fails
for a dead thread, which is what the surrounding code already expects. Cleared
at finalize so a thread stops being sampled before it goes away. Other platforms
keep the original path.

Found and fixed by Zulux91 in PS3Native.
2026-08-10 11:42:14 -04:00
jpolo1224 6463d010e9 Ask the game for its content id when installing its licence
A licence installed from a locked game's own menu reported success and left the
game locked. The install writes the file into exdata under its own name, because
a RAP's name is conventionally the content id it unlocks, and that convention is
the whole lookup: the core opens exdata/<content id>.rap and nothing else. A file
saved as "license(1).rap", renamed, or tidied up on the way over therefore lands
where nothing will look for it, and the only feedback is the game asking again.

Where the game is known, ask it. The native path decrypts the EBOOT's
supplemental header to read the content id, which works on a locked game because
that header is not what the licence protects, and names the file correctly
whatever the user's copy is called. Falls back to the name when there is no game
to ask or the header cannot be read.

The package screen keeps the name-based path: a licence installed on its own has
no game to resolve against.
2026-08-10 11:36:42 -04:00
jpolo1224 6961adf597 Split the DMA and blit engine handlers
Those two are 7.4 ms a frame in Arkham City, a fifth of it, and the GPU side of
the same work is 0.76 ms, so it is host work. Each handler does several unrelated
things and nothing separates them: a read barrier that can force a readback, the
memory copy the transfer exists to perform, and in the blit engine a software
scale through ffmpeg for the cases the GPU path does not take.

Scope the three. The scale is scoped inside convert_scale_image rather than at
its four call sites in the blit engine, and only the RSX thread is ever reported,
so calls from elsewhere cost nothing to cover.

Also let a scope be closed early, so a region ending part-way through a function
does not need a block introduced purely to place a brace.
2026-08-10 05:32:03 -04:00
jpolo1224 d7f2eba643 Look method names up by the key the table actually uses
The name table is keyed by register index, and both method reports passed the
byte offset. A lookup therefore matched whichever unrelated method happened to
have that value as its enum, so the costliest entry in Arkham City came out as
NV4097_SET_CONTEXT_DMA_VERTEX_B, which has no handler and cannot cost anything.
It was NV406E_SEMAPHORE_ACQUIRE: the RSX waiting for the guest to signal, which
is the one entry in that list that is supposed to block and the one that should
not be optimised.

A wrong name is worse than none here. The hex fallback was right the whole time
and is left as the byte offset, which is what a reader looks up.
2026-08-10 05:28:11 -04:00
jpolo1224 b209907dc1 Rank method handlers by cost instead of by volume
The handler bodies are 60% of the RSX thread in Arkham City and the dispatch
machinery around them is 3.5%, so the question is which handlers. The method
histogram cannot answer it: it counts calls, and the busiest method may be a
register write while a rare one does the work.

Keep the interval the dispatch site already measures. It brackets the call with
two counter reads to fill the method_call bucket and then throws the difference
away; billing it to the method's slot as well costs one add.

Inclusive of whatever the handler calls into, including scopes that charge
themselves elsewhere. For ranking handlers that is the useful reading, and the
per-bucket totals stay exclusive as they were.
2026-08-10 05:22:13 -04:00
jpolo1224 b4d63d6c0a Split method handler bodies out of FIFO decode
Arkham City spends 38.5 ms a frame in FIFO decode, 58% of the RSX thread, at
164 ns a dispatch. Sonic manages 45 ns on the same loop, the same decode and the
same counters, so the difference is in what the handlers do rather than in the
dispatch. Nothing separates the two: fifo_decode encloses the whole loop, so it
holds every handler body as well as the machinery around them.

The two handlers already scoped, transform program and transform constant,
measure 0.02 and 0.06 ms here, which rules them out and leaves the rest of the
mix unaccounted for.

Wrap the handler call. This is the only per-dispatch scope in the profiler and an
earlier attempt at one measured mostly itself; it is affordable here because it
brackets a call rather than a loop iteration, and handlers carrying their own
scope still attribute inward. It costs a few percent of the bucket it splits, so
the split is the number to read, not the total.
2026-08-10 05:17:54 -04:00
jpolo1224 d182338669 Re-bind the profiler when the RSX thread changes
Booting a second game without restarting the app builds a new RSX thread.
set_enabled is the only thing that binds the profiler to a thread, and it
early-returns when the setting has not changed, so it stayed bound to the
previous game's thread. Every scope then failed its owner check, nothing
switched buckets, and the whole window was charged to whichever bucket happened
to be current.

That prints as "FIFO decode 100.0%", which is indistinguishable from a genuine
finding about a command-bound title, and was briefly read as one.

Notice the change per frame and re-bind, dropping the accumulated window and the
per-pass counters: they belong to a thread that is gone, and keeping them would
blend two games into one report.
2026-08-10 04:59:03 -04:00
jpolo1224 171dcfc4ad Release 0.4 2026-08-10 04:37:59 -04:00
jpolo1224 776d8c65fc Back up the data this emulator actually has
The include list came over from ARMSX2 and names sstates, memcards, gamesettings,
cheats and snaps. None of those exist here, so a backup collected a few kilobytes
of controller profiles, reported success, and left every save behind. Nothing
warned: skipping an absent folder silently is right for an optional one and wrong
for a list aimed at a different emulator.

Name RPCS3's paths instead. The part that matters is config/dev_hdd0/home, which
holds save data, trophies and licences, and is the only thing in here that cannot
be rebuilt or re-downloaded. Save states, input configs and patches come along.

Installed titles are left out on purpose, along with firmware and dev_hdd1: a PKG
reinstalls and a PUP reinstalls, a save does not, and that is the line this list
is drawn on. Including them would have taken the archive from tens of megabytes
to nearly five hundred on the device this was sized against.

The description on the screen said memory cards and artwork too, so the one place
a user could have noticed agreed with the bug.
2026-08-10 04:34:41 -04:00
jpolo1224 4440eb30b9 Count occlusion queries per pass
Everything measured so far describes what a draw contains: vertices, pixels,
shader length, subdraws, barriers. By all of them pass six should be the
cheapest of the expensive passes, and it is the dearest by a factor of seven.

An occlusion query is none of those things. On a tiler it makes the visibility
stream resolve, it costs the same whatever the framebuffer size, and no counter
here would show it. That matches every property this pass has: indifferent to a
sixteen fold cut in pixels, indifferent to tiling being switched off, no
barriers, one subdraw per draw, shorter shaders than the passes it dwarfs.

ZCULL is active, and emit_geometry opens a query whenever the command buffer
carries the occlusion flag. Count them where they open.
2026-08-10 04:21:33 -04:00
Zulux91 1e320e3de2 Fix .rap licence installing and surface licence-locked games
Installing a .rap never worked at all. The package screen routed licences to
installKey, whose RAP branch works out the content id by decrypting the game's
EBOOT, so it needs a game path, and the only caller passed an empty one. Every
attempt died at "Failed to fetch NPDRM of SELF". A RAP's filename is the content
id it unlocks, which is why RPCS3 desktop's InstallFileInExData simply copies the
file into exdata. That is what this does now, lower case extension included,
because unself.cpp searches for it that way.

Picking a game together with its licence could not work either. The installer
routed on file count rather than file kind, so any multi file selection went to
installSplitPkg, whose first act is to reject anything that is not a .pkg part.
Multiple selection has been allowed since split packages landed, so the obvious
thing to do was the one thing guaranteed to fail. The selection is split by kind
now, packages first, since a licence unlocks content the package has to have
written already.

Both failures showed the same generic "Install failed. The file may be encrypted,
incomplete or not a PS3 package", which reads as a bad file rather than a bug in
the app. The reason the native side already reported now reaches the screen.

A licence-locked title also looked like any other until it refused to boot. The
core works that flag out by attempting decrypt_self on the EBOOT, but the library
never asked it. The scan asks now, and a locked game gets a badge on its cover, an
Install licence entry in its context menu, and a prompt instead of a doomed boot
from every launch path: the library cards, the context menu, the controller, and
the settings screen's Play button.

Boot failures were silent besides. Rpcs3Bridge.boot threw away BootGame's return
code and MainActivityRuntime dropped runVMThread's result, so a failed boot was
indistinguishable from a game that started and exited immediately. Both are
reported now, which is how I found the licence problem in the first place.

External intents and launcher shortcuts are not covered, because externalGameInfo
builds a fresh GameInfo where locked defaults to false. Those still fall back to
the boot failure message.
2026-08-10 04:20:21 -04:00
Zulux91 de34f7f173 Remove a title's shader and PPU cache when uninstalling it
Uninstalling only ever removed dev_hdd0/game/<TITLEID>, so the title's compiled
code and shader cache stayed on disk forever. On my device that was between 7 and
58 MB per title, and one of those caches belonged to a game I had already removed.

I made it a checkbox on the existing confirmation rather than doing it silently,
defaulted on, which is how RPCS3 desktop's own remove dialog treats caches. The
row is hidden when there is no cache, and it shows the measured size so you can
see what you are freeing. The size is measured off the main thread because a cache
directory holds hundreds of files and this runs while the dialog is opening.

The cache goes only after the native uninstall reports success, since dropping the
cache for a title that is still installed would just cost a recompile. The title
id is validated before the recursive delete: it comes from a directory listing,
but a path separator or a dot dot in it would resolve outside the per title
folder, so anything that is not a single plain segment is refused.

Save data, trophies and licences are deliberately left alone. Those belong to the
user rather than to the install, and desktop does not offer to remove them either.
2026-08-10 04:16:00 -04:00
jpolo1224 e5673c43ea Give back the extra frame in flight when memory is tight
A session ended in a fatal VK_ERROR_OUT_OF_DEVICE_MEMORY, the first in any log
here. On this GPU that is system memory, and the device had two gigabytes free
of seven with the emulator holding most of the rest.

Two frames in flight is what makes the CPU and the GPU overlap, and it is also a
second frame's worth of resources alive before anything retires them. That trade
is worth making at rest and not worth making into a crash on a handheld sharing
memory with everything else.

Fall back to the single frame this used to run with when the memory load is
above low. Slower, and slower is recoverable.
2026-08-10 04:06:41 -04:00
jpolo1224 675b2e679f Count the draws the GPU receives, not the ones the guest issued
Shader length settled that pass six is not the game's workload: it has the
shortest shaders of the expensive passes, a quarter of the vertices of a pass
that costs a seventh as much, no barriers, and no reaction to resolution or to
tiling being switched off. Every quantity measured so far says it should be
cheap, and it takes nine milliseconds.

The draw count is the one that has been lying. It counts clauses, and a clause
is expanded over its subranges, so a single entry can become thousands of draws.
Batching them through VK_EXT_multi_draw, which this device does support, saves
our command overhead and changes nothing about how many the GPU processes.

Count them at every submission site. Thousands of tiny draws at a fixed cost
each is the last shape that fits, and nothing else measured would reveal it.
2026-08-10 04:02:39 -04:00
jpolo1224 2b036458ee Measure shader complexity per pass
Pass six costs about 26 times what pass eight does per vertex: 123 draws and 68
thousand vertices for 9.15 ms against 532 draws and 253 thousand vertices for
1.26 ms. It has no barriers, does not care about resolution, and does not change
when TU_DEBUG=sysmem takes tiling and binning out of the picture entirely. The
only thing left that behaves that way is the shader.

Record vertex and fragment ucode length per pass. This decides whether there is
a bug here at all, which nothing measured so far can: shaders genuinely that
much longer are the game's own workload and there is nothing to fix, while
comparable ones mean something is happening to those draws that should not be.
2026-08-10 03:57:18 -04:00
jpolo1224 0cc115af48 Log the driver options actually applied
The previous commit read driver_env.txt before the log file was opened, so the
one thing worth knowing -- whether the option was applied -- was written into a
listener that did not exist yet and then thrown away when the log rotated.

Move the read to just after the log file is created and report each option by
reading it back rather than echoing what was meant to be set. There is no other
honest confirmation available: /proc/<pid>/environ is the snapshot taken at exec
and never reflects a runtime setenv, and Mesa's own logging goes to stderr,
which Android discards. Still long before any Vulkan instance exists, which is
the only ordering Mesa cares about.
2026-08-10 03:52:23 -04:00
jpolo1224 a2dd09376b Let Mesa driver options be set without a rebuild
This device needs Turnip; the stock Adreno driver does not render the game at
all. Turnip is steered by environment variables such as TU_DEBUG, and the usual
way to set one on Android, the wrap.<package> property, is ignored on a user
build. It can be set and read back while never reaching the process, which makes
a flag that never applied look exactly like a flag that made no difference. That
is how the first attempt at this measured stock Turnip twice and called it a
result.

Read NAME=VALUE lines from <root>/driver_env.txt during initialize, before any
Vulkan instance exists, since Mesa caches each option the first time it is read.
A missing file does nothing, which is the normal case.
2026-08-10 03:45:22 -04:00
jpolo1224 32c6bc1de2 Write the resolution scale from the setting that has a control
Picking a scale and launching a game still rendered at native. The previous
attempt read the launch-time write from ps3.resolutionScale, which turns out to
be the wrong end of it: that field has no writer anywhere in the UI, so it holds
its default of 100 permanently.

applyTo pushed that default onto Video@@Resolution Scale, the same node the
upscale multiplier writes, and applyTo runs after the launch path, so the orphan
won every time. Changing the scale in game appeared to work only because nothing
calls applyTo again afterwards.

Emit the node from upscaleFloat instead, which is what the preset grid, the
custom percentage slider and the in-game overlay all write, using the same
conversion and clamp as the other writer so the two cannot disagree. Restores
the launch-time call to the multiplier it always used.
2026-08-10 03:31:18 -04:00
jpolo1224 9c62fbd3a8 Stop the PS2 upscale multiplier overwriting the PS3 resolution scale at boot
Picking a resolution scale and then launching a game ran at native. The UI kept
showing the chosen value, the config held the default, and changing it in game
worked, which made it look like the setting was not saving.

Both settings write the same native node. applyTo writes the PS3 percentage to
Video@@Resolution Scale, and renderUpscalemultiplier writes the ARMSX2-lineage
multiplier times a hundred to the same place, from the launch path, after
applyTo. So the last writer won and it was the one carrying a default of 1.0.
Changing the value in game appeared to work only because nothing writes the node
again afterwards.

Drive the launch-time write from the PS3 setting so the two agree. Same node,
one owner.
2026-08-10 03:27:52 -04:00
jpolo1224 cc63d66148 Count the barriers landing inside each render pass
Pass six spends 9.8 ms on 44 draws and 33 thousand vertices at ordinary
resolution, which is 300 ns a vertex. That is not vertex work, and the 2048
square shadow map next to it costs under a quarter of a millisecond with twice
the geometry, so it is not target size either. What is left is the GPU being
serialised inside the pass.

texture_barrier keeps the pass open on Android and issues a by-region
self dependency instead, which was the right trade against a tile store and
reload. But that barrier still makes a tiler resolve the tile and fetch it back,
and one per draw would cost about what pass six is costing. Nothing counts them.

Count barriers issued while a pass is open, per pass, and how many came from a
cyclic reference. If pass six shows one per draw the mechanism is named; if it
shows none, the serialisation is somewhere else and this rules out the obvious
candidate cheaply.
2026-08-10 03:16:01 -04:00
jpolo1224 f115c7b554 Say what the expensive passes are, not just which
Two passes hold 69% of GPU time and one of them, pass six, costs 76us a draw
against 2.4us in pass eight while holding 7% of the frame's draws. Rendering at
quarter resolution changed nothing, so it is not fragment work, and the ordinal
on its own says nothing about what the pass is for.

Record the render target size and the vertex count per pass alongside the draw
count. Size names the pass in the game's terms, since a shadow map, a reflection
and the main scene do not share dimensions. Vertices per draw separates a lot of
geometry from a lot of cost per vertex, which is the question the timing cannot
answer and which decides what a fix would even look like.
2026-08-10 03:09:49 -04:00
jpolo1224 5a6f32f93d Break the GPU draw total down by render pass
Rendering at a quarter resolution changed the GPU time not at all, which rules
out fill rate, fragment shading and tile traffic in one measurement, since all
three scale with pixels. What is left inside the passes is geometry, binning and
per-draw cost. It also retires the tile bandwidth theory the previous two
attempts were built on: that traffic would have fallen sixteen fold.

So the draw total needs splitting, and the timer already measures each pass
individually and only reports the sum. Report the distribution instead, keyed by
the pass ordinal within the frame: the frame structure is stable, so pass N is
the same logical pass each time, which is what makes it something to act on.

Count draws per pass alongside it, on the same ordinal. A pass that is expensive
holding few draws is expensive per draw; one holding most of the frame's draws
is carrying the geometry. Same milliseconds, opposite fixes.

Reporting only. No new timestamps and nothing recorded that was not already
being measured.
2026-08-10 03:04:10 -04:00
jpolo1224 6e15b16941 Revert "Clear at pass begin instead of reading the framebuffer to overwrite it"
This reverts commit 42e3d3b261.
2026-08-10 02:55:27 -04:00
jpolo1224 42e3d3b261 Clear at pass begin instead of reading the framebuffer to overwrite it
vkCmdClearAttachments needs the pass open, and the pass opens with LOAD_OP_LOAD,
so clearing a target reads the whole framebuffer into tile memory and then
throws it away. LOAD_OP_CLEAR skips the read. On a tiler that read is the whole
attachment every time, and this title runs about thirty passes a frame at 720p
with colour and depth.

Taken only when the clear covers the entire render area and no pass is already
open. A partial clear is not a load op, and ending an open pass to change its
load ops would store the framebuffer in order to discard it, which costs more
than it saves. Colour is all attachments or none, since a load op applies to the
attachment as a whole. Depth and stencil get separate bits because clearing one
and keeping the other is common.

Whether an open instance can serve a request now compares the key with the clear
bits masked off rather than the pass pointer. Load ops do not affect render pass
compatibility, so the two variants are interchangeable for an open instance and
for the pipelines inside it; comparing pointers would have ended the instance to
begin an equivalent one, paying the store and reload this is meant to avoid and
discarding the clear on the way. Callers that pass no key keep the old pointer
comparison.
2026-08-10 02:48:49 -04:00
jpolo1224 0cafae85c3 Open the GPU frame region on the path every frame takes
The collector had gathered eight frames in five thousand flips and its ring was
parked on slot zero with every slot unreset and empty. All of that follows from
one thing: the frame region was opened at device init and in flush_command_queue
only, and this title takes that path roughly never, so the region opened once at
boot, closed on the first submit and was never opened again.

Everything else depends on it. The slot's query range is reset when the frame
region opens, the ring only advances past a slot once something in it has
completed, and collection refuses a slot that was never reset. So a timer that
initialised cleanly and logged its tick period produced no report for an entire
session, which reads the same as a GPU with nothing to do.

Open it where the primary command buffer is actually begun for the next frame.
2026-08-10 02:36:22 -04:00
jpolo1224 1ad04fb13f Make the GPU collector say why it has nothing
The GPU timer initialises, reports its tick period, and then never produces a
report: eighteen RSX profiles came and went in one session against zero GPU
profiles. Collection has several preconditions and the report only prints once
three hundred frames have been gathered, so a collector stuck on any of them
prints nothing at all, which reads exactly like a GPU that is idle.

Log the collector's state periodically while it has nothing, with the slot
flags, the open regions and the drop count, so the precondition that is not
being met can be read instead of guessed at.

Also stop recording anything but the frame region into a slot that still needs
its reset. Writing a timestamp into a range that has not been reset is invalid,
and the reset only happens when the frame region opens, so a render pass that
begins first -- the ones flip() runs after next_frame has already rotated the
slot -- was writing into stale queries.
2026-08-10 02:32:18 -04:00
jpolo1224 9bad25466a Record the GPU draw region that was declared and never written
The GPU timer measures the whole frame, readbacks, blits and uploads, and the
one region it names but never records is draw. So the split it exists to provide
has been missing exactly where it matters: with the RSX thread no longer waiting
on a fence, the Adreno sits at 99% busy at its top clock and nothing says how
much of that is drawing the game.

Bracket the render pass at the only place one actually starts, not at the
wrapper, which early-outs when the same pass and framebuffer are already bound.
Roughly thirty passes a frame, comfortably inside the per-frame event cap.

Both timestamps sit outside the pass rather than inside it. On a tiler the load
at the start and the store at the end are the expensive part, and timing from
within would exclude the cost worth knowing about.

Take the command buffer by const reference, which is what the render pass
helpers hold and what the conversion operator already permits.
2026-08-10 02:26:45 -04:00
jpolo1224 d18f1fe55d Attribute render pass teardowns to the code that wanted them
Ending the open pass to change an image layout costs a tile store and a reload
on a tiler, and it happens about twenty times a frame out of twenty nine passes.
The counter on it says how many and never which: change_image_layout is reached
from seventy five call sites, tagging them by hand would be tedious and would
still miss the next one added.

Record the return address instead. Two levels, because image::change_layout
funnels most callers and one level would name that function for nearly
everything. Only recorded when a pass is actually open, so the count is
teardowns caused rather than layout changes attempted, and only while profiling
is armed, on a path taken twenty times a frame.

Reported as a symbol where the dynamic table has one and as a module offset
otherwise, which llvm-symbolizer resolves against the unstripped core.
2026-08-10 02:19:47 -04:00
jpolo1224 85c18bd67d Name the three biggest things inside FIFO decode
FIFO decode is 56.7% of the RSX thread now that it is no longer waiting on a
fence, and it is the enclosing scope of the dispatch loop, so it holds every
method handler body as well as the loop itself. 201 ns a dispatch is far too
much for reading a word and calling a handler, so the cost is in a handler or in
the per-dispatch machinery, and nothing in the report separates those.

Scope the two batching handlers and the FIFO cache refill. All three run a few
thousand times a frame at most rather than per dispatch, so unlike the earlier
attempt at a per-command scope none of them measures mostly itself.

Count calls, not methods. Both handlers consume a run and skip the rest, so
their share of the method histogram counts what they swallowed rather than how
often they ran, and dividing by it would price a batch as a single method.
2026-08-10 02:09:55 -04:00
jpolo1224 c78e48bd28 Wait for the GPU after recording a frame instead of before it
Neither vkGetFenceStatus nor vkWaitForFences with a zero timeout returns without
waiting on this driver: both measured 17-28 ms a call and neither returned
not-ready once in 300 frames. So the poll cannot be made honest at the call
site, and the previous commit's zero timeout changed nothing.

What can move is where the wait happens. Draining the present queue at the first
draw of a frame meant the CPU started recording only once the GPU had finished,
and frame time became GPU plus CPU rather than the larger of the two: 42 ms made
of 27.7 GPU and 14.3 CPU, which is the two of them end to end. Stop draining
there and let the throttle at flip bound the pipeline, which is the same wait
placed after the frame's recording rather than in front of it, so recording runs
while the GPU is still busy.

The rotated context should already be retired by then. If it is not, borrow the
aux context as before, and if that is busy too, wait for this one specifically
rather than trip the ensure behind it.
2026-08-10 01:59:49 -04:00
jpolo1224 f592ebb752 Stop asking the driver a question that answers by waiting
vkGetFenceStatus measured 19.7 ms per call on Adreno and returned VK_NOT_READY
zero times in 300 frames. Every caller of poke() wants "is it done, do not
wait", so the one call per frame turned an intended poll into a full GPU sync
and ran the CPU and the GPU in series: frame time was CPU plus GPU rather than
the larger of the two, with the GPU only 66% busy at less than its top clock.
Use vkWaitForFences with a zero timeout, which is specified to answer without
waiting.

That poll was also the only thing bounding the pipeline, because a queue whose
oldest entry is always retired before the next is added never holds more than
one. With an honest answer the frames accumulate, so bound them on purpose, one
below the frame context count: the queue and the context rotation advance
together, so retiring the front is what frees the context about to be handed
out. Allowing the full count would route every frame through the single aux
context borrow and hit the ensure behind it.

The remaining wait is real frame pacing and is charged to swap_wait, where it
can be read.
2026-08-10 01:53:02 -04:00
Zulux91 27465da4ce Improve ARM64 CPU detection and Android device diagnostics
The fallback CPU table was missing cores found in recent handheld SoCs
(Cortex-A510, A715, X3, A520, A720, X4). Because get_cpu_name() bails out
when any detected MIDR is unknown, a single missing core sent the whole
lookup to the cortex-a78 fallback whenever LLVM host detection returned
"generic".

Display names were also reused as LLVM -mcpu values, which happens to work
for the Cortex names but not for Qualcomm Oryon: the display name lowercased
to "x-elite", which is not an LLVM processor, so the JIT silently lost
per-CPU scheduling. Entries now carry an explicit canonical LLVM name
alongside the human-readable one; get_cpu_brand() keeps using the latter.
The Qualcomm entry is named "Oryon" rather than "X-Elite" because MIDR
0x51/0x001 only identifies an Oryon core, not the SoC it sits in.

MIDRs cannot identify the SoC at all, which made bug reports ambiguous.
Android's own SOC_MANUFACTURER/SOC_MODEL are now passed to the core and
logged as a separate "SoC:" line, so SoC identity, core topology and the
resolved LLVM target are three distinct values. The LLVM target reported by
system info now comes from the same resolution path the JIT uses, rather
than from the fallback alone, so it no longer disagrees with the target
actually compiled for.

The Vulkan renderer logs one verdict for the adapter it selected, recording
whether BC1-BC3 support keeps DXT textures compressed or whether they are
decoded on the CPU. It sits in render_device::create rather than where the
flag is resolved, because physical_device::create runs for every GPU of
every instance, and not in TextureUtils, whose fallback branches run per
texture and per mip level.

SoC information travels through a new optional _rpcsx_setSocInfo export
instead of an added _rpcsx_initialize parameter. The core is dlopen()ed and
can be updated independently of the JNI glue, so changing an existing
export's signature would make older glue call it with a garbage argument.
Older glue simply never calls the setter, and newer glue null-checks the
symbol against older cores.

No JIT feature policy and no texture decoding behaviour changed.

Verified on an AYN Odin 3 (ayn CQ8725S, 8x Oryon, Adreno 830) running
Turnip 26.2.99: SoC line reads "ayn CQ8725S (Snapdragon 8 Elite-class)",
the brand line reports Oryon rather than X-Elite, the JIT resolves to
oryon-1, and a single BC verdict reports the GPU path. That BC result
applies to the Turnip driver tested; stock-driver behaviour is unmeasured.
2026-08-10 01:43:45 -04:00
jpolo1224 76b556a492 Count the fence polls before believing what they cost
Resource destruction was the stated suspect and measured 0.056 ms, so the time
is in vkGetFenceStatus, which is over half the RSX thread. Every call site that
reaches it appears to run once or twice a frame, and a status query that blocks
for milliseconds would be a driver problem while one called a hundred thousand
times would be ours. Nothing in the report distinguishes those.

Count the calls and how many come back not ready. This is the same denominator
the FIFO buckets needed twice already, once for packets against commands and
once for draws against setup.
2026-08-10 01:38:34 -04:00
jpolo1224 93585560c2 Separate retiring GPU objects from noticing the fence
The present check kept its 18.9 ms after the fence wait and the reclaim both
measured zero, which leaves the poke, and the only thing in a poke that can
sleep is the event completion callback. Without multithreaded RSX that callback
runs inline, and popping an event scope runs the destructor for every GPU object
that event retired, so the RSX thread frees a frame's worth of images and memory
through the kernel driver before it can record the next draw.

Scope the destruction and the fence status query separately. If neither holds
the time, what is left is the lock at the top of the poke, and that is a
different bug again.
2026-08-10 01:33:13 -04:00
jpolo1224 5171e21dc3 Split the present check into waiting and working
The mid-draw present check turned out to be two thirds of the RSX thread, which
the previous commit could only say as one number. It covers three things that
mean opposite things: a fence wait, a poke that takes a shared lock, and the
per-frame resource reclaim. A wait says the GPU is the bottleneck and every CPU
change aimed at this path was aimed at nothing; the reclaim says the opposite.

Scope the fence wait and the reclaim separately, so whatever is left in the
present check bucket is the poke and its lock. Count entries and cleanups too:
the block is written as a rare async flip fixup, so how often it runs is the
first thing worth knowing about it.
2026-08-10 01:24:04 -04:00
jpolo1224 dc884d6599 Give the draw setup remainder a name instead of a plausible label
Draw setup was 66.7% of the RSX thread, but the bucket only ever held whatever
VKGSRender::begin and end did not charge to a nested scope. Everything with a
body of its own now carries one: the surface write barriers, the render target
on_write pass, the temporary texture release, the mid-draw present check, and
rsx::thread's own prologue and epilogue, which were unscoped on both backends.

Count draws too. A large per-draw bucket is a lot of draws at a fair price or a
few at an unfair one, and those want opposite fixes; the FIFO buckets already
learned that lesson the hard way when a per-packet figure was read per command.

Draw setup keeps the leftovers, which is now the draw clause loop and nothing
that can hide 23 ms.
2026-08-10 01:14:00 -04:00
jpolo1224 e13fc184f0 Make the redundant vertex program check actually compare
The two-point probe read the incoming words as be_t<u64>, an eight byte swap,
and compared that against a destination written by copy_data_swap_u32, which
swaps each word on its own. The wide swap also exchanges the two words, so the
comparison was (w0,w1) against (w1,w0) and could only match when w0 equalled w1.
It never reported a match.

Every upload therefore set vertex_program_ucode_dirty. That forces a full vertex
program re-analysis per draw clause, drops the program cache hint, nulls the
bound program so load_program runs again, and re-uploads the transform constants
unconditionally. Sonic '06 issues 8088 of these a frame against 3429 draws, and
a corrected profile puts 24.7ms of a 36.4ms frame in draw setup, which is what
all of that lands in.

Rotating the source back by 32 bits puts both sides in the same word order. The
change can only remove spurious invalidations: a clean verdict from the probe is
still confirmed word for word by the full compare below it, so a false clean is
not reachable.

Upstream inherited, introduced in ae39c5b8cb.
2026-08-10 01:01:48 -04:00
jpolo1224 42d33d7fd7 Attribute the per-draw work instead of billing it to FIFO decode
fifo_decode is the enclosing scope of the whole RSX loop, and the profiler is
exclusive, so it holds whatever no nested scope claimed. VKGSRender::begin()
carries a scope and end() did not, so essentially every per-draw cost landed
there: load_texture_env's texture cache search and sampler lookup, the vertex
and fragment ucode analysis, and the write barriers. shader_translate and
barrier had no instrumentation sites anywhere in the tree.

That is why the bucket read as 100% of a 29ms frame while the decode loop itself
only accounts for a couple of milliseconds: 36728 packets and 48737 dispatches
cannot cost 29ms when the loop body is an inlined exchange, a table load and an
indirect call.

Scopes added to end(), load_texture_env and analyse_current_rsx_pipeline, so the
next capture shows where the frame actually goes.
2026-08-10 00:57:22 -04:00
jpolo1224 5636c9f3ff Stop paying per-packet and per-argument costs in the FIFO loop
Three changes to the same loop, all measured against 36728 packets and 48737
dispatches per frame in Sonic '06.

GET is published on a bounded lag rather than every packet. It is a release
store into guest DMA memory, and get shares a 64-byte line with put which the
guest PPU writes from another CPU cluster, so each publish was a cross-cluster
coherence miss. The guest reads GET to size its free ring space and is far ahead
of us here, FIFO stalls measuring 0.1 a frame, so lag is invisible to it.
Anything that can idle or block publishes immediately: the put wait in inc_get,
the NOP path, and set_get.

The FIFO accuracy setting is snapshotted once per packet instead of being read
per argument. Reading it goes through a seq_cst atomic load, which on ARM64 is
an ldar the compiler cannot hoist out of the loop.

The again poll is relaxed. That flag is only ever set by this thread, by the
handler invoked immediately before, so sequential consistency buys nothing and
cost another ldar per dispatch.
2026-08-10 00:43:34 -04:00
jpolo1224 1c371cfad7 Count FIFO dispatches, not packets, and stop paying for two hot loads
Three separate fixes to the same hot path.

The profiler's FIFO figure counted the wrong thing. g_fifo_commands is
incremented once per run_FIFO entry, and one of those drains a whole packet, so
dividing by it priced a packet rather than a method. Sonic '06 averages about 17
methods per packet, so the reported 612 ns per command was really 612 ns per
packet and the per-method cost was closer to 36 ns. Cross-checks: 413.6 FIFO
refills a frame at 4096 bytes is 1.69 MB, about 423000 words, which 24248
packets can only consume at roughly 17 words each. Dispatches are now counted
where they are dispatched, both figures are reported, and the per-method
histogram divides by the right one. The line is labelled packets, and notes that
fifo_decode is a catch-all holding every handler body too, since no handler
carries its own scope.

rsx_state::decode was a cross-TU call per dispatched method. The body is one
exchange, but the definition lived in rsx_methods.cpp and LTO is disabled
project-wide, so it never inlined, and being opaque it also forced the caller to
reload its context pointer afterwards. Moved to the header.

set_transform_constant and set_transform_program read ctrl->put through a
seq_cst load. That is an ldar on ARM64, on a cache line shared with ctrl->get
which the guest PPU writes from another cluster, in the two hottest handlers in
this title. Relaxed: a stale value only shrinks the batch, and the remainder is
picked up on the next call.
2026-08-10 00:31:26 -04:00
jpolo1224 b971d81862 Byte-swap four words at a time on ARM64
copy_data_swap_u32 and its compare variant are assembled by asmjit under
ARCH_X64 only. Every ARM64 build fell through to the scalar per-word loop,
reached through a function pointer so it could not be inlined, and LTO is
disabled project-wide so nothing recovered it afterwards.

It is not a cold path: transform constants, transform programs and vertex data
all upload through it, and a Sonic '06 profile on a Snapdragon 8 Gen 2 put 82.5%
of the RSX thread in FIFO decode with thousands of these blocks per frame.

vrev32q_u8 reverses bytes within each 32-bit lane, which is the same swap the
scalar path does per element. The compare variant accumulates differences and
reduces once at the end rather than branching per element. Checked against the
scalar version over 20000 randomised trials at counts 0 to 39, covering every
tail remainder, for both variants: identical output and identical return value.
2026-08-10 00:28:19 -04:00
jpolo1224 6581973646 Restore the constant load pointer, not the program load pointer
transform_constant_load_modifier_barrier decoded its argument into
NV4097_SET_TRANSFORM_PROGRAM_LOAD. The barrier is pushed by
nv4097::set_transform_constant_load, so it should target
NV4097_SET_TRANSFORM_CONSTANT_LOAD.

A title that moves its constant load pointer mid-draw therefore had the move
dropped, leaving every constant after it written at the old offset, and had its
vertex program upload position overwritten with a constant index at the same
time.
2026-08-10 00:28:19 -04:00
jpolo1224 cdcf384df2 Block on the GPU fence instead of spinning on it
An unbounded wait polled vkGetFenceStatus in a tight loop with nothing but a
pause hint between calls. command_buffer::flush() takes that path for the submit
fence, so it is what a frame does while it waits on the GPU: a core pinned at
100% for the whole wait, hammering a driver entry point while the driver is
trying to do the work being waited on.

Cheap on a desktop with cores to spare. Not here, where it competes with the SPU
and PPU threads for a handful of cores. Arkham City measured 24ms of a 53ms
frame in this function with the GPU only 71-77% busy, which is what a stall
looks like when the waiter is too busy spinning to prepare the next submission.

Polls briefly first, since most waits are for a fence about to signal and
blocking would cost a syscall and a wake-up for nothing, then hands the wait to
vkWaitForFences so the driver can sleep the thread. The blocking call was
already there, three lines up, used only when a finite timeout was supplied.
Same shape as wait_for_event below, which already had this treatment.
2026-08-10 00:04:14 -04:00
jpolo1224 b55cd3dd44 Show installed licences
Installing a licence is a copy into exdata, a directory nothing on the screen
read, so a success looked exactly like a failure: a licence belongs to no title,
never appears under Installed titles, and left nothing visible anywhere in the
app. Reported as the .rap doing nothing, when the file had in fact been written
correctly.

Listed now beside the installed titles, refreshed on the same events.
2026-08-09 23:49:40 -04:00
jpolo1224 58bf1b3b13 List only real content under Installed titles
Every directory under dev_hdd0/game was listed with an Uninstall button beside
it. RPCS3 keeps its own lock directory in there, get_hdd0_locks_dir() being
get_hdd0_game_dir() + "$locks/", so the screen offered to delete the emulator's
lock state, and any folder a failed install left behind was offered as a title.

A PARAM.SFO is the test now. Game data installs keep theirs and stay listed on
purpose: a 1.1GB BLUS30464_INSTALL is the kind of thing someone opens this
screen to reclaim, bootable or not.
2026-08-09 23:34:49 -04:00
jpolo1224 0bd30e3c9b Log directory entries at trace, not warning
sys_fs_readdir fires once per directory ENTRY, unlike opendir and closedir
either side of it which fire once per operation. A game scanning its own USRDIR
emits a line per file: one such scan measured 346 lines in 22ms, during boot,
for no diagnostic gain.

Moved to trace, which is where the other per-datum calls already sit
(sys_fs_read, sys_fs_write). opendir and closedir stay at warning, so a scan is
still visible in the log without being enumerated.
2026-08-09 23:00:04 -04:00
jpolo1224 79df76242b Request only the Vulkan version the loader supports
The instance asked for 1.2 unconditionally. A loader that predates it may answer
VK_ERROR_INCOMPATIBLE_DRIVER to a higher request, and the spec tells applications
to check the version first for that reason, so on those devices the renderer
never started at all.

Queried through the global procedure address, since vkEnumerateInstanceVersion is
itself a 1.1 entry point and its absence means 1.0, then clamped. A no-op wherever
1.2 or better is available, and logged when it is not so a device report says so.
2026-08-09 22:38:10 -04:00
jpolo1224 f73143fad9 Release the JNI references the progress reporter takes
Every JNI object handed to native code is a local reference, reclaimed only when
the frame that created it returns to Java. The frames this runs on do not return:
the main thread processor and the compilation queue are infinite loops inside a
single JNI call.

Progress took one reference per instance from FindClass and one per report() from
NewStringUTF, and released neither. There is no DeleteLocalRef, PushLocalFrame or
NewGlobalRef anywhere in the native tree. The progress dialog server pushes
several updates per tick and a firmware precompile emits thousands of ticks, so
ART's local reference table filled and the runtime aborted.

Time-proportional, which is why it showed as a crash during firmware install on
slower devices and not on faster ones.

Copy construction is deleted along with it: the class owns a reference now, and a
copy would have had its destructor release one the original still used.
2026-08-09 22:38:10 -04:00
jpolo1224 774636642f Stop a per-title workaround following the user into every game
The Android port keeps one global config.yml: settingsSet persists through
SaveSettings(g_cfg.to_string(), "") and an empty title id is the global path.
apply() returned early for any title without an entry, writing nothing, so
Uncharted 3's Stub PPU Traps = 1 stayed set once it had been booted and every
game launched afterwards ran with a PPU that silently skips an instruction on
any trap rather than stopping.

Nothing said so on screen and nothing else writes that node: it is not in the
curated push, and CoreSettingOverrides only replays paths the user recorded
themselves.

Every managed path is now written on every boot, this title's value where it has
one and the upstream stock value where it does not. Anything added to BY_SERIAL
has to gain its default in STOCK.
2026-08-09 22:31:27 -04:00
jpolo1224 2d40dd1627 Scan dev_hdd0/game as installed titles, not as a folder to search
The recursive scan descends into anything that is not itself a game folder and
then accepts any file whose extension is in gameExtensions. "img" is one of
them and a title's own data is full of them, so once a package unpacked over
dev_hdd0/game the library filled with GTA IV's archives: manhat01, props_ab,
vehicles, script, weapons.

dev_hdd0/game is the emulator's own install root and holds one directory per
title, so it is now read that way. A direct child is a title or it is not
listed. Folders a user pointed us at keep the recursive scan, because games
legitimately sit at any depth there.

The extractor no longer unpacks into this directory either, but the library
should not have depended on that, and existing installs still have the debris.
2026-08-09 22:29:18 -04:00
jpolo1224 cd96d55d05 Refuse to unpack a package into the games root
A package's install directory is taken straight from its own metadata and was
never checked. Both sources can produce nothing: read_metadata sizes the string
to 9 and reads the title ID over it without testing the result, so a short read
leaves nine NUL bytes, and the DLC path takes c_str() + 8, which is empty
whenever byte 8 is a NUL.

Appending either left the destination as dev_hdd0/game itself, so the package
unpacked its contents over the games root. Users reported a library full of
asset directories, storage consumed with nothing listed as installed, and
folders that outlived uninstalling the title, because uninstall only removes
dev_hdd0/game/<TITLEID>. It looked random because it depends on the individual
package's metadata.

Checked against c_str() so the nine-NUL case reads as empty. Separators and dot
entries are refused as well: this is one path component chosen by the package
and it has no business pointing anywhere else.
2026-08-09 22:29:18 -04:00
Zulux91 c2b5f0c400 Add missing ARM64 instruction-cache maintenance to the JIT
While chasing an unrelated SPURS hang I noticed the JIT publishes
freshly written code on ARM64 with no instruction-cache maintenance at
all. A grep for clear_cache or flushInstructionCache over the JIT layer
comes back empty. The branch-rewrite sites only issue ISB; DSB ISH,
which performs no D-cache clean or I-cache invalidation and is ordered
backwards for self-modifying code besides. On ARMv8 a correct
publication needs the DC CVAU / IC IVAU broadcast sequence; x86 has a
coherent instruction cache, so none of this was ever visible there.
All sites use the bundled asmjit::VirtMem::flushInstructionCache(),
which emits that sequence portably across toolchains.

This covers every publication path I could find:

- MemoryManager1::finalizeMemory() and MemoryManager2::finalizeMemory()
  were both no-ops. RuntimeDyld calls finalizeMemory() after writing
  code and relies on it for cache maintenance, so LLVM emitted PPU and
  SPU code was never flushed. MemoryManager1 serves the primary PPU
  JIT, MemoryManager2 the SPU JIT and auxiliary engines. Both managers
  now record code section allocations and flush them on finalize. I
  confirmed at runtime that the MemoryManager2 path executes (about
  12800 calls per cold boot).
- jit_runtime_base::_add() copies asmjit output into executable memory
  with no flush.
- jit_runtime::finalize() restores an executable code snapshot in place
  during emulator restart with only the ISB/DSB pair.
- spu_runtime::rebuild_ubertrampoline() publishes a hand-written
  trampoline via CAS with no flush; the flush now happens before the
  publication.
- spu_runtime::make_branch_patchpoint() writes a patchpoint byte by
  byte and returns it with only the ISB/DSB pair.
- Both 16-byte branch-site rewrites (dispatch and branch) atomically
  overwrite live code and only issued the ISB/DSB pair.

The ISB/DSB pairs adjacent to the new flushes are removed along with
their misleading "flush all cache lines" comments: the flush helper
already issues the trailing barriers, and the pairs never performed
any cache maintenance in the first place.

I want to be upfront that this was not the cause of the hang I was
debugging (a same-item compilation race, fixed separately), and I have
not observed a failure that this change alone fixes. It is a latent
correctness issue on any ARM64 host: nothing prevents another core
from fetching stale instruction bytes for freshly published code.
2026-08-09 22:19:32 -04:00
Zulux91 1847433eb5 Serialize LLVM compilation of identical SPU programs
Five SPURS kernel threads executing the same uncached code at the same
address all reached spu_llvm_recompiler::compile() for one spu_item.
add_empty() returns the existing item for an identical program without
telling the caller it did not insert, and the entry-point dedup only
catches equivalent code at a different address. Each thread then
compiled the program with its own LLVM instance, racing the compiled
pointer publication, the ubertrampoline rebuild, and the waiter
notification.

On my 8-core ARM64 device this wedged SPURS bring-up on every single
cold-cache boot of Virtua Tennis 4 (BLUS30529). The kernels ended up
parked polling zeroed workload state, the PPU main thread blocked
forever in sys_event_queue_receive on a queue no SPU would ever signal,
and the title never reached the menu. Warm boots never hit it because
cached programs are compiled before SPU execution starts, one
presentation per program. When I instrumented the compile path I saw up
to five concurrent compilations of a single item, around 790 collision
events per boot, with duplicates accounting for roughly two thirds of
all cold compilation work.

This change gives spu_item an explicit LLVM compilation state
(unclaimed, compiling, complete, failed). The first compiler claims the
item; later arrivals wait and take the published result, mirroring the
existing dedup-wait path. I made the claim a state on the item rather
than an inserted-flag from add_empty() so that an item pre-inserted by
spu_fast is still claimed by the first LLVM worker, which preserves the
asynchronous optimized replacement on x86-64. A scope guard marks the
item failed on any early exit so waiters cannot be stranded.

The pre-existing wait for relocated duplicates (same program, different
entry point) is also covered: it still waits on the compiled pointer,
because that result can be published by spu_fast from the asmjit path
which never touches the LLVM state, but it now observes the failure
state on each wakeup with a bounded timeout, and the failure guard
wakes those waiters too. Without this an owner that bailed out early
would have stranded them forever.

I verified this on device: 11 out of 11 cold boots stalled before the
change, 7 out of 7 pass after it, plus 2 out of 2 warm controls, and
Mirror's Edge now reaches gameplay past its previous SPURS stall.
Cold-boot SPU compilation dropped from about 12900 blocks to 3900.
2026-08-09 22:19:32 -04:00
jpolo1224 a862c4b8b5 Install licence files by name, the way upstream does
Every .rap failed. A .rap is 16 raw bytes of key and carries nothing that says
which content it unlocks; that lives in the filename, as the content id. Upstream
copies the file into dev_hdd0/home/<usr>/exdata/ under its own name and is done,
so the name is the whole mechanism.

This called the native installKey with an empty game path instead. That path
decrypts the GAME's EBOOT to read an NPDRM header out of it, so with no game path
there was no EBOOT, no header, and the install could never succeed. Handing it a
bare file descriptor had already thrown the name away regardless.

Licences are now handled before any descriptor is opened. Reported for Resident
Evil 4 HD and for DLC licences generally.
2026-08-09 22:14:40 -04:00
jpolo1224 ce5bd4687c Release 0.3.1 2026-08-09 02:57:13 -04:00
jpolo1224 3f91dfac12 Bundle the Sonic '06 graphics fix and enable it
SONIC THE HEDGEHOG (2006) renders only its HUD and skybox and flickers
everything else in and out of existence. That is upstream RPCS3 issue
#4122, open since 2018: a PPU/SPU race where an SNR is overwritten while
still non-empty, so the SPU jobs feeding geometry lose their signal.

Upstream never fixed it in code. The fix is elad335's canary patch, which
hooks RPCS3_HLE_LIBRARY:WaitForSPUsToEmptySNRs -- a function that already
exists in the core and has no other user. The patch is not in the official
feed: neither our stored database nor a fresh pull from rpcs3.net carries
a single BLUS30008 entry, jumpf op, or RPCS3_HLE_LIBRARY reference. Desktop
users add it by hand through the Patch Manager's import button, which has
no equivalent here, so on Android the game was simply broken.

Ship it as an asset and merge it into patch.yml at boot, before the core
reads that file and once the config directory is known to exist. Enable it
rather than only listing it: a user who has to find and tick a box before
the game renders has already decided the emulator is broken. Gated on a
stored revision, not run every boot, so turning it off sticks.

Everything ARM64 needs is already in this tree -- the calloc code-cave
registry, the is_faux_function guard that keeps the tail-call return trap
from firing on a patch-point, and the PPU LLVM filter for patched
functions -- so the patch applies here rather than crashing as it once did
on ARM.

Keyed by PPU hash, so it covers only the dump it was built for; other
regions and revisions need their own entry.
2026-08-09 02:57:07 -04:00
jpolo1224 aa7a37be75 Report real CPU usage on Android
get_per_core_usage() fills the per-core vector with zeros and then wraps
the whole Linux /proc/stat body in #ifndef ANDROID, because an app cannot
read the per-cpu lines. The exclusion left the zeros in place, so the
monitor did not go quiet -- it reported an idle machine. On device it
logged "CPU Usage: Total: 0.0%, Cores: 0.0%, 0.0%, ..." while three
emulator threads were pegged at 100%, which hides exactly the class of
problem the monitor exists to surface.

Report process usage from times(), which is POSIX and readable by our own
process, and leave the per-core vector empty so perf_monitor prints no
"Cores:" list rather than a fabricated one.
2026-08-09 02:56:50 -04:00
jpolo1224 d36e0b93a9 Stop the RSX offload thread spinning when idle
When the transfer queue was drained the offload thread called
std::this_thread::yield() in a tight loop, which is a sched_yield()
spin rather than a wait. Measured on an Adreno 740 handheld it burned
1142s of CPU over a 1145s session, 85% of it system time, for no work
at all -- a whole core taken from the RSX and SPU threads that need it,
plus the battery and thermal budget that goes with it.

Wait on the queue instead. lf_queue::push notifies on the empty to
non-empty transition so a real job still wakes the thread immediately
and transfer latency is unchanged, and if a push lands between the
emptiness check and the wait the atomic is already non-zero so there is
no lost wakeup. The timeout is only there so the loop can re-check
thread_ctrl::state(): aborting pushes nothing, so an untimed wait would
never return and shutdown would hang instead of spin.

dma_manager::sync() is unaffected -- it only waits while work is
outstanding, which is exactly when the thread is not idle.
2026-08-09 02:56:42 -04:00
jpolo1224 4a7ef6f4c8 Match RPCS3's default for PPU Vector NaN Handling
Ours was false against upstream's true, so the curated push wrote false over the
node on every boot and every install ran with an accuracy fixup disabled that
RPCS3 ships on. Games that depend on it get NaNs through vector maths, which
shows up as geometry behaving impossibly rather than as any kind of error.

The disagreement was not free either. The node feeds ppu_settings::fixup_vnan,
which is part of the compiled PPU object filename, so flipping it renames every
object and the next boot recompiles all of them. A device here carries both
variants, 76 modules under one key and 58 under the other, from one flip.

Audited the other two settings in that key: Accurate Cache Line Stores and Use
Accurate DFMA both already match upstream. This was the only one adrift.

All three now say that changing them rebuilds compiled PPU code, which is the
part that had no way of being known from the screen.
2026-08-09 01:37:24 -04:00
jpolo1224 9e70983a49 Release 0.3
versionCode 4, versionName 0.3.
2026-08-09 00:53:34 -04:00
jpolo1224 65898619c3 Name the threads that did not stop
The unresponsive-stop callback reported that a stop was hanging but not what was
holding it, and that is the one thing that cannot be recovered afterwards: from
outside, a wedged shutdown looks like a sleeping thread with no CPU time and no
reason attached.

Enumerate the SPU threads still registered and log their cpu_flag state. Whether
exit was ever delivered, whether the thread is parked in a wait, or whether it
never left its initial stopped state are three different faults with three
different fixes, and the flags separate them.

This is what exposed the failed savestate: the thread that would not stop was
debris from a save that had already been abandoned, not a shutdown bug.
2026-08-09 00:35:06 -04:00
jpolo1224 8dded5deaf Hold Compatible Savestate Mode at the upstream default
It was turned on so savestates could save at all, and savestates are no longer a
feature: a PS3 state runs 500MB to 3GB, which fills a phone in a handful of
saves. What is left is the cost, and it is a real one, which is why upstream
defaults it off.

Written as false rather than dropped from the push. It shipped as true for a
while, so installs from that window have it persisted in config.yml and would go
on paying SPU performance for something nothing uses.

Also drops the thumbnail read logging, which did its job: it proved the read
path was never called, because the in-game menu has its own numbered slot list
and only the touch overlay's picker asks for a preview.
2026-08-09 00:34:44 -04:00
jpolo1224 26e4a93c4e Turn on Compatible Savestate Mode
Savestates could not work without it. Saving locks every SPU thread into a state
it can be serialised from, and with this off that lock fails on any title with
SPU work running. The save is abandoned with "failed to lock SPU threads
execution", and the SPU it gave up on then never answers the stop request, so
the join thread spins and the app hangs on the next save or load.

The deadlock chased through the SPU shutdown path was this setting all along.
The state on disk looked valid because a previous attempt had written one.

Upstream defaults it off for SPU performance, which suits a desktop where
savestates are optional. Here they are on the in-game menu and the touch
overlay, and a save that wedges the emulator costs more than slightly slower
SPU emulation.
2026-08-09 00:16:58 -04:00
jpolo1224 7fe099f40c Report a stop that is not responding
RPCS3's stop watchdog calls on_emulation_stop_no_response once shutting the VM
down has taken about ten seconds. That callback was a no-op here, so the one
thing upstream does about a deadlocked stop, say so, did not happen: the join
thread went on spinning, the overlay sat at its last figure, and the app looked
frozen with nothing in the log to explain it.

Reported rather than aborted. The desktop build offers to terminate but asks
first, and slow is not the same as stuck: a savestate write was measured here
taking 71 seconds legitimately, so killing the process on a timer would trade a
hang for a corrupted save.
2026-08-09 00:11:28 -04:00
jpolo1224 bb4ec4fc13 Add save slot thumbnails
The core already renders the finished frame and hands it to take_screenshot
whenever a screenshot is asked for. That override was empty, so the picture was
built and thrown away and every slot tile had nothing to draw.

Cached rather than captured on demand: a save kills the VM, and the point where
the state is known to be on disk is after_kill_callback, by which time there is
no renderer left to ask. So the save requests a frame on its way in, the
override catches whatever the renderer produces, and the capture step writes it
out once the state has landed.

Stored downscaled to a ~320px edge as AX3T + width + height + RGBA, and
re-encoded to PNG on the Kotlin side, which is where a Bitmap can be built from
raw pixels directly. That keeps a compressor out of the core for something only
ever drawn as a tile. A slot with no picture still loads.
2026-08-09 00:04:33 -04:00
jpolo1224 68666237b3 Boot the savestate after teardown finishes, not during it
Loading a slot closed the game and dropped to the library. BootGame failed in
the same millisecond it was called, without ever reading the file, and the
Emulation Join Thread warnings then ran for seconds after: the previous VM was
still coming down while BootGame was already being asked to bring the next one
up, so it bailed.

boot_current_game_savestate gets away with that sequence because it runs from a
shortcut handler rather than from inside the emulation callback queue. Kill
first and boot from after_kill_callback, which is the shape the save path uses
and the reason that one works.

The boot result is logged too. A bare failure could not tell a corrupt state
from a version mismatch from a path the loader never accepted.
2026-08-08 23:54:32 -04:00
jpolo1224 e4d99dc83d Show which save slots are occupied
Every slot tile read as empty and Load was disabled on all ten, whatever was on
disk. Occupancy comes from getGamePathSlot, which was a stub returning the empty
string, so saving had been working with no way to see that it had.

Answer it from hasState, which was written for this and had no callers. The
subtitle is the title id rather than the state's path: the picker strips to the
last segment and drops the extension, which would render a real path as
slot0.SAVESTAT.
2026-08-08 23:49:39 -04:00
jpolo1224 fbda9ce4d3 Give librashader the present surface's real format
RetroArch shaders shifted the colours, pink over anything green. The output
image handed to librashader is the swapchain image but the format reported with
it was the source framebuffer's. librashader builds its output view and render
pass from that, so with a BGRA swapchain and an RGBA source it wrote through a
mismatched view and swapped red with blue instead of converting.

Only the shader path was affected because the bilinear and nearest passes reach
the swapchain through vkCmdBlitImage, which converts formats itself. Carry the
swapchain format through to the pass, refreshed per frame so a swapchain rebuilt
at a new format cannot leave a stale value behind.
2026-08-08 23:41:53 -04:00
jpolo1224 43d1add7ec Make the save slots addressable
RPCS3 keeps savestates as a rolling history addressed by age, 1 being the most
recent, and the app offers ten numbered slots. The slot was dropped on save and
read as an age on load, so saving to slot 3 pushed a new newest state and
loading slot 3 fetched the fourth-newest. States appeared to wander between
slots on their own.

The history stays RPCS3's. A slot is now a copy parked under
savestates/<title>/armsx3_slots/, in its own directory because get_savestate_file
derives the next auto id by listing the title's directory and must not be fed
names it never generated. Copy rather than move: the restart after a save boots
from the file the core just wrote.
2026-08-08 23:41:53 -04:00
jpolo1224 72b6ce0449 Default the D-pad to 7% spacing
At 0 the four direction keys meet in the middle, which only reads correctly on
the flat built-in drawables. The bundled skins draw four separate keys and they
came out as one clumped blob.

Migrated once for existing installs: the key is written by the bulk save, so
anyone who had ever opened a touch setting already had 0 stored and would never
have picked up a new default. Only a stored 0 is rewritten.
2026-08-08 23:41:53 -04:00
jpolo1224 23fe26fdb9 Keep the Back button on screen in All Core Settings
The button was always there. The LazyColumn above it had no weight, so in a
Column it took the whole remaining height and left nothing for the row below,
putting Back off the bottom of the screen. With a controller you could still
leave; on a touch-only device this was the one screen with no visible way out.

Weight the list so it shares the space instead of consuming it.
2026-08-08 23:41:53 -04:00
jpolo1224 aaf3322d4e Recover skin previews from a manifest with trailing commas
No preview rendered for any downloadable skin. The published manifest carries a
trailing comma before two of its closing braces, which org.json rejects, so
parseManifest returned null and fetch fell through to the git tree, which only
ever reported filenames and set previewPath to null for everything. One comma in
a file this app does not own cost the whole index.

Retry the parse with trailing commas dropped, string aware so a name containing
a comma survives, and pair Previews images to skins by name in the tree fallback
so a broken manifest costs detail rather than every thumbnail.
2026-08-08 22:39:37 -04:00
jpolo1224 b7eff5bb12 Migrate existing installs onto the bundled default skin
Changing DEFAULT_SKIN_ID moved nobody. resolveRaw only falls back to the default
when nothing was ever stored, and every install from before the change has a
stored value already, so existing users kept the old pad and reported the new
default as not applying.

Rewrite the stored global skin once, and only when it is absent or the explicit
no-skin sentinel. An install pointing at a real skin chose it on purpose. The
flag is set either way so picking the ARMSX2 row afterwards sticks.
2026-08-08 22:39:37 -04:00
jpolo1224 b4026baacf Name the ARMSX2 skin row and tag the active default
The second row read 'Built-in (default)', which was accurate when the built-in
drawables were the default and wrong the moment a bundled pack took over: it
still claimed to be the default while ARMSX3 Textured actually was. Name it
'ARMSX2' for what it is and tag whichever row matches DEFAULT_SKIN_ID, so the
label follows the constant instead of restating it.
2026-08-08 22:39:37 -04:00
jpolo1224 812ef3763b Bundle bagas' controller skins and preview every built-in one
Adds two packs from bagas as app assets, ARMSX3 Textured and ARMSX1, and makes
ARMSX3 Textured what a fresh install gets. The order offered is ARMSX3 Textured,
ARMSX2, ARMSX1, NetherSX2, NetherSX2 Old. Their files arrive named plainly
(L1.png, cross.png), which the loader already handles: it strips ic_controller_
and a trailing _button, so they are renamed to the canonical form on the way in
and both packs land on exactly the key set nethersx2 uses.

The default needed untangling first. Null meant the built-in ARMSX2 drawables
AND was what an install with no stored choice got, so the two were the same
thing and nothing had to tell them apart. They are different now. Resolution
moved into one function both callers use, because ensureLoaded and
applyForSerial each resolved separately and disagreeing would have changed the
pad the first time a game started:

  nothing stored  the default, ARMSX3 Textured
  NONE            the ARMSX2 row was chosen, so the drawables
  an id           that skin, or the default if it names one no longer installed

setActive stores NONE at the global tier rather than removing the key. Removing
it used to mean the drawables; with a bundled default it would now mean "never
chose", so picking ARMSX2 would have silently handed back ARMSX3 Textured on the
next launch. Nobody's existing choice moves: only the absence of a stored value
resolves to the default, and that is not written back, so the default can change
again later without pinning every install to today's answer.

Every built-in now shows a preview strip beside its name, matching what the
downloader already does for remote skins. Baked in as preview.png per pack
rather than composed at runtime: the list draws several at once and compositing
ten bitmaps per row per recomposition is not worth it for a picture that never
changes. keyForFilename ignores the file, so it is not counted as a button. The
ARMSX2 look has no asset folder, so its strip is a drawable.

ARMSX3 Dark was offered and is deliberately not included. It ships 17 images
against the other packs' 18, with no start button.

Verified in the APK rather than assumed: four asset packs present, 18 images
each in the two new ones, four asset previews plus the drawable.
2026-08-08 22:30:30 -04:00
jpolo1224 123b980e32 Reach All Core Settings from the in-game menu
It was reachable only from the library drawer, which is a global screen. That
put the moment a core node is actually worth touching, a game that needs one
while that game is loaded, in the one place it could not be set without setting
it for every other game too.

Add it to the Options pane beside All Settings, opening over the paused game
like the other manager screens. Scope and serial come from the overlay's own
scope state, resolved for the running title when the menu opened, so an edit
made mid-session is remembered for that title alone.
2026-08-08 22:12:39 -04:00
jpolo1224 e58b8bdb2e Scope All Core Settings edits to one game
The core settings screen recorded every edit into a single global store, so a
node set to get one title running was then set for every title that booted
afterwards, with nothing on screen to say so.

Give that store the two tiers ConfigStore already gives curated settings: a
global set, and a per-game set keyed on the same settingsKey, so both stores
agree on which title is being configured. Replay pushes global first and the
running title's set on top, so a per-game value wins where the two disagree and
every node a title never touched still follows global. With no game loaded the
per-game tier is skipped rather than guessed at, so the last title played cannot
leak into a BIOS boot.

The screen now takes its scope and serial from the caller instead of resolving
them itself: the drawer route is global and would otherwise inherit whatever
scope the last per-game settings visit left behind. It also states which tier
the next edit lands in, since the screen looks identical either way.
2026-08-08 22:12:32 -04:00
jpolo1224 8dac3c534d Add the in-app updater
Ports ARMSX2's GitHub-release updater. The UI hooks and all seventeen update.*
strings were already here from the UI port; only the implementation was missing,
so both hooks were sitting behind IN_APP_UPDATER doing nothing.

A "Check for updates" panel at the top of the App tab queries the ARMSX3
releases API, semver-compares the tag against the installed build, downloads the
.apk asset with a progress bar into externalCacheDir/updates/, and hands it to
the system package installer through a FileProvider. The user confirms the
install; nothing happens silently. Two opt-in toggles come with it, both default
off: check on launch, which pops a prompt only when something newer exists, and
include nightly builds.

Points at ARMSX2/ARMSX3 releases, not ARMSX2's. Download goes to the app cache
directory, which the OS can evict, and each download clears the folder first, so
it never accumulates.

ONE DELIBERATE DIFFERENCE FROM ARMSX2, and it needs to be understood before any
Play build exists. ARMSX2 keeps this out of its bundle with a src/github versus
src/play flavor split plus a build script that fails closed if
REQUEST_INSTALL_PACKAGES appears in the bundle manifest. ARMSX3 has neither a
Play build nor flavors, so the code, the permission and the provider live in
src/main behind the runtime flag. That is fine today and NOT fine the moment a
Play target appears: Play rejects the permission in the bundle, and a runtime
flag does not remove a permission. Adding a Play build means doing the flavor
split first. The requirement is written at the manifest, at the buildConfigField
and in the file header, because one comment is easy to miss.

Verified in the produced APK rather than assumed: the permission is present, the
provider is registered as com.armsx3.updateprovider, and UpdaterEntry is in
classes18.dex.
2026-08-08 21:56:58 -04:00
jpolo1224 97f082c2cb Wait for memory to recover before starting the next module
Demon's Souls precompiles for 27 minutes and was killed partway through. The
trajectory says why serialising alone could not save it: the process sat between
4.3GB and 5.8GB for the whole run on a 7GB device, so this was sustained
footprint rather than a transient overlap the existing mutex could flatten. It
died when the system wanted memory back, and Zygote logged signal 9 for four
processes at once, so the pressure was not ours alone.

Adds a second tier. Below 2GB free, workers already compile one at a time; below
1GB free, the worker holding that lock now waits for the system to recover before
starting the next module, up to ten seconds, rechecking every 100ms.

The wait happens AFTER taking the serialisation lock deliberately. Waiting first
would have the other worker still allocating, so the wait would be watching
memory it is not allowed to influence.

Safe to wait here because precompilation writes each object to disk and links
none of them: the modules are not mapped into the VM, so is_being_used_in_emulation
is false and no JIT instance is created. Pausing costs time and nothing else.

Bounded rather than indefinite, because failing to compile is worse than
compiling under pressure, and it gives up cleanly if the emulator is stopping.
2026-08-08 21:51:28 -04:00
jpolo1224 d7c4d4d6c0 Stop the GPU timer recording when the profiler is off
The readback in VKPresent was already gated on Video@@RSX Profiler, but
gpu_timer::begin and end were not. A release build with the profiler off, which
is the default and what everyone runs, still paid a vkCmdResetQueryPool and two
vkCmdWriteTimestamp calls per region per frame for results nothing would ever
read. Timestamp writes are not free on a tiled GPU; they are pipeline sync
points, which is the opposite of what a diagnostic should cost when disabled.

Both sides are gated now. end() deliberately clears m_open before returning
rather than bailing first: disarming between a region's begin and its end would
otherwise leave the flag set, and every later begin for that region would drop
itself as unbalanced, silently killing that region's timing for the rest of the
session. The timestamp is skipped, the bookkeeping is not.

The pool is still created at device init so arming mid-session works without a
restart. That is one small allocation, not a per-frame cost.
2026-08-08 21:45:41 -04:00
jpolo1224 08553f02d8 Keep the render pass open across attachment feedback barriers
Arkham City runs about 83 render passes a frame for roughly 339 draws, and on a
tiled GPU every pass is a tile store plus a reload of the attachment. Attributing
every end site showed where they come from:

    ImgHelper:43   37-48/frame   change_image_layout
    Barrier:inout  20-23/frame   insert_texture_barrier
    Draw:1093      13-18/frame   subpass mismatch
    Barrier:img     3-5/frame

insert_texture_barrier handles the feedback case, an attachment sampled while it
is still bound. It ended the pass because it had no choice: Vulkan forbids
vkCmdPipelineBarrier inside a render pass unless the subpass declares a
dependency on itself, and this render pass cache declared no dependencies at all.
The function already took a preserve_renderpass flag; there was simply no way for
a caller to use it legally.

So the pass now declares a by-region self-dependency, and the feedback barrier
opts in. Three parts that have to agree:

  VKRenderPass.cpp     declares the self-dependency, framebuffer-local stages
  barriers.cpp         drops the vertex stage when preserving, since
                       VK_DEPENDENCY_BY_REGION_BIT permits framebuffer-space
                       stages only and naming the vertex stage would make the
                       barrier invalid
  VKRenderTargets.cpp  passes preserve_renderpass at the cyclic-reference site

Correct for the use: the feedback case is a fragment shader sampling the
attachment its own fragments write. Anything needing vertex-stage visibility
leaves the flag false and still gets the pass ended.

Android only at the call site. The self-dependency itself is declared everywhere,
which is harmless where nothing issues an in-pass barrier.

Does not touch ImgHelper:43, the larger site. Layout transitions of
non-attachment images are illegal inside a pass whatever dependencies exist, so
that one needs resource preparation hoisted ahead of the pass instead.
2026-08-08 21:17:24 -04:00
jpolo1224 ef4ac7702e Account for every render pass end, not a third of them
Arkham City runs about 90 render passes a frame for roughly 339 draws, under four
draws per pass, on a tiled GPU where every pass costs a tile load and store. That
is worth attacking, since Fence wait is 24ms of a 53ms frame and disabling
culling to add GPU work pushed it to 43.8ms, which is what being GPU bound looks
like.

But only about 19 of those 90 ends were attributed: Draw:1093 at 16.1 and
Texture:921 at 2.9. The other 71 happened at sites with no counter, so the
report pointed at the wrong two.

Instruments the rest: the four barrier sites, both texture cache sites, the image
helper, and the occlusion query one in VKGSRender. The barrier sites are the ones
to watch, since every barrier that cannot preserve the pass ends it, and that is
the same mechanism as the Adreno leak fixed earlier: this driver does real work
on every vkCmdEndRenderPass.

Counters only. Which site dominates decides whether the fix is barrier batching,
texture cache scheduling, or something else, and guessing between those has a
poor record here.
2026-08-08 21:07:12 -04:00
jpolo1224 0ce28165e5 Stop burning a core spinning on occlusion query results
A simpleperf profile of the RSX thread during Arkham City gameplay, 83261 samples:

    24.91%  vk::query_pool_manager::get_query_result
    11.63%  [kernel]
    10.16%  rsx::nv406e::semaphore_acquire
     9.37%  rsx::FIFO::FIFO_control::fetch_u32_refill
     3.96%  memcpy_opt
     3.26%  VKGSRender::do_local_task

get_query_result is a quarter of the thread on its own, and what it does is spin:
pause(), re-poke the query, repeat, until the GPU has the result. That is a
defensible trade on a desktop, where the answer lands in microseconds and there
are cores going spare. On a tiled mobile GPU the result is not available until
the tile pass resolves, so the wait is much longer, and this device runs eleven
hot emulator threads across five usable cores. The spin does not make the result
arrive sooner; it just denies the core to an SPU thread that had work.

Keeps a short 64-iteration spin so a nearly-ready result still returns without a
scheduler round trip, then yields. Android only.

Also scopes the wait as fence_wait, which is what it is. This was completely
invisible before: the bucket report attributed 0.072ms/frame to ZCULL and showed
nothing here, because the ZCULL scope covers zcull_ctrl->update and this is
reached by another path. A bucket reading zero means no scope reached it, not
that the work is free.
2026-08-08 20:54:45 -04:00
jpolo1224 4ceefdcd08 Histogram FIFO commands by method
The per-command figure came back at 1017ns across 43870 commands per frame. That
is not a fair price for reading a word and calling a handler, so the cost is
concentrated in particular handlers rather than spread across the dispatch, and
the useful question is which.

Counts commands per method register and reports the top eight with their share,
named through gcm_printing so they read the same as the log's own FIFO traces.

A handful of methods dominating means a fast path is worth writing for them. An
even spread means the dispatch itself is the problem and this was the wrong tree.
Either way it is the last thing hidden inside fifo_decode, which now holds 44.6ms
of a 59.2ms frame with every sub-unit around it measured and small: ZCULL 0.111ms,
page protect 0.133ms, local tasks 1.357ms, the whole draw path under 6ms.

One increment behind the enabled() branch, no counter-timer read, 64KB of
counters touched only while armed.
2026-08-08 20:42:57 -04:00
jpolo1224 40ed60b2a5 Count FIFO commands, to get the per-command cost
Every sub-unit inside the RSX dispatch loop is now measured and every one is
small. ZCULL is 0.083ms/frame, page protection 0.094ms, local tasks 1.691ms,
and the entire draw path under 5ms of a 53.4ms frame. Fence wait takes 9.2ms.
That leaves 34.9ms in fifo_decode with nothing left to attribute it to except
the dispatch itself, and FIFO stalls at 1.0/frame say it is not waiting to be
fed either.

Which leaves one question worth asking: is that a lot of commands at a fair
cost each, or few commands at an unfair one. Those want opposite work. A lot of
commands means the volume is the problem and the answer is upstream of the
loop; an unfair per-command cost means the dispatch is the problem and can be
attacked directly.

So the loop counts its iterations, and the report divides fifo_decode by them.
An increment behind the enabled() branch with no counter-timer read: the
earlier attempt at a per-command SCOPE read cntvct_el0 twice per command and
took run_FIFO from 3% of samples to 35%, which is the mistake this avoids.
2026-08-08 20:35:29 -04:00
jpolo1224 19cf988c11 Split ZCULL and local tasks out of the FIFO bucket
Page protection turned out to be 0.090ms/frame, 0.2%, so the 38ms sitting in
fifo_decode is not mprotect and not the drawing either: the entire draw path
comes to under 5ms of a 54ms frame, and fence wait accounts for 8.4ms more.
Roughly 38ms had no owner.

fifo_decode is scoped around the whole RSX loop rather than around run_FIFO,
deliberately, because a scope inside run_FIFO reads the counter-timer twice per
FIFO command and previously turned a 3% bucket into 35% of samples. So anything
in that loop without a scope of its own accumulates there, and the loop's
per-64-cycle sub-unit updates are the largest unmeasured things left in it.

Both now have buckets. They run once per 64 commands, so the counter read is
amortised and the earlier distortion does not apply.

ZCULL is the specific suspect: this title logs "Reports area at location
CELL_GCM_LOCATION_MAIN was accessed. ZCULL optimizations will be disabled" and
then runs the unoptimised path for the whole session, with Accurate ZCULL stats
on, while the game polls occlusion reports.

Measurement only, no behaviour change.
2026-08-08 20:29:11 -04:00
jpolo1224 a1e5421744 Time page protection, not just count it
Arkham City under load spends 36.9ms of a 46.5ms frame in fifo_decode, which is the
bucket the FIFO loop leaves active and therefore holds everything without a scope of
its own. The drawing is not the cost: draw_setup, vertex, pipeline, descriptors,
texture upload, RT prep, blit and submit together come to under 5ms. Fence wait falls
to 5.4% under load, so it is not waiting on the GPU either.

The one number in the report large enough to explain the hole is page protection:
16.8 mprotect calls covering 40MB every frame. That is on the order of ten thousand
pages of kernel page-table work plus TLB shootdowns across eight cores, and it is
reached from RSX state handling, so every microsecond of it lands in fifo_decode.

Counting it was not enough to know whether it is most of that 36.9ms or almost none
of it, and the two point at completely different work. So it gets a bucket and a
scope at the syscall itself, charged only on the RSX thread.

No behaviour change; the scope compiles to a branch on the profiler flag, which is
off by default.
2026-08-08 20:22:21 -04:00
jpolo1224 7daa89e0e7 Give the SIGSEGV handler a stack to report from
Arkham City dies of SIGSEGV about 140ms after the game writes PS3Progress_Frame_1,
on both the Qualcomm driver and Turnip, with the database on or off, Multithreaded
RSX on or off, and the RSX profiler on or off. A table of ten runs showed no setting
correlates with it.

The reason it took so long to even establish that it WAS a segfault: nothing records
it. No tombstone (/data/tombstones is root-only, so its emptiness proves nothing), no
logcat crash-buffer entry, and no line from our own handler. The single witness
anywhere is Zygote:

    I Zygote : Process <pid> exited due to signal 11 (Segmentation fault)

That combination is what a stack overflow looks like here. The handler is installed
with SA_SIGINFO alone, so it runs on the faulting thread's own stack; if that stack is
what overflowed there is nowhere to run, it faults again immediately, and the kernel
applies the default action having written nothing.

So: an alternate signal stack per thread in thread_base::initialize, and SA_ONSTACK on
the handler. Thread-local rather than shared, because two threads can fault at once and
a shared stack would corrupt whichever report lost the race.

This does not fix the fault. It makes the fault reportable, which is the thing that has
been missing all along: the next occurrence should log where it came from instead of
vanishing. Android only.
2026-08-08 20:12:08 -04:00
jpolo1224 9d85567743 Instrument the draw path the first profile could not see
Arkham City's first RSX profile put 83.9% of a 33.45ms frame in FIFO decode and
essentially zero everywhere else. That was not a finding, it was a gap: fifo_decode
is the bucket the FIFO loop leaves active, so everything without a scope of its own
accumulates there, and draw_setup, vertex, texture_upload, shader_translate,
shader_compile, barrier and cmdbuf had no scope sites at all. They could only ever
read zero.

Adds the three that account for the draw path:

  begin()            -> draw_setup     per-draw setup, with the existing pipeline,
                                       descriptors and texcache_lookup scopes nesting
                                       inside so each is charged to itself
  emit_geometry()    -> vertex         vertex and index upload plus the draw
  bind_texture_env() -> texture_upload sampler setup and any upload the bind forces

load_program already carried a pipeline scope and reported 0.006ms/frame, so shader
and pipeline work is genuinely negligible here rather than unmeasured, and is left
alone.

What the first profile did establish stands: Present wait and Fence wait at ~0 mean
the thread is not waiting on the GPU, Idle at 15.7% means it is not starved, and
FIFO stalls at 0/frame mean it is not waiting on the guest. The 28ms is real CPU
work in the draw path. This says which part.
2026-08-08 20:06:29 -04:00
jpolo1224 65abd324b6 Compile one module at a time when memory is short
The worker count is decided once, from a reading taken before the emulator has
mapped the PS3 address space or the game has loaded anything. On a cold cache
that reading goes stale almost immediately, and by the time it matters the
count is fixed and cannot respond.

Measured on Arkham City: a first boot peaked at 5228MB where the same session
with the modules already cached sits at 2636MB, and sampling RssAnon against
RssFile and RssShmem put the growth in anon, so it is the compilers holding
LLVM contexts rather than the GPU caches. The process was killed partway
through; on a warm cache the identical settings run fine.

So the number of workers is not really the problem, their overlap is. Below
2GB free, a worker now takes a mutex around a single module's compilation,
which makes it one at a time exactly when that is the difference between
finishing and being killed. Checked per module, at the point of use, because
rechecking is the entire point. With headroom the check fails and nothing is
serialised, so there is no cost in the common case.

The lock is scoped to the compile alone, so a worker waiting on it is never
holding a context while it waits.

Android only.
2026-08-08 19:41:23 -04:00
jpolo1224 7683c9def6 Budget the PPU JIT group against memory instead of lowering it outright
Follow-up to the previous commit, which lowered modules-per-JIT to a flat 25
on Android. That fixed the kill but paid for it everywhere, including on
devices with memory to spare and on titles that were never at risk. The two
things the constant governs are not the same concern: branch reachability is
about translated game code, while the symbol resolver is a one-shot boot-time
initialiser that fills the jumptable and never runs again. Only the second is
a memory problem, so only the second should be allowed to force a split.

The group is now sized from MemAvailable, the same way the compile worker
count already is. A quarter of what is free, with the ceiling set at what a
full group of 100 costs, since budgeting past the point where nothing would
be split buys nothing.

  ~2GB free  -> 26 per JIT, resolver peak ~500MB
  ~4.4GB     -> 57 per JIT, resolver peak ~1.1GB   (this device)
  8GB+       -> 100 per JIT, i.e. upstream, untouched

Two separate reasons this stays free in the common case. A device with room
lands on 100 and is not split at all. And a title whose parts fit in one group
gets a single JIT instance at any limit at or above its part count, so every
title below the threshold is unaffected regardless: same instances, same
codegen. At roughly 4000 functions per part that covers everything under about
100k analysed functions, which is most of the library. Arkham City, at ~457k,
is not, and that is the point.

The 5KB per function and 4000 functions per part are measured off the run that
died: 2.3GB across ~457k functions, with parts holding 2800 to 4900 each.
2026-08-08 18:44:37 -04:00
jpolo1224 06a40a8637 Split the PPU JIT into smaller groups on Android
Arkham City was killed partway through compiling its PPU modules. Memory sat
level around 4GB for four minutes of ordinary compile-and-free, then went
4010MB -> 6323MB in seventeen seconds and the log stops mid-compile, no
tombstone: the kernel OOM killer on a 7GB device.

The module part that carries jit_bounds also builds the symbol resolver, and
GetSymbolResolver spans every function across its entire JIT instance rather
than its own part: an LLVM Function declaration and a constant-array entry per
function, then a relocation each through MCJIT. Arkham City analyses to about
457k functions, and at 100 modules per instance the first resolver covered all
of them at once. The log records it plainly, 457209 functions generated in one
module where every other module in the run reports between 2800 and 4900.

No new mechanism was needed. ppu_initialize already splits modules across JIT
instances and jit_mod.symbol_resolvers is already a vector with one entry per
instance, executed in a loop. The group size was simply tuned for a desktop.
Upstream's own comment on the constant names this exact trade: lowering it
lowers continuous memory requirements, at the cost of more branches unable to
reach with a direct B. The resolver's cost is linear in the functions it spans,
so a quarter of the group size is a quarter of the peak.

Android only. Desktop keeps 100.
2026-08-08 18:38:07 -04:00
jpolo1224 a49268f1a0 Stop leftover PCSX2 keys overwriting the PS3 settings they collide with
Enable Time Stretching read true on the device, though upstream defaults it
false, Settings writes ps3.audioTimeStretch which is false, and no core
override touches it. Something later was setting it back.

Six PCSX2 keys reach RPCS3 nodes that a PS3/ key already owns:

  Enable Time Stretching        <- SPU2/Output/SyncMode
  Desired Audio Buffer Duration <- SPU2/Output/OutputLatencyMS
  Enable Buffering              <- SPU2/Output/BufferMS
  Anisotropic Filter Override   <- EmuCore/GS/MaxAnisotropy
  Clocks scale                  <- Framerate/NominalScalar
  Frame limit                   <- EmuCore/GS/SyncToHostRefreshRate

They date from before the PS3/ pseudo-sections existed, which were added for
exactly this reason. Being unambiguous in the bridge was not enough: put()
calls setSetting immediately rather than collecting into a map, so applyTo's
source order is the call order, and every PS3 write lands first (L939-994)
with the PCSX2 one later (L1044-1765). The leftover won every time.

The field each UI row is bound to is the PS3 one in all six cases; the PCSX2
counterparts appear only in the reset-fields list or nowhere. So the Audio
tab's Time Stretching toggle did nothing at all: it wrote false, and SyncMode
wrote true ninety lines later.

Two were independently wrong. BufferMS turned a buffer size in milliseconds
into the boolean "buffering enabled". SyncToHostRefreshRate mapped to Frame
limit "Display", which resolves to the host panel's refresh, so on a 120Hz
handheld it asked for a 120fps cap, the same mistake just fixed in
setDisplayRefreshRate; and its `if (asBool(value))` guard could only ever set
the mode, never clear it, so turning the setting off left the cap in place.

All six now fall through to the unhandled path. Verified no RPCS3 setter is
left with more than one writer.
2026-08-08 16:58:03 -04:00
jpolo1224 5ff702f437 Stop the panel's refresh rate becoming the console's vblank
The frame rate still ran past 60. The live config on the device explains it:
Vblank Rate 120, Frame limit Auto, and Auto resolves to the vblank rate.

setDisplayRefreshRate was writing the HOST PANEL's refresh into
Video@@Vblank Rate. Those are not the same quantity. Vblank Rate is the
frequency of the emulated console's vblank, and a PS3 runs 60Hz whatever
display is attached, so a 120Hz handheld was asking the emulator for 120 frames
a second: twice the RSX command volume, twice the GPU work, for frames no PS3
game was ever written to produce.

It also could not be corrected from settings. EmulationSurface reports the panel
rate from surfaceChanged, which runs after ApplySettings on boot and again on
every rotation and resume, so it overwrote the pushed 60 every single time. That
is why recording 60 as a core override did not hold.

Dropped rather than redirected. RPCS3 reads the host rate itself through
get_display_refresh_rate() for Frame limit Display, and never wanted to be told.

Frame limit now goes through the curated push next to Vblank Rate, so the cap
does not depend on the vblank path holding. A deliberate choice on the core
screen still wins, since the overrides replay after.

Also drops the two core overrides the profiling work left behind. RSX Profiler
belongs at its false default in a build meant for playing, and Eager Surface
Readback names a node this build no longer has. Removed by path, because
clearing the store would take the user's real edits with it.
2026-08-08 16:32:09 -04:00
jpolo1224 850ce7cd4b Size the present framebuffer off the swapchain, not the request
Rotating the device left the picture corrupt in both orientations.

Two independent faults, and both have to go.

m_swapchain_dims held the size passed to swapchain::init, but the WSI backend
replaces that with the surface's currentExtent whenever the platform reports
one, so after any window reshape the two disagreed. That value is not
bookkeeping: it sizes the framebuffer the swapchain image is attached to, and
the present blit region, so a frame was drawn at one size into images of
another. Nor did it recover, because flip() decides whether to rebuild by
comparing that same stale number, so it kept confirming itself. Both init sites
now adopt the size the swapchain actually got.

The other half is that nothing told the renderer the window had changed shape.
The activity handles orientation itself, so rotation keeps the same Surface and
the same ANativeWindow, leaving no new handle to notice; and once a swapchain is
connected, ANativeWindow_getWidth answers about buffers rather than about the
window. SurfaceHolder.Callback::surfaceChanged is the one authoritative source
and it was being discarded, so it now reaches the core and drives client_width().

Three smaller things on the same path. ANativeWindow_fromSurface returns an
already acquired reference and the old code acquired again on top of it, leaking
a window on every surfaceChanged. The event code was computed after
currentSurface had been overwritten, so SURFACE_CREATED could never be sent and
the resume paired with the surface loss pause was dead code. That resume now
undoes only a pause the surface loss itself caused, so it cannot fight the
overlay's.
2026-08-08 16:32:09 -04:00
jpolo1224 82c6dcd2cd Skip conditional rendering prep when nothing can consume it
The Qualcomm driver does not expose VK_EXT_conditional_rendering, which is why
the vendor gate added earlier never fired: the feature was already off, so
turning it off changed nothing.

But VKGSRender::begin_conditional_rendering does its work regardless of support.
It allocates the predicate buffer, copies query results into it and barriers it,
then falls through to the base implementation. Only vkCmdBeginConditionalRendering
reads that buffer, so without the extension all of it is discarded.

On Adreno the waste is not merely wasted. insert_buffer_memory_barrier ends the
open render pass, and that driver allocates on every vkCmdEndRenderPass and does
not release it. A heap profile of a Skate 3 session put its largest allocation
stacks, 157MB, 152MB, 150MB and more, on exactly this path through
qglinternal::vkCmdEndRenderPass into calloc, with the process killed at 4.3GB of
anonymous memory after about 2.4GB arrived in eight seconds.

Returns to the base implementation immediately when the extension is missing.
Behaviour is unchanged, since nothing was being predicated anyway; what goes is
the buffer traffic, the barriers and the render pass ends.
2026-08-08 16:32:09 -04:00
jpolo1224 6bc5eb206c Stop using conditional rendering on the Adreno proprietary driver
A heap profile of a Skate 3 session, sampled through the crash, put every one of
the top allocation stacks on the same path:

  run_FIFO -> VKGSRender::begin -> begin_conditional_rendering
    -> insert_buffer_memory_barrier -> end_renderpass
      -> qglinternal::vkCmdEndRenderPass -> calloc

157MB, 152MB, 150MB and more from that single stack. begin_conditional_rendering
inserts a buffer memory barrier, which ends the render pass, and every
vkCmdEndRenderPass makes the Qualcomm driver allocate memory it never returns.

The process reached 4.3GB of anonymous memory, took the device to 54MB free with
3GB in swap, and was killed. About 2.4GB of it arrived in eight seconds.
Anonymous, so unreclaimable, and not ours to free: it belongs to the driver.

Without the extension RSX falls back to thread::begin_conditional_rendering,
which performs the draws instead of predicating them. Occlusion results stop
culling, which costs some GPU work, in exchange for sessions that do not end in
an OOM kill.

Scoped to the proprietary driver, which is what was measured. Turnip is a
separate implementation and is left alone until there is evidence about it.

Three earlier guesses at this, the texture cache quota, the SPU JIT and the
eager readback, were all wrong. This one came from allocation stacks.
2026-08-08 16:32:09 -04:00
jpolo1224 b82ba11b69 Budget the GPU caches against real memory, and refresh window size on rotation
Two separate fixes.

Memory: the texture and surface caches size their quotas from
device_local_total_bytes. On a discrete GPU that is right, a 3GB texture cache
out of 8GB of dedicated VRAM costs system RAM nothing. A phone has one pool for
both, so that figure is system RAM and the caches budget memory the OS also
needs. Here it reported 7446MB, which resolved the texture cache quota to
2978MB.

Watched a Skate 3 session die: steady around 1.6GB for two minutes, then 2GB
allocated in fourteen seconds, available memory down to 54MB, 3GB pushed into
swap, killed at a 4.3GB peak with no tombstone. That is the quota being honoured
on a device that cannot pay it.

Adds get_budgetable_device_memory, which is the device local heap everywhere
except Android, where it is what is actually free less room for the emulator,
clamped between 1GB and 2.5GB. Both caches now evict against that.

Rotation: getNativeWindow only re-read the window size when the window pointer
changed, and rotating keeps the same ANativeWindow while changing its
dimensions, so the cached size stayed at whatever the first orientation was and
the swapchain was rebuilt at portrait extent inside a landscape window. Size is
re-read on every query now.
2026-08-08 16:32:09 -04:00
jpolo1224 e7606bda06 Make the PPU compile worker budget survive a large title
Skate 3 still aborted in llvm::report_bad_alloc_error during PPU compilation with
4.6GB reported available, where the previous figures allowed three workers.

Both numbers were too optimistic. A single large PPU module can take well over a
gigabyte through MCJIT and relocation processing, so 1GB per worker does not
cover a big title, and the reading is taken before the emulator maps the PS3
address space, so some of what it counts is already spoken for.

Reserves 2GB for the emulator and budgets 1.5GB per worker. On a 7GB phone with
4.6GB free that is a single worker: slower to compile, but it finishes. Three was
faster right up to the point it killed the process, and a game that aborts during
compilation cannot be played at all.

Minecraft exposed this class of bug after a cache clear and Skate 3 exposed that
the first fix was not enough; neither is new, the original cap sized against
installed rather than available memory.
2026-08-08 16:32:09 -04:00
jpolo1224 671bb9b800 Keep the SPU threads off the core RSX needs most
RSX and every SPU thread shared one affinity mask, so on a 4+3+1 phone that is
six hot threads over five cores. RSX is the thread the frame waits on, and it
was measured spending about 10ms per frame inside its own loop without running:
not blocked on the GPU, not faulting, just waiting for a core.

Carves the single fastest core out of the SPU mask and leaves it in the RSX one.
RSX keeps the whole fast cluster and only loses the contention for the best
core; the SPUs lose one core out of several. Skipped when it would leave the
SPUs with a single core, which would be worse than the problem.

Only takes effect under the alternative scheduler. On Operating System mode
Android places threads itself and this code does not run.
2026-08-08 16:32:09 -04:00
jpolo1224 738da2154f Default vblank to the PS3's own 60Hz through the curated push
A PS3 runs a 60Hz vblank and every game was written against it. The stored value
was 120, which asks for twice the frames the hardware ever produced: twice the
RSX command volume, twice the vertex upload, twice the GPU work, on a handheld
chasing a panel refresh rate the games predate.

Recording it as a core override did not hold. The override is stored correctly
and the two beside it apply, but they only persist because toggling them in the
UI writes config.yml directly, and CoreSettingOverrides.replay never ran at boot
in any log taken today. So this goes through the curated push instead, which
runs on every apply.

A deliberate change in All Core Settings still wins, since the override replay
happens immediately after.
2026-08-08 16:32:09 -04:00
jpolo1224 ab3fcd735d Make thread priority actually work on Android
set_native_priority used pthread_setschedparam with sched_priority. Android
threads run under SCHED_OTHER, where sched_priority must be zero and
sched_get_priority_max returns zero, so the call succeeded and changed nothing.

The RSX thread asks for a boost when it starts and was still measured at nice 0,
taking about 5300 involuntary preemptions a second, roughly 130 per frame, from
the PPU, SPU and audio threads sharing its cores. It is the thread everything
else waits on.

Under SCHED_OTHER the scheduler weights by nice, which setpriority does set, and
Android gives apps enough RLIMIT_NICE headroom to go negative for their own
threads. Applies -8 for a raise and +8 for a drop, and warns rather than fails
if the headroom is not there.

Modest on purpose: a hint to be scheduled ahead of the other emulator threads,
not a bid to starve them.
2026-08-08 16:32:09 -04:00
jpolo1224 30124838bc Count render passes instead of timing them
Timing each render pass overran the GPU timer's per-frame event cap by two
orders of magnitude: 554600 events dropped over 300 frames, so no draw region
ever completed and the line was missing from the report entirely.

That failure is the finding. Roughly 1800 render pass begins per frame, for a
game that should need a handful. On a tiled GPU every pass boundary stores the
tile buffer to memory and reloads it, which is the most expensive thing the
architecture does, and it would account for the 20ms of GPU time on its own.

Counts them plainly instead, since a counter cannot be overrun. The blit,
upload and readback regions stay timed and are all tiny: 0.012, 0.463 and 0.144
ms per frame, so none of them is where the GPU time goes.
2026-08-08 16:32:09 -04:00
jpolo1224 903220790c Size PPU compile workers against free memory, not installed memory
Clearing the shader cache forces every PPU module to recompile at once, and the
process aborted partway through: scudo internal map failure, NO MEMORY, in
RuntimeDyldELF relocation processing on a PPU worker thread.

The existing cap allowed one worker per 1.5GB of total RAM, so four on this
device. Total RAM is the wrong number. The same device reported 7.3GB installed
while sitting at 76MB actually free, because it is also holding everything else
the user is running. Four LLVM workers on top of the emulator's own couple of
gigabytes had nowhere to go.

Adds utils::get_avail_memory, reading MemAvailable from /proc/meminfo, which is
the kernel's own estimate of what can be handed out without swapping. Workers are
then budgeted against that with a 1.5GB floor reserved for the emulator, falling
back to a more conservative slice of total where it cannot be read.

At 4.9GB available that allows three workers instead of four, and near zero it
correctly allows one.
2026-08-08 16:32:09 -04:00
jpolo1224 e1986f953b Count pipeline drains by cause
The texture cache now reports zero misses and zero hard faults, so the eager
readback is doing its job and the guest no longer faults on surfaces. Yet
submissions per frame are still around six, and GPU work plus fence wait still
sum to the whole frame, which is the serialisation keeping this at 40fps.

So the drains are not readback faults, which is what the last several changes
assumed. Counts the three remaining callers instead: GCM label release with
texture loads outstanding, the FIFO sync hint that declares a hard sync coming,
and flushes requested by another thread.
2026-08-08 16:32:09 -04:00
jpolo1224 ccbcbce360 Fetch FIFO in 4KB blocks instead of 1KB
A refill pays a fixed cost regardless of size: read_put, the iotable lookup, the
reservation lock, and a reservation_acquire per line. Measured at about 10us per
refill against roughly 1us of actual copying, so nine tenths of it was that fixed
cost, and a heavy scene ran over 1200 refills per frame to move 1.29MB of FIFO,
which is around 324,000 command words.

Fetching 4KB at a time pays that cost a quarter as often for the same bytes.

The line mask was a u8 pinned to 8 lines, so it widens to u32 with the full-mask
case special-cased, since 1u << 32 is undefined. The traversal order was checked
against the original at both widths before changing it.

Also adds counters for refill stalls, which ruled out the alternative
explanation: the retry spin is 0.006 ms/frame, so the refill is doing real work
rather than waiting on the guest.
2026-08-08 16:32:09 -04:00
jpolo1224 0f7265364c Build quad and fan index patterns once instead of per draw
The expansion indices for primitives the host cannot draw natively depend only
on the index, never on the draw's data, so the buffer for N primitives is
exactly a prefix of the buffer for any larger N. They were regenerated on every
draw call anyway, one u16 at a time, written straight into mapped GPU memory.

Builds each table once and copies the prefix. Tens of thousands of dependent
scalar stores become a single bulk copy, which also suits write-combined memory
far better than a scatter of small writes.

Minecraft draws quads throughout, so this ran on essentially every draw and
measured 5.7% of RSX thread samples.

Patterns were diffed against the original loops across edge cases before
replacing them; u16 indices bound both tables at 65536 vertices and anything
larger keeps the old path.
2026-08-08 16:32:09 -04:00
jpolo1224 1c2f13fa5a Inline the FIFO cache hit path
fetch_u32 is called once per FIFO command word and the guest pushes about 52,000
of them per frame, while the cache refills only around 208 times. Better than
99% of calls are a compare and a load, but the function lived out of line in
RSXFIFO.cpp for the sake of the rare refill, so every one of those 52,000 was a
real call into another translation unit.

It measured 15.6% of RSX thread samples, which no amount of refill work
explains: 208 refills of a 1KB cache is a few tens of microseconds of copying.
The cost was call overhead, not work.

Splits the refill into fetch_u32_refill and leaves the hit path inline.
2026-08-08 16:32:09 -04:00
jpolo1224 a8607c2fcb Stop the profiler distorting the bucket it measures
Two measurement faults, both mine, both of which sent this investigation at the
wrong targets.

The scope timing the swapchain acquire was declared at function scope, so it
lived until flip() returned and charged the whole present path against
present_wait. That bucket read 10.12 ms and looked like the largest cost in the
frame. It is now braced around the acquire alone.

The accounting was in thread locals, and under the generic TLS model every scope
enter and exit went through the linker's tlsdesc resolver: 21% of RSX thread
samples against 0.07% uninstrumented, landing hardest on the FIFO bucket, which
is why that number would not move when the FIFO fetch path was bypassed.
initial-exec removed the cost but stopped every game booting.

Since only the RSX thread is ever reported, there is now one copy of the
accounting and a thread-pointer check at the door instead of a copy per thread.
On ARM64 that is a single register read with no relocation, cheaper than one
tlsdesc access where a scope previously did six.
2026-08-08 16:32:09 -04:00
jpolo1224 d3a6269ce0 Measure GPU time by category, not just totals
CPU profiling has taken this as far as it goes. With the readback wait backed
off, the RSX thread drops to ~22% host CPU while the Adreno holds 81-88% busy at
its maximum clock and nothing throttles, so the GPU is the limiter and every
remaining question is on its side.

A whole-frame GPU timestamp would not have been worth writing: the kernel
already exposes gpu_busy_percentage and it says the same thing. What no counter
exposes is the split, specifically how much GPU time goes to copying render
targets back for the guest to read rather than to drawing. So the regions are
labelled, and readback is bracketed where the copy is actually recorded.

Readback of the queries is deferred: results come from ring slots written frames
earlier, a slot that is not ready is retried later, and no path waits on the GPU.
Reading a query in the frame that wrote it would stall on exactly the thing being
measured. vkDeviceWaitIdle is never called.

Frames are retired from flip rather than from the frame region closing, because
the primary command buffer is submitted once per flush_command_queue and several
times per frame, which would divide every per-frame figure by the wrong number.
Untimed events past the per-frame cap are reported rather than dropped silently,
so a truncated frame cannot read as a cheap one.

vkCmdWriteTimestamp had to be added to the Android Vulkan loader, which resolves
an explicit list of entry points and did not include it.
2026-08-08 16:32:09 -04:00
jpolo1224 98be68af9e Stop the readback wait from starving the GPU it waits on
vk::wait_for_event spun on vkGetEventStatus with nothing but an isb between
calls. Profiling the RSX thread put 44 to 53 percent of its samples in that
loop, on a build with no instrumentation in it, while the GPU sat at 88 percent
busy pinned at its maximum clock and nothing was thermally throttled.

A readback issued mid frame queues behind everything already submitted, so the
wait drains the whole pipeline and runs into milliseconds. Spinning through that
does not make the event arrive sooner. It holds a core at peak clock and, worse
on a tiled mobile part, keeps reading memory the GPU is writing, taking
bandwidth from the device we are blocked on.

Polls hot for a bounded window so short waits behave as before, then backs off
to 50us. Against a wait measured in milliseconds that granularity is noise.

Also pins the profiler's thread locals to initial-exec. Reading them through the
generic model routed every scope through the linker's tlsdesc resolver and cost
21 percent of RSX thread samples against 0.07 percent uninstrumented, which
inflated the FIFO bucket it was meant to measure.
2026-08-08 16:32:09 -04:00
jpolo1224 4645408eb7 Add exclusive RSX thread time accounting
The overlay's RSX percentage measures what the thread is not doing: get_load()
counts everything outside four idle sites, so a thread decoding commands and a
thread spinning on a Vulkan fence both read as fully loaded. That is exactly the
distinction that decides what is worth optimising, and it could not be read off
any existing counter.

Splits RSX thread time into exclusive buckets instead. Entering a scope charges
elapsed time to whatever was active and switches, so nesting attributes to the
innermost scope and the totals sum to wall clock rather than double counting a
caller with its callee. Reports to the log every 300 frames, including an
explicit unscoped remainder so the percentages cannot read as complete coverage
when they are not.

Behind the "RSX Profiler" video setting, dynamic, so it can be armed once a
slowdown has already started. Off by default, costing one predictable branch per
scope. Accounting is per thread because some of these paths run on whichever
guest thread faulted rather than on RSX; only the RSX thread's copy is reported.

No optimisation here, only measurement.
2026-08-08 16:32:09 -04:00
jpolo1224 3efd2a68df Let the downloaded config database be switched off and removed
Adds an apply toggle and a remove action next to the download row, so a title can
be run with and without its database entry to compare. Disabling renames the split
files aside rather than deleting them, so flipping back does not mean re-fetching
two thousand titles; remove deletes both copies and returns to stock.

Also drops Multithreaded RSX from the deny list. It was listed on the belief that
it froze Minecraft, which was wrong: the freeze was Accurate SPU DMA plus Accurate
Cache Line Stores, and it reproduced with Multithreaded RSX off and an empty
database. It is a real upstream feature backed by the RSXOffload thread, so there
was never evidence against it, and the toggle is the honest way to settle whether
a database entry helps or hurts.
2026-08-08 16:30:53 -04:00
jpolo1224 20aebe9517 Fix SPU livelock from atomic DMA cache line stores
Accurate SPU DMA and Accurate Cache Line Stores were both on. Together they
route every 128-byte SPU DMA store through do_cell_atomic_128_store, turning
bulk DMA into one atomic reservation store per cache line. Reservation
contention then outruns the rate it can drain, an SPU spins in do_putllc
forever, and the PPU stalls behind it on a semaphore the SPU never signals
while the RSX idles.

Both default off upstream. Cache Line Stores was wrong in our defaults;
SPU DMA already defaulted off but was stored on, so a default change alone
would not reach anyone who had already run the app, hence the migration.

Seen on Minecraft, which froze loading world chunks, that being bulk SPU DMA
and little else. Load dependent, so it presented as an intermittent freeze.
2026-08-08 16:30:53 -04:00
jpolo1224 25d01cd7d3 Stop routing the overlay reset through CallFromMainThread
It defers nothing on this port: the Android call_from_main_thread callback runs
the function inline on the caller's thread. All it added was RPCS3's state-guard
wrapper, a std::function allocation and a log call, on a path applyTo hits eleven
times per settings change.

The guard that skips the reset entirely while stopped is what actually prevents
the crash it was meant to fix, and that stays.
2026-08-08 16:30:53 -04:00
jpolo1224 dd7cda5ad5 Port RPCS3's config database
The recommended per-title settings the desktop build offers under "Download
Config Database". The core already knows how to use them: Emulator::Load asks
through the get_database_config callback and passes the result to BootGame as
db_config, which sits under the user's own settings.

That callback was returning a PATH, which was simply the wrong thing. The return
value is taken as the config CONTENT, so what it got back was a filename that
failed to parse and was discarded, and the feature has never done anything here.
It reads the config for the title now.

The download and the JSON parsing were the only parts missing, and they lived in
rpcs3qt, which this build does not compile. Those are done in the app instead:
api.rpcs3.net returns every title in one object, and it is split into one YAML
per title so the callback, which runs on the boot path, does not have to parse a
database of every PS3 game to find one entry.

Manual rather than automatic on launch, since it is a network call to a third
party. 2164 titles at the time of writing.
2026-08-08 16:30:53 -04:00
jpolo1224 7849ea3fdc Serialise emulator teardown so boots stop failing
Emu.Kill() spawns an Emulation Join Thread that joins every emulator thread, and
nothing stopped two of those existing at once. They end up joining each other and
neither finishes, so the emulator never reaches stopped. On device that showed as
four live join threads and six leaked AudioTrack threads after a few close then
boot cycles, with the join thread logging that it was waiting on itself.

Boot took the lifecycle lock only after calling Kill, so a Close and the boot that
followed it each started a teardown and deadlocked. The lock is now taken first
and held across the whole boot, which is also what the disc probe and shutdown
already use, so probing, booting and killing can no longer overlap at all.

This is what was behind the reports of being kicked back to the library when
opening a game, of crashes when switching games quickly, and of the save screen
never appearing: all of them were the emulator failing to reach a stopped state
rather than anything wrong with booting or with saves.
2026-08-07 23:46:46 -04:00
jpolo1224 53094d3127 Stop library scanning from racing the emulator, and fix core settings persistence
Disc probing was the cause of the game switching crashes. probeDisc mounts the
image into the emulator's GLOBAL vfs to read its PARAM.SFO, vfs::mount begins
with g_fxo->need<vfs_manager>(), and the scan runs on a background thread. So a
scan overlapping a boot or a teardown aborted the process inside vfs::mount.
Closing a game does both at once: Emu.Kill() resets g_fxo and returning to the
library starts a rescan. Waiting ten seconds only worked because the scan had
finished by then.

Three parts. The probe cache is now seeded from the last scan, so a disc that
has been seen before is never mounted again, which removes almost all of the
window and stops rescans re-reading multi gigabyte images. Probing, booting and
killing take a shared lock, so what is left cannot overlap. Booting waits for the
previous VM to actually stop, since BootGame's failure path asserts IsStopped and
aborts otherwise, which is what killed PES 2018 on a failed boot.

The overlay reset that settingsSet performs is posted through CallFromMainThread
rather than run on the JNI caller's thread. It walks the overlay manager into
perf_metrics_overlay::update(), which is RSX-owned, and doing that inline is what
crashed when a setting was changed while a game was starting or closing.

All Core Settings edits are recorded and replayed at the tail of applyTo. The
curated store pushes about 165 nodes on every settings change and on every boot,
so anything set on that screen which overlapped one of them was reverted moments
later. Per-title required settings land just before them, so a game that needs a
workaround gets it while an explicit user choice still wins. Uncharted 3 needs
Stub PPU Traps to get past its own crash handler.

Also adds .rap and .edat licence installing, which routes to installKey rather
than install, and migrates the stored Scaling Mode value so PR 10's change of
meaning does not silently move everyone from Bilinear to Nearest.
2026-08-07 23:37:51 -04:00
Zulux91 c4790f8508 Stop PCSX2's duplicate frame setting from halving the framerate
SkipDuplicateFrames in PCSX2 means "do not present a frame identical to the
last one". It is harmless there and on by default, which is why Settings
defaults it to true. RPCS3 has no equivalent, and the bridge mapped it onto
Enable Frame Skip, which means something completely different: drop one frame
in every two, unconditionally.

So every game presented at half the rate the guest asked for, on a fresh
install, with nothing touched. It also made the frameskip row in the in-game
menu useless, because applyToInner pushes the explicit frameskip first and this
key afterwards, so this one always won no matter what you picked.

Measured on Mirror's Edge, which asks for 30 flips a second. SurfaceFlinger was
presenting 15.0 fps, one frame every 66.65 ms, rock steady. The RSX thread
reported 0-5% load with six of eight cores idle, which is what sent me looking
for a performance problem that was never there. With the mapping gone it sits
at 30.0 fps, 33.32 ms.

Dropped rather than remapped. Frameskip belongs to the explicit control, which
is the one the user actually set.
2026-08-07 23:09:12 -04:00
Zulux91 d9799627ff Make the Scaling Mode row actually pick the scaling mode
Four keys were writing Output Scaling Mode and the two with nothing behind them
were winning. IntegerScaling and linear_present_mode are PCSX2 keys that no
screen in this app exposes, both wrote the node unconditionally, and
IntegerScaling was emitted last, so it overwrote whatever the visible Scaling
Mode row had asked for.

On top of that the row itself was misread. It offers Nearest, Bilinear and FSR
and stores the index in casMode, but the bridge treated casMode as PCSX2's CAS
mode, where anything above zero means "CAS on". So picking Bilinear asked for
FSR, picking Nearest asked for nothing, and none of it survived anyway.

The two invisible keys no longer touch the node, and CASMode maps its own
indices. CAS is emitted before ShaderChainEnabled now so the chain still gets
the last word when both are on, which is what the bridge already documented.

casMode defaults to Bilinear rather than Nearest. Bilinear is what the core has
been using all along, and what RPCS3 itself defaults to, so nobody's picture
changes. Leaving the default at 0 would have quietly switched every user to
nearest neighbour the moment the mapping started working.

Checked on an Odin 3, reading Output Scaling Mode back out of config.yml:
picking FSR now gives FidelityFX Super Resolution where it used to give
Bilinear, and picking Bilinear gives Bilinear for the right reason.
2026-08-07 23:09:12 -04:00
Zulux91 21d95f1140 Fix console aspect ratio Auto resolving to 4:3
Closes #3.

setAspectRatio was written against ARMSX2's PS2 aspect enum, where index 1 was
4:3, and its javadoc still described that enum. ARMSX3's picker in RendererTab
is a different one: 0 stretch, 1 Auto, 2 is 4:3 and 3 is 16:9. So the check for
index 1 was matching Auto, and Auto is the default.

Every game therefore came up in 4:3 on a fresh install, and RPCS3's own default
for that node is 16:9. Setting the picker to 16:9 by hand worked, which is what
the issue reports, because index 3 fell through to the 16:9 branch.

Compare against index 2 instead, so only an explicit 4:3 selects 4:3 and
everything else lands on 16:9. That also lines it up with the other writer of
this node, writeGsToNative, which maps the same indices by name and already
treated anything other than "4:3" as widescreen.

Verified on an Odin 3: config.yml went from "Aspect ratio: 4:3" to
"Aspect ratio: 16:9" with the picker left on Auto.
2026-08-07 23:09:03 -04:00
Zulux91 599473e757 Don't let the OSD mode switch the performance overlay off at boot
Two things end up writing the same config node. RPCS3 has its own Performance
Overlay switch, in OverlayTab and the in-game menu, both writing
ps3.overlayEnabled. ARMSX2 has its twelve per-stat osdShow* flags, and
osdApplyFlags derives Enabled purely from those, all of which default to off.
So it pushed Enabled=false right over the switch.

Boot order decided the winner. MainActivityRuntime calls applyTo(), which
pushes the switch, and then applyStoredOsdMode() one line later, which pushes
the flags. Turning the overlay on in settings did nothing at all.

Only that one key was affected, because osdApplyFlags returns as soon as it
sees nothing enabled and never reaches the graph, font size and opacity
settings. That is why config.yml held the user's values for those while
Enabled sat at false.

Re-assert the switch after the flags have been applied. Scoped to the Custom
path, since that is the mode that reads saved settings and the one that runs
at boot. Full and Min pass explicit flags, and Off is meant to be off.
2026-08-07 23:09:03 -04:00
Zulux91 a4eeae8b5b Keep game data installs out of the library
A game data install looks exactly like an installed HDD game on disk, a
PARAM.SFO next to USRDIR, so isPs3GameFolder accepted it. Every title you
install data for therefore got a second tile that cannot boot, since game data
holds no EBOOT. Skate 3 ships a 1.1 GB BLUS30464_INSTALL and showed up twice.

CATEGORY is what tells them apart. GD is data, HG and DG are games. The native
scanner already rejects these, because fetchGameInfo requires BOOTABLE and game
data does not set it, but the folder path never opened the SFO at all.

Reads just that one field rather than adding a general SFO parser. Nothing else
needs it, and an unreadable or malformed file falls back to listing the folder,
so the worst case is a stray tile and never a hidden game.

ScanSchemaVersion goes up as the comment on cacheKey asks, since the cache
stores the scan result and an existing one would keep serving the old entry.
2026-08-07 23:09:03 -04:00
Zulux91 ba8947f3a3 Unmap guest memory when precompilation finishes
The precompilation queue calls vm::init() so that ppu_register_range has
somewhere to register, but nothing ever unmapped it again. vm::close() is only
reached from Emu.Stop(), and precompilation deliberately never boots anything.

So the blocks stayed mapped past "Finalization". The next vm::init(), either
the next workload or Emulator::Load() booting a game, assigns over g_locations
and destroys them, which trips ensure(!is_valid()) in ~block_t() and takes the
whole process down.

Easy to reproduce: install firmware and then boot a game without restarting the
app. The firmware pass leaves five live blocks behind and the boot dies inside
vm::ps3_::init() before it reads a byte of the disc.
2026-08-07 23:09:03 -04:00
jpolo1224 cac6590b04 Keep game and firmware art out of the gallery
Two sources, both writing real PNGs to shared storage where Android's media
scanner indexes them and they turn up in the camera roll.

The data root holds firmware assets: trophy icons under dev_hdd0/home, the whole
dev_flash VSH resource set, and an ICON0.PNG per installed game. That was 216
images. A .nomedia now goes in before the core initialises, since that is what
unpacks the firmware and creates most of them.

The ROM folder is the one people actually notice, and only since folder format
games started working. An .iso is a single opaque file so the scanner sees
nothing inside it, but a disc in folder form lays its ICON0.PNG, PIC1.PNG and
every DLC image out in the open. One Minecraft folder accounted for 245 images,
which was every image in the whole ROM tree.

The ROM marker goes at the configured directory root rather than inside a game
folder, so it covers current and future folder games with one file and never
leaves a stray file inside content that gets mounted as a disc. Writing it also
triggers a rescan, because the marker alone does not drop what MediaStore has
already indexed. Zero bytes and reversible either way.
2026-08-07 00:25:47 -04:00
jpolo1224 da6cf3bb49 Add PPU and SPU cache clearing
Two rows under a Compiled Code Cache section on the Performance tab, next to the
recompiler settings and separate from the shader cache on the Renderer tab, which
holds GPU pipelines rather than recompiled code.

The layout is <files>/cache/cache/, with ppu-<hash>-<name> directories for
firmware modules at the top level and <TITLEID>/ppu-<hash>-EBOOT.BIN per game.
The SPU cache is a spu-*.dat inside those, so the two options are not symmetric
and are worded accordingly: clearing SPU removes only those files and leaves
booting as fast as it was, while clearing PPU removes the directories outright
and necessarily takes the SPU caches with them.

Both report how much was freed, and both refuse while a game is loaded, since a
running VM holds those files open and is still writing to them.
2026-08-07 00:01:59 -04:00
jpolo1224 dbc43ec6dc Guarantee . and .. from sys_fs_opendir on host directories
fs::unix_dir::read is a bare readdir passthrough, so a listing is whatever the
host filesystem reports, in whatever order. ext4 conventionally yields . and ..
first; Android's FUSE layer over exFAT does not emit them at all. A directory
holding one file therefore left a single entry, and the stable_sort that follows
starts at data.begin() + 2, so it ran with first past last. That is undefined
behaviour rather than a no-op: it corrupted memory and the guest died later
inside libfs reading a wild pointer, with nothing at the crash site pointing back
at the cause.

Not only a crash guard. The PS3 returns both entries from opendir, so any game
walking a directory on the host filesystem was getting a listing the console
would never produce. iso_device synthesises them already, which is why the same
game booted from an .iso was unaffected and every folder format game on an SD
card was exposed. Found by booting Minecraft both ways.
2026-08-06 23:58:46 -04:00
jpolo1224 92816b9424 Fix two crashes booting folder format games
Boot audio aborted the process. init_audio does ensure() on
Emu.GetCallbacks().make_video_source(), and the Android callbacks return nullptr
because there is no media backend, so ensure() killed the app outright. It fired
for any game whose folder holds a SND0.AT3, which is every folder format game:
for an .iso the fs::is_file check in rsx::thread::thread looks inside the mounted
virtual device and never finds one, so every .iso booted so far dodged it by
accident. A null source is now handled and logged. Boot music does not play,
which it could not have anyway. PKG installed games were exposed to this too,
since they also sit on the real filesystem.

PPU compilation ran out of memory. jit_core_allocator::limit() sizes the LLVM
compile workers on core count alone, which is right on a desktop and fatal on a
handheld: eight workers on a 7 GB device aborted inside
llvm::report_bad_alloc_error partway through a large title, with Max LLVM
Compile Threads left at 0 for "use every core". The limit is now bounded by
physical memory as well, roughly one worker per 1.5 GB and never below one. It
only caps the automatic default; an explicit setting is still honoured.
2026-08-06 23:48:51 -04:00
jpolo1224 f04aa0e823 Close the progress dialog when work is complete but its text is held
The progress dialog server only leaves its loop when the counters match AND
g_progr_text is empty. That text is refcounted across nested progress scopes, so
a leaked reference leaves the loop spinning forever: the dialog is never closed,
and the cleanup that resets g_progr_ptotal never runs either, which is what
ppu_thread::cpu_task waits on before switching to overlay-message mode.

Seen on device with a fully booted, running game sitting behind a "Building SPU
Cache... 941 of 941" dialog for over ten minutes. The RSX thread and two SPU
threads were at 97%, syscall counters were climbing, and nothing had compiled
since six minutes in. Note the label is stale in that state: the server only
overwrites its cached text when it receives a non-empty one, so the last
meaningful message stays on screen and says nothing about which scope leaked.

Closes the dialog once the counters are complete and have been completely idle
for roughly five seconds. wait_no_update_count resets on any change to any
counter or to the text, so work in progress can never reach the threshold. The
warning names the held text, which is what will identify the leaking scope.

This is a safety net. The reference leak itself is still there.
2026-08-06 23:34:25 -04:00
jpolo1224 2c06abaf57 Fix aspect ratio, PKG install crash and folder boot; add split PKG and uninstall
The aspect ratio rows did nothing because Rpcs3Bridge's PS3/Video branch has an
explicit key list ending in `else -> return false`, and neither the new Display
Aspect Override nor Stretch To Display Area was in it, so both were dropped into
Unsupported.note(). Stretch had only ever reached the core through the legacy
EmuCore/GS AspectRatio key, which is why Display Mode looked inert too. Audited
the rest: all 66 PS3 keys applyTo writes are handled now. Screen aspect is also
in the in-game menu, where you can see what you are changing.

Installing a package crashed because a successful install queued the new title
for precompilation, and that path calls Emu.SetState(running), g_fxo->init<> and
vm::init(). Those are safe once, during onboarding, and not in a process that has
already booted a game. Extraction had finished by then, so the title still showed
up on the next launch. Nothing precompiles on install now; it happens on first
boot like any other game.

Installed titles booted to a black screen because BootGame was handed the game
directory. RPCSX boots them by the bootable path the installer reports, which is
the EBOOT, so directories are resolved through locateEbootPath first.

Split packages can now be installed. package_reader::extract_data always took a
deque of readers, only the entry point was single file, so a game split into
parts could not be installed at all. Select all the parts and confirm; they are
sorted by name and handed over together.

Installed titles can be uninstalled from the same screen. The path is checked
against dev_hdd0/game before anything is deleted, so it cannot touch a ROM folder.
2026-08-06 23:09:33 -04:00
jpolo1224 60aa81287f Fix first round of community reports
Settings that would not stick: the eleven Ps3Settings overlay fields were
wired into the Overlay tab but missing from all four of Settings.kt's
serialisation paths, so they only ever lived in memory and reverted the next
time the screen re-read the store. Added them to toJson, fromJson, diffFrom,
merge and the per-tab reset list.

In-game menu lag: every settingsSet ended in SaveSettings(g_cfg.to_string()),
which serialises the whole config and writes it out. applyTo pushes about 165
keys per change, so one toggle cost 165 whole-config writes on the UI thread.
Added settingsBeginBatch/settingsEndBatch and wrapped applyTo in it.

Folder format games are now detected, both the disc layout with PS3_GAME and
an installed game folder with PARAM.SFO next to USRDIR, and probeDiscInfo
reads either without mounting anything. The emulator's own dev_hdd0/game and
games directories are scanned alongside the user's ROM folders, which is why
nothing installed was ever showing up.

Package installer: the native side already handled pkg, pup, edat and iso,
but nothing in the app ever called it. Added a screen and a drawer entry.

Screen aspect ratio: the PS3 only signalled 4:3 or 16:9 so there was no way
to fill a handheld panel without stretching. Added an output aspect override
with presets and a custom slider.

All core settings screen, generated from the config tree rather than written
by hand, so nodes added upstream are editable with no app change.

Also: 176 of the 245 settings search entries still named PCSX2 settings, the
tagline still said PlayStation 2, and a missing fw.json on first run printed
a stack trace that read as a crash.
2026-08-06 21:32:13 -04:00
jpolo1224 4a3d9e322f Point the in-app links at ARMSX3 and RPCS3
What's New was reading releases from the ARMSX2 repo, and the About page
credited PCSX2 and linked to it. The navigation drawer had the same stale
GitHub link.

The website entry in the drawer still goes to armsx2.net since there is no
ARMSX3 site yet.
2026-08-06 10:02:59 -04:00
jpolo1224 5ceb0d84c1 Update README.md 2026-08-06 09:08:18 -04:00
jpolo1224 4ccd0efe75 Update README.md 2026-08-06 09:07:26 -04:00
jpolo1224 622f6306e9 Update README.md 2026-08-06 09:06:42 -04:00
159 changed files with 11445 additions and 2979 deletions
+3
View File
@@ -161,3 +161,6 @@ android/**/keystore.properties
3rdparty/librashader/
android/app-upstream/
android/**/cpp/libadrenotools/
# Kotlin incremental-compile scratch dir
android/armsx3-ui/.kotlin/
+4 -7
View File
@@ -3,15 +3,14 @@ ARMSX3
Proof of concept Android port of RPCS3.
This is early work. A game boots and plays, but it is slow and most of it is
untested. It is not a usable emulator yet.
Uses the latest RPCS3 upstream code (the recent ARM64 improvements included).
Status
------
Skate 3 boots, loads and reaches gameplay at roughly 20 to 30 fps on a
From my testing, I only tried Skate 3. It boots, loads and reaches gameplay at roughly 20 to 30 fps on a
Snapdragon 8 Gen 2. Rendering, audio, touch controls and physical controllers
work. Almost nothing else has been tested.
work. Almost nothing else has been tested. So the main stop gap at the moment is performance/speed.
Differences from upstream RPCS3
-------------------------------
@@ -82,9 +81,7 @@ Discord's developer portal and drop it in app/libs/ and
app/src/main/cpp/discord_sdk/ if you want that feature. The build skips it
otherwise.
Running it needs PS3 firmware, which is not included. Install PS3UPDAT.PUP from
Sony's support site through the setup screen in the app.
Running it needs PS3 firmware, which is not included.
License
-------
+7
View File
@@ -545,11 +545,18 @@ class jit_compiler final
// Disk Space left
atomic_t<usz> m_disk_space = umax;
bool m_poisoned = false;
public:
jit_compiler(const std::unordered_map<std::string, u64>& _link, std::string_view _cpu, u32 flags = 0, std::function<u64(const std::string&)> symbols_cement = {}) noexcept;
jit_compiler& operator=(thread_state) noexcept;
~jit_compiler() noexcept;
bool is_poisoned() const noexcept
{
return m_poisoned;
}
// Get LLVM context
auto& get_context()
{
+15 -4
View File
@@ -250,6 +250,13 @@ void* jit_runtime_base::_add(asmjit::CodeHolder* code, usz align) noexcept
}
}
#if defined(ARCH_ARM64)
// Instruction-cache maintenance for freshly copied code (trampolines, branch
// patchpoints). Nothing flushed these before; another core could fetch stale
// icache contents for this range.
asmjit::VirtMem::flushInstructionCache(p, codeSize);
#endif
return p;
}
@@ -326,16 +333,20 @@ void jit_runtime::finalize() noexcept
s_data_pos = 0;
// Restore code/data snapshot
std::memcpy(alloc(s_code_init.size(), 1, true), s_code_init.data(), s_code_init.size());
u8* const code_ptr = alloc(s_code_init.size(), 1, true);
std::memcpy(code_ptr, s_code_init.data(), s_code_init.size());
std::memcpy(alloc(s_data_init.size(), 1, false), s_data_init.data(), s_data_init.size());
#ifdef __APPLE__
pthread_jit_write_protect_np(true);
#endif
#ifdef ARCH_ARM64
// Flush all cache lines after potentially writing executable code
asm("ISB");
asm("DSB ISH");
// The restored range is executable code rewritten in place: perform real
// instruction-cache maintenance for it (ISB/DSB alone cleans nothing).
if (code_ptr && !s_code_init.empty())
{
asmjit::VirtMem::flushInstructionCache(code_ptr, s_code_init.size());
}
#endif
}
+80 -5
View File
@@ -238,6 +238,11 @@ struct MemoryManager1 : llvm::RTDyldMemoryManager
// May be a memory container internally
std::function<u64(const std::string&)> m_symbols_cement;
#if defined(ARCH_ARM64)
// Code ranges allocated since the last finalizeMemory(), for icache maintenance
std::vector<std::pair<u8*, uptr>> m_code_ranges;
#endif
MemoryManager1(std::function<u64(const std::string&)> symbols_cement = {}) noexcept
: m_symbols_cement(std::move(symbols_cement))
{
@@ -346,7 +351,17 @@ struct MemoryManager1 : llvm::RTDyldMemoryManager
u8* allocateCodeSection(uptr size, uint align, uint /*sec_id*/, llvm::StringRef /*sec_name*/) override
{
return allocate(code_ptr, m_code_mems, size, align, utils::protection::wx);
u8* const p = allocate(code_ptr, m_code_mems, size, align, utils::protection::wx);
#if defined(ARCH_ARM64)
// Track for instruction-cache maintenance in finalizeMemory()
if (p)
{
m_code_ranges.emplace_back(p, size);
}
#endif
return p;
}
u8* allocateDataSection(uptr size, uint align, uint /*sec_id*/, llvm::StringRef /*sec_name*/, bool is_ro) override
@@ -362,6 +377,16 @@ struct MemoryManager1 : llvm::RTDyldMemoryManager
bool finalizeMemory(std::string* = nullptr) override
{
#if defined(ARCH_ARM64)
// See MemoryManager2::finalizeMemory(): RuntimeDyld relies on this callback
// for instruction-cache maintenance of freshly written code sections.
for (const auto& [p, size] : m_code_ranges)
{
asmjit::VirtMem::flushInstructionCache(p, size);
}
m_code_ranges.clear();
#endif
return false;
}
@@ -381,6 +406,11 @@ struct MemoryManager2 : llvm::RTDyldMemoryManager
// May be a memory container internally
std::function<u64(const std::string&)> m_symbols_cement;
#if defined(ARCH_ARM64)
// Code ranges allocated since the last finalizeMemory(), for icache maintenance
std::vector<std::pair<u8*, uptr>> m_code_ranges;
#endif
MemoryManager2(std::function<u64(const std::string&)> symbols_cement = {}) noexcept
: m_symbols_cement(std::move(symbols_cement))
{
@@ -414,7 +444,17 @@ struct MemoryManager2 : llvm::RTDyldMemoryManager
u8* allocateCodeSection(uptr size, uint align, uint /*sec_id*/, llvm::StringRef /*sec_name*/) override
{
return jit_runtime::alloc(size, align, true);
u8* const p = jit_runtime::alloc(size, align, true);
#if defined(ARCH_ARM64)
// Track for instruction-cache maintenance in finalizeMemory()
if (p)
{
m_code_ranges.emplace_back(p, size);
}
#endif
return p;
}
u8* allocateDataSection(uptr size, uint align, uint /*sec_id*/, llvm::StringRef /*sec_name*/, bool /*is_ro*/) override
@@ -424,6 +464,19 @@ struct MemoryManager2 : llvm::RTDyldMemoryManager
bool finalizeMemory(std::string* = nullptr) override
{
#if defined(ARCH_ARM64)
// RuntimeDyld calls finalizeMemory() after writing code and relies on it for
// instruction-cache maintenance. This was a no-op: freshly emitted code was
// never flushed, so other cores could execute stale icache contents for it.
// x86 has a coherent instruction cache and never noticed. The asmjit helper
// performs the required DC CVAU / IC IVAU broadcast sequence.
for (const auto& [p, size] : m_code_ranges)
{
asmjit::VirtMem::flushInstructionCache(p, size);
}
m_code_ranges.clear();
#endif
return false;
}
@@ -819,17 +872,26 @@ jit_compiler& jit_compiler::operator=(thread_state s) noexcept
jit_compiler::~jit_compiler() noexcept
{
if (m_poisoned)
{
jit_log.error("Abandoning poisoned LLVM execution engine (leaked to avoid a deadlock in ~MCJIT)");
static_cast<void>(m_engine.release());
static_cast<void>(m_context.release());
}
}
void jit_compiler::add(std::unique_ptr<llvm::Module> _module, const std::string& path)
{
ObjectCache cache{path, this};
m_poisoned = true;
m_engine->setObjectCache(&cache);
const auto ptr = _module.get();
m_engine->addModule(std::move(_module));
m_engine->generateCodeForModule(ptr);
m_engine->setObjectCache(nullptr);
m_poisoned = false;
for (auto& func : ptr->functions())
{
@@ -851,6 +913,7 @@ bool jit_compiler::try_add(std::unique_ptr<llvm::Module> _module, const std::str
m_engine->generateCodeForModule(ptr);
}, error))
{
m_poisoned = true;
return false;
}
@@ -868,8 +931,11 @@ bool jit_compiler::try_add(std::unique_ptr<llvm::Module> _module, const std::str
void jit_compiler::add(std::unique_ptr<llvm::Module> _module)
{
const auto ptr = _module.get();
m_poisoned = true;
m_engine->addModule(std::move(_module));
m_engine->generateCodeForModule(ptr);
m_poisoned = false;
for (auto& func : ptr->functions())
{
@@ -888,6 +954,7 @@ bool jit_compiler::try_add(std::unique_ptr<llvm::Module> _module, std::string& e
m_engine->generateCodeForModule(ptr);
}, error))
{
m_poisoned = true;
return false;
}
@@ -948,15 +1015,23 @@ void jit_compiler::update_global_mapping(const std::string& name, u64 addr)
void jit_compiler::fin()
{
m_poisoned = true;
m_engine->finalizeObject();
m_poisoned = false;
}
bool jit_compiler::try_fin(std::string& error)
{
return run_recoverable_llvm([&]()
if (!run_recoverable_llvm([&]()
{
m_engine->finalizeObject();
}, error);
}, error))
{
m_poisoned = true;
return false;
}
return true;
}
u64 jit_compiler::get(const std::string& name)
@@ -1033,13 +1108,13 @@ const char * fallback_cpu_detection()
#ifdef ANDROID
static std::string s_result = []() -> std::string
{
// get_cpu_name() already returns a canonical LLVM processor name
std::string result = aarch64::get_cpu_name();
if (result.empty())
{
return "cortex-a78";
}
std::transform(result.begin(), result.end(), result.begin(), ::tolower);
return result;
}();
+118 -2
View File
@@ -7,6 +7,9 @@
#include "Emu/Cell/lv2/sys_process.h"
#include "Emu/RSX/RSXThread.h"
#include "Thread.h"
#include <bit>
#include <cstring>
#include <cerrno>
#include "Utilities/JIT.h"
#include <cfenv>
#include <charconv>
@@ -2687,7 +2690,21 @@ void sigpipe_signaling_handler(int)
const bool s_exception_handler_set = []() -> bool
{
struct ::sigaction sa;
#ifdef __ANDROID__
// Run the handler on the alternate stack installed per thread in
// thread_base::initialize. Without this the handler runs on the faulting thread's own
// stack, so a stack overflow has nowhere to report itself from: the handler faults
// again immediately and the kernel applies the default action, killing the process
// having written nothing.
//
// Arkham City does exactly that. The only record anywhere of the crash was a single
// Zygote line, "exited due to signal 11 (Segmentation fault)", with no tombstone, no
// crash-buffer entry and no line from this handler, which cost hours of diagnosing it
// as an external kill.
sa.sa_flags = SA_SIGINFO | SA_ONSTACK;
#else
sa.sa_flags = SA_SIGINFO;
#endif
sigemptyset(&sa.sa_mask);
sa.sa_sigaction = signal_handler;
@@ -2791,6 +2808,33 @@ void thread_base::start()
void thread_base::initialize(void (*error_cb)())
{
#ifdef __ANDROID__
// Somewhere for the SIGSEGV handler to run, per thread. See SA_ONSTACK above.
//
// Deliberately a thread_local rather than a shared buffer: the handler can fire on any
// thread, two threads can fault at once, and a shared stack would corrupt whichever
// report lost the race. It is released with the thread, after which no handler can run
// on it.
//
// 128KB because the handler formats and logs rather than just setting a flag. That is
// real memory across the emulator's thread count, and it buys turning a silent death
// into a reported one.
static thread_local std::array<u8, 128 * 1024> s_signal_stack;
stack_t alt{};
alt.ss_sp = s_signal_stack.data();
alt.ss_size = s_signal_stack.size();
alt.ss_flags = 0;
if (::sigaltstack(&alt, nullptr) == -1)
{
// Not fatal: the handler simply falls back to the faulting stack, which is the
// behaviour everywhere else. Worth knowing about, because it means a stack
// overflow will go unreported again.
sig_log.error("sigaltstack failed (%d); stack overflows will not be reported", errno);
}
#endif
#ifndef _WIN32
#ifdef __APPLE__
while (!m_thread)
@@ -2800,6 +2844,7 @@ void thread_base::initialize(void (*error_cb)())
[[maybe_unused]] u64 new_tid = 0;
#elif defined(ANDROID)
const u64 new_tid = pthread_self();
m_native_tid = static_cast<u32>(gettid());
#else
const u64 new_tid = reinterpret_cast<u64>(pthread_self());
#endif
@@ -2954,6 +2999,10 @@ u64 thread_base::finalize(thread_state result_state) noexcept
// Avoid race with the destructor
const u64 _self = m_thread;
#ifdef ANDROID
m_native_tid = 0;
#endif
// Set result state (errored or finalized)
m_sync.fetch_op([&](u32& v)
{
@@ -3329,11 +3378,21 @@ u64 thread_base::get_cycles()
clockid_t _clock;
struct timespec thread_time;
#ifdef ANDROID
pthread_t thread_id = handle;
const u32 native_tid = m_native_tid;
if (!handle || !native_tid)
{
return m_cycles;
}
_clock = (~static_cast<clockid_t>(native_tid) << 3) | 6;
if (!clock_gettime(_clock, &thread_time))
#else
pthread_t thread_id = reinterpret_cast<pthread_t>(handle);
#endif
if (!pthread_getcpuclockid(thread_id, &_clock) && !clock_gettime(_clock, &thread_time))
#endif
{
cycles = static_cast<u64>(thread_time.tv_sec) * 1'000'000'000 + thread_time.tv_nsec;
#endif
@@ -3732,9 +3791,42 @@ u64 thread_ctrl::get_affinity_mask(thread_class group)
return all_cores_mask;
}
// Reserve the single fastest core for RSX where there is one to spare.
//
// RSX and all the SPU threads previously shared one mask, so on a 4+3+1 phone
// that was six hot threads over five cores. RSX is the thread the frame waits
// on: it was measured spending about 10ms per frame inside its own loop without
// running, not blocked on the GPU and not faulting, simply waiting for a core.
//
// Keeping the SPUs off the prime core leaves it for RSX without fencing RSX in,
// since RSX keeps the whole fast cluster and only loses the contention for the
// best core. Only applied when doing so still leaves the SPUs more than one
// core, otherwise they would be crowded worse than the problem being fixed.
u64 prime_mask = 0;
u32 best_capacity = 0;
for (u32 core = 0; core < 64u; core++)
{
if (~fast_mask & (u64{1} << core))
{
continue;
}
if (caps[core] > best_capacity)
{
best_capacity = caps[core];
prime_mask = (u64{1} << core);
}
}
const u64 spu_mask = (std::popcount(fast_mask & ~prime_mask) > 1)
? (fast_mask & ~prime_mask)
: fast_mask;
switch (group)
{
case thread_class::spu:
return spu_mask;
case thread_class::rsx:
return fast_mask;
case thread_class::ppu:
@@ -3954,6 +4046,30 @@ void thread_ctrl::set_native_priority(int priority)
{
sig_log.error("SetThreadPriority() failed: %s", fmt::win_error{GetLastError(), nullptr});
}
#elif defined(__ANDROID__)
// Nice value, not sched_priority.
//
// Android threads run under SCHED_OTHER, where sched_priority must be 0 and
// sched_get_priority_max returns 0, so the pthread_setschedparam path below sets
// nothing at all. The RSX thread asks for a boost on startup and was still measured at
// nice 0, being involuntarily preempted about 5300 times a second, roughly 130 times
// per frame, by the PPU, SPU and audio threads sharing its cores.
//
// Under SCHED_OTHER the scheduler's weighting comes from nice, which setpriority does
// set. Android grants apps enough RLIMIT_NICE headroom to go negative for their own
// threads, which is how audio threads get their priority.
//
// Modest values on purpose: this is a hint to be scheduled ahead of the other emulator
// threads, not a bid to starve them, and the RSX thread is the one everything else
// waits on.
const int nice_value = (priority > 0) ? -8 : (priority < 0 ? 8 : 0);
errno = 0;
if (setpriority(PRIO_PROCESS, static_cast<id_t>(gettid()), nice_value) == -1 && errno)
{
// Not fatal. Without the headroom the thread simply keeps its default weighting.
sig_log.warning("setpriority(%d) failed: %s", nice_value, strerror(errno));
}
#else
int policy;
struct sched_param param;
+4
View File
@@ -139,6 +139,10 @@ private:
// Thread handle (platform-specific)
atomic_t<u64> m_thread{0};
#ifdef ANDROID
atomic_t<u32> m_native_tid{0};
#endif
// Thread cycles
atomic_t<u64> m_cycles{0};
+62
View File
@@ -107,3 +107,65 @@ target_link_libraries(rpcsx-android
android
log
)
# ---------------------------------------------------------------------------
# Profile-guided optimisation
# ---------------------------------------------------------------------------
#
# Scoped to rpcs3_emu and rpcsx-android deliberately, NOT to the whole build.
# 3rdparty is mostly LLVM, which is only hot while compiling guest code; adding
# instrumentation to it would multiply an already 1.2GB unstripped artifact for
# a payoff in compile speed rather than in frame time. What we measured as hot
# lives in these two: a simpleperf profile of the RSX thread during gameplay put
# 24.9% in vk::query_pool_manager::get_query_result, 9.4% in
# FIFO_control::fetch_u32_refill and the rest across the VK backend and the FIFO
# dispatch, all of which are rpcs3_emu.
#
# -DARMSX3_PGO=generate instrumented build, writes .profraw
# -DARMSX3_PGO=use -DARMSX3_PGO_PROFILE=<abs> optimised build against a profile
#
# Configure these into a SEPARATE build directory (BUILD_DIR=... android/configure.sh)
# so the instrumented objects never mix with the normal ones.
set(ARMSX3_PGO "off" CACHE STRING "Profile-guided optimisation: off, generate, or use")
set(ARMSX3_PGO_PROFILE "" CACHE FILEPATH "Merged .profdata, required when ARMSX3_PGO=use")
if(ARMSX3_PGO STREQUAL "generate")
# -fprofile-generate on BOTH compile and link: the link is what pulls in the
# profile runtime that writes the file.
foreach(pgo_target rpcs3_emu rpcsx-android)
target_compile_options(${pgo_target} PRIVATE -fprofile-generate)
target_link_options(${pgo_target} PRIVATE -fprofile-generate)
endforeach()
# Tells src/rpcsx-android.cpp to name the profile somewhere the app can
# actually write, and to flush it when the app goes to the background.
# Without that the runtime writes to the process CWD, which on Android is /
# and is not writable, so a whole play session produces nothing.
target_compile_definitions(rpcsx-android PRIVATE ARMSX3_PGO_GENERATE=1)
message(STATUS "ARMSX3: PGO instrumentation ON for rpcs3_emu and rpcsx-android")
elseif(ARMSX3_PGO STREQUAL "use")
if(NOT ARMSX3_PGO_PROFILE)
message(FATAL_ERROR "ARMSX3_PGO=use requires -DARMSX3_PGO_PROFILE=<absolute .profdata>")
endif()
if(NOT EXISTS "${ARMSX3_PGO_PROFILE}")
message(FATAL_ERROR "ARMSX3_PGO_PROFILE does not exist: ${ARMSX3_PGO_PROFILE}")
endif()
foreach(pgo_target rpcs3_emu rpcsx-android)
# Drift is tolerated rather than fatal: the profile ages the moment the
# code changes, and a warning per stale function would otherwise turn
# into thousands of errors under -Werror. Stale enough to matter shows up
# as a regression, which is why the profile gets regenerated rather than
# carried forward. A stale profile has actively pessimised this codebase's
# sibling project before, so treat age as a real risk, not a formality.
target_compile_options(${pgo_target} PRIVATE
-fprofile-use=${ARMSX3_PGO_PROFILE}
-Wno-error=profile-instr-out-of-date
-Wno-error=profile-instr-unprofiled
-Wno-profile-instr-out-of-date
-Wno-profile-instr-unprofiled)
endforeach()
message(STATUS "ARMSX3: PGO optimising against ${ARMSX3_PGO_PROFILE}")
endif()
+11 -7
View File
@@ -29,15 +29,19 @@ android {
applicationId = "com.armsx3"
minSdk = 26
targetSdk = 37
versionCode = 1
versionName = "0.2.0-alpha"
versionCode = 7
versionName = "0.4.1"
// ARMSX2's UI reads these. STORAGE_ALL_FILES gates the all-files
// storage path in onboarding; IN_APP_UPDATER gates self-update (off:
// ARMSX3 updates come from its own release channel, and shipping an
// in-app APK installer is a Play-policy problem).
// ARMSX2's UI reads these. STORAGE_ALL_FILES gates the all-files storage path in
// onboarding; IN_APP_UPDATER gates the in-app GitHub-release updater.
//
// On because ARMSX3 ships as a sideloaded APK from its own GitHub releases, which is
// exactly the case an in-app updater is for. It must go back off, and the code and the
// REQUEST_INSTALL_PACKAGES permission must move into a github-only flavor, before any
// Play build exists: Play forbids self-updating apps, and it is the PERMISSION in the
// bundle that gets rejected, which this runtime flag does nothing about.
buildConfigField("boolean", "STORAGE_ALL_FILES", "true")
buildConfigField("boolean", "IN_APP_UPDATER", "false")
buildConfigField("boolean", "IN_APP_UPDATER", "true")
ndk {
// The core is arm64-only.
@@ -23,6 +23,14 @@
Sideload/GitHub builds only. The Play flavour must NOT ship this (the
policy needs a declared exemption); that is what the STORAGE_ALL_FILES
buildConfig flag gates in code. -->
<!-- In-app updater: install the downloaded APK. SIDELOAD ONLY.
A self-updating app is a hard Play-policy violation, and it is this permission in the
bundle that gets rejected, not the runtime flag. ARMSX2 keeps it out of its Play build
with a github-only flavor and a build script that fails closed if it ever appears;
ARMSX3 has no Play build, so it lives here. Adding a Play target means moving this and
the provider below into a github flavor FIRST. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
@@ -196,7 +204,20 @@
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:excludeFromRecents="true"
android:exported="false" />
</application>
<!-- Hands the downloaded update APK to the system package installer. Paired with
REQUEST_INSTALL_PACKAGES above; see the note there before shipping to Play. -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.updateprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/update_paths" />
</provider>
</application>
@@ -0,0 +1,52 @@
Version: 1.2
# Canary patches bundled with ARMSX3.
#
# RPCS3's official feed (rpcs3.net/compatibility?patch&api=v1&v=1.2) does not carry
# these. They live on the RPCS3 wiki as "canary" patches that a desktop user adds by
# hand through the Patch Manager's import button -- a step there is no equivalent of
# on Android, so without bundling them the affected games are simply broken for us.
#
# Only patches that fix a game which is otherwise unplayable belong here. This is not
# a place for 60fps unlocks or resolution mods: those are the online database's job,
# and shipping them would fork a database we would then have to maintain.
#
# Ps3PatchRepo.BUNDLED must list every patch here, or it is imported but never enabled.
# SONIC THE HEDGEHOG (2006) -- upstream RPCS3 issue #4122, open since 2018.
#
# A PPU/SPU race: an SNR (signal notification register) is overwritten while still
# non-empty, so the SPU jobs that feed geometry lose their signal and whole draw
# batches never render. The game shows the HUD and the skybox and flickers everything
# else in and out of existence. Desktop RPCS3 behaves identically without this patch.
#
# Hooks RPCS3_HLE_LIBRARY:WaitForSPUsToEmptySNRs, which is in the core at
# rpcs3/Emu/Cell/Modules/HLE_PATCHES.cpp and exists solely to serve this patch.
#
# ARM64 needs three upstream fixes for this to work rather than crash, all present in
# this tree: PR #16022 (calloc code-cave blocks registered in ppu_patch_block_registry_t),
# the is_faux_function guard in AArch64JIT.cpp that stops the tail-call return trap from
# firing on a patch-point, and PR #17526 (PPU LLVM filters functions carrying patches).
#
# Keyed by PPU hash, so it applies only to the dump it was built for -- other regions
# and revisions (BLES00028, v01.00) need their own entry with their own hash.
PPU-4b46d0161ca657ab16b0a779d9062810ea5ea2dd:
Graphics Fix:
Games:
"SONIC THE HEDGEHOG":
BLUS30008:
# Quoted deliberately. The official database writes these bare (- 01.00) and
# yaml-cpp is fine with that because it hands back the raw scalar text, but a
# bare 01.01 is a float to most YAML readers and round-trips as "1.01". This
# key is compared against PARAM.SFO's VERSION at apply time, so if it ever
# became 1.01 the patch would silently stop matching the disc.
- "01.01"
Author: elad335
Patch Version: 1.0
Notes: Fixes missing graphics ingame.
Patch:
- [ calloc, 0x00f07714, 0x04 ]
- [ be32, 0x00000000, 0x38800003 ] # li r4, 3
- [ jumpf, 0x00000000, "RPCS3_HLE_LIBRARY:WaitForSPUsToEmptySNRs" ] # Args: (SPU ID, 3)
- [ be32, 0x00000000, 0x38800000 ] # li r4, 0
- [ be32, 0x00000000, 0x44000002 ] # sc
Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 965 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 584 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

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