25 Commits
Author SHA1 Message Date
jpolo1224 e10f846924 App: make pause, restart and the FPS cap do what they say, and add trophies
Pause reached the core for the first time. Rpcs3Bridge.pause() set a bool and
returned, on the belief that RPCS3 has no explicit pause entry point -- Emu.Pause()
exists and _rpcsx_surfaceEvent has always called it on surface loss, which is why
backgrounding the app was the only thing that paused. Exported as _rpcsx_pause
through all four layers; resume already reached the core, so the pair was asymmetric.

Restart no longer crashes: setCustomDriver dlclose'd the previous driver handle, and
applyRendererPrefs re-applies the driver on every start, so restart unloaded the
library VMA had resolved vkGetPhysicalDeviceMemoryProperties2 out of. ~VKGSRender then
freed its heaps and UpdateVulkanBudget called into an unmapped mapping. An ICD cannot
be unloaded while anything resolved from it is reachable, so it is no longer closed.

Restart no longer returns to the library either: shutdown() set stopRequested, called
kill() and returned with the VM still live, so the run loop's finally started the
replacement and the in-flight teardown killed it -- two BootGame calls, then Unloading
ISO, by which point the restart flag was spent. shutdown() now waits (bounded) for the
core to report Stopped, and the restart is queued on vmStopControl behind it.

FPS cap applies at every value. ConfigStore recorded a persistent core override of
Video@@Frame limit=60 and Settings rewrote it on every push, both from a migration
escaping a stored 120 -- but that node is the cap control, and overrides replay last,
so presets were pinned at 60 while 20 and 45 worked through Second Frame Limit. The
Vblank Rate force stays, since Frame limit Auto resolves to it. Stale overrides are
cleared once. 90 and 120 dropped from the row: the min() in the pacer discards them.

Cover art for PKG installs: the library grid's fallback chain stopped one leg short of
the extracted ICON0.PNG while the in-game menu's did not. Both now share one chain, so
they cannot diverge again. has()/discIconFile require bytes rather than existence, and
the staging rename is checked instead of discarded.

Licences are grouped per game and collapsed instead of a flat list of content ids.

Trophies: a library-wide browser and an in-game tab for the running title, reading
TROPCONF.SFM and TROPUSR.DAT directly -- no account, no network. The in-game set is
identified from the core's own current_trophy_name (try_get, since get<> would
construct it outside emulation and hand back an empty name), falling back to TROPDIR
on disk because a game registers its context lazily. Note the entry stride there is
16 + entries_size, not entries_size.

Also: renderer.upscale.label was defined twice, so Internal Resolution was dead.
2026-08-12 16:46:01 -04:00
jpolo1224 db6ee86806 Core: survive a module LLVM cannot compile, and fill in the Android string table
PPU: a module that fails codegen no longer takes the boot with it. ppu_initialize2
called the fatal jit.add(); run_recoverable_llvm and the try_* pair already existed
in this tree but were used only by the SPU recompiler, so LLVM's fatal handler threw
on a thread with no recovery context and killed the worker. That is not one lost
module: g_progr_pdone is incremented in the compile loop's INCREMENT, so the module
the dead worker held was never accounted for, g_progr_ptotal could never reach zero,
and the boot waited on it forever. Saint Seiya: The Sanctuary (BLES01421, issue #25)
stopped at 133 of 134 on 'Cannot scavenge register without an emergency spill slot'.
Now routed through try_add on ARCH_ARM64, mirroring the SPU branch, with
ppu_initialize2 returning bool so the caller stops logging a dead module as compiled.
Losing the worker also halved the rate for everything left.

VK: a data_heap block no longer frees through an allocator that is not the current
one. Borrowed pointer, cached at construction with nothing tying it to the
allocator's lifetime; declining the free costs nothing the device teardown does not
already release.

Android: g_strings held 180 of localized_string_id's 323 entries and the callbacks
ignored their args entirely, so every string carrying a name, date, size or error
code lost it -- including CELL_SAVEDATA_LOAD, which is why the save prompt was Yes
and No over an empty message (open_msg_dialog logged msgString=""). All 322 the Qt
switch provides are present, in enum order, with QString::arg's %0 substitution
reproduced and utf8_to_u32string on the u32 path so trophy names survive. A
static_assert on the table size fails the build when upstream adds an id.

probeDiscInfo: set g_fxo up before mounting. vfs::mount lazily constructs vfs_manager
through manual_typemap::init<T>(), which writes *m_order++, and clear() nulls that
when a game stops -- so scanning a new disc image after playing anything wrote
through null. Emu.IsStopped() cannot guard it, because stopped is the cleared state.
2026-08-12 16:45:29 -04:00
jpolo1224 708582e523 Ship the ANGLE libraries from the module that actually builds
libEGL_angle.so and libGLESv2_angle.so lived in armsx3-app, which stopped being the
built module, so selecting ANGLE for the OpenGL renderer silently fell back to the
system driver with nothing in any log to contradict it. Moved into armsx3-ui beside
the core, with the jniLibs .gitignore negations that keep them tracked.

verifyAngleLibs comes with them and now runs on the release graph ahead of
mergeReleaseJniLibFolders, so packaging an APK that offers ANGLE without shipping it
fails the build. The copy left behind in armsx3-app could never have protected
anything from there, and did not even compile -- its GradleException message escaped
'$' as if the file were a template, and the quotes inside the escaped interpolation
closed the string early, so the project failed to configure. Deleted rather than
fixed, with a comment pointing at the live one.

Version to 0.6 (versionCode 10).
2026-08-12 16:45:12 -04:00
jpolo1224 8a6deab362 Touch: put the tap-to-reveal pause row back in the in-game menu
Its comment was still there, above the OSD selector, describing a control that no
longer existed -- the row was lost in the port and the setting left with no writer,
so nobody could hide the glyph or bring it back. Reported as the option missing from
the menu, which is what it was.

Goes where the comment says rather than in the touch editor toolbar, which is where
I first put it: this is a pause-button behaviour toggle and belongs with the overlay
controls it was written for.
2026-08-12 10:22:35 -04:00
jpolo1224 5b740f8921 Touch: give tap-to-reveal pause a control
The setting has existed since the pause button moved to the top right, and is seeded
once from the old show/hide pref so anyone who had the button hidden keeps it hidden.
Nothing ever wrote it afterwards. A user whose button was visible had no way to hide
it and a user migrated into hidden had no way back, which is how it was reported:
the option is not in the in-game menu.

Sits with multi-touch, gliding and floating stick in the editor toolbar, since those
are the other whole-overlay behaviour toggles and setPauseTapToReveal already
existed to be called.
2026-08-12 10:16:05 -04:00
jpolo1224 11f043b529 VK: retire completed frames on flush again, so the upload rings reclaim
The freeze-with-audio in Ratchet & Clank is the RSX thread dying in the allocator,
and the heap growth log says why. The index buffer went 16M to 64M to 128M to 192M
to 256M inside 290ms, on requests of 2K, 4K, 5K and 3K; the attrib buffer did the
same and died growing to 192M. Kilobyte allocations cannot need a quarter gigabyte.
The rings were never wrapping, they were only ever growing.

frame_context_cleanup is what returns a frame's ring memory, and check_present_status
is what calls it. I removed that call from flush_command_queue in 0.5 because the
drain poked the oldest queued frame's fence and on Adreno vkGetFenceStatus blocks
until signalled instead of answering -- 14.6ms a frame, second only to the FIFO decode
loop. The reasoning was that the flip path retires frames anyway. It does, enough to
keep presenting, but not often enough to keep the rings bounded, and nothing else
reclaims them.

Restoring it costs nothing now. poke() no longer asks with vkGetFenceStatus: it uses
vkWaitForFences with a zero timeout, which is specified to return VK_TIMEOUT without
waiting. The measurement that motivated the removal was of the old implementation, so
the speedup stays and the reclaim comes back.

Keeps the heap growth log that found this. The allocator reports only a size and a
pool number, and pool 1 covers every data_heap, so three fixes were aimed at a target
that could not be seen. One line naming the heap settled it.
2026-08-12 10:02:13 -04:00
jpolo1224 b8987b8c92 Revert "VK: grow upload heaps in steps a phone can actually place"
This reverts commit 403df1c651.
2026-08-12 09:54:00 -04:00
jpolo1224 403df1c651 VK: grow upload heaps in steps a phone can actually place
Ratchet & Clank freezes with audio still playing, which is the RSX thread dying:
'Failed to allocate 131072K of video memory (pool=1, pool total=561M, heap cap=
2048M)'. Pool 1 is VMM_ALLOCATION_POOL_SYSTEM, and 131072K is a data_heap taking
its second growth step, 64M to 128M.

The device is not out of memory. It holds 561M against a 2048M cap and cannot place
128M in one piece, which is a different failure from being full and has a different
fix. The heap grew by aligning up to 64M, so every growth demands a single
contiguous block of at least that size, and each step doubles what the allocator has
to find unbroken. A heap that fails to grow has nowhere to degrade to, so the
renderer ends there.

Android now grows in 16M steps and stops at 256M. The smaller granularity asks for a
quarter as much contiguous memory per step and lets the heap settle near the size
actually wanted rather than overshooting to the next 64M boundary. The ceiling comes
down to match: a 1GiB upload ring would exhaust the device long before it was
reached, so as written it was a limit only reachable by dying. Desktop keeps 64M and
1GiB.

Does not touch the separate pool-0 case fixed in the previous commit, where recovery
does run and the last-ditch eviction now gets a turn before the thread is killed.
2026-08-12 09:50:26 -04:00
jpolo1224 614cdd74a3 VK: evict everything before ending the RSX thread, not after
Ratchet & Clank freezes with audio still playing, which is the RSX thread dying on
VK_ERROR_OUT_OF_DEVICE_MEMORY while the rest of the process lives. Caught on an
Adreno 740: 'Failed to allocate 86016K of video memory (pool=0, pool total=472M,
heap cap=2048M)'. One 84MB request refused while we held 472MB of a 2048MB cap, so
the heap was not full -- a single large allocation could not be placed.

The allocator already retries once after asking for pressure relief, and it did.
The relief is what fell short. on_vram_exhausted refuses the hard sync whenever the
RSX is uninterruptible, and clamps the request below fatal, so the eviction that
drops everything unlocked was unreachable from here. That refusal is right while
there is still a way out: eviction touches resources the driver may still be
reading. It is wrong on the last attempt, where the alternative is not a glitch but
the renderer ending.

So the final attempt is now exempt, through a thread-local set only for the width of
that call. Everything else keeps the existing behaviour, and a caller that opted out
of recovery is not handed it here by the back door. Recovery logs at error level and
says a visual glitch is the expected outcome, since a silent recovery that costs
texture quality reads as a new bug otherwise.

Measured against this failure the eviction ran once, six microseconds before the
allocation failed, and never in the five minutes before it -- so nothing was
reclaimed while it still would have been cheap. That part is not addressed here: the
budget-based ladder cannot see mobile unified memory, where the driver reports one
large shared heap and 472MB against it never crosses a threshold. This makes the
failure survivable rather than preventing it.
2026-08-12 09:42:11 -04:00
jpolo1224 76af990eed VK: stop reserving a desktop-sized descriptor cache on Android
A heap profile of Ratchet & Clank on an Adreno 740 put the only real growth during
play on vk::descriptor_set: 56 sets created in half a session, 49MB, through
simple_array::reserve from descriptor_set::operator=. Nothing else grew that was
not one-time JIT or shader compilation.

Each set reserves m_pool_size entries in three pools the first time it is used --
16448 image infos, 16448 buffer infos, 16448 buffer views, about 920KB -- and there
is one set per shader program. simple_array::clear() only resets the size, so that
memory is held for the object's whole life, and the total climbs for as long as new
pipelines keep appearing. Ratchet compiles a lot of them.

It is also why this was invisible from the Vulkan side: these are plain malloc, not
device memory, so the VMM never sees them and no amount of texture eviction reclaims
them. The tester's log shows the shape exactly -- our pool at 516MB while the process
walked from 4448MB to 5626MB without ever dropping, then died on a 32MB allocation.

The reservation is a correctness requirement, not a tuning knob: push_*() hands
Vulkan the address of a pool entry and it has to stay valid until flush(), so the
pools must not reallocate while writes are pending. What makes it safe to shrink is
that max_cache_size is also the flush threshold, in both on_bind() and
storage_cache_pressure(), so the queue can never outrun the reservation. Moving it
takes the guard with it and leaves the same 64 entries of headroom.

1024 on Android: about 57KB a set instead of 920KB, for one extra
vkUpdateDescriptorSets per 1024 writes. Desktop keeps 16384.
2026-08-12 09:05:39 -04:00
jpolo1224 441c3f1bde Merge PR #34: vectorize the primitive-restart index upload on ARM64 2026-08-12 09:03:52 -04:00
Zulux91 fcdd7cd6af RSX: static_assert the all-ones identity the NEON restart lanes rely on
The vector body stores vorrq(v, eq) into restart lanes, which is all-ones
regardless of what index_limit() returns -- it only matches the scalar
tail's index_limit store because index_limit is all bits set. Assert that
beside the splats so a change to index_limit fails the ARM64 build instead
of silently diverging the vector body from its own tail. No codegen change
(emitted assembly is identical).
2026-08-12 07:49:32 -05:00
Zulux91 ad15f81d6e RSX: clarify comments on the ARM64 index-upload paths
Explain why the non-restart loop stays scalar (clang already
auto-vectorizes it) and spell out which allocations each caller passes,
so the no-overlap contract of upload_untouched_neon is checkable.
2026-08-12 07:24:20 -05:00
Zulux91 2df75aa604 RSX: vectorize the primitive-restart index upload on ARM64
The primitive-restart variant of upload_untouched had no SIMD path on
ARM64: the asmjit builder is x86-only, and clang cannot auto-vectorize
the scalar loop (-Rpass-analysis: "value that could not be identified
as reduction is used outside the loop") because the min/max updates are
conditional on the restart compare -- while the non-restart loop next to
it does auto-vectorize. Net effect: 16 scalar instructions per index on
a path some titles saturate. Measured on a Snapdragon 8 Elite (Odin 3),
Virtua Tennis 4 routes its entire indexed-draw traffic through this
loop: 2.81 billion indices in a 9-minute match session, median 159k
indices per frame.

Port the x86 lane algebra to NEON, 8x u16 / 4x u32 per iteration: the
restart-equal mask ORs the lane to all-ones for the min accumulator and
the store (all-ones is index_limit, exactly what the scalar loop
writes) and BICs it to zero for the max accumulator, so restart lanes
can never win either reduction; UMINV/UMAXV reduce once at the end and
the tail stays scalar. Baseline v8.0 AdvSIMD only.

Supporting results, all on the Odin 3 with the system driver:

- Correctness: 216-case differential (scalar vs NEON vs the dispatched
  path; every tail residue mod 8 and mod 4; restart index absent,
  present, 0, index_limit, all-restart, and index_limit present while
  not the restart value; u16 and u32) ran on device at RSX init in all
  four A/B runs: 0 mismatches. An independent 65,000-case host-side
  model of the same lane algebra also matched the scalar loop, and a
  blind review of the diff could not construct a diverging input.

- Performance A/B (cntvct_el0 around the dispatch, null-region
  calibration subtracted, per-window medians over 120-flip windows with
  >10k restart indices/flip, runs interleaved scalar/NEON/NEON/scalar):
    scalar: 0.02504 and 0.02464 ticks/index  (1.30 ns/index)
    NEON:   0.00346 and 0.00381 ticks/index  (0.19 ns/index)
  ~6.8x faster per index; scalar-scalar repeatability 1.6%. Worst
  single-frame cost in this loop fell from 3.64 ms to 0.99 ms. FPS
  stayed 60/60 in all runs on this device; the win is RSX-thread
  occupancy and worst-frame cost, and would be frame time where the
  RSX thread is the bottleneck.

Titles that never enable primitive restart are unaffected: they route
through the untouched path, which clang already vectorizes.
2026-08-12 05:53:44 -05:00
jpolo1224 b0c7a0260e VK: judge memory pressure against what the driver will give, not our own cap
vmm_determine_memory_load_severity is a set of thresholds on get_memory_usage,
which is usage/budget straight out of vmaGetHeapBudgets. VK_EXT_memory_budget was
never enabled, so VMA had no budget from the driver and used the heap size -- and
where pHeapSizeLimit is set, that limit, which is our own vram_allocation_limit.
An Adreno 740 log shows the consequence: 516MB against a 2048MB cap is 25%, below
even the 50% mark, so the allocator kept its fastest flags, severity stayed 'low',
and the 75/90/95 eviction ladder never fired. The first allocation the driver
refused was also the first sign of trouble, and that one is fatal.

Enabling the extension gives VMA the driver's own estimate. VMA takes the smaller
of it and pHeapSizeLimit, so the cap still caps -- it just stops being mistaken for
headroom that exists. Gated on support and logged when absent.

NOT a fix for the Ratchet & Clank crash this was found in, and it should not be
credited as one. That log leaks about 60MB per sample, 4448MB to 5626MB with no
drop, while our own pool sits at 516MB -- so nearly all of it is outside anything
VMA can see or evict, and a truthful budget only makes us give up our own memory
sooner. The tester reports 0.4 unaffected, which makes it a 0.5 regression still
to be found; see the Adreno per-vkCmdEndRenderPass allocation already recorded
against this codebase.

Also: licences can be removed. Installing one was one-way -- the row existed only
to prove the install had happened -- so a wrong or duplicate .rap could only be
cleared through a file manager, which on a scoped-storage device most people
cannot do at all. Confirmed before deleting, like uninstalling a title, because
content stops working without it.
2026-08-11 23:43:23 -04:00
jpolo1224 7745a3c92d Apply the README trim from PR #28
Reverted during the merge on the assumption it was a contributor's deletion; it
was a deliberate one, so take it as authored.
2026-08-11 22:01:43 -04:00
jpolo1224 b09815e595 Pad: carry analog button pressure through to the game
Every pressure-capable button was fully digital. _rpcsx_overlayPadData ended with
btn.m_value = m_pressed ? 255 : 0, and that value is what cellPad copies into the
press byte a game reads for an analog button, so no half-press could ever reach
one. Rpcs3Bridge.setPadButton threw the magnitude away before that, using `range`
only for stick directions and calling applyButton -- pressed or not -- for
everything else.

Two features were silently dead as a result. A physical L2/R2 went 0 to 100 like
a digital button, reported on Iron Man, whose level-two hover tutorial cannot be
passed without a half-press; the trigger axis was read and scaled correctly all
the way to the JNI boundary and discarded there, which is why remapping and
recalibrating changed nothing. The touch overlay's pressure modifier had the same
end: it computes a range through pressureRangeFor and hands it to the same call.

Pressure now travels as its own export rather than widening overlayPadData, whose
signature is frozen -- the core is dlopen()ed and updated independently of the JNI
glue, so a wider existing export would have older glue passing a garbage argument.
Glue or core predating _rpcsx_overlayPadPressure keeps the old digital behaviour.

0 means "nothing analog drives this button", which is a safe sentinel rather than
a lost level: an unpressed button already reports 0, so a pressed button at 0
cannot occur, and the zero-initialised array is exactly the previous behaviour.
Pushed only when it changes, so an all-digital pad adds no JNI call per event.

All twelve buttons the PS3 pad reports pressure for, not just the triggers, since
the offsets are contiguous and cellPad already routes each one. sendTrigger also
floors to at least 1: the lightest real squeeze truncated to 0, which is the input
layer's "full press" convention and would have delivered the opposite of a
half-press.
2026-08-11 21:53:09 -04:00
jpolo1224 3791865e2f Merge PR #28: make a Vulkan driver that stops answering diagnosable
Rebased by the author onto 0.5, so the occlusion-query bound we shipped stays as
it is and this only adds diagnostics on top of it: the fatal throw that ended the
session is gone, and with it the Web of Shadows regression that kept both PRs out
of 0.5. Also leaves the wait on shutdown, so a driver that never answers cannot
wedge the exit.

README.md is deliberately not taken from the PR -- it removed the whole status
and differences-from-upstream section.
2026-08-11 21:47:58 -04:00
jpolo1224 6831c87a0e Merge PR #23: fix PS3 patch state reporting and verify downloaded patches 2026-08-11 21:47:43 -04:00
Zulux91 b4378d8977 Say why a custom driver cannot load, before trying to load it
adrenotools swallows this failure. When its dlopen of the custom driver fails it
logs to logcat and hands back the system driver, so the load looks successful
from here, the reason never reaches the emulator log, and the user runs a driver
they did not choose while believing otherwise. The existing dlerror() report
cannot fire, because the pointer that comes back is not null.

That cost real time. Mr Purple T29 fails on an Android 15 device with "cannot
locate symbol pthread_getaffinity_np", falls back, and every log looked exactly
like a successful custom-driver session -- I recorded it as passing a driver
comparison it had never taken. The only hint was its reported driver version
matching the system driver's, which took three saved logs side by side to spot.

The requirement is stated in the file. DT_VERNEED lists the libc versions a
binary needs, and T29 needs LIBC_36, meaning API 36, on a device that provides
35. Reading that before the attempt turns "failed to load" into the reason, and
covers the whole class of community drivers built against a newer NDK than the
device runs -- likely the most common way these packages fail.

Metadata cannot answer this: T29's own meta.json declares minApi 30. That field
is author-declared and unverified, so only the binary is trustworthy.

Reported through the emulator log as well as logcat. The UI glue can only reach
logcat, which is not the file anyone attaches to an issue -- the reason would
exist and no report would ever contain it. It lands beside the driver identity
that it explains.

Advisory on purpose. The load is still attempted and nothing is rejected, so a
wrong answer here costs one log line and never a working driver. It stays quiet
unless it positively finds a LIBC_<n> requirement above the running API, and
declines to answer at all when section headers are absent.

Verified on device both ways: T29 reports needing LIBC_36 against API 35 in
RPCSX.log, and stevenmxz v33, which loads correctly, produces nothing.
2026-08-11 19:59:51 -05:00
Zulux91 8fed09d40e Say which way an abandoned occlusion query was failing
Upstream now bounds this wait itself: warn at one second, abandon at
three and use whatever the query holds. That replaces the fatal timeout
this commit previously carried, and it is the better answer -- the throw
could end a session over a driver that was merely slow, at worst wrong
culling for a frame was the actual cost. What remains here is the part
the bound does not cover:

- On abandonment, ask the driver once more directly with
  VK_QUERY_RESULT_WITH_AVAILABILITY_BIT and log which way it is
  stalling: VK_NOT_READY, or VK_SUCCESS with the availability word still
  clear. The two are indistinguishable through poke_query and need
  different conversations with whoever maintains the driver.

- Leave the loop when emulation is aborting. A driver that never answers
  must not also wedge the exit path, and the value is irrelevant once
  the session is going away.

Found chasing a Skate 3 freeze on an Adreno 830, where stevenmxz's gen8
driver builds accept occlusion queries and never complete them; the
system driver completes them in microseconds.
2026-08-11 19:59:51 -05:00
Zulux91 8772786f9a Say which Vulkan driver actually answered
The startup log named the GPU and a driver version, and on Android neither
identifies the driver. adrenotools' hook falls back to the system driver when
its dlopen of the custom one fails, and reports that only to logcat, so a
session that silently ran the system driver logged exactly the same thing as
one that ran the custom driver it was asked for.

That is not hypothetical. Chasing a Skate 3 freeze on an Adreno 830 I recorded
a custom driver as passing a test it never took: it had failed to load with
"cannot locate symbol pthread_getaffinity_np", fallen back, and the log still
said the custom driver was bound. The only tell was that its reported version
matched the system driver's exactly, which needed three saved logs side by side
to notice.

Logs the driver identity Vulkan already reports -- name, driverID, info and
conformance version, all of which were being fetched and thrown away -- and
falls back to saying the identity is name-derived when VK_KHR_driver_properties
is missing, which is common on the older Android devices this matters most on.

Where a custom driver was requested and Qualcomm's own driver answered, that is
a silent fallback, since adrenotools installs Mesa/Turnip builds. It now says
so, and points at the logcat line carrying the actual reason.

The loader's own message no longer claims more than it knows: the handle it
binds is the one it was handed, and whether the driver behind it is the
intended one is not something it can see.
2026-08-11 19:57:37 -05:00
jpolo1224 5405d71e2e Update README.md 2026-08-11 18:12:01 -04:00
Zulux91 4101367b2d Verify downloaded patches and match the engine's wildcard serial
Three things in the patch download path, all of them things desktop
RPCS3 already does.

The download URL had the patch schema version written into it as 1.2.
That is right today, but patch_engine::load rejects any file whose
Version header doesn't match the core's patch_engine_version, so the day
upstream bumps that constant every download starts failing to parse. The
core now hands the version out through patchEngineVersion() and the URL
is built from it. I also check the version the server echoes back, which
turns a several megabyte download into an early error instead of a
parser complaint.

Nothing verified the sha256 the server sends alongside the patch text.
Desktop checks it before it writes anything (patch_manager_dialog::
handle_json). Patches are writes into the guest executable, and
move_file/hide_file patches reach the emulator's own filesystem, so I'd
rather not import bytes that aren't what the server hashed. Mismatches
get their own message rather than being reported as a parse failure.

Last, the wildcard serial. patch_key::all is spelled "All", and
patchSetEnabled compared against a lowercase "all", so a patch carrying
a wildcard entry never had that entry written.

I first "fixed" the same typo in patchesList and let per-game lists match
the wildcard too. Ooops. Turns out that is not a typo doing nothing, it
is a typo doing the right thing by accident: wildcard patches are keyed
by SPU or PPU hash and leave the serial as "All" because the hash is the
filter, so they belong to no single game. There are 17 in the database,
and on device Skate 3 cheerfully offered me a pile of LittleBigPlanet
MLAA patches, where toggling one writes the shared entry and changes
every other game as well. Desktop shows them once under an "All titles"
node, so per-game lists stay serial-only here and the global list is the
equivalent. Only patchSetEnabled and the enabled-state read get the
spelling fix.
2026-08-10 05:48:03 -05:00
Zulux91 466d85d5b9 Report PS3 patch state per game and say when it takes effect
Two things in the patch list reported something the game wasn't getting.

First, patchesList marked a patch as enabled if any entry under its hash
was enabled, whichever serial that entry belonged to. patchSetEnabled
writes per serial, so a patch I switched on from one game's list showed
as on in every other game that patch covers. It now scans only the
requested serial, plus RPCS3's "all" wildcard, which really does apply to
the game being listed.

Second, the patch engine builds its map once while the game loads, then
writes the patches into each module as that module loads. Toggling a
patch only rewrites patch_config.yml, so nothing happens in a game that
is already running. The in-game tab never said so, which makes the switch
look broken. It says so now, above the list.
2026-08-10 05:01:54 -05:00
45 changed files with 2749 additions and 213 deletions
-31
View File
@@ -1,39 +1,8 @@
ARMSX3
======
Proof of concept Android port of RPCS3.
Uses the latest RPCS3 upstream code (the recent ARM64 improvements included).
Status
------
From my testing, I only tried Skate 3. It boots, loads and reaches gameplay at roughly 20 to 30 fps on a
Snapdragon 8 Gen 2. Rendering, audio, touch controls and physical controllers
work. Almost nothing else has been tested. So the main stop gap at the moment is performance/speed.
Differences from upstream RPCS3
-------------------------------
Some of the fixes here are not in upstream and affect any ARM64 build, not only
Android:
* Shaders declared runtime sized arrays inside uniform blocks, which requires
VK_EXT_shader_uniform_buffer_unsized_array. Adreno does not support that
extension, so every game pipeline failed to compile and nothing rendered.
Concrete array bounds are emitted when the extension is missing.
* The ARM64 SPU block verification checksum folded two thirds of every block
through an absolute difference. That collides on the near identical job
binaries an SPU job manager streams through the same local store address, so
a cached block could end up running against another job's code. It sums now.
* Thread affinity was compiled out on Android, and the core had no ARM
big.LITTLE topology, so SPU and RSX threads were never placed on the fast
cores.
* The LLVM JIT target was pinned to cortex-a34, an in order core from 2016. It
detects the host now.
Building
--------
+7 -36
View File
@@ -87,42 +87,13 @@ android {
}
}
// ARMSX3: fail the build if the bundled ANGLE libraries are not there.
//
// This check exists because of a specific, expensive bug in ARMSX2: the repo's
// blanket `*.so` gitignore rule swallowed the ANGLE prebuilts, they never made it
// into release staging, the APK shipped without them, and the core fell back to
// the system GLES driver in complete silence. Users reported "ANGLE is broken"
// and there was nothing in any log to contradict them.
//
// jniLibs/.gitignore now un-ignores the two files by name. This task is the
// second lock: packaging an APK that claims to support ANGLE without shipping
// ANGLE is a build error, not a runtime surprise. The core-side counterpart is
// the loud error in gl::es::egl_initialize() when the override library is
// selected but cannot be dlopen'd.
val verifyAngleLibs by tasks.registering {
val angleLibs = listOf("libEGL_angle.so", "libGLESv2_angle.so")
val jniLibDir = file("src/main/jniLibs/arm64-v8a")
doLast {
val missing = angleLibs.filter { !File(jniLibDir, it).isFile }
if (missing.isNotEmpty()) {
throw GradleException(
"ANGLE libraries missing from ${'$'}jniLibDir: ${'$'}{missing.joinToString(", ")}.\n" +
"The OpenGL renderer's ANGLE option cannot work without them and would " +
"silently fall back to the system GLES driver.\n" +
"They are tracked in git - check them out, or remove the ANGLE option."
)
}
angleLibs.forEach {
logger.lifecycle("ANGLE: packaging ${'$'}it (${'$'}{File(jniLibDir, it).length()} bytes)")
}
}
}
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }
.configureEach { dependsOn(verifyAngleLibs) }
// ARMSX3: the ANGLE prebuilts and the verifyAngleLibs task that guarded them used
// to live here. They now live in android/armsx3-ui, which is the module that
// actually ships (applicationId com.armsx3) and the module whose UI exposes the
// OpenGL renderer's ANGLE option. This module builds nothing that ships, so the
// guard here could never protect the APK it was written for -- and it never ran at
// all: its message was written with `${'$'}`-style template escaping, and the `", "`
// inside it closed the Kotlin string early, so this file did not compile.
base.archivesName = "rpcsx"
+47 -2
View File
@@ -29,8 +29,8 @@ android {
applicationId = "com.armsx3"
minSdk = 26
targetSdk = 37
versionCode = 9
versionName = "0.5"
versionCode = 10
versionName = "0.6"
// 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.
@@ -110,6 +110,51 @@ android {
}
}
// ARMSX3: fail the build if the bundled ANGLE libraries are not there.
//
// This check exists because of a specific, expensive bug in ARMSX2: the repo's
// blanket `*.so` gitignore rule swallowed the ANGLE prebuilts, they never made it
// into release staging, the APK shipped without them, and the core fell back to
// the system GLES driver in complete silence. Users reported "ANGLE is broken"
// and there was nothing in any log to contradict them.
//
// jniLibs/.gitignore un-ignores the two files by name. This task is the second
// lock: packaging an APK that claims to support ANGLE without shipping ANGLE is a
// build error, not a runtime surprise. The core-side counterpart is the loud error
// in gl::es::egl_initialize() when the override library is selected but cannot be
// dlopen'd; the UI-side counterpart is the MISSING_LIBS line that
// MainActivityRuntime.applyAngleEnv logs when the option is on and the .so is not
// in nativeLibraryDir.
//
// The claim being guarded is live in THIS module: RendererBackendSection ->
// AngleDriverSection writes Settings.useAngleOpenGL, and applyAngleEnv turns it
// into ARMSX2_ANGLE_EGL_LIBRARY. (Both the libraries and this task used to sit in
// the stale android/armsx3-app module, which builds nothing that ships -- so the
// guard could not fire for the APK it was meant to protect.)
val verifyAngleLibs by tasks.registering {
val angleLibs = listOf("libEGL_angle.so", "libGLESv2_angle.so")
val jniLibDir = file("src/main/jniLibs/arm64-v8a")
doLast {
val missing = angleLibs.filter { !jniLibDir.resolve(it).isFile }
if (missing.isNotEmpty()) {
throw GradleException(
"ANGLE libraries missing from $jniLibDir: ${missing.joinToString(", ")}.\n" +
"The OpenGL renderer's ANGLE option cannot work without them and would " +
"silently fall back to the system GLES driver.\n" +
"They are tracked in git - check them out, or remove the ANGLE option."
)
}
angleLibs.forEach {
logger.lifecycle("ANGLE: packaging $it (${jniLibDir.resolve(it).length()} bytes)")
}
}
}
tasks.matching { it.name.startsWith("merge") && it.name.endsWith("JniLibFolders") }
.configureEach { dependsOn(verifyAngleLibs) }
dependencies {
// Discord Social SDK, staged locally rather than pulled from a repo: it is
// proprietary and distributed per-application from the developer portal.
@@ -1,7 +1,11 @@
#include <algorithm>
#include <android/api-level.h>
#include <android/dlext.h>
#include <android/log.h>
#include <cstdio>
#include <cstring>
#include <dlfcn.h>
#include <elf.h>
#include <jni.h>
#include <optional>
#include <string>
@@ -19,6 +23,7 @@
struct RPCSXApi {
bool (*overlayPadData)(int port, int digital1, int digital2, int leftStickX,
int leftStickY, int rightStickX, int rightStickY);
bool (*overlayPadPressure)(int port, const int *values, int count);
bool (*initialize)(std::string_view rootDir, std::string_view user);
void (*setSocInfo)(std::string_view socInfo);
bool (*processCompilationQueue)(JNIEnv *env);
@@ -30,8 +35,10 @@ struct RPCSXApi {
int (*getState)();
void (*kill)();
void (*resume)();
void (*pause)();
void (*openHomeMenu)();
std::string (*getTitleId)();
std::string (*getCurrentTrophyName)();
bool (*surfaceEvent)(JNIEnv *env, jobject surface, jint event);
void (*surfaceSizeChanged)(int width, int height);
bool (*usbDeviceEvent)(int fd, int vendorId, int productId, int event);
@@ -52,6 +59,7 @@ struct RPCSXApi {
bool (*uninstallGame)(std::string_view path);
std::string (*getVersion)();
void *(*setCustomDriver)(void *driverHandle);
void (*reportDriverProblem)(std::string message);
bool (*saveState)();
bool (*loadState)(unsigned int index);
bool (*hasState)(unsigned int index);
@@ -61,6 +69,7 @@ struct RPCSXApi {
bool (*saveStateToSlot)(unsigned int slot);
bool (*loadStateFromSlot)(unsigned int slot);
bool (*hasStateInSlot)(unsigned int slot);
std::string (*patchEngineVersion)();
int (*patchesImport)(std::string_view content);
std::string (*patchesList)(std::string_view serial);
std::string (*probeDiscInfo)(std::string_view isoPath, std::string_view iconOut);
@@ -104,6 +113,7 @@ struct RPCSXLibrary : RPCSXApi {
// clang-format off
result.overlayPadData = reinterpret_cast<decltype(overlayPadData)>(dlsym(handle, "_rpcsx_overlayPadData"));
result.overlayPadPressure = reinterpret_cast<decltype(overlayPadPressure)>(dlsym(handle, "_rpcsx_overlayPadPressure"));
result.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"));
@@ -114,8 +124,10 @@ struct RPCSXLibrary : RPCSXApi {
result.getState = reinterpret_cast<decltype(getState)>(dlsym(handle, "_rpcsx_getState"));
result.kill = reinterpret_cast<decltype(kill)>(dlsym(handle, "_rpcsx_kill"));
result.resume = reinterpret_cast<decltype(resume)>(dlsym(handle, "_rpcsx_resume"));
result.pause = reinterpret_cast<decltype(pause)>(dlsym(handle, "_rpcsx_pause"));
result.openHomeMenu = reinterpret_cast<decltype(openHomeMenu)>(dlsym(handle, "_rpcsx_openHomeMenu"));
result.getTitleId = reinterpret_cast<decltype(getTitleId)>(dlsym(handle, "_rpcsx_getTitleId"));
result.getCurrentTrophyName = reinterpret_cast<decltype(getCurrentTrophyName)>(dlsym(handle, "_rpcsx_getCurrentTrophyName"));
result.surfaceEvent = reinterpret_cast<decltype(surfaceEvent)>(dlsym(handle, "_rpcsx_surfaceEvent"));
result.surfaceSizeChanged = reinterpret_cast<decltype(surfaceSizeChanged)>(dlsym(handle, "_rpcsx_surfaceSizeChanged"));
result.usbDeviceEvent = reinterpret_cast<decltype(usbDeviceEvent)>(dlsym(handle, "_rpcsx_usbDeviceEvent"));
@@ -135,12 +147,14 @@ struct RPCSXLibrary : RPCSXApi {
result.uninstallGame = reinterpret_cast<decltype(uninstallGame)>(dlsym(handle, "_rpcsx_uninstallGame"));
result.getVersion = reinterpret_cast<decltype(getVersion)>(dlsym(handle, "_rpcsx_getVersion"));
result.setCustomDriver = reinterpret_cast<decltype(setCustomDriver)>(dlsym(handle, "_rpcsx_setCustomDriver"));
result.reportDriverProblem = reinterpret_cast<decltype(reportDriverProblem)>(dlsym(handle, "_rpcsx_reportDriverProblem"));
result.saveState = reinterpret_cast<decltype(saveState)>(dlsym(handle, "_rpcsx_saveState"));
result.loadState = reinterpret_cast<decltype(loadState)>(dlsym(handle, "_rpcsx_loadState"));
result.hasState = reinterpret_cast<decltype(hasState)>(dlsym(handle, "_rpcsx_hasState"));
result.saveStateToSlot = reinterpret_cast<decltype(saveStateToSlot)>(dlsym(handle, "_rpcsx_saveStateToSlot"));
result.loadStateFromSlot = reinterpret_cast<decltype(loadStateFromSlot)>(dlsym(handle, "_rpcsx_loadStateFromSlot"));
result.hasStateInSlot = reinterpret_cast<decltype(hasStateInSlot)>(dlsym(handle, "_rpcsx_hasStateInSlot"));
result.patchEngineVersion = reinterpret_cast<decltype(patchEngineVersion)>(dlsym(handle, "_rpcsx_patchEngineVersion"));
result.patchesImport = reinterpret_cast<decltype(patchesImport)>(dlsym(handle, "_rpcsx_patchesImport"));
result.patchesList = reinterpret_cast<decltype(patchesList)>(dlsym(handle, "_rpcsx_patchesList"));
result.probeDiscInfo = reinterpret_cast<decltype(probeDiscInfo)>(dlsym(handle, "_rpcsx_probeDiscInfo"));
@@ -201,6 +215,35 @@ extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_overlayPadData(
leftStickY, rightStickX, rightStickY);
}
extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_overlayPadPressure(
JNIEnv *env, jobject, jint port, jintArray values) {
// Absent on a core older than this export: the pad still works, every button
// is just digital, which is the behaviour that shipped before it existed.
if (rpcsxLib.overlayPadPressure == nullptr || values == nullptr) {
return false;
}
const jsize count = env->GetArrayLength(values);
if (count <= 0) {
return false;
}
// Critical rather than a copy: this runs on every input event that moves a
// trigger, and the callee only reads the values before returning.
auto *elems = static_cast<jint *>(env->GetPrimitiveArrayCritical(values, nullptr));
if (elems == nullptr) {
return false;
}
static_assert(sizeof(jint) == sizeof(int),
"jint and int must match for the pressure array to be passed through");
const bool ok = rpcsxLib.overlayPadPressure(
port, reinterpret_cast<const int *>(elems), static_cast<int>(count));
env->ReleasePrimitiveArrayCritical(values, elems, JNI_ABORT);
return ok;
}
extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_initialize(
JNIEnv *env, jobject, jstring rootDir, jstring user, jstring socInfo) {
// The core is dlopen()ed separately and may not be up yet -- during
@@ -316,6 +359,18 @@ extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_resume(JNIEnv *env,
return rpcsxLib.resume();
}
extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_pause(JNIEnv *env,
jobject) {
// Same null guard as resume: the core is dlopen()ed separately and may not be
// up yet. A missing symbol also means an older core, so an app built against
// this cannot assume the export is there.
if (rpcsxLib.pause == nullptr) {
return;
}
return rpcsxLib.pause();
}
extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_openHomeMenu(JNIEnv *env,
jobject) {
// The core is dlopen()ed separately and may not be up yet -- during
@@ -340,6 +395,21 @@ Java_net_rpcsx_RPCSX_getTitleId(JNIEnv *env, jobject) {
return wrap(env, rpcsxLib.getTitleId());
}
extern "C" JNIEXPORT jstring JNICALL
Java_net_rpcsx_RPCSX_getCurrentTrophyName(JNIEnv *env, jobject) {
// 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.
//
// Also null on an OLDER core that predates this export, since it is resolved
// by dlsym: the frontend must treat null as "unknown", not as "no trophies".
if (rpcsxLib.getCurrentTrophyName == nullptr) {
return nullptr;
}
return wrap(env, rpcsxLib.getCurrentTrophyName());
}
extern "C" JNIEXPORT jboolean JNICALL Java_net_rpcsx_RPCSX_surfaceEvent(
JNIEnv *env, jobject, jobject surface, jint event) {
// The core is dlopen()ed separately and may not be up yet -- during
@@ -556,6 +626,145 @@ Java_net_rpcsx_RPCSX_getVersion(JNIEnv *env, jobject) {
return wrap(env, rpcsxLib.getVersion());
}
#if defined(__aarch64__)
// Why a driver will not load, when the answer is knowable before trying.
//
// A community driver built against a newer NDK imports symbols versioned against a libc
// this device does not have, and the linker then refuses it. adrenotools reports that to
// logcat and quietly substitutes the system driver, so from the app's side the load
// "succeeded" and the user runs a driver they did not choose.
//
// The requirement is stated in the file: DT_VERNEED / .gnu.version_r lists the libc
// versions it needs, e.g. LIBC_36 for API 36. Comparing that against the running API
// turns "failed to load" into the actual reason, which is the difference between a
// usable bug report and a shrug. Mr Purple T29 needs LIBC_36 (Android 16) and its own
// meta.json claims minApi 30, so the metadata cannot be trusted for this -- only the
// binary can.
//
// Returns an empty string when nothing conclusive was found. Advisory only: the load is
// still attempted, so a wrong answer here costs a log line and never a working driver.
static std::string driver_libc_requirement_blocker(const std::string &soPath) {
std::FILE *f = std::fopen(soPath.c_str(), "rb");
if (f == nullptr) {
return {};
}
std::vector<char> data;
std::fseek(f, 0, SEEK_END);
const long size = std::ftell(f);
// Header tables live near the start and end; the symbol names they point at can be
// anywhere, so read the whole file. These are ~15-20 MB and read once per driver
// switch, not per boot.
if (size <= 0 || size > (256 << 20)) {
std::fclose(f);
return {};
}
std::fseek(f, 0, SEEK_SET);
data.resize(static_cast<size_t>(size));
const size_t got = std::fread(data.data(), 1, data.size(), f);
std::fclose(f);
if (got != data.size() || data.size() < sizeof(Elf64_Ehdr)) {
return {};
}
const auto *ehdr = reinterpret_cast<const Elf64_Ehdr *>(data.data());
if (std::memcmp(ehdr->e_ident, ELFMAG, SELFMAG) != 0 ||
ehdr->e_ident[EI_CLASS] != ELFCLASS64 || ehdr->e_shoff == 0 ||
ehdr->e_shentsize != sizeof(Elf64_Shdr)) {
return {};
}
// Section headers rather than PT_DYNAMIC: they carry file offsets directly, so no
// vaddr-to-offset mapping is needed. Shared objects keep them; if they are gone, this
// check simply declines to answer.
const auto section_at = [&](size_t i) -> const Elf64_Shdr * {
const size_t off = ehdr->e_shoff + i * sizeof(Elf64_Shdr);
if (off + sizeof(Elf64_Shdr) > data.size()) {
return nullptr;
}
return reinterpret_cast<const Elf64_Shdr *>(data.data() + off);
};
for (size_t i = 0; i < ehdr->e_shnum; i++) {
const Elf64_Shdr *sh = section_at(i);
if (sh == nullptr || sh->sh_type != SHT_GNU_verneed) {
continue;
}
const Elf64_Shdr *strtab = section_at(sh->sh_link);
if (strtab == nullptr || strtab->sh_offset >= data.size()) {
return {};
}
const char *strings = data.data() + strtab->sh_offset;
const size_t strings_max = data.size() - strtab->sh_offset;
size_t offset = sh->sh_offset;
int highest_libc = 0;
for (size_t entry = 0; entry < sh->sh_info; entry++) {
if (offset + sizeof(Elf64_Verneed) > data.size()) {
break;
}
const auto *vn = reinterpret_cast<const Elf64_Verneed *>(data.data() + offset);
size_t aux_offset = offset + vn->vn_aux;
for (size_t aux = 0; aux < vn->vn_cnt; aux++) {
if (aux_offset + sizeof(Elf64_Vernaux) > data.size()) {
break;
}
const auto *vna = reinterpret_cast<const Elf64_Vernaux *>(data.data() + aux_offset);
if (vna->vna_name < strings_max) {
const char *name = strings + vna->vna_name;
int level = 0;
// Only LIBC_<n> is a device-capability statement. Anything else (LIBC,
// LIBC_PRIVATE, other sonames) says nothing about the API level.
if (std::sscanf(name, "LIBC_%d", &level) == 1 && level > highest_libc) {
highest_libc = level;
}
}
if (vna->vna_next == 0) {
break;
}
aux_offset += vna->vna_next;
}
if (vn->vn_next == 0) {
break;
}
offset += vn->vn_next;
}
const int device_api = android_get_device_api_level();
if (highest_libc > 0 && device_api > 0 && highest_libc > device_api) {
char buf[256];
std::snprintf(buf, sizeof(buf),
"it requires LIBC_%d (Android API %d) but this device provides API %d",
highest_libc, highest_libc, device_api);
return buf;
}
return {};
}
return {};
}
#endif // __aarch64__
extern "C" JNIEXPORT jboolean JNICALL
Java_net_rpcsx_RPCSX_setCustomDriver(JNIEnv *env, jobject, jstring jpath,
jstring jlibraryName, jstring jhookDir) {
@@ -573,6 +782,26 @@ Java_net_rpcsx_RPCSX_setCustomDriver(JNIEnv *env, jobject, jstring jpath,
__android_log_print(ANDROID_LOG_INFO, "RPCSX-UI", "Loading custom driver %s",
path.c_str());
// Said before the attempt, because adrenotools swallows the failure: it logs to
// logcat and hands back the system driver, so the caller cannot tell a real load
// from a substitution, and the reason never reaches the emulator log at all.
if (auto blocker = driver_libc_requirement_blocker(path + "/" + libraryName);
!blocker.empty()) {
const std::string report =
"Custom driver '" + libraryName + "' cannot load on this device: " + blocker +
". It was built against a newer NDK than this Android version supports; the "
"driver's own metadata does not carry this. The system driver will be used "
"instead.";
__android_log_print(ANDROID_LOG_ERROR, "RPCSX-UI", "%s", report.c_str());
// Also to the emulator log, which is the file that gets attached to issues.
// logcat alone means the reason exists and no report ever contains it.
if (rpcsxLib.reportDriverProblem != nullptr) {
rpcsxLib.reportDriverProblem(report);
}
}
::dlerror();
loader = adrenotools_open_libvulkan(
RTLD_NOW, ADRENOTOOLS_DRIVER_CUSTOM, nullptr, (hookDir + "/").c_str(),
@@ -586,10 +815,27 @@ Java_net_rpcsx_RPCSX_setCustomDriver(JNIEnv *env, jobject, jstring jpath,
}
}
auto prevLoader = rpcsxLib.setCustomDriver(loader);
if (prevLoader != nullptr) {
::dlclose(prevLoader);
}
// Deliberately NOT dlclose()ing the previous handle.
//
// A Vulkan driver cannot be unloaded while anything resolved out of it is still reachable, and
// from here there is no way to know that. VMA caches vkGetPhysicalDeviceMemoryProperties2 in the
// allocator at creation time, so the address lives inside the driver library for as long as the
// renderer does.
//
// Restart is the one flow where a start races a teardown that has not finished:
// applyRendererPrefs() re-applies the driver on EVERY start, so it dlopen'd a new handle and
// closed the old one while the previous VKGSRender was still unwinding. Its destructor then
// freed its data heaps, VMA went to refresh its budget, and called through a pointer into a
// library that was no longer mapped -- "Segfault executing location <addr> at <addr>", inside
// VmaAllocator_T::UpdateVulkanBudget. The give-away was the fault address landing on the same
// offset every time with a different base: a live function in an unmapped library, not a
// corrupted pointer. That is the "Restart crashes the app" report, and the same for
// apply-and-restart after picking a driver.
//
// Leaking one handle per driver SWITCH is the cheap side of this trade: it is bounded by how
// many times a user changes driver in a session, the mapping is shared, and dlclose on an ICD
// is not something the loader promises to honour anyway.
rpcsxLib.setCustomDriver(loader);
return true;
#else
@@ -663,6 +909,17 @@ Java_net_rpcsx_RPCSX_hasStateInSlot(JNIEnv *, jobject, jint slot) {
// Patches
// ---------------------------------------------------------------------------
extern "C" JNIEXPORT jstring JNICALL
Java_net_rpcsx_RPCSX_patchEngineVersion(JNIEnv *env, jobject) {
// Empty rather than a guessed version: the caller asks precisely because it
// must not name a schema version of its own.
if (rpcsxLib.patchEngineVersion == nullptr) {
return wrap(env, "");
}
return wrap(env, rpcsxLib.patchEngineVersion());
}
extern "C" JNIEXPORT jint JNICALL
Java_net_rpcsx_RPCSX_patchesImport(JNIEnv *env, jobject, jstring jcontent) {
if (rpcsxLib.patchesImport == nullptr) {
@@ -24,8 +24,17 @@ object DiscIcons {
fun fileFor(titleId: String): File = File(dir(), "$titleId.png")
/** True when this game's icon has already been extracted. */
fun has(titleId: String): Boolean = fileFor(titleId).isFile
/**
* True when this game's icon has already been extracted.
*
* Length, not isFile: an empty file is indistinguishable from a real one to isFile, and
* it is a shape this can actually end up in -- the extraction writes to a staging name
* and renames, and neither the write nor the rename is checked. An empty icon then reads
* as "already extracted" forever, and the card falls through to the text placeholder
* because there is nothing for Coil to decode. Requiring bytes makes that self-repairing:
* the next scan re-probes and overwrites it.
*/
fun has(titleId: String): Boolean = fileFor(titleId).length() > 0L
fun clear() {
runCatching { dir().listFiles()?.forEach { it.delete() } }
@@ -330,7 +330,10 @@ data class GameInfo(
* 404, and always match the disc. There is no PS3 equivalent of xlenore's
* ps2-covers to point at anyway.
*/
val discIconFile: java.io.File? get() = serial?.let { DiscIcons.fileFor(it) }?.takeIf { it.isFile }
// Length rather than isFile, for the same reason as DiscIcons.has: an empty file passes
// isFile, hands Coil something undecodable, and costs the card its placeholder-vs-cover
// decision. Nothing is a better answer than zero bytes.
val discIconFile: java.io.File? get() = serial?.let { DiscIcons.fileFor(it) }?.takeIf { it.length() > 0L }
private fun coverUrlFor(s: String): String {
// PS3 art comes from aldostools/Resources, which is flat: COV/<TITLE_ID>.JPG
@@ -22,8 +22,14 @@ object Ps3PatchRepo {
* RPCS3's official patch feed. `v` is the patch-engine version the server
* uses to decide which schema to hand back, so it is not cosmetic -- an
* older value returns patches this core cannot parse.
*
* The version comes from the core (patch_engine_version) rather than being
* written here. It was spelled out as 1.2, which is correct only until
* upstream bumps the constant: patch_engine::load rejects any file whose
* Version header does not match, so the two have to move together.
*/
private const val PATCH_URL = "https://rpcs3.net/compatibility?patch&api=v1&v=1.2"
private fun patchUrl(version: String) =
"https://rpcs3.net/compatibility?patch&api=v1&v=$version"
data class Patch(
val hash: String,
@@ -49,11 +55,15 @@ object Ps3PatchRepo {
data object Network : Result
data class Server(val code: Int) : Result
data object Parse : Result
data object Checksum : Result
}
fun download(): Result {
val engineVersion = runCatching { RPCSX.instance.patchEngineVersion() }.getOrDefault("")
if (engineVersion.isBlank()) return Result.Parse
val res = runCatching {
com.armsx3.HttpClient.doRequest(PATCH_URL, userAgent = "ARMSX3")
com.armsx3.HttpClient.doRequest(patchUrl(engineVersion), userAgent = "ARMSX3")
}.getOrNull() ?: return Result.Network
if (res.statusCode != 200 || res.data.isEmpty()) return Result.Network
@@ -62,19 +72,40 @@ object Ps3PatchRepo {
// { "return_code": 0, "version": "1.2", "sha256": "...", "patch": "<yaml>" }
// Handing the envelope straight to the YAML parser fails on the first
// line, which is exactly what it did.
val yaml = runCatching {
val envelope = runCatching {
val obj = org.json.JSONObject(String(res.data, Charsets.UTF_8))
val code = obj.optInt("return_code", -1)
if (code != 0) return Result.Server(code)
obj.optString("patch")
}.getOrNull()
obj
}.getOrNull() ?: return Result.Parse
if (yaml.isNullOrBlank()) return Result.Parse
// The server picks the schema from the version we asked for, so a reply
// for a different one is a server-side surprise rather than something to
// hand to the parser: patch_engine::load would reject the whole file on
// its Version header anyway, several megabytes later.
if (envelope.optString("version") != engineVersion) return Result.Parse
val yaml = envelope.optString("patch")
if (yaml.isBlank()) return Result.Parse
// Desktop RPCS3 verifies this digest before it writes anything
// (patch_manager_dialog::handle_json), and the check was missing here.
// Patches are writes into the guest executable, and move_file/hide_file
// patches reach the emulator's own filesystem, so content that is not
// what the server hashed does not get imported.
val expected = envelope.optString("sha256")
if (!expected.equals(sha256(yaml), ignoreCase = true)) return Result.Checksum
val n = runCatching { RPCSX.instance.patchesImport(yaml) }.getOrDefault(-1)
return if (n >= 0) Result.Ok(n) else Result.Parse
}
/** Lowercase hex SHA-256, the form rpcs3.net sends and desktop compares against. */
private fun sha256(text: String): String =
java.security.MessageDigest.getInstance("SHA-256")
.digest(text.toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
/**
* Patches applicable to a serial. An empty serial lists everything, which is
* what the standalone tab shows when no game is selected.
@@ -53,6 +53,8 @@ object ConfigStore {
// One-time flip of existing all-on OSD saves to the new default-off.
private const val KEY_OSD_OFF_MIGRATED = "config.migrated.osdDefaultOff"
private const val KEY_OSD_SCALE_MIGRATED = "config.migrated.osdScale65"
/** One-time removal of the Frame limit core override that made the FPS cap inert. */
private const val KEY_FRAME_LIMIT_UNPINNED = "config.migrated.frameLimitUnpinned"
// One-time reconcile for the fresh-install + reused-data-folder case (people who
// can't update in place and re-point setup at their old folder). See reconcileReusedFolder.
private const val KEY_FOLDER_RECONCILE = "config.migrated.folderReconcile"
@@ -373,16 +375,32 @@ object ConfigStore {
runCatching {
CoreSettingOverrides.record(SettingsScope.Global, null, "Video@@Vblank Rate", "60")
}
// Frame limit as well as Vblank Rate. Vblank alone did not hold: the override is
// stored and the two beside it apply, yet the live value came back as 120. Frame
// limit is the dedicated cap and does not depend on the vblank path at all, so
// whichever of the two takes, the result is 60.
runCatching {
CoreSettingOverrides.record(SettingsScope.Global, null, "Video@@Frame limit", "60")
}
MainActivityRuntime.prefs.edit { putBoolean(KEY_VBLANK_60, true) }
}
// The migration above used to pin Video@@Frame limit to 60 as well, as a second way to
// reach a 60Hz cap in case the Vblank Rate override did not hold.
//
// Frame limit is not a spare knob: it is the node the Display FPS Cap row writes. Pinned as
// a core override it replayed AFTER every settings push, so the cap silently did nothing for
// every value that uses the enum -- 30, 50, 60, 120 -- while 20 and 45 appeared to work
// because those are not presets and go to the free-form Second Frame Limit instead.
// Measured: the UI wrote '30', settingsSet accepted it, and the core still reported
// frame_limit=_60 on every flip.
//
// Nothing is lost by dropping it. Frame limit Auto resolves to the vblank rate
// (RSXThread.cpp, the _auto case), and the Vblank Rate override above is already 60, so the
// 60Hz default this was protecting still holds.
//
// forgetEverywhere, not a scoped forget: overrides live in two stores across two scopes, and
// an install that ran the old migration has the Global one recorded already. Anyone who
// deliberately set a Frame limit in All Core Settings loses that override here, which is the
// right trade against a cap control that cannot work.
if (!MainActivityRuntime.prefs.getBoolean(KEY_FRAME_LIMIT_UNPINNED, false)) {
runCatching { CoreSettingOverrides.forgetEverywhere("Video@@Frame limit") }
MainActivityRuntime.prefs.edit { putBoolean(KEY_FRAME_LIMIT_UNPINNED, true) }
}
// Diagnostic settings that got recorded as core overrides during the profiling work
// and would otherwise follow people into a release build.
//
@@ -1294,10 +1294,18 @@ data class Settings(
// A deliberate change in All Core Settings still wins, because CoreSettingOverrides
// replays immediately below this.
runCatching { net.rpcsx.RPCSX.instance.settingsSet("Video@@Vblank Rate", "60") }
// And the cap itself. Frame limit Auto resolves to the vblank rate, so with the line
// 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\"") }
// Frame limit is deliberately NOT forced here any more.
//
// It used to be written to "60" on every push as a belt-and-braces way to reach a 60Hz cap
// alongside the Vblank Rate above. But this is the same node the Display FPS Cap row writes,
// and this push runs after it, so choosing 30 wrote the enum and then this overwrote it --
// the cap did nothing for every preset value while 20 and 45 worked, because those take the
// free-form Second Frame Limit path instead. The core reported frame_limit=_60 on every flip
// no matter what the UI had just been told.
//
// The 60Hz intent survives without it: Frame limit Auto resolves to the vblank rate, which
// the line above pins to 60. ConfigStore also clears the stale core override that pinned
// this node, since that replayed even later than this did.
// Left at upstream's 100: busy-wait on a reservation rather than sleeping.
//
// This was dropped to 20 while the emulator was starved for cores, on the reasoning that
@@ -191,9 +191,36 @@ class GameLibraryRepository(private val context: Context) {
// Everything the probe produces is already durable: the serial and title are in the
// library cache, and the icon is on disk under disc-icons. So a disc we have seen
// before never needs mounting again.
//
// That last clause is only true if the extraction actually succeeded once. The serial
// and title are durable by construction -- this loop is reading them -- but the icon
// is a separate file that may never have been written: probeDiscInfo answers "{}"
// whenever a game is loaded, and the serial then comes from the FILENAME instead,
// which for dev_hdd0/game/<title id> is indistinguishable from one the SFO gave us.
// Seeding on the serial alone made that miss permanent, because every later rescan
// skipped the one thing that would repair it. Reported as a PKG-installed title
// showing a text placeholder for good, with its ICON0.PNG sitting unread in the game
// folder and its path already recorded in games.json.
//
// So gate the skip on the icon as well, for the games that have one. Re-probing costs
// one mount, once, and only for a game actually missing it; a game whose icon is on
// disk still never mounts again, which is what the reasoning above is protecting.
loadCached().games.forEach { game ->
val serial = game.serial?.takeIf { it.isNotBlank() } ?: return@forEach
val path = runCatching { game.uri.path }.getOrNull() ?: return@forEach
// Folders only, and that is not a convenience: re-probing an ISO means load_iso ->
// vfs::mount, which is the process-wide mount this whole seeding exists to avoid, and
// it has crashed the app for real -- twice in one day, faulting in
// manual_typemap::init<vfs_manager> from this very thread while a boot was starting.
// A directory is read straight off disk by read_sfo_game_info with no mount at all,
// so it carries none of that risk. The game this was reported for was a PKG install
// (folder form) whose ICON0.PNG was sitting there unread, which is exactly the case
// that stays covered.
val isFolder = game.extension.equals("folder", ignoreCase = true)
if (isFolder && !DiscIcons.has(serial)) {
android.util.Log.i(ScanTag, "re-probing folder '$serial': no usable disc icon on disk")
return@forEach
}
discInfoCache.putIfAbsent(path, DiscInfo(serial, game.title))
}
@@ -582,8 +609,22 @@ class GameLibraryRepository(private val context: Context) {
// title ID until it has already parsed the SFO.
if (o.optBoolean("icon")) {
val staged = DiscIcons.fileFor(PendingIcon)
if (staged.isFile) {
staged.renameTo(DiscIcons.fileFor(id))
val target = DiscIcons.fileFor(id)
// renameTo answers false instead of throwing when the target already exists,
// and the answer was discarded: a re-extraction for a title that already had
// an icon silently kept the old file and left the staging one behind. Clear
// the target first, and say so if it still fails -- a stale or empty icon must
// not outlive the probe that was meant to replace it, because every reader
// downstream treats "a file is there" as "the cover is good".
if (staged.length() > 0L) {
target.delete()
if (!staged.renameTo(target)) {
android.util.Log.w(ScanTag, " could not place disc icon for $id")
staged.delete()
}
} else {
android.util.Log.w(ScanTag, " probe claimed an icon for $id, staged 0 bytes")
staged.delete()
}
}
DiscInfo(id, o.optString("title"))
@@ -0,0 +1,423 @@
package com.armsx2.data.trophies
import android.util.Log
import java.io.File
import net.rpcsx.RPCSX
import org.xmlpull.v1.XmlPullParser
import org.xmlpull.v1.XmlPullParserFactory
/**
* Reader for RPCS3's NATIVE PS3 trophy data the real thing the games write, not
* RetroAchievements (which has no PS3 support at all, which is why the RA screen is
* hidden in ARMSX3).
*
* Where the data lives, and why this reads it from disk rather than through JNI:
*
* config/dev_hdd0/home/<user>/trophy/<NPWRxxxxx_00>/
* TROPCONF.SFM the trophy DEFINITIONS, installed from the game's TROPHY.TRP.
* Plain XML: <title-name>, then one <trophy id hidden ttype pid>
* per trophy with <name> and <detail> children.
* TROPUSR.DAT the user's UNLOCK STATE. Big-endian binary, written by
* sceNpTrophyUnlockTrophy via TROPUSRLoader::Save.
* ICON0.PNG the game's icon; TROP000.PNG the per-trophy icons.
*
* The emulator's own loaders (rpcs3/Loader/TROPUSR.cpp, and the overlay's
* load_trophies in Emu/RSX/Overlays/Trophies/overlay_trophy_list_dialog.cpp) sit behind
* vfs::get, so reaching them needs the core dlopen()ed AND its VFS mounted neither is
* guaranteed in the library, which is exactly where this screen is used. These files are
* inside the app's own external files dir, so plain java.io reads them with no core at
* all: no JNI, no native rebuild, and the browser works before a game has ever booted.
*
* The parse mirrors TROPUSR.h/.cpp field for field; see [readTropUsr] for the one
* non-obvious part (the entry stride).
*/
object TrophyRepository {
private const val TAG = "Trophies"
/** TROPUSR.DAT magic, from TROPUSR.cpp's TROPUSR_MAGIC. */
private const val TROPUSR_MAGIC = 0x818F54AD.toInt()
/**
* Microseconds from 0001-01-01 to 1970-01-01 (719162 days).
*
* A trophy timestamp is a CellRtcTick sceNpTrophyUnlockTrophy stores
* cellRtcGetCurrentTick's value straight into the entry and cellRtc counts
* microseconds from year 1 UTC (see tick_to_date_time in cellRtc.cpp). Subtracting
* this turns it into a Unix epoch.
*/
private const val RTC_EPOCH_US = 62135596800L * 1_000_000L
enum class Grade { Unknown, Platinum, Gold, Silver, Bronze }
data class Trophy(
val id: Int,
/** Real name from TROPCONF.SFM. Masked by the UI while a hidden trophy is locked. */
val name: String,
val description: String,
val grade: Grade,
val hidden: Boolean,
val unlocked: Boolean,
/** Unix millis, or null when the file carries no timestamp (never unlocked, or an
* unlock written by something that did not stamp it). */
val unlockedAt: Long?,
/** TROP%03d.PNG for this trophy, or null when the icon is missing. */
val icon: File?,
)
data class Game(
/** The trophy folder name, e.g. NPWR05636_00. The only stable id here a trophy
* set is keyed by comm id, not by the game's title id. */
val commId: String,
val title: String,
val detail: String,
val icon: File?,
val trophies: List<Trophy>,
) {
val total: Int get() = trophies.size
val unlocked: Int get() = trophies.count { it.unlocked }
val percent: Int get() = if (total > 0) 100 * unlocked / total else 0
}
/** Root of the emulator's HDD, i.e. what RPCS3 mounts as /dev_hdd0. */
private fun hdd0(): File = File(RPCSX.getHdd0Dir())
/**
* The trophy directories to scan.
*
* Prefers the logged-in user (Rpcs3Bridge logs in "00000001"), but falls back to
* whichever user folder actually holds a trophy dir: getUser() reaches through JNI
* into the core, which returns null when the core is not open yet, and a browser that
* showed nothing until a game had booted would look broken.
*/
private fun trophyRoots(): List<File> {
val home = File(hdd0(), "home")
val users = home.listFiles().orEmpty().filter { it.isDirectory }
val preferred = runCatching { RPCSX.instance.getUser() }.getOrNull()?.takeIf { it.isNotBlank() }
val ordered = if (preferred != null) {
users.sortedBy { it.name != preferred }
} else {
users
}
val roots = ordered.map { File(it, "trophy") }.filter { it.isDirectory }
// One user is the norm; only that user's sets are shown. Scanning every user would
// merge two people's progress into one list.
return roots.take(1)
}
/**
* Every trophy set on disk, newest-played first is NOT assumed sorted by title so the
* list is stable across sessions.
*
* Blocking disk work: call from Dispatchers.IO.
*/
fun load(): List<Game> {
val dirs = trophyRoots().flatMap { it.listFiles().orEmpty().filter { d -> d.isDirectory } }
return dirs.mapNotNull { readGame(it) }.sortedBy { it.title.lowercase() }
}
/**
* The RUNNING game's trophy set, or null when it has none (or none can be identified).
*
* Blocking disk work: call from Dispatchers.IO.
*
* IDENTIFYING THE SET is the whole difficulty here, because trophy folders are named by
* NPWR comm id and nothing on the folder says which title it belongs to. Two sources,
* in order:
*
* 1. The core's own `current_trophy_name`, via [RPCSX.getCurrentTrophyName]. This is
* exactly what RPCS3's home menu uses to pick the set for its native overlay list,
* written by sceNpTrophyCreateContext. Authoritative, and works for disc and
* installed titles alike.
* 2. The title's TROPDIR, whose subfolders ARE the NPWR ids the title ships. Used only
* when (1) is empty, which happens for a real reason: a game creates its trophy
* context lazily, often not until you reach a menu, so early in a boot the core
* genuinely does not know yet. This covers INSTALLED titles only a disc game's
* TROPDIR is inside the ISO and never lands on the HDD.
*/
fun loadCurrentGame(): Game? {
val roots = trophyRoots()
if (roots.isEmpty()) return null
for (commId in currentGameCommIds()) {
val dir = roots.asSequence().map { File(it, commId) }.firstOrNull { it.isDirectory }
?: continue
readGame(dir)?.let { return it }
}
return null
}
/**
* Candidate trophy folder names for the running game, best guess first. Empty when
* nothing identifies it.
*/
private fun currentGameCommIds(): List<String> {
val fromCore = runCatching { RPCSX.instance.getCurrentTrophyName() }
.getOrNull()?.trim()?.takeIf { it.isNotEmpty() }
if (fromCore != null) return listOf(fromCore)
val titleId = runCatching { RPCSX.instance.getTitleId() }
.getOrNull()?.trim()?.takeIf { it.isNotEmpty() } ?: return emptyList()
// TROPDIR/<NPWRxxxxx_00>/TROPHY.TRP — the folder names are the comm ids.
return File(hdd0(), "game/$titleId/TROPDIR").listFiles().orEmpty()
.filter { it.isDirectory }
.map { it.name }
}
private fun readGame(dir: File): Game? {
val conf = File(dir, "TROPCONF.SFM")
if (!conf.isFile) {
Log.i(TAG, "skipping ${dir.name}: no TROPCONF.SFM")
return null
}
val parsed = runCatching { readTropConf(conf) }.getOrElse {
Log.w(TAG, "failed to parse ${conf.absolutePath}", it)
return null
}
if (parsed.trophies.isEmpty()) return null
// Unlock state is optional: TROPUSR.DAT only exists once the game has registered its
// trophy context. Without it every trophy simply reads as locked, which is correct.
val state = runCatching { readTropUsr(File(dir, "TROPUSR.DAT")) }.getOrElse {
Log.w(TAG, "failed to parse TROPUSR.DAT in ${dir.name}", it)
emptyMap()
}
val trophies = parsed.trophies.map { def ->
val entry = state[def.id]
Trophy(
id = def.id,
name = def.name,
description = def.detail,
// ttype from the XML is what the native overlay uses; the grade duplicated in
// TROPUSR table 4 is the fallback for a set with a missing/odd ttype.
grade = def.grade.takeIf { it != Grade.Unknown } ?: entry?.grade ?: Grade.Unknown,
hidden = def.hidden,
unlocked = entry?.unlocked == true,
unlockedAt = entry?.takeIf { it.unlocked }?.timestamp?.let(::tickToUnixMillis),
// Locale.ROOT: the default locale would render the digits in its own numeral
// system for e.g. Arabic, and the file name is ASCII.
icon = File(dir, String.format(java.util.Locale.ROOT, "TROP%03d.PNG", def.id))
.takeIf { it.isFile },
)
}
return Game(
commId = dir.name,
title = parsed.title.ifBlank { dir.name },
detail = parsed.detail,
icon = File(dir, "ICON0.PNG").takeIf { it.isFile },
trophies = trophies,
)
}
/**
* A CellRtcTick to Unix millis, or null when it is absent or implausible.
*
* Range-checked rather than trusted: a tick of 0 means "no timestamp", and a corrupt
* entry would otherwise render as a date in the year 1 or 30000.
*/
private fun tickToUnixMillis(tick: Long): Long? {
if (tick <= RTC_EPOCH_US) return null
val millis = (tick - RTC_EPOCH_US) / 1000L
// 1980-01-01 .. 2100-01-01. The PS3 itself did not exist before the lower bound.
return millis.takeIf { it in 315_532_800_000L..4_102_444_800_000L }
}
// ---------------------------------------------------------------------------
// TROPCONF.SFM (definitions)
// ---------------------------------------------------------------------------
private data class TrophyDef(
val id: Int,
val name: String,
val detail: String,
val grade: Grade,
val hidden: Boolean,
)
private data class TropConf(
val title: String,
val detail: String,
val trophies: List<TrophyDef>,
)
/**
* Parse the definitions.
*
* The file is plain XML with a signature COMMENT before the root, and no XML
* declaration XmlPullParser handles both. Attribute names and the 'y' test on
* `hidden` follow the native overlay's reload() exactly.
*/
private fun readTropConf(file: File): TropConf {
val parser = XmlPullParserFactory.newInstance().apply { isNamespaceAware = false }
.newPullParser()
var title = ""
var titleDetail = ""
val trophies = ArrayList<TrophyDef>()
file.inputStream().use { stream ->
parser.setInput(stream, null)
// Fields of the <trophy> currently being read; null id = not inside one.
var id: Int? = null
var hidden = false
var grade = Grade.Unknown
var name = ""
var detail = ""
// Which leaf we are collecting text into. <name>/<detail> appear both at
// trophyconf level (title-name/title-detail are separate tags) and inside a
// <trophy>, so the text handler has to know where it is.
var leaf = ""
var event = parser.eventType
while (event != XmlPullParser.END_DOCUMENT) {
when (event) {
XmlPullParser.START_TAG -> when (val tag = parser.name) {
"trophy" -> {
id = parser.getAttributeValue(null, "id")?.trim()?.toIntOrNull()
hidden = parser.getAttributeValue(null, "hidden")
?.firstOrNull()?.lowercaseChar() == 'y'
grade = gradeOf(parser.getAttributeValue(null, "ttype"))
name = ""
detail = ""
leaf = ""
}
else -> leaf = tag
}
XmlPullParser.TEXT -> {
val text = parser.text ?: ""
// Appended unconditionally, not skipped when blank: a parser is free to
// split a run of text at an entity reference, and dropping the blank
// pieces would silently glue "a & b" into "a&b". leaf is cleared on
// every END_TAG, so inter-element whitespace is never collected.
when {
leaf == "title-name" && id == null -> title += text
leaf == "title-detail" && id == null -> titleDetail += text
leaf == "name" && id != null -> name += text
leaf == "detail" && id != null -> detail += text
}
}
XmlPullParser.END_TAG -> {
if (parser.name == "trophy") {
id?.let {
trophies += TrophyDef(
id = it,
name = name.trim(),
detail = detail.trim(),
grade = grade,
hidden = hidden,
)
}
id = null
}
leaf = ""
}
}
event = parser.next()
}
}
return TropConf(title.trim(), titleDetail.trim(), trophies.sortedBy { it.id })
}
private fun gradeOf(ttype: String?): Grade = when (ttype?.firstOrNull()?.uppercaseChar()) {
'B' -> Grade.Bronze
'S' -> Grade.Silver
'G' -> Grade.Gold
'P' -> Grade.Platinum
else -> Grade.Unknown
}
// ---------------------------------------------------------------------------
// TROPUSR.DAT (unlock state)
// ---------------------------------------------------------------------------
private data class UsrEntry(val unlocked: Boolean, val timestamp: Long, val grade: Grade)
/**
* Parse the unlock state, keyed by trophy id.
*
* Layout, from TROPUSR.h:
* 0x00 u32 magic, u32 unk1, u32 tables_count, u32 unk2, char reserved[32]
* 0x30 tables_count * { u32 type, u32 entries_size, u32 unk1, u32 entries_count,
* u64 offset, u64 reserved } (32 bytes each)
* then, per table, entries_count records at `offset`.
*
* All big-endian.
*
* THE STRIDE IS NOT entries_size. entries_size is the size of an entry's PAYLOAD after
* its 16-byte header (type/size/id/unk1), so a record is 16 + entries_size bytes:
* table 4 reports 0x50 and its records are 96 bytes, table 6 reports 0x60 and its
* records are 112 which is exactly sizeof(TROPUSREntry4/6), the stride RPCS3 gets for
* free by reading the structs directly. Using entries_size as the stride parses
* garbage that still looks superficially plausible (verified against a real file: it
* yielded 5 entries out of 29 and grade "unknown" for all of them).
*
* Table 4 carries the grade; table 6 the unlock flag and timestamps.
*/
private fun readTropUsr(file: File): Map<Int, UsrEntry> {
if (!file.isFile) return emptyMap()
val bytes = file.readBytes()
if (bytes.size < 0x30) return emptyMap()
val buf = java.nio.ByteBuffer.wrap(bytes).order(java.nio.ByteOrder.BIG_ENDIAN)
if (buf.getInt(0) != TROPUSR_MAGIC) {
Log.w(TAG, "${file.name}: bad magic")
return emptyMap()
}
val tableCount = buf.getInt(8)
if (tableCount <= 0 || tableCount > 32) return emptyMap()
val grades = HashMap<Int, Grade>()
val states = HashMap<Int, Pair<Boolean, Long>>()
for (t in 0 until tableCount) {
val head = 0x30 + t * 32
if (head + 32 > bytes.size) break
val type = buf.getInt(head)
val entrySize = buf.getInt(head + 4)
val entryCount = buf.getInt(head + 12)
val offset = buf.getLong(head + 16)
if (entrySize <= 0 || entryCount <= 0 || offset < 0) continue
val stride = 16 + entrySize
// Longest field this reads is table 6's timestamp2, at body+24..body+31.
val needed = 16 + 32
for (i in 0 until entryCount) {
val base = offset + i.toLong() * stride
// Bounds-check the bytes actually read, not just the nominal record: a bogus
// entries_size would otherwise let the last record's fields run off the end.
if (base < 0 || base + maxOf(stride, needed) > bytes.size) break
val body = (base + 16).toInt()
when (type) {
4 -> {
val id = buf.getInt(body)
grades[id] = usrGradeOf(buf.getInt(body + 4))
}
6 -> {
val id = buf.getInt(body)
val unlocked = buf.getInt(body + 4) == 1
// timestamp1 at body+16, timestamp2 at body+24. RPCS3's
// GetTrophyTimestamp returns timestamp2; UnlockTrophy writes the same
// tick to both, so they agree in practice.
val timestamp = buf.getLong(body + 24)
states[id] = unlocked to timestamp
}
// Other tables are unused here, as in RPCS3.
}
}
}
return states.mapValues { (id, state) ->
UsrEntry(unlocked = state.first, timestamp = state.second, grade = grades[id] ?: Grade.Unknown)
}
}
/** TROPUSRLoader::trophy_grade — note it is NOT the same numbering as ttype. */
private fun usrGradeOf(value: Int): Grade = when (value) {
1 -> Grade.Platinum
2 -> Grade.Gold
3 -> Grade.Silver
4 -> Grade.Bronze
else -> Grade.Unknown
}
}
@@ -383,6 +383,14 @@ val EN: Map<String, String> = mapOf(
"action.allSettings" to "All Settings",
"action.back" to "Back",
"action.cancel" to "Cancel",
"action.remove" to "Remove",
"overlay.pauseTapReveal.label" to "Tap to reveal pause button",
"overlay.pauseTapReveal.desc" to "Keeps the pause glyph hidden until you tap the top-right corner. That corner always opens this menu either way.",
"packages.licence.remove" to "Remove",
"packages.licence.remove.title" to "Remove this licence?",
"packages.licence.remove.body" to "%s will be deleted. Content it unlocks will stop working until the licence is installed again.",
"packages.licence.removed" to "Licence removed.",
"packages.licence.remove.failed" to "Could not remove the licence file.",
"action.ok" to "OK",
"action.save" to "Save",
"core.settings.title" to "All Core Settings",
@@ -390,6 +398,31 @@ val EN: Map<String, String> = mapOf(
"core.settings.unavailable" to "The emulator core is not loaded, so its settings cannot be read.",
"core.settings.scope.game" to "Changes are remembered for this game only",
"core.settings.scope.global" to "Changes are remembered for every game",
// PS3 trophies (TrophiesScreen). Numbered placeholders (%1/%2/%3) rather than %d, because
// several of these take more than one number and a translator has to be able to reorder them.
"trophies.title" to "Trophies",
"trophies.loading" to "Reading trophy data…",
"trophies.overall" to "%1 of %2 earned across %3 game(s)",
"trophies.progress" to "%1 / %2 earned (%3%)",
"trophies.earned" to "Earned",
"trophies.notEarned" to "Not earned",
"trophies.earnedOn" to "Earned %s",
"trophies.grade.bronze" to "Bronze",
"trophies.grade.silver" to "Silver",
"trophies.grade.gold" to "Gold",
"trophies.grade.platinum" to "Platinum",
"trophies.hidden.name" to "Hidden trophy",
"trophies.hidden.desc" to "This trophy is hidden",
"trophies.showHidden" to "Show hidden trophies",
"trophies.showHidden.desc" to "Some games hide a trophy until you earn it, usually because its name gives away a twist. This lists them, still without their real names.",
"trophies.allHidden" to "Every trophy in this set is hidden and not yet earned.",
"trophies.empty.title" to "No trophies yet",
"trophies.empty.body" to "Trophies appear here once you play a game that has them — the game installs its trophy set the first time it runs, and this reads the same data the console would. A game with no trophy set never adds one.",
// In-game (pause menu) trophies tab. Separate from empty.* above: "this game has none" and
// "you have none at all" are different facts and must not share a string.
"trophies.viewTrophies" to "View trophies",
"trophies.none.title" to "No trophies for this game",
"trophies.none.body" to "This game either has no trophy set, or has not opened it yet — many games only do that once you reach a menu or start playing. Check again later in the session.",
"packages.title" to "Install Package",
"packages.description" to "Install a .pkg game, update or DLC, or a .rap licence file. Some games need both: the .pkg holds the content and the .rap unlocks it. Installed titles are added to your library automatically, and updates and DLC need the base game installed first.",
"packages.select.title" to "Select a .pkg or .rap file",
@@ -407,6 +440,8 @@ val EN: Map<String, String> = mapOf(
"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.licences.count" to "%d licence(s)",
"packages.licences.unattributed" to "Unattributed",
"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.",
@@ -574,6 +609,8 @@ val EN: Map<String, String> = mapOf(
"patches.ps3.serverError" to "RPCS3's patch server returned an error.",
"patches.ps3.parseFailed" to "The patch database downloaded but could not be read. It may be for a newer patch format than this build supports.",
"patches.ps3.toggleFailed" to "Could not change that patch.",
"patches.ps3.checksumFailed" to "The patch database did not match its checksum, so nothing was imported. Try again.",
"patches.ps3.restartNeeded" to "Patches are applied while the game loads. A change here takes effect the next time you launch it, not in a game that is already running.",
"patches.ps3.emptyAll" to "No patches yet. Download the database above to get started.",
"patches.ps3.emptyGame" to "No patches available for this game. Try downloading the database above.",
"common.off" to "Off",
@@ -1100,7 +1137,6 @@ val EN: Map<String, String> = mapOf(
"renderer.shaderPack.presets" to "presets",
"renderer.shaderPack.starting" to "Starting…",
"renderer.upscale.description" to "Internal resolution. Higher values are sharper but can expose game-specific bloom or alignment artifacts.",
"renderer.upscale.label" to "Upscale",
"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 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",
@@ -28,6 +28,7 @@ import com.armsx2.ui.home.HomeScreen
import com.armsx2.ui.language.LanguageScreen
import com.armsx2.ui.saves.SaveManagerScreen
import com.armsx2.ui.textures.TextureManagerScreen
import com.armsx2.ui.trophies.TrophiesScreen
import com.armsx2.ui.settingshub.SettingsScreen
@Composable
@@ -106,6 +107,7 @@ fun AppNavigation() {
AppRoute.ControllerManager -> ControllerManagerScreen(onBack = UiNavigator::home)
AppRoute.TextureManager -> TextureManagerScreen(onBack = UiNavigator::home)
AppRoute.Achievements -> AchievementsScreen(onBack = UiNavigator::home)
AppRoute.Trophies -> TrophiesScreen(onBack = UiNavigator::home)
AppRoute.Language -> LanguageScreen(
onBack = { UiNavigator.navigate(AppRoute.Settings(SettingsCategory.General)) },
)
@@ -19,6 +19,9 @@ sealed interface AppRoute {
data object ControllerManager : AppRoute
data object TextureManager : AppRoute
data object Achievements : AppRoute
// PS3 trophies, read from the emulator's own dev_hdd0 trophy folders. Distinct from
// Achievements above, which is the (hidden) RetroAchievements screen.
data object Trophies : AppRoute
data object Language : AppRoute
data object News : AppRoute
data object Friends : AppRoute
@@ -199,7 +199,12 @@ private fun DrawerContent(selected: AppRoute, onNavigate: (AppRoute) -> Unit, on
// below, which only points the emulator at your BIOS file.
DrawerItem("bios.boot.title", "▶️", onAction = { MainActivityRuntime.startBios(); onDismiss() }),
// ARMSX3: RetroAchievements removed - RA has no PS3 support at all, so
// the screen could only ever be empty.
// the screen could only ever be empty. PS3 TROPHIES take its slot: RPCS3
// tracks the real ones the games unlock, and its own list is reachable only
// from inside a running game (the home menu's Trophies item, which shows
// that game's set alone). This is the across-titles browser, which was
// Qt-only upstream and so had no Android entry point at all.
DrawerItem("trophies.title", "🏆", AppRoute.Trophies),
DrawerItem("action.settings", "⚙️", AppRoute.Settings()),
// Everything the core exposes, generated from its config tree rather than
// hand-written. The curated tabs above stay small on purpose; this is the
@@ -369,6 +374,7 @@ private fun sameDestination(current: AppRoute, target: AppRoute): Boolean = when
AppRoute.ControllerManager -> current is AppRoute.ControllerManager
AppRoute.TextureManager -> current is AppRoute.TextureManager
AppRoute.Achievements -> current is AppRoute.Achievements
AppRoute.Trophies -> current is AppRoute.Trophies
AppRoute.Language -> current is AppRoute.Language
AppRoute.News -> current is AppRoute.News
AppRoute.Friends -> current is AppRoute.Friends
@@ -710,7 +710,21 @@ open class MainActivityRuntime : ComponentActivity() {
}
}
if (restartNow) {
start()
// Queued on vmStopControl rather than called here, because "the run loop has
// exited" is NOT "the stop has finished". stop() enqueues NativeApp.shutdown()
// on that same single-thread executor, and shutdown() sets stopRequested and
// calls kill(). This finally block runs as soon as boot() returns -- which
// stopRequested is exactly what causes -- so calling start() straight from
// here raced ahead of the shutdown that released it, and the kill then landed
// on the VM that had just started, killing it too. Traced on a Restart press:
// START_VM 6ms after the stop began, then a SECOND START_VM 15s later once
// the freshly-killed VM unwound, which is the "Restart kicks you back to the
// library" report.
//
// vmStopControl is single-threaded, so this cannot begin until the pending
// shutdown has returned. execute() and not submit().get(): waiting here would
// block the run-loop thread that kill() may itself be waiting on.
vmStopControl.execute { start() }
} else {
WindowImpl.toolbarVisible.value = true
WindowImpl.showLibrary.value = false
@@ -1039,7 +1053,8 @@ open class MainActivityRuntime : ComponentActivity() {
}
}
if (restartNow) {
start()
// Same ordering as the game path above: queued behind any pending shutdown.
vmStopControl.execute { start() }
} else {
// BIOS exit had no cleanup at all — it relied entirely on stop()'s racy
// branch, so quitting the BIOS also left the launcher stuck in its rotation.
@@ -4703,7 +4718,12 @@ open class MainActivityRuntime : ComponentActivity() {
// the target stick's own (resting) ANALOG writer in the same event.
accumAnalog(target, out)
} else {
NativeApp.setPadButtonForPort(port, target, (out * 32767).toInt(), out > 0f)
// coerceAtLeast(1) because range 0 is the input layer's "full press"
// convention: the lightest real squeeze floors to 0 here, which would
// otherwise be delivered as a FULL press -- the exact opposite of the
// half-press these games want.
val range = if (out > 0f) (out * 32767).toInt().coerceAtLeast(1) else 0
NativeApp.setPadButtonForPort(port, target, range, out > 0f)
}
}
@@ -28,7 +28,9 @@ import kotlinx.coroutines.flow.first
/** A full manager screen shown as an overlay over the paused game (in-game menu). */
enum class InGameScreen {
Settings, CoreSettings, Achievements, Controls, Skins, Textures, SaveState, LoadState
Settings, CoreSettings, Achievements, Controls, Skins, Textures, SaveState, LoadState,
// PS3 trophies for the RUNNING title (the library's Trophies screen, scoped).
Trophies,
}
object WindowImpl {
@@ -193,6 +195,17 @@ object WindowImpl {
InGameScreen.LoadState -> com.armsx2.ui.saves.SaveStatePickerScreen(
mode = com.armsx2.ui.saves.SaveMode.Load, onBack = dismiss,
)
// The library's Trophies screen, scoped to the running title. Same
// screen, not a second implementation. Keyed to the same ViewModel the
// pause menu's Trophies pane uses, so the set it already scanned is
// reused instead of being read off disk again.
InGameScreen.Trophies -> com.armsx2.ui.trophies.TrophiesScreen(
onBack = dismiss,
currentGameOnly = true,
viewModel = androidx.lifecycle.viewmodel.compose.viewModel(
key = com.armsx2.ui.emulation.InGameTrophiesVmKey,
),
)
}
}
}
@@ -25,6 +25,42 @@ import coil.request.ImageRequest
import com.armsx2.CustomCovers
import com.armsx2.GameInfo
/**
* Try each cover source in turn, falling through to [placeholder] when none load.
*
* There were two hand-rolled copies of this, one here and one in the library grid, and they
* disagreed: this one ended its chain at the extracted ICON0.PNG, the grid's ended one step
* earlier at the remote cover. So the in-game menu showed a PS3 game's own artwork while the
* library showed a text placeholder for the same game -- reported for a European PSN title,
* because the art repo's COV set is keyed by USA title IDs and has no entry for it.
*
* Both were also only ONE retry deep, which hid the divergence: the retry slot was spent on
* the regional cover, and whether the local icon ever got a turn depended on whether that URL
* happened to differ from the first one. A chain has no such limit, and one chain cannot
* disagree with itself.
*/
@Composable
fun CoverFallbackChain(
models: List<Any>,
contentDescription: String,
contentScale: ContentScale,
placeholder: @Composable () -> Unit,
) {
val head = models.firstOrNull()
if (head == null) {
placeholder()
return
}
SubcomposeAsyncImage(
model = head,
contentDescription = contentDescription,
modifier = Modifier.fillMaxSize(),
contentScale = contentScale,
loading = { placeholder() },
error = { CoverFallbackChain(models.drop(1), contentDescription, contentScale, placeholder) },
)
}
@Composable
fun GameCoverArt(game: GameInfo, modifier: Modifier = Modifier) {
val context = LocalContext.current
@@ -43,26 +79,18 @@ fun GameCoverArt(game: GameInfo, modifier: Modifier = Modifier) {
error = {
// Cover Region can point at a release the art repo has no cover for; falling straight
// to the placeholder would BLANK a cover the user already had (reported for the in-game
// menu, which uses this component). Retry with this disc's own serial first.
// aldostools does not have art for every title. Rather than drop
// straight to a text placeholder, fall back to the ICON0.PNG we
// extracted from the disc itself -- wrong shape, but it is the real
// game's art and beats nothing.
val discUrl: Any? = if (customCover == null) {
game.discCoverUrl?.takeIf { it != game.coverUrl } ?: game.discIconFile
} else null
if (discUrl != null) {
SubcomposeAsyncImage(
model = discUrl,
contentDescription = game.title,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop,
loading = { GameCoverPlaceholder(game.title, game.serial) },
error = { GameCoverPlaceholder(game.title, game.serial) },
)
} else {
GameCoverPlaceholder(game.title, game.serial)
}
// menu, which uses this component). Retry with this disc's own serial first, then with
// the ICON0.PNG extracted from the disc itself -- wrong shape, but it is the real
// game's art and beats nothing, and aldostools does not have art for every title.
CoverFallbackChain(
models = if (customCover != null) emptyList() else listOfNotNull(
game.discCoverUrl?.takeIf { it != game.coverUrl },
game.discIconFile,
),
contentDescription = game.title,
contentScale = ContentScale.Crop,
placeholder = { GameCoverPlaceholder(game.title, game.serial) },
)
},
)
}
@@ -373,6 +373,7 @@ private fun MenuPage(
EmulationMenuTab.Controls -> ControlsPane(state, viewModel)
EmulationMenuTab.Options -> OptionsPane(state, viewModel)
EmulationMenuTab.Achievements -> AchievementsPane(state, viewModel)
EmulationMenuTab.Trophies -> TrophiesPane(viewModel)
}
}
}
@@ -513,6 +514,9 @@ private fun tabGlyph(tab: EmulationMenuTab): String = when (tab) {
EmulationMenuTab.Controls -> "🎮"
EmulationMenuTab.Options -> ""
EmulationMenuTab.Achievements -> "🏆"
// The trophy cup, same glyph the library drawer's Trophies row uses. It does not collide
// with the RA tab above because that one is filtered out of the rail on ARMSX3.
EmulationMenuTab.Trophies -> "🏆"
}
@Composable
@@ -655,6 +659,17 @@ private fun SessionPane(state: EmulationMenuUiState, viewModel: EmulationMenuVie
// "tap to reveal", NOT show/hide: on = the glyph stays hidden until you tap its top-right
// corner, which surfaces it. Either way that corner always opens this menu, so unlike the
// old on/off toggle there's no setting here that can lock you out of it.
//
// The comment above outlived its control: the row was lost somewhere in the port and the
// setting was left with no writer at all, so the glyph could not be hidden or brought back
// by anyone. Reported as the option missing from this menu, which is exactly what it was.
MenuSwitchRow(
str("overlay.pauseTapReveal.label"),
com.armsx2.ui.touch.TouchControls.pauseTapToReveal.value,
description = str("overlay.pauseTapReveal.desc"),
) { v ->
com.armsx2.ui.touch.TouchControls.setPauseTapToReveal(v)
}
// Removed: PS2 per-primitive filtering; use Anisotropic Filtering.
Spacer(Modifier.height(6.dp))
// OSD mode selector — one control (Full / Minimal / Custom / Off) in place of the old
@@ -956,7 +971,12 @@ private fun PerformancePane(state: EmulationMenuUiState, viewModel: EmulationMen
}
HorizontalOptions(
title = str("perf.displayFpsCap.label"),
options = listOf(0, 20, 30, 45, 60, 90, 120).map {
// 90 and 120 removed: neither can take effect. RPCS3 caps the presented rate at the
// Frame limit enum, which tops out at 60 for anything the PS3 outputs, so a Second Frame
// Limit above that loses the min() at RSXThread.cpp:3676 and the rate stays 60. Offering
// them just invited "the cap does nothing" reports for the two values where that is true
// by construction. (Measured: second=90.00 -> limit=60.00.)
options = listOf(0, 20, 30, 45, 60).map {
it to if (it == 0) str("setup.toggle.off") else "$it FPS"
},
selected = settings.fpsLimit,
@@ -1282,6 +1302,79 @@ private fun AchievementsPane(state: EmulationMenuUiState, viewModel: EmulationMe
state.achievements.forEach { item -> InGameAchievementRow(item) }
}
/**
* The running game's PS3 trophies. RPCS3's own data, not RetroAchievements.
*
* The rows are [com.armsx2.ui.trophies.TrophyRow] the SAME composable the library's Trophies
* screen uses, not a copy so the two lists cannot drift apart. Scoping is
* TrophyRepository.loadCurrentGame(), which asks the core for its `current_trophy_name`.
*
* Its ViewModel is keyed apart from the library screen's so the two do not fight over one
* instance: this pane and the full in-game screen deliberately SHARE that keyed instance, so
* opening the full list reuses what the pane already loaded instead of rescanning.
*/
@Composable
private fun TrophiesPane(viewModel: EmulationMenuViewModel) {
val trophies: com.armsx2.ui.trophies.TrophiesViewModel =
androidx.lifecycle.viewmodel.compose.viewModel(key = InGameTrophiesVmKey)
val state = trophies.state.value
// Re-read on every entry to the tab: a trophy can unlock while the game is running, and the
// set itself only appears once the game creates its trophy context.
LaunchedEffect(Unit) { trophies.refresh(currentGameOnly = true) }
val game = state.games.firstOrNull()
CompactAction(
str("trophies.viewTrophies"),
"🏆",
Modifier.fillMaxWidth(),
viewModel::openTrophies,
)
Spacer(Modifier.height(4.dp))
SectionCard(str("trophies.title")) {
when {
state.loading && game == null -> Text(
str("trophies.loading"),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// Most PS3 games have no trophy set at all, and plenty of those that do only
// register it once you reach a menu — so this is an ordinary state, not an error.
game == null -> Text(
str("trophies.none.body"),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
else -> {
Text(
game.title,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(4.dp))
Text(
com.armsx2.i18n.I18n.get("trophies.progress")
.replace("%1", game.unlocked.toString())
.replace("%2", game.total.toString())
.replace("%3", game.percent.toString()),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Inline list, mirroring the RA pane: the gateway above is still there for the full screen
// (with the show-hidden toggle), but the common case is a glance at what is left.
game?.let { set ->
trophies.visibleTrophies(set).forEach { com.armsx2.ui.trophies.TrophyRow(it) }
}
}
/** Shared ViewModel key for the two in-game trophy surfaces (this pane and the full screen). */
internal const val InGameTrophiesVmKey = "trophies-ingame"
@Composable
private fun InGameAchievementRow(item: AchievementItem) {
Surface(
@@ -19,6 +19,9 @@ enum class EmulationMenuTab(val titleKey: String) {
Controls("tab.controls"),
Options("action.settings"),
Achievements("ra.title"),
// ARMSX3's answer to the (hidden) Achievements tab: the RUNNING game's real PS3
// trophies, read from RPCS3's own dev_hdd0 trophy data.
Trophies("trophies.title"),
// No Friends tab. It lived at the end of a rail that scrolls, so reaching it meant knowing it
// was there and then hunting for it — it is a header button with its own overlay instead.
;
@@ -169,6 +172,9 @@ class EmulationMenuViewModel(application: Application) : AndroidViewModel(applic
0 -> requestToggleHardcore()
1 -> openAchievements()
}
EmulationMenuTab.Trophies -> when (state.value.selectedAction) {
0 -> openTrophies()
}
}
}
@@ -360,6 +366,9 @@ class EmulationMenuViewModel(application: Application) : AndroidViewModel(applic
/** Open the full RetroAchievements screen (list + options) over the paused game. */
fun openAchievements() = com.armsx2.ui.WindowImpl.openInGameScreen(com.armsx2.ui.InGameScreen.Achievements)
/** Open the full trophy list over the paused game, scoped to the running title. */
fun openTrophies() = com.armsx2.ui.WindowImpl.openInGameScreen(com.armsx2.ui.InGameScreen.Trophies)
fun updateSettings(transform: (Settings) -> Settings) {
// ★ Transform the LIVE shared settings, not this screen's snapshot. state.value.settings is
// only refreshed in load(), so every write here shipped the whole Settings object as it
@@ -381,6 +390,9 @@ class EmulationMenuViewModel(application: Application) : AndroidViewModel(applic
EmulationMenuTab.Controls -> 2
EmulationMenuTab.Options -> 5
EmulationMenuTab.Achievements -> 2
// Just the "view trophies" gateway. The rows below it are read-only, and the
// show-hidden toggle lives on the full screen the gateway opens.
EmulationMenuTab.Trophies -> 1
}
private fun Int.floorMod(modulus: Int): Int = ((this % modulus) + modulus) % modulus

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