65 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
66 changed files with 4394 additions and 1842 deletions
+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;
}();
+17 -2
View File
@@ -2844,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
@@ -2998,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)
{
@@ -3373,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
+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};
+2 -2
View File
@@ -29,8 +29,8 @@ android {
applicationId = "com.armsx3"
minSdk = 26
targetSdk = 37
versionCode = 5
versionName = "0.3.1"
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 the in-app GitHub-release updater.
@@ -20,6 +20,7 @@ struct RPCSXApi {
bool (*overlayPadData)(int port, int digital1, int digital2, int leftStickX,
int leftStickY, int rightStickX, int rightStickY);
bool (*initialize)(std::string_view rootDir, std::string_view user);
void (*setSocInfo)(std::string_view socInfo);
bool (*processCompilationQueue)(JNIEnv *env);
bool (*startMainThreadProcessor)(JNIEnv *env);
bool (*collectGameInfo)(JNIEnv *env, std::string_view rootDir,
@@ -104,6 +105,7 @@ struct RPCSXLibrary : RPCSXApi {
// clang-format off
result.overlayPadData = reinterpret_cast<decltype(overlayPadData)>(dlsym(handle, "_rpcsx_overlayPadData"));
result.initialize = reinterpret_cast<decltype(initialize)>(dlsym(handle, "_rpcsx_initialize"));
result.setSocInfo = reinterpret_cast<decltype(setSocInfo)>(dlsym(handle, "_rpcsx_setSocInfo"));
result.processCompilationQueue = reinterpret_cast<decltype(processCompilationQueue)>(dlsym(handle, "_rpcsx_processCompilationQueue"));
result.startMainThreadProcessor = reinterpret_cast<decltype(startMainThreadProcessor)>(dlsym(handle, "_rpcsx_startMainThreadProcessor"));
result.collectGameInfo = reinterpret_cast<decltype(collectGameInfo)>(dlsym(handle, "_rpcsx_collectGameInfo"));
@@ -200,7 +202,7 @@ extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_overlayPadData(
}
extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_initialize(
JNIEnv *env, jobject, jstring rootDir, jstring user) {
JNIEnv *env, jobject, jstring rootDir, jstring user, jstring socInfo) {
// The core is dlopen()ed separately and may not be up yet -- during
// onboarding, or if it failed to load. Calling through a null pointer
// is an instant SIGSEGV, so fail the call instead.
@@ -208,6 +210,12 @@ extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_initialize(
return false;
}
// Before initialize(), which is where the core assembles its startup log.
// Null on cores older than this export; the SoC line then reads "unknown".
if (rpcsxLib.setSocInfo != nullptr) {
rpcsxLib.setSocInfo(unwrap(env, socInfo));
}
return rpcsxLib.initialize(unwrap(env, rootDir), unwrap(env, user));
}
@@ -10,8 +10,8 @@ import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
/**
* One-file export/import of everything the user would lose by reinstalling: save states, memory
* cards, artwork, per-game settings, controller profiles, patches, and every preference.
* One-file export/import of everything the user would lose by reinstalling: save data, trophies,
* licences, save states, controller profiles, patches, and every preference.
*
* Reinstalling currently wipes all of it. ROMs and BIOS survive because they live outside the app
* (true-SAF), but app-private data does not and SharedPreferences are wiped even when the data
@@ -23,24 +23,50 @@ import java.util.zip.ZipOutputStream
* a device with a different data folder restores into the right place either way.
*
* Deliberately does NOT include:
* - `textures/` and `videos/` texture packs run to gigabytes and are re-downloadable; putting
* them in would make the archive impossible to hand around, which defeats the purpose.
* - `config/dev_hdd0/game` installed titles. A PKG reinstalls; a save does not, which is the
* whole distinction this list is drawn on. Unbounded in size.
* - `config/dev_flash`, `dev_flash2`, `dev_flash3` firmware, reinstalled from the user's PUP
* (193 MB on the device this was sized against).
* - `config/dev_hdd1` the scratch disk titles use for their own caches (106 MB), regenerated.
* - `bios/` the user's own dumps; they keep those themselves and we should not copy them around.
* - `cache/`, `logs/`, `pgo/`, `resources/`, `shaders/` all regenerated on demand. Shader caches
* in particular are large, GPU-specific, and actively harmful to restore onto another device.
* - `cache/`, `config/config_db`, `logs/`, `pgo/`, `resources/`, `shaders/` all regenerated on
* demand. Shader caches in particular are large, GPU-specific, and actively harmful to restore
* onto another device.
*/
object BackupManager {
private const val MANIFEST = "armsx2-backup.json"
private const val PREFS_DIR = "prefs/"
private const val FILES_DIR = "files/"
/** Data-root entries worth preserving. Anything absent is skipped silently. */
/**
* Data-root entries worth preserving. Anything absent is skipped silently.
*
* These are RPCS3's paths, not PCSX2's. The list arrived from ARMSX2 naming sstates,
* memcards, gamesettings, cheats and snaps -- none of which this emulator creates, so a
* backup collected a few kilobytes of controller profiles, reported success, and left the
* save data behind. Nothing warned, because "anything absent is skipped silently" is
* exactly right for an optional folder and exactly wrong for a list aimed at the wrong app.
*
* Nested paths are fine: entries resolve against the data root and are stored relative to
* it, so a subdirectory restores to the same place.
*/
private val INCLUDED = listOf(
"sstates", "memcards", "covers", "gamesettings", "inputprofiles",
"cheats", "patches", "snaps",
// The irreplaceable part: save data, trophies and licences. Everything else here can
// be rebuilt or re-downloaded; this cannot.
"config/dev_hdd0/home",
"config/savestates",
"config/input_configs",
"config/patches",
"inputprofiles",
"overlays",
)
private val INCLUDED_FILES = listOf(
"armsx2-settings.json", "PCSX2-Android.ini", "achievements.ini",
"armsx2-settings.json",
"games.json", "recent_games.json", "fw.json",
// RPCS3's own configuration. config.yml holds every emulator setting, and the per-game
// entries in games.yml are what map a title id back to its folder.
"config/config.yml", "config/games.yml", "config/patch_config.yml",
"config/rpcn.yml", "config/players_history.yml",
)
/** Named to avoid colliding with `kotlin.Result`, which is a default import. */
@@ -133,7 +159,7 @@ object BackupManager {
zip.closeEntry()
}
}
if (files == 0) BackupResult(false, "not an ARMSX2 backup")
if (files == 0) BackupResult(false, "not a backup archive")
else BackupResult(true, "$files files")
}.getOrElse { BackupResult(false, it.message ?: "restore failed") }
}
@@ -83,4 +83,53 @@ object DeviceTier {
} catch (_: Throwable) {
"unknown"
}
/** Known annotations for exact SoC model strings. Exact matches only
* equivalences are never guessed from CPU topology.
*
* CQ8725S is the Qualcomm Dragonwing Q8 part in the AYN Odin 3, confirmed
* from the device itself (ro.soc.model=CQ8725S, reporting 8 Oryon cores and
* an Adreno 830). "-class" is deliberate: it is 8 Elite-family silicon, not
* a claim that the part is identical to the phone SKU. */
private val socAnnotations = mapOf(
"QCS8550" to "Snapdragon 8 Gen 2-class",
"QCS9075" to "Snapdragon 8 Elite-class",
"CQ8725S" to "Snapdragon 8 Elite-class",
)
/** Android reports missing Build fields as the literal [Build.UNKNOWN]
* ("unknown") rather than null, so that value counts as "not reported"
* otherwise diagnostics read "unknown unknown" instead of falling back to
* Build.HARDWARE. */
private fun String?.orNotReported(): String? =
this?.takeIf { it.isNotBlank() && !it.equals(Build.UNKNOWN, ignoreCase = true) }
/**
* Pure, JVM-testable formatter for the device's SoC identity, e.g.
* "Qualcomm QCS8550 (Snapdragon 8 Gen 2-class)".
*
* The model is what identifies the SoC, so a manufacturer on its own is not
* an identity: without a model this falls back to [hardware], the platform
* codename, which at least names the silicon. Model strings without a known
* annotation are preserved unchanged. Returns "" when nothing is reported.
*/
fun formatSocIdentity(manufacturer: String?, model: String?, hardware: String?): String {
val socModel = model.orNotReported()
?: return hardware.orNotReported() ?: ""
val base = listOfNotNull(manufacturer.orNotReported(), socModel).joinToString(" ")
val annotation = socAnnotations[socModel]
return if (annotation != null) "$base ($annotation)" else base
}
/** SoC identity from Android's public fields: Build.SOC_MANUFACTURER /
* Build.SOC_MODEL on API 31+, Build.HARDWARE before that. */
fun socIdentity(): String = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
formatSocIdentity(Build.SOC_MANUFACTURER, Build.SOC_MODEL, Build.HARDWARE)
else
formatSocIdentity(null, null, Build.HARDWARE)
} catch (_: Throwable) {
""
}
}
@@ -241,6 +241,24 @@ enum class GamePlatform(val key: String) {
}
}
/**
* The licence-locked game whose launch was intercepted, or null.
*
* A top-level object rather than HomeViewModel state because the interception has to happen in
* [MainActivityRuntime.launchGame] the choke point EVERY launch funnels through, including the
* settings screen's Play action and the Save Manager's post-exit re-launch and that has no
* HomeViewModel to write to. HomeScreen observes this and raises the "Licence required" prompt,
* which reaches the settings path too because its Play action navigates Home before launching.
*
* The library's own taps are caught one level earlier, in HomeViewModel.launch, so that a refused
* launch never reaches markPlayed. Both routes set this same object, so there is one prompt.
*/
object LicencePrompt {
val game = mutableStateOf<GameInfo?>(null)
fun ask(target: GameInfo) { game.value = target }
fun clear() { game.value = null }
}
data class GameInfo(
val uri: Uri,
val title: String,
@@ -256,6 +274,10 @@ data class GameInfo(
/** GameDB `name-en` the romanised title, present only where the original isn't
* English. Its presence is exactly how we know [title] is non-English. */
val titleEn: String = "",
/** The core could not decrypt this title's EBOOT, so it needs a .rap licence before it
* will boot. Only ever set for games in the emulator's own storage, since that is where
* a PKG install puts them and the only place the core is asked about. */
val locked: Boolean = false,
) {
/** The title to show. Mirrors GameList.h's `GetTitle(force_en)`: the original unless
* English is asked for AND a separate English title exists. */
@@ -26,6 +26,27 @@ object GameDefaults {
"BCES01175" to mapOf("Core@@Stub PPU Traps" to "1"),
)
/**
* Stock value for every path any title here can set.
*
* Required, not decorative. The Android port keeps ONE global config.yml: settingsSet
* persists through Emulator::SaveSettings(g_cfg.to_string(), "") and an empty title id is
* the global path. So a value written for one game stays written for the next one.
*
* [apply] used to return early for a title with no entry, writing nothing. Booting
* Uncharted 3 therefore set Core@@Stub PPU Traps to 1 and left it there, and every game
* launched afterwards ran with a PPU that silently skips an instruction on any trap
* instead of stopping. Nothing on screen said so, and nothing else writes that node: it
* is not in the curated push, and CoreSettingOverrides only replays paths a user
* explicitly recorded.
*
* Anything added to [BY_SERIAL] must gain its upstream default here.
*/
private val STOCK: Map<String, String> = mapOf(
// system_config.h: cfg::_int<-64, 64> stub_ppu_traps{ this, "Stub PPU Traps", 0, true }
"Core@@Stub PPU Traps" to "0",
)
fun forSerial(serial: String?): Map<String, String> =
BY_SERIAL[serial?.uppercase()?.trim().orEmpty()].orEmpty()
@@ -37,14 +58,18 @@ object GameDefaults {
* serialises the whole config and writes it out.
*/
fun apply(serial: String?) {
val settings = forSerial(serial)
if (settings.isEmpty()) return
// Every managed path is written on every boot, this title's value where it has one
// and the stock value where it does not. Returning early for a title with no entry
// is what let Uncharted 3's Stub PPU Traps follow the user into every other game;
// see [STOCK]. A workaround for one title must not become a setting for all of them.
val settings = STOCK + forSerial(serial)
runCatching { RPCSX.instance.settingsBeginBatch() }
try {
settings.forEach { (path, value) ->
runCatching { RPCSX.instance.settingsSet(path, value) }
android.util.Log.i("ARMSX3", "game default for $serial: $path = $value")
val why = if (forSerial(serial).containsKey(path)) "game default for $serial" else "stock"
android.util.Log.i("ARMSX3", "$why: $path = $value")
}
} finally {
runCatching { RPCSX.instance.settingsEndBatch() }
@@ -99,6 +99,17 @@ data class Ps3Settings(
val spuCache: Boolean = true,
val llvmPrecompile: Boolean = true,
val accurateSpuDma: Boolean = false,
/** Locks every SPU thread into a state a savestate can be serialised from.
*
* Savestates cannot be taken without it: the save has to stop each SPU somewhere it can
* be written out, and with this off that fails on any title with SPU work running.
*
* On by default, unlike upstream, so the feature works for someone who never opens
* settings -- a save that fails with "missing SPU setting" reads as broken, not as a
* setting waiting to be found. The costs are real and are 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 SPU performance back. */
val savestateCompatibleMode: Boolean = true,
val clocksScale: Int = 100,
val resolutionScale: Int = 100,
/** 0 = Disabled. Off by default: mobile drivers routinely lack the MSAA
@@ -949,8 +960,21 @@ data class Settings(
put("PS3/Core", "SPU Cache", "bool", ps3.spuCache.toString())
put("PS3/Core", "LLVM Precompilation", "bool", ps3.llvmPrecompile.toString())
put("PS3/Core", "Accurate SPU DMA", "bool", ps3.accurateSpuDma.toString())
put("Savestate", "Compatible Savestate Mode", "bool", ps3.savestateCompatibleMode.toString())
put("PS3/Core", "Clocks scale", "int", ps3.clocksScale.toString())
put("PS3/Video", "Resolution Scale", "int", ps3.resolutionScale.toString())
// From upscaleFloat, which is the control that exists.
//
// ps3.resolutionScale has no writer anywhere in the UI, so it sits at its default of
// 100 forever and this line used to push that default onto the same native node the
// upscale multiplier writes, Video@@Resolution Scale. applyTo runs after the launch
// path, so picking a scale and then booting a game silently rendered at native while
// the UI kept showing the chosen value. Changing it in game worked only because
// nothing calls applyTo again afterwards.
//
// Same conversion and clamp as Rpcs3Settings.setUpscaleMultiplier, so the two writers
// cannot disagree about what a given multiplier means.
put("PS3/Video", "Resolution Scale", "int",
(upscaleFloat * 100f).toInt().coerceIn(25, 800).toString())
// Stretch is the only fit mode the CORE participates in; the rest are
// surface layout. Keeping them in sync stops "Stretch" looking inert.
put("PS3/Video", "Stretch To Display Area", "bool", (displayFitMode == 1).toString())
@@ -1236,19 +1260,12 @@ data class Settings(
// above it would already be 60; setting it explicitly means the cap does not depend
// on the vblank path holding, which it did not. Enum node, so the value is quoted.
runCatching { net.rpcsx.RPCSX.instance.settingsSet("Video@@Frame limit", "\"60\"") }
// Held at the upstream default, which is off.
//
// Savestates cannot work without it: saving has to lock 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. It was turned on for exactly that reason and then turned back off,
// because a PS3 savestate runs 500MB to 3GB and the feature was dropped rather than
// ship something that fills a phone in a handful of saves.
//
// Written explicitly rather than left alone: it was pushed as true for a while, so
// installs from that window have true persisted in config.yml and would keep paying
// for it. It costs SPU performance, which is the whole reason upstream defaults it
// off, and nothing here uses what it buys.
runCatching { net.rpcsx.RPCSX.instance.settingsSet("Savestate@@Compatible Savestate Mode", "false") }
// Compatible Savestate Mode is no longer forced off here; applyTo writes it from
// ps3.savestateCompatibleMode above, so the two costs it carries -- SPU performance
// and a 500MB to 3GB state file -- are the user's to accept rather than a decision
// taken for them. Defaulted on so the feature works without hunting for a switch,
// and written every boot either way, so a user who turns it off has that respected
// rather than re-enabled on the next launch.
// Settings a specific title needs in order to run at all, then the user's own core
// edits on top. Order matters: game defaults are a floor, an explicit user choice
@@ -1913,6 +1930,7 @@ data class Settings(
put("ps3SpuCache", ps3.spuCache)
put("ps3LlvmPrecompile", ps3.llvmPrecompile)
put("ps3AccurateSpuDma", ps3.accurateSpuDma)
put("ps3SavestateCompatibleMode", ps3.savestateCompatibleMode)
put("ps3ClocksScale", ps3.clocksScale)
put("ps3ResolutionScale", ps3.resolutionScale)
put("ps3MsaaMode", ps3.msaaMode)
@@ -2247,6 +2265,7 @@ data class Settings(
spuCache = json.optBoolean("ps3SpuCache", def.ps3.spuCache),
llvmPrecompile = json.optBoolean("ps3LlvmPrecompile", def.ps3.llvmPrecompile),
accurateSpuDma = json.optBoolean("ps3AccurateSpuDma", def.ps3.accurateSpuDma),
savestateCompatibleMode = json.optBoolean("ps3SavestateCompatibleMode", def.ps3.savestateCompatibleMode),
clocksScale = json.optInt("ps3ClocksScale", def.ps3.clocksScale),
resolutionScale = json.optInt("ps3ResolutionScale", def.ps3.resolutionScale),
msaaMode = json.optInt("ps3MsaaMode", def.ps3.msaaMode),
@@ -2561,6 +2580,7 @@ data class Settings(
if (current.ps3.spuCache != base.ps3.spuCache) j.put("ps3SpuCache", current.ps3.spuCache)
if (current.ps3.llvmPrecompile != base.ps3.llvmPrecompile) j.put("ps3LlvmPrecompile", current.ps3.llvmPrecompile)
if (current.ps3.accurateSpuDma != base.ps3.accurateSpuDma) j.put("ps3AccurateSpuDma", current.ps3.accurateSpuDma)
if (current.ps3.savestateCompatibleMode != base.ps3.savestateCompatibleMode) j.put("ps3SavestateCompatibleMode", current.ps3.savestateCompatibleMode)
if (current.ps3.clocksScale != base.ps3.clocksScale) j.put("ps3ClocksScale", current.ps3.clocksScale)
if (current.ps3.resolutionScale != base.ps3.resolutionScale) j.put("ps3ResolutionScale", current.ps3.resolutionScale)
if (current.ps3.msaaMode != base.ps3.msaaMode) j.put("ps3MsaaMode", current.ps3.msaaMode)
@@ -2856,6 +2876,7 @@ data class Settings(
spuCache = if (overrides.has("ps3SpuCache")) overrides.getBoolean("ps3SpuCache") else base.ps3.spuCache,
llvmPrecompile = if (overrides.has("ps3LlvmPrecompile")) overrides.getBoolean("ps3LlvmPrecompile") else base.ps3.llvmPrecompile,
accurateSpuDma = if (overrides.has("ps3AccurateSpuDma")) overrides.getBoolean("ps3AccurateSpuDma") else base.ps3.accurateSpuDma,
savestateCompatibleMode = if (overrides.has("ps3SavestateCompatibleMode")) overrides.getBoolean("ps3SavestateCompatibleMode") else base.ps3.savestateCompatibleMode,
clocksScale = if (overrides.has("ps3ClocksScale")) overrides.getInt("ps3ClocksScale") else base.ps3.clocksScale,
resolutionScale = if (overrides.has("ps3ResolutionScale")) overrides.getInt("ps3ResolutionScale") else base.ps3.resolutionScale,
msaaMode = if (overrides.has("ps3MsaaMode")) overrides.getInt("ps3MsaaMode") else base.ps3.msaaMode,
@@ -18,7 +18,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import com.armsx2.DiscIcons
import com.armsx3.NativeApp
import net.rpcsx.GameFlag
import net.rpcsx.RPCSX
import net.rpcsx.GameRepository as NativeGames
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
@@ -96,6 +98,34 @@ class GameLibraryRepository(private val context: Context) {
runCatching { it.listFiles()?.isNotEmpty() }.getOrNull() == true
}
/**
* Absolute paths of installed titles the core cannot decrypt without a licence.
*
* Asked of the CORE rather than worked out here. The flag comes from actually attempting
* decrypt_self on the game's EBOOT (fetchGameInfo, rpcsx-android.cpp), which is the only
* honest answer to "does this need a .rap". Checking PARAM.SFO's CONTENT_ID against
* exdata instead -- the obvious pure-Kotlin shortcut -- would call every PSN title locked,
* including the many whose EBOOT needs no licence at all.
*
* Canonical paths on both sides: the core resolves the paths it reports, and
* /storage/emulated/0 and /data/media/0 are one directory under two names.
*
* Only the emulator's own storage is asked about, which is where PKG installs land.
*/
private fun lockedGamePaths(): Set<String> = runCatching {
if (!RPCSX.initialized) return emptySet()
// The native repository is a scratch buffer here: nothing else in this app reads it,
// and collectGameInfo appends into it.
NativeGames.clear()
internalGameDirectories().forEach { dir ->
RPCSX.instance.collectGameInfo(dir.absolutePath, -1)
}
NativeGames.list()
.filter { it.hasFlag(GameFlag.Locked) }
.mapNotNull { game -> runCatching { File(game.info.path).canonicalPath }.getOrNull() }
.toSet()
}.getOrDefault(emptySet())
private fun internalGameDirectories(): List<File> = listOf(
File(RPCSX.rootDirectory, "config/dev_hdd0/game"),
File(RPCSX.rootDirectory, "config/games"),
@@ -140,6 +170,7 @@ class GameLibraryRepository(private val context: Context) {
// old cache degrades to the previous behaviour until a rescan.
titleSort = item.optString("titleSort"),
titleEn = item.optString("titleEn"),
locked = item.optBoolean("locked", false),
),
)
}
@@ -184,10 +215,21 @@ class GameLibraryRepository(private val context: Context) {
}
internalGameDirectories().forEach { dir ->
android.util.Log.i(ScanTag, "internal dir=${dir.absolutePath}")
scanRawDirectory(dir, collected, 0)
// dev_hdd0/game is the emulator's own install root and its shape is known: one
// directory per title, nothing else. It gets the strict scan. Everything else is
// a folder a user pointed us at, where games legitimately sit at any depth.
if (dir.name == "game") scanInstalledTitles(dir, collected)
else scanRawDirectory(dir, collected, 0)
}
android.util.Log.i(ScanTag, "scan done: ${collected.size} game(s)")
collected.values.sortedBy { it.title.lowercase() }.also { saveCache(directories, it) }
val locked = lockedGamePaths()
android.util.Log.i(ScanTag, "scan done: ${collected.size} game(s), ${locked.size} locked")
collected.values
.map { game ->
val path = runCatching { game.uri.path?.let { File(it).canonicalPath } }.getOrNull()
if (path != null && path in locked) game.copy(locked = true) else game
}
.sortedBy { it.title.lowercase() }
.also { saveCache(directories, it) }
}
fun recentGames(allGames: List<GameInfo>): List<GameInfo> {
@@ -401,6 +443,37 @@ class GameLibraryRepository(private val context: Context) {
return find("PARAM.SFO")?.isFile == true && find("USRDIR")?.isDirectory == true
}
/**
* dev_hdd0/game, scanned as what it is: one directory per installed title.
*
* The recursive scan cannot be used here. It descends into anything that is not itself a
* game folder and then accepts any file whose extension is in [gameExtensions], and "img"
* is one of those. A title's own data is full of them, so once a package unpacked its
* contents over this directory rather than into a title folder of its own, GTA IV's
* archives arrived in the library as games: manhat01, props_ab, vehicles, script,
* weapons. Every one of them a .img sitting where the scanner was willing to look.
*
* The extractor no longer unpacks into this directory (see unpkg.cpp set_install_path),
* but nothing should be relying on that to keep the library clean, and existing installs
* still have the debris. A direct child here is a title or it is not listed.
*/
private fun scanInstalledTitles(directory: File, output: MutableMap<String, GameInfo>) {
val children = runCatching { directory.listFiles() }.getOrNull() ?: return
children.forEach { file ->
if (!file.isDirectory) return@forEach
if (!runCatching { isPs3GameFolder(file) }.getOrDefault(false)) {
android.util.Log.i(ScanTag, " skipping non-title '${file.name}' in dev_hdd0/game")
return@forEach
}
val uri = Uri.fromFile(file)
android.util.Log.i(ScanTag, " installed title '${file.name}'")
output.putIfAbsent(
uri.toString(),
createGame(uri, file.name, "folder", null, probeDisc(file)),
)
}
}
private fun scanRawDirectory(
directory: File,
output: MutableMap<String, GameInfo>,
@@ -565,6 +638,7 @@ class GameLibraryRepository(private val context: Context) {
put("platform", game.platform.key)
put("titleSort", game.titleSort)
put("titleEn", game.titleEn)
put("locked", game.locked)
})
}
MainActivityRuntime.prefs.edit {
@@ -581,8 +655,9 @@ class GameLibraryRepository(private val context: Context) {
private companion object {
/** v2: PS3 title ID + title + ICON0.PNG read from the disc's PARAM.SFO.
* v5: folder-format games (JB folder / installed game folder).
* v6: PARAM.SFO CATEGORY read, to drop game-data installs. */
const val ScanSchemaVersion = 6
* v6: PARAM.SFO CATEGORY read, to drop game-data installs.
* v7: licence-locked state, asked of the core per installed title. */
const val ScanSchemaVersion = 7
const val ScanTag = "ARMSX3-Scan"
/** Staging name for an extracted icon, renamed once the title ID is known. */
const val PendingIcon = "__pending"
@@ -0,0 +1,72 @@
package com.armsx2.data.library
import android.content.Context
import android.os.ParcelFileDescriptor
import net.rpcsx.ProgressRepository
import net.rpcsx.RPCSX
import java.io.File
/**
* Licence (.rap) installation.
*
* A RAP's FILENAME is the content id it unlocks, so installing one is a copy into the user's
* exdata directory and nothing else -- which is exactly what RPCS3 desktop's
* InstallFileInExData does. The native installKey path cannot serve here: its RAP branch
* derives the content id by decrypting the game's EBOOT, so it needs a game path, and calling
* it with an empty one fails with "Failed to fetch NPDRM of SELF".
*/
object Licences {
/** Where RPCS3 looks for the logged-in user's licence files. */
fun exdataDir(): File = File(
RPCSX.getHdd0Dir(),
"home/${currentUser()}/exdata",
)
private fun currentUser(): String =
runCatching { RPCSX.instance.getUser() }.getOrNull().orEmpty().ifBlank { "00000001" }
/**
* Copy [file] into exdata under its own name.
*
* The extension is written lower-case because that is what unself.cpp looks for when it
* searches exdata for a licence matching a game's content id.
*/
/**
* Install [file] as the licence for the game at [gamePath].
*
* Prefers the content id read out of the game's own EBOOT over the one in the file name.
* The name is only a convention: a licence saved as "license(1).rap", renamed, or handed
* around by someone who tidied it up copies into exdata under a name nothing looks for,
* so the install reports success and the game stays locked -- which is exactly what it
* looks like from the outside, and what it was reported as.
*
* 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. Falls back
* to the name when there is no game to ask, or when the header cannot be read.
*/
fun installRapForGame(context: Context, file: File, gamePath: String?): Boolean {
if (!gamePath.isNullOrBlank() && RPCSX.initialized) {
val installed = runCatching {
val id = ProgressRepository.create(context, "Installing ${file.name}")
ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
RPCSX.instance.installKey(fd.fd, id, gamePath)
}
}.getOrDefault(false)
if (installed) return true
}
return installRap(file)
}
fun installRap(file: File): Boolean = runCatching {
val bytes = file.readBytes()
// Same floor as InstallFileInExData: anything shorter is not a key.
if (bytes.size < 0x10) return false
val dir = exdataDir()
if (!dir.isDirectory && !dir.mkdirs()) return false
File(dir, file.nameWithoutExtension + ".rap").writeBytes(bytes)
true
}.getOrDefault(false)
}
@@ -297,7 +297,7 @@ val EN: Map<String, String> = mapOf(
"ra.library.syncing" to "Syncing…",
"ra.library.notReady" to "Sign in and scan your library first.",
"app.backup.export" to "Back up app data",
"app.backup.export.desc" to "Save states, memory cards, artwork, per-game settings, controller profiles, patches and all settings into one .zip. Games and BIOS are not included.",
"app.backup.export.desc" to "Save data, trophies, licences, save states, controller profiles, patches and all settings into one .zip. Installed games, firmware and BIOS are not included.",
"app.backup.import" to "Restore app data",
"app.backup.import.desc" to "Load a backup .zip. Files with the same name are replaced, and the app restarts.",
"app.reset" to "Reset app",
@@ -400,8 +400,10 @@ val EN: Map<String, String> = mapOf(
"packages.installing" to "Installing. Large packages can take a few minutes.",
"packages.install.done" to "Installed. It will appear in your library on the next scan.",
"packages.install.failed" to "Install failed. The file may be encrypted, incomplete or not a PS3 package.",
"packages.multiHint" to "Tap several .pkg files to select them all if a game is split into parts, then confirm. Licence files are installed one at a time.",
"packages.multiHint" to "Tap several files to select them all, then confirm: the parts of a split game, or a game together with its .rap licence.",
"packages.uninstall.alsoCache" to "Also remove cached shaders and compiled code (%s)",
"packages.installed.header" to "Installed titles",
"packages.licences.header" to "Installed licences",
"packages.uninstall" to "Uninstall",
"packages.uninstall.confirmTitle" to "Uninstall this title?",
"packages.uninstall.confirmBody" to "This deletes %s and everything installed with it. Save data stored separately is not touched. This cannot be undone.",
@@ -778,6 +780,13 @@ val EN: Map<String, String> = mapOf(
"games.overflow.showHidden" to "Show hidden games",
"games.addToHome" to "Add to home screen",
"games.addToHome.unsupported" to "This launcher doesn't support adding shortcuts to the home screen.",
"games.locked.chip" to "🔒 Licence",
"games.locked.title" to "Licence required",
"games.locked.message" to "%s is licence-locked and cannot start without its .rap key. Install the key for this title to play it.",
"games.installLicence" to "Install licence (.rap)",
"games.installLicence.title" to "Select a .rap licence",
"games.installLicence.done" to "Licence installed. The game should boot now.",
"games.installLicence.failed" to "Could not install that licence file.",
"games.hide" to "Hide from library",
"games.unhide" to "Unhide",
"games.removeRecent" to "Remove from Recently Played",
@@ -1554,6 +1563,8 @@ val EN: Map<String, String> = mapOf(
"perf.llvmPrecompile.description" to "Compiles modules ahead of the game running instead of on demand. Longer wait at boot, fewer stutters in play.",
"perf.spuLoopDetection.label" to "SPU Loop Detection",
"perf.spuLoopDetection.description" to "Detects SPU wait loops and yields the thread instead of spinning. Can free CPU time on a handheld; a few games misbehave with it on.",
"perf.savestateCompatible.label" to "Allow save states (Compatible Savestate Mode)",
"perf.savestateCompatible.description" to "Required for save states to work at all: saving has to stop every SPU somewhere it can be written out, and without this that fails on any game with SPU work running. Costs SPU performance while it is on, and each save state is roughly 500 MB to 3 GB, so a few of them will fill your storage.",
"perf.accurateSpuDma.label" to "Accurate SPU DMA",
"perf.accurateSpuDma.description" to "Emulates SPU DMA transfers precisely. Slower, and only needed by a handful of games that corrupt without it.",
"common.auto" to "Auto",
@@ -1670,7 +1681,7 @@ val EN: Map<String, String> = mapOf(
"renderer.upscale.label" to "Upscale",
"renderer.vsync.description" to "Sync presentation to the display refresh — less tearing/smoother, slightly more latency. Restart the game to apply.",
"savestate.autoLoadOnBoot" to "Auto-load last state on boot",
"savestate.autoSaveInterval.description" to "Save automatically while you play, so a crash or a flat battery costs at most this much progress. It writes the same auto-save slot as the option above, so your numbered slots stay yours. Saving pauses the game for a moment, so a short interval is felt — 5 minutes is a good starting point.",
"savestate.autoSaveInterval.description" to "Save automatically while you play, so a crash or a flat battery costs at most this much progress. It writes the same auto-save slot as the option above, so your numbered slots stay yours. Saving a PS3 state stops and reloads the game, which takes several seconds each time — keep the interval long, 15 minutes or more.",
"savestate.autoSaveInterval.every" to "Every %d min",
"savestate.autoSaveInterval.label" to "Auto-save while playing",
"savestate.autoSaveInterval.off" to "Off",
@@ -1681,6 +1692,7 @@ val EN: Map<String, String> = mapOf(
"savestate.backup" to "Backup",
"savestate.import" to "Import",
"savestate.hint" to "Choose a slot. Hold a slot, or use the trash button, to delete it.",
"savestate.sizeWarning" to "Save states are big — usually 2540 MB each, and it varies by game. Ten slots per game adds up, so keep an eye on storage.",
"savestate.delete.mode" to "Delete a save",
"savestate.delete.modeHint" to "Delete mode: choose a save to delete. Tap the trash button again to cancel.",
"savestate.delete.title" to "Delete save state",
@@ -667,7 +667,32 @@ open class MainActivityRuntime : ComponentActivity() {
// The hold itself waits for the VM to come up. BIOS boots skip it.
if (bootCfg.autoProgressiveScan)
startAutoProgressiveScanHold()
NativeApp.runVMThread(m_szGamefile)
val booted = NativeApp.runVMThread(m_szGamefile)
// A failed boot used to be indistinguishable from an instant game exit:
// runVMThread's result was dropped, so the app bounced back to the
// library with no message and no log. Surface the BootResult the bridge
// now keeps — DecryptionError in particular means "no licence for this
// PKG game", which the user can actually fix.
if (!booted) {
val reason = com.armsx3.Rpcs3Bridge.lastBootError ?: "unknown"
println("@@ANDROID_BOOT_FAILED@@ reason=$reason path=${m_szGamefile.take(240)}")
// The library normally intercepts a locked game before it ever boots
// (HomeViewModel.launch), so reaching here means the lock state was
// stale — a licence deleted outside the app, say. Point at the per-game
// action that fixes it, which a rescan will also surface as a badge.
val hint = if (reason == "DecryptionError")
" — it needs a .rap licence. Long-press the game and choose Install licence."
else ""
instance?.let { act ->
act.runOnUiThread {
android.widget.Toast.makeText(
act,
"Game failed to start: $reason$hint",
android.widget.Toast.LENGTH_LONG,
).show()
}
}
}
} finally {
// runVMThread blocks until the VM exits (Stopping/Shutdown
// observed). Drop back to STOPPED only after native has
@@ -773,6 +798,7 @@ open class MainActivityRuntime : ComponentActivity() {
}
upscale.value = resolved.upscaleFloat
renderer.value = resolved.renderer
NativeApp.renderUpscalemultiplier(upscale.value)
// Pin custom Vulkan driver (if any) BEFORE the renderer write —
// the renderer JNI may trigger MTGS::ApplySettings which can
@@ -860,6 +886,19 @@ open class MainActivityRuntime : ComponentActivity() {
println("@@ANDROID_LAUNCH_REJECT@@ reason=blank_uri title=${info?.title ?: ""}")
return
}
// A licence-locked title cannot boot — BootGame fails with DecryptionError — so ask
// for the key instead of spending a whole VM start-up to say so. The library catches
// this a level earlier (HomeViewModel.launch, before markPlayed); this is what covers
// the paths that bypass the ViewModel, the settings screen's Play action above all.
//
// Only titles carrying a scanned GameInfo are caught: externalGameInfo() builds a
// fresh one for intents and shortcuts, where locked defaults to false, and those
// still fall through to the boot-failure toast.
if (info?.locked == true) {
println("@@ANDROID_LAUNCH_REJECT@@ reason=locked title=${info.title}")
com.armsx2.LicencePrompt.ask(info)
return
}
// Remember the game for a post-exit re-launch from the Save Manager (#374).
if (info != null) contextGame.value = info
println(
@@ -67,13 +67,8 @@ class AboutViewModel(application: Application) : AndroidViewModel(application) {
val memoryInfo = ActivityManager.MemoryInfo().also(activityManager::getMemoryInfo)
val metrics = context.resources.displayMetrics
val pageBytes = runCatching { Os.sysconf(OsConstants._SC_PAGESIZE) }.getOrDefault(4096L)
val soc = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
listOf(Build.SOC_MANUFACTURER, Build.SOC_MODEL).filter(String::isNotBlank).joinToString(" ")
} else {
Build.HARDWARE
}
return DeviceDetails(
soc = soc.ifBlank { "" },
soc = com.armsx2.DeviceTier.socIdentity().ifBlank { "" },
gpu = queryGpuRenderer().ifBlank { "" },
cpu = Runtime.getRuntime().availableProcessors().toString(),
memory = String.format(Locale.US, "%.1f GB", memoryInfo.totalMem / 1_073_741_824.0),
@@ -114,6 +114,7 @@ import com.armsx2.runtime.MainActivityRuntime
import com.armsx2.ui.common.ArmsBackdrop
import com.armsx2.ui.common.ArmsTopBar
import com.armsx2.ui.common.EmptyState
import com.armsx2.ui.common.FileBrowserDialog
import com.armsx2.ui.common.RoundAction
import com.armsx2.ui.common.SearchField
import com.armsx2.ui.common.SectionTitle
@@ -140,6 +141,8 @@ fun HomeScreen(
var overflowMenu by remember { mutableStateOf(false) }
var showExitConfirm by remember { mutableStateOf(false) }
var menuGame by remember { mutableStateOf<GameInfo?>(null) }
// Set by the context menu's "Install licence" entry; drives the .rap file browser.
var licenceGame by remember { mutableStateOf<GameInfo?>(null) }
var showClearRecentsConfirm by remember { mutableStateOf(false) }
// #9 custom library background — inert until the user picks an image.
LaunchedEffect(Unit) { LibraryBackground.ensureLoaded(); CoverArtStyle.load() }
@@ -696,6 +699,24 @@ fun HomeScreen(
)
}
// Tapping a licence-locked game asks for the key instead of booting into a failure.
// ConfirmOverlay rather than a Compose Dialog for the reason given above: this has to stay
// reachable from a controller, and a dialog window swallows the D-pad.
com.armsx2.LicencePrompt.game.value?.let { game ->
com.armsx2.ui.common.ConfirmOverlay(
title = str("games.locked.title"),
message = str("games.locked.message").format(game.displayTitle(EnglishTitles.enabled.value)),
confirmLabel = str("games.installLicence"),
idPrefix = "install-licence",
onConfirm = {
// Same picker the context menu opens, so both routes share one flow.
licenceGame = game
com.armsx2.LicencePrompt.clear()
},
onDismiss = { com.armsx2.LicencePrompt.clear() },
)
}
menuGame?.let { game ->
// Tri-state on purpose: null while identifying, blank when the image cannot be identified.
// produceState alone cannot tell those apart — both are null — so an unidentifiable game
@@ -761,6 +782,14 @@ fun HomeScreen(
menuGame = null
}
}
// Only for a game the core said it cannot decrypt. Offering it on every game
// would invite installing a licence against a title it does not belong to.
if (game.locked) {
GameMenuAction("🔑", str("games.installLicence")) {
licenceGame = game
menuGame = null
}
}
val hidden = com.armsx2.HiddenGames.isHidden(game)
GameMenuAction(if (hidden) "" else "🚫", str(if (hidden) "games.unhide" else "games.hide")) {
viewModel.setHidden(game, !hidden)
@@ -769,6 +798,35 @@ fun HomeScreen(
}
}
}
// The game IS needed, when we have it. A RAP's filename is only conventionally the content
// id it unlocks: one saved as "license(1).rap" or renamed on the way over installs under a
// name nothing looks for, so the install reports success and the game stays locked. Asking
// the game's own EBOOT for the id gets it right whatever the file is called.
if (licenceGame != null) {
// Resolved during composition: str() is @Composable and cannot be called from the
// pick callback, the same reason games.addToHome.unsupported is hoisted above.
val installedMsg = str("games.installLicence.done")
val failedMsg = str("games.installLicence.failed")
FileBrowserDialog(
title = str("games.installLicence.title"),
// .rap only. An EDAT is a licence too, but it carries its own content id and
// installs through the package screen's native path; this button exists for the
// key that belongs to THIS game.
extensions = setOf("rap"),
onPick = { file ->
val gamePath = licenceGame?.uri?.path
licenceGame = null
val ok = com.armsx2.data.library.Licences
.installRapForGame(context, file, gamePath)
Toast.makeText(context, if (ok) installedMsg else failedMsg, Toast.LENGTH_LONG).show()
// The folder set is unchanged, so only the lock state is stale — nothing else
// would prompt a rescan.
if (ok) viewModel.refreshAfterLicenceInstall()
},
onDismiss = { licenceGame = null },
)
}
}
@Composable
@@ -1059,7 +1117,9 @@ private fun GameListCard(game: GameInfo, selected: Boolean, onClick: () -> Unit,
),
) {
Row(Modifier.padding(7.dp), verticalAlignment = Alignment.CenterVertically) {
GameCover(game, Modifier.width(54.dp).aspectRatio(coverAspectRatio()))
// No overlay badge on a 54.dp cover, it would swamp it; GameMetadata below
// carries the lock as a chip alongside the other per-game marks.
GameCover(game, Modifier.width(54.dp).aspectRatio(coverAspectRatio()), showBadges = false)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(game.displayTitle(EnglishTitles.enabled.value), style = MaterialTheme.typography.titleSmall, maxLines = 1, overflow = TextOverflow.Ellipsis)
@@ -1093,6 +1153,7 @@ private fun RecentGameCard(game: GameInfo, selected: Boolean = false, onClick: (
private fun GameMetadata(game: GameInfo) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(5.dp)) {
StatusChip(game.extension.ifBlank { game.platform.key.uppercase() })
if (game.locked) StatusChip(str("games.locked.chip"), Color(0xFFFFC857))
game.regionFlag?.let { Text(it, fontSize = 13.sp) }
if (game.compatibility > 0) {
Text("".repeat(game.compatibility), color = Color(0xFFFFC857), fontSize = 9.sp, maxLines = 1)
@@ -1198,6 +1259,9 @@ private fun GameCover(
// covers whose source art is a touch taller than the 0.7 slot.
contentScale: ContentScale = ContentScale.Fit,
placeholderText: Boolean = true,
/** Off for the shelf's mirrored reflection, which would otherwise render the badge
* upside down, and for the list card, whose 54.dp cover shows it as a chip instead. */
showBadges: Boolean = true,
) {
val context = LocalContext.current
// Read the 3D-cover flag explicitly (not just via game.coverUrl, which is
@@ -1251,6 +1315,24 @@ private fun GameCover(
},
)
}
// A licence-locked game looks exactly like any other until it refuses to boot, so
// the cover is where it has to be said.
if (showBadges && game.locked) {
LockedBadge(Modifier.align(Alignment.TopEnd).padding(5.dp))
}
}
}
/** Gold to match the library's other "worth noticing" marks (compat stars, the HC pill),
* and shaped like HardcoreBadge so it reads as the same family of badge. */
@Composable
private fun LockedBadge(modifier: Modifier = Modifier) {
Surface(shape = RoundedCornerShape(6.dp), color = Color(0xFFFFC857), modifier = modifier) {
Text(
"🔒",
modifier = Modifier.padding(horizontal = 5.dp, vertical = 2.dp),
fontSize = 11.sp,
)
}
}
@@ -1626,6 +1708,7 @@ private fun ShelfGameCard(game: GameInfo, width: Dp, reflectionHeight: Dp, selec
cornerRadius = 0.dp,
contentScale = ContentScale.Fit,
placeholderText = false,
showBadges = false,
)
// Fade the reflection out toward the front of the shelf.
Box(Modifier.matchParentSize().background(Brush.verticalGradient(listOf(Color.Transparent, Color(0x55000000)))))
@@ -41,6 +41,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
var state = androidx.compose.runtime.mutableStateOf(HomeUiState())
private set
fun load(romDirectories: List<String>, nativeReady: Boolean) {
directories = romDirectories
if (!loaded) {
@@ -110,6 +111,17 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
}
}
/**
* Re-read storage after a licence install.
*
* The folder set is unchanged, so nothing else would prompt a rescan but the lock state
* the scan stamps onto each game has just changed, and it is cached with the library.
*/
fun refreshAfterLicenceInstall() {
repository.invalidateCache()
refresh()
}
fun setQuery(value: String) {
state.value = buildState(state.value.copy(query = value, selectedIndex = 0))
}
@@ -139,6 +151,16 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
fun selectedGame(): GameInfo? = state.value.visibleGames.getOrNull(state.value.selectedIndex)
fun launch(game: GameInfo) {
// A licence-locked game cannot boot: BootGame fails with DecryptionError, the app hides
// the library, spins up a VM, tears it back down and returns here. Ask for the key up
// front rather than spending a whole boot to tell the user what the scan already knows.
//
// Not marked as played either — a launch that never happened does not belong in Recently
// Played, which is what returning BEFORE markPlayed achieves.
if (game.locked) {
com.armsx2.LicencePrompt.ask(game)
return
}
repository.markPlayed(game)
state.value = buildState(state.value)
val launchPath = if (game.uri.scheme == "file") game.uri.path ?: game.uri.toString() else game.uri.toString()
@@ -11,7 +11,9 @@ import androidx.compose.ui.Alignment
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.clickable
import androidx.compose.material3.Button
import androidx.compose.material3.Checkbox
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
@@ -31,6 +33,7 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.armsx2.data.library.GameLibraryRepository
import com.armsx2.data.library.Licences
import com.armsx2.i18n.I18n
import com.armsx2.i18n.str
import com.armsx2.runtime.MainActivityRuntime
@@ -63,13 +66,86 @@ private fun isLicence(file: java.io.File): Boolean =
file.extension.equals("rap", ignoreCase = true) ||
file.extension.equals("edat", ignoreCase = true)
/**
* Licence files sitting in exdata.
*
* Installing one is a silent copy into a directory nothing else on this screen reads, so a
* success looked exactly like a failure: a licence belongs to no title, never appears under
* Installed titles, and left no trace anywhere in the app. Reported as "I installed the .rap
* but nothing seemed to happen" -- the file was there the whole time.
*
* Listed through Licences.exdataDir() rather than a path assembled here, so it follows the
* logged-in user instead of assuming 00000001, and cannot drift from where installs land.
*/
private fun readLicences(): List<java.io.File> =
Licences.exdataDir()
.listFiles()
?.filter { it.isFile && isLicence(it) }
?.sortedBy { it.name }
.orEmpty()
private fun readInstalled(): List<java.io.File> =
java.io.File(RPCSX.rootDirectory, "config/dev_hdd0/game")
.listFiles()
?.filter { it.isDirectory }
?.filter { it.isDirectory && isInstalledContent(it) }
?.sortedBy { it.name }
.orEmpty()
/**
* True for a directory under dev_hdd0/game that is actually installed content.
*
* Everything in there used to be listed with an Uninstall button beside it, including
* things that are not titles at all. RPCS3 keeps its own "$locks" directory in here
* (rpcs3::utils::get_hdd0_locks_dir is get_hdd0_game_dir() + "$locks/", which reaches
* Android as the escaped locks), so the screen offered to delete the emulator's lock
* state, and any stray folder a failed install left behind was offered as a title too.
*
* A PARAM.SFO is the test. Game data installs keep theirs and are deliberately still
* listed: a 1.1GB BLUS30464_INSTALL is exactly the kind of thing someone comes here to
* reclaim, even though it is not bootable.
*/
private fun isInstalledContent(dir: java.io.File): Boolean {
if (dir.name.startsWith("$") || dir.name.startsWith("")) return false
return runCatching {
dir.listFiles()?.any { it.isFile && it.name.equals("PARAM.SFO", ignoreCase = true) }
}.getOrNull() == true
}
/**
* A title's compiled-code and shader cache.
*
* Keyed by title id, which is also the name of the title's folder under dev_hdd0/game --
* rpcs3::utils::get_cache_dir() appends Emu.GetTitleID() to <root>/cache/cache/, and a PKG
* installs into a directory named for the same id. Measured at 7-58 MB per title, which is
* why uninstalling without it leaves the bulk of the disk usage behind.
*/
private fun cacheDirFor(titleId: String): java.io.File =
java.io.File(RPCSX.rootDirectory, "cache/cache/$titleId")
private fun dirSize(dir: java.io.File): Long =
if (!dir.isDirectory) 0L else dir.walkTopDown().filter { it.isFile }.sumOf { it.length() }
private fun formatSize(bytes: Long): String = when {
bytes >= 1024L * 1024L -> "%.0f MB".format(bytes / 1024.0 / 1024.0)
else -> "%.0f KB".format(bytes / 1024.0)
}
/**
* Delete a title's cache directory.
*
* The name is checked rather than trusted: it comes from a directory listing, but a path
* separator or a dot-dot in it would resolve outside the per-title folder and take the whole
* cache or more with it. Anything but a single plain segment is refused.
*/
private fun removeCacheFor(titleId: String): Boolean = runCatching {
if (titleId.isBlank() || titleId == "." || titleId == ".." ||
titleId.contains('/') || titleId.contains('\\')
) return false
val dir = cacheDirFor(titleId)
if (!dir.isDirectory) return false
dir.deleteRecursively()
}.getOrDefault(false)
@Composable
fun PackageInstallerScreen(onBack: () -> Unit) {
val context = LocalContext.current
@@ -78,6 +154,7 @@ fun PackageInstallerScreen(onBack: () -> Unit) {
var showBrowser by remember { mutableStateOf(false) }
var progressId by remember { mutableStateOf<Long?>(null) }
var installed by remember { mutableStateOf(readInstalled()) }
var licences by remember { mutableStateOf(readLicences()) }
var confirmRemove by remember { mutableStateOf<java.io.File?>(null) }
// getItem returns MutableState<ProgressEntry>; reading .value here and .longValue
@@ -88,40 +165,81 @@ fun PackageInstallerScreen(onBack: () -> Unit) {
else (it.value.longValue.toFloat() / it.max.longValue.coerceAtLeast(1)).coerceIn(0f, 1f)
}
// One path for both the single pick and the multi-part pick. Every descriptor stays
// open for the whole install: the native side takes raw fds and releases the handles
// itself, so closing them early would pull the file out from under the extractor.
// One path for every pick: a lone package, several parts of a split one, a licence, or
// a package together with the licence that unlocks it.
//
// The selection is SPLIT BY KIND rather than by count. It used to be routed on count
// alone, so picking a game's .pkg and its .rap together -- which the file browser
// invites, since it allows multiple selection -- handed both to installSplitPkg, whose
// first act is to reject anything that is not a .pkg part. Choosing a game and its
// licence, the obvious thing to do, could therefore only ever fail.
//
// Packages install FIRST: a licence unlocks content the package has to have written.
fun install(files: List<java.io.File>) {
if (files.isEmpty()) return
showBrowser = false
busy = true
message = null
MainActivityRuntime.invoke {
// The reason the native side reported, if it failed. Held here because
// ProgressRepository drops its handler entry as soon as a request finishes, so
// the entry has to be captured while the install is still running.
var nativeFailure: String? = null
val ok = withContext(Dispatchers.IO) {
runCatching {
val licences = files.filter { isLicence(it) }
val packages = files.filterNot { isLicence(it) }
val label = if (files.size == 1) files[0].name
else "${files.size} package parts"
else "${files.size} files"
val id = ProgressRepository.create(context, "Installing $label")
progressId = id
val descriptors = files.map {
ParcelFileDescriptor.open(it, ParcelFileDescriptor.MODE_READ_ONLY)
}
try {
if (descriptors.size == 1 && isLicence(files[0])) {
// installKey, not install: _rpcsx_install rejects a RAP outright
// ("cannot be preinstalled"). Empty game path means "work it out
// from the licence", which is what the exdata directory needs.
RPCSX.instance.installKey(descriptors[0].fd, id, "")
} else if (descriptors.size == 1) {
RPCSX.instance.install(descriptors[0].fd, id)
} else {
RPCSX.instance.installSplitPkg(
descriptors.map { it.fd }.toIntArray(), id,
)
val progressEntry = ProgressRepository.getItem(id)
// Every descriptor stays open for the whole install: the native side
// takes raw fds and releases the handles itself, so closing them early
// would pull the file out from under the extractor.
var result = true
if (packages.isNotEmpty()) {
val descriptors = packages.map {
ParcelFileDescriptor.open(it, ParcelFileDescriptor.MODE_READ_ONLY)
}
try {
result = if (descriptors.size == 1) {
RPCSX.instance.install(descriptors[0].fd, id)
} else {
RPCSX.instance.installSplitPkg(
descriptors.map { it.fd }.toIntArray(), id,
)
}
} finally {
descriptors.forEach { runCatching { it.close() } }
}
} finally {
descriptors.forEach { runCatching { it.close() } }
}
for (licence in licences) {
if (!result) break
result = if (licence.extension.equals("rap", ignoreCase = true)) {
Licences.installRap(licence)
} else {
// EDAT carries its own content id, so installKey works out the
// exdata name from the file itself and needs no game path.
val descriptor = ParcelFileDescriptor.open(
licence, ParcelFileDescriptor.MODE_READ_ONLY,
)
try {
RPCSX.instance.installKey(descriptor.fd, id, "")
} finally {
runCatching { descriptor.close() }
}
}
}
// Read after the calls return: onProgressEvent writes the message on the
// calling thread before it reports, so a failure reason is already there.
progressEntry?.value?.takeIf { it.isFailed() }?.let {
nativeFailure = it.message.value
}
result
}.getOrDefault(false)
}
busy = false
@@ -133,29 +251,69 @@ fun PackageInstallerScreen(onBack: () -> Unit) {
// so nothing else would prompt a rescan.
GameLibraryRepository(context).invalidateCache()
installed = readInstalled()
licences = readLicences()
I18n.get("packages.install.done")
} else {
I18n.get("packages.install.failed")
// The native reason names the actual problem ("Game is broken: PARAM.SFO not
// found", "Every selected file must be a .pkg part"); the generic string
// guesses, and used to be all the user ever saw.
nativeFailure?.takeIf { it.isNotBlank() } ?: I18n.get("packages.install.failed")
}
}
}
// Deleting a game folder is not undoable, so it is confirmed rather than done on tap.
confirmRemove?.let { target ->
// Uninstall only ever removed dev_hdd0/game/<TITLEID>, so the title's compiled-code and
// shader cache -- by far the larger of the two on disk -- stayed forever. Offered as a
// checkbox rather than done silently, and defaulted on, which is how RPCS3 desktop's own
// remove dialog treats caches. Save data, trophies and licences are deliberately NOT
// touched: those are the user's, not the install's.
var alsoRemoveCache by remember(target) { mutableStateOf(true) }
// Off the main thread: a cache directory holds hundreds of files and this runs while the
// dialog is opening.
val cacheBytes by androidx.compose.runtime.produceState(0L, target) {
value = withContext(Dispatchers.IO) { dirSize(cacheDirFor(target.name)) }
}
AlertDialog(
onDismissRequest = { confirmRemove = null },
title = { Text(str("packages.uninstall.confirmTitle")) },
text = { Text(str("packages.uninstall.confirmBody").format(target.name)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
Text(str("packages.uninstall.confirmBody").format(target.name))
// Hidden when there is no cache, so the row never offers to free nothing.
if (cacheBytes > 0) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.clickable { alsoRemoveCache = !alsoRemoveCache },
) {
Checkbox(checked = alsoRemoveCache, onCheckedChange = { alsoRemoveCache = it })
Text(
str("packages.uninstall.alsoCache").format(formatSize(cacheBytes)),
style = MaterialTheme.typography.bodySmall,
)
}
}
}
},
confirmButton = {
TextButton(onClick = {
confirmRemove = null
val removeCache = alsoRemoveCache
val titleId = target.name
MainActivityRuntime.invoke {
val ok = withContext(Dispatchers.IO) {
runCatching {
RPCSX.instance.uninstallGame(target.absolutePath)
}.getOrDefault(false)
}
// Only after the game itself is gone: dropping the cache for a title that
// is still installed would just cost the user a recompile.
if (ok && removeCache) {
withContext(Dispatchers.IO) { removeCacheFor(titleId) }
}
installed = readInstalled()
licences = readLicences()
if (ok) GameLibraryRepository(context).invalidateCache()
message = I18n.get(
if (ok) "packages.uninstall.done" else "packages.uninstall.failed",
@@ -172,14 +330,11 @@ fun PackageInstallerScreen(onBack: () -> Unit) {
if (showBrowser) {
FileBrowserDialog(
title = str("packages.select.title"),
// PUP is deliberately absent: firmware has its own screen, and routing it
// through here would let someone install firmware from a menu that says
// nothing about it. EDAT rides along because _rpcsx_install handles it and
// it is what DLC licences arrive as.
// RAP and EDAT are licence files, not packages, so they go through installKey
// rather than install. Some titles need both: the .pkg carries the content and
// the .rap is what unlocks it. PUP stays out on purpose, firmware has its own
// screen and should not be installable from a menu that says nothing about it.
// RAP and EDAT are licence files, not packages, and install() routes them
// accordingly. Some titles need both: the .pkg carries the content and the .rap
// is what unlocks it, so both can be selected in one go. PUP stays out on
// purpose, firmware has its own screen and should not be installable from a
// menu that says nothing about it.
extensions = setOf("pkg", "rap", "edat"),
// Split releases ship as several .pkg parts that only install correctly when
// handed to the installer together, the way RPCS3 desktop does it.
@@ -255,6 +410,32 @@ fun PackageInstallerScreen(onBack: () -> Unit) {
}
}
// Installed licences. Read from exdata, which is where Licences.installRap puts
// them: without this the screen showed nothing at all after a .rap install and
// a success was indistinguishable from a failure.
if (licences.isNotEmpty()) {
Text(
str("packages.licences.header"),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface,
)
licences.forEach { file ->
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
modifier = Modifier.fillMaxWidth(),
) {
Text(
file.name,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 8.dp),
)
}
}
}
// Installed titles, with uninstall. Only what is under the emulator's own
// dev_hdd0/game is listed, so this can never remove a disc or ROM folder.
if (installed.isNotEmpty()) {

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