27 Commits
Author SHA1 Message Date
jpolo1224 c4b45eee27 0.7: SPU and RSX fixes, Oboe audio, and the ports from ouroboros420 and rfandango
SPU: the ARM64 block checksum folded two thirds of every block through
absolute difference, which is not injective, so adding the same value to
two words left the checksum unchanged and similar job binaries hashed
alike. Plain summation now. This is what Precise SPU Verification was
working around, and that setting is exposed properly instead of only being
reachable by hand editing the config.

SPU: a block is no longer marked permanently failed when the trampoline
rebuild fails. The compiled function was live, the state was not
recoverable for the rest of the session, and the claim could never be
retaken.

RSX: render pass churn cut in heavy scenes, roughly 113 to 85 passes per
frame. On a tile based GPU every pass boundary is a full tile store and
reload. Two Vulkan specification violations fixed, and a read/write hazard
on the render pass path.

RSX: the FIFO no longer burns a core on sched_yield while idle.

Android: ADPF is implemented rather than an inert setting, logcat no longer
allocates and makes an IPC call per line, and Silence All Logs is available
for playable titles.

Audio: Oboe backend, for the per device quirks database and stream recovery
on disconnect and route change.

Ported from ouroboros420/rpcsx: GPU Turbo, power and thermal handling, the
crash and freeze fixes, savestate and WSI surface lifetime, honest RAM VRAM
budgeting, the persistent SPU object cache design, occlusion query and RSX
fixes, frame pacing and tiler tuning.

Ported from rfandango/rpcsx: the Turnip ZCULL deadlock fix and ARM64 SPU
checksum handling.

Individual commits are credited in comments at each site.
2026-08-13 18:36:41 -04:00
jpolo1224 87ccdb8515 PPU: stop inverting float-to-int saturation on ARM64
FCTIW, FCTIWZ, FCTID and FCTIDZ carried a saturation correction that only
makes sense on x86. cvtsd2si returns 0x80000000 for any value it cannot
represent, so the result is XORed back into 0x7fffffff on overflow.

FCVTNS and FCVTZS already saturate on their own, so the same XOR turned a
correct result into its opposite: every overflowing conversion produced
INT_MIN where it should have produced INT_MAX. The mask is a no-op when
there is no overflow, so it never did anything except break that case.

Armored Core: For Answer put the player under the floor in the tutorial
because a coordinate that should have clamped high arrived clamped low.

The cache needed a build identity as well. Its key is the executable's
SHA-1 plus a settings bitset and nothing more, so the first attempt at this
fix silently reused objects compiled by the previous build and looked like
it had done nothing. Every earlier PPU codegen change had the same problem
for anyone with a warm cache.

Module loading also no longer abandons the remaining modules after one
object fails to load.
2026-08-13 18:36:26 -04:00
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
99 changed files with 6263 additions and 380 deletions
+3
View File
@@ -112,3 +112,6 @@
path = 3rdparty/protobuf/protobuf
url = ../../protocolbuffers/protobuf.git
ignore = dirty
[submodule "3rdparty/oboe/oboe"]
path = 3rdparty/oboe/oboe
url = https://github.com/google/oboe.git
+6
View File
@@ -141,6 +141,12 @@ else()
add_subdirectory(cubeb EXCLUDE_FROM_ALL)
endif()
# Oboe (Android only)
if(ANDROID)
message(STATUS "Using static oboe from 3rdparty")
add_subdirectory(oboe EXCLUDE_FROM_ALL)
endif()
# SoundTouch
add_subdirectory(SoundTouch EXCLUDE_FROM_ALL)
+8
View File
@@ -0,0 +1,8 @@
# Oboe
#
# Android-only. Oboe wraps AAudio (and OpenSL ES on older devices) and carries a
# per-device quirks database plus stream-restart handling, which is the part that
# matters on the low-end parts where plain AAudio glitches.
add_subdirectory(oboe EXCLUDE_FROM_ALL)
add_library(3rdparty::oboe ALIAS oboe)
Vendored Submodule
+1
Submodule 3rdparty/oboe/oboe added at 0da326e4ef
-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
--------
+34 -3
View File
@@ -1,3 +1,4 @@
#include <cerrno>
#include "File.h"
#include "mutex.h"
#include "StrFmt.h"
@@ -684,8 +685,20 @@ namespace fs
u64 result = 0;
// Loop because (huge?) read can be processed partially
while (auto r = ::read(m_fd, buffer, count))
for (;;)
{
const auto r = ::read(m_fd, buffer, count);
// EINTR is benign -- a signal landed mid-syscall -- and must be retried, not treated
// as failure. Android app storage is FUSE-backed, where this genuinely happens, and
// the ensure() below turns it into a process abort. Ported in spirit from
// ouroboros420/rpcsx (92144f094).
if (r < 0 && errno == EINTR)
{
continue;
}
if (!r) break; // EOF
ensure(r > 0); // "file::read"
count -= r;
result += r;
@@ -702,8 +715,17 @@ namespace fs
u64 result = 0;
// For safety; see read()
while (auto r = ::pread(m_fd, buffer, count, offset))
for (;;)
{
const auto r = ::pread(m_fd, buffer, count, offset);
// See read(): retry EINTR rather than aborting.
if (r < 0 && errno == EINTR)
{
continue;
}
if (!r) break; // EOF
ensure(r > 0); // "file::read_at"
count -= r;
offset += r;
@@ -721,8 +743,17 @@ namespace fs
u64 result = 0;
// For safety; see read()
while (auto r = ::write(m_fd, buffer, count))
for (;;)
{
const auto r = ::write(m_fd, buffer, count);
// See read(): retry EINTR rather than aborting.
if (r < 0 && errno == EINTR)
{
continue;
}
if (!r) break;
ensure(r > 0); // "file::write"
count -= r;
result += r;
+39
View File
@@ -618,6 +618,22 @@ std::string jit_compiler::cpu(std::string_view _cpu)
m_cpu = fallback_cpu_detection();
}
#ifdef ARCH_ARM64
// Detection reads the MIDR of whichever core happens to be running, so on big.LITTLE it
// can name a small in-order core. JIT'd code runs on every core, so scheduling for the
// smallest one is the wrong default -- fall back to the same wide out-of-order baseline
// used when detection fails outright. Only the schedule/cost model is affected: the
// instruction set still comes from setMAttrs (HWCAP-gated), so this can never emit an
// illegal instruction. Ported from ouroboros420/rpcsx (cc3a18e29), widened to the
// A5xx little cores that modern SoCs actually ship.
if (m_cpu == "cortex-a34" || m_cpu == "cortex-a35" || m_cpu == "cortex-a53" ||
m_cpu == "cortex-a55" || m_cpu == "cortex-a510" || m_cpu == "cortex-a520")
{
jit_log.notice("CPU detection named a little core ('%s'); using cortex-a78 as the schedule baseline.", m_cpu);
m_cpu = "cortex-a78";
}
#endif
if (m_cpu == "sandybridge" ||
m_cpu == "ivybridge" ||
m_cpu == "haswell" ||
@@ -754,6 +770,29 @@ jit_compiler::jit_compiler(const std::unordered_map<std::string, u64>& _link, st
fmt::throw_exception("LLVM Emergency Exit Invoked: '%s'", out);
}, nullptr);
// A separate handler from the fatal one -- LLVM installs and dispatches the two
// independently. Without this, an allocation failure inside LLVM (SmallVector growth
// while codegenning the enormous PPU symbol-resolver module, say) writes "LLVM ERROR:
// out of memory" to fd 2 -- which goes nowhere in an Android app -- and calls abort():
// a signal-6 death with nothing whatsoever in the log. Route it through the same
// recoverable path as the fatal handler, so a guarded compile survives and anything
// else at least says why it died. Allocating inside a bad-alloc handler is
// best-effort, but the failures here are huge single allocations, so a short log
// string still succeeds. Ported from ouroboros420/rpcsx (39a6a4c36).
llvm::remove_bad_alloc_error_handler();
llvm::install_bad_alloc_error_handler([](void*, const char* msg, bool)
{
const std::string_view out = msg ? msg : "";
if (g_llvm_fatal_message)
{
*g_llvm_fatal_message = out;
thread_ctrl::silent_exit();
}
fmt::throw_exception("LLVM Out Of Memory: '%s'", out);
}, nullptr);
return true;
}();
+14
View File
@@ -2746,6 +2746,20 @@ const bool s_terminate_handler_set = []() -> bool
{
std::set_terminate([]()
{
// Re-entrancy guard. Under memory exhaustion the terminate path itself allocates
// (report_fatal_error formats a message -> operator new; with -fno-exceptions a failed
// allocation calls std::terminate again), which recurses forever and buries the real
// crash under a stack of aborts. If terminate re-enters, hard-stop without allocating
// so there is exactly one clean tombstone.
// Ported from ouroboros420/rpcsx (281654906).
static atomic_t<int> s_terminating{0};
if (s_terminating.exchange(1) != 0)
{
::signal(SIGABRT, SIG_DFL);
std::abort();
}
if (IsDebuggerPresent())
{
logs::listener::sync_all();
+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 = 11
versionName = "0.7"
// 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,13 @@ struct RPCSXApi {
int (*getState)();
void (*kill)();
void (*resume)();
void (*pause)();
void (*openHomeMenu)();
std::string (*getTitleId)();
unsigned long long (*getFramePeriodNs)();
unsigned long long (*getFrameWorkNs)();
int (*getRsxThreadTid)();
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 +62,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 +72,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 +116,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 +127,13 @@ 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.getFramePeriodNs = reinterpret_cast<decltype(getFramePeriodNs)>(dlsym(handle, "_rpcsx_getFramePeriodNs"));
result.getFrameWorkNs = reinterpret_cast<decltype(getFrameWorkNs)>(dlsym(handle, "_rpcsx_getFrameWorkNs"));
result.getRsxThreadTid = reinterpret_cast<decltype(getRsxThreadTid)>(dlsym(handle, "_rpcsx_getRsxThreadTid"));
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 +153,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 +221,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 +365,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 +401,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
@@ -544,6 +620,26 @@ Java_net_rpcsx_RPCSX_supportsCustomDriverLoading(JNIEnv *env,
return access("/dev/kgsl-3d0", F_OK) == 0;
}
// Force the Adreno GPU to its maximum clocks, or release it back to normal scaling.
//
// Adreno's DVFS ramps clocks up only after it has already seen load, so a scene that suddenly
// becomes GPU-bound stutters through the ramp every time it happens. Pinning the clocks removes
// that at the cost of heat and battery, which is why it is opt-in rather than a default.
//
// Nothing here talks to the emulator core: adrenotools is linked into this JNI library, and
// adrenotools_set_turbo opens /dev/kgsl-3d0 itself and silently does nothing on non-Adreno
// hardware or if the open fails. So it is safe to call unconditionally, including before the core
// is loaded. Ported from the RPCSX Android fork, which ships it default-off; kept default-off here
// for the same reason.
extern "C" JNIEXPORT void JNICALL
Java_net_rpcsx_RPCSX_setGpuTurbo(JNIEnv *, jobject, jboolean on) {
#if defined(__aarch64__)
adrenotools_set_turbo(on == JNI_TRUE);
#else
(void) on;
#endif
}
extern "C" JNIEXPORT jstring JNICALL
Java_net_rpcsx_RPCSX_getVersion(JNIEnv *env, jobject) {
// The core is dlopen()ed separately and may not be up yet -- during
@@ -556,6 +652,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 +808,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 +841,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 +935,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) {
@@ -702,3 +985,29 @@ Java_net_rpcsx_RPCSX_patchSetEnabled(JNIEnv *env, jobject, jstring jhash,
unwrap(env, jserial),
unwrap(env, jappVersion), enabled);
}
// ADPF telemetry. All three return 0 when unmeasured or on a core too old to export them,
// and the Kotlin side treats 0 as "skip this update" rather than feeding the OS a bogus hint.
extern "C" JNIEXPORT jlong JNICALL
Java_net_rpcsx_RPCSX_getFramePeriodNs(JNIEnv *, jobject) {
if (rpcsxLib.getFramePeriodNs == nullptr) {
return 0;
}
return static_cast<jlong>(rpcsxLib.getFramePeriodNs());
}
extern "C" JNIEXPORT jlong JNICALL
Java_net_rpcsx_RPCSX_getFrameWorkNs(JNIEnv *, jobject) {
if (rpcsxLib.getFrameWorkNs == nullptr) {
return 0;
}
return static_cast<jlong>(rpcsxLib.getFrameWorkNs());
}
extern "C" JNIEXPORT jint JNICALL
Java_net_rpcsx_RPCSX_getRsxThreadTid(JNIEnv *, jobject) {
if (rpcsxLib.getRsxThreadTid == nullptr) {
return 0;
}
return static_cast<jint>(rpcsxLib.getRsxThreadTid());
}
@@ -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,62 @@ 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
}
/**
* Import a patch.yml the user picked themselves.
*
* No checksum here, unlike [download]: there is no publisher digest to compare a
* local file against, and the user choosing the file IS the trust decision. The
* core still parses it, so a malformed file is rejected rather than half-applied.
*
* Merges into patches/patch.yml like every other import, so a hand-added patch
* sits alongside the downloaded database instead of replacing it.
*/
fun importLocal(context: Context, uri: android.net.Uri): Result {
val yaml = runCatching {
context.contentResolver.openInputStream(uri)?.use { stream ->
stream.bufferedReader().readText()
}
}.getOrNull()
if (yaml.isNullOrBlank()) return Result.Network
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.
//
@@ -176,6 +176,16 @@ data class Ps3Settings(
val audioTimeStretch: Boolean = false,
val audioBuffering: Boolean = true,
val audioBufferMs: Int = 34,
/** cubeb backend: 0 auto, 1 aaudio, 2 opensl, 3 audiotrack. Auto reaches AAudio on
* anything modern; opensl trades latency for far fewer underruns on weak devices. */
val audioCubebBackend: Int = 0,
/** Pin Adreno to max clocks: removes DVFS ramp-up stutter in GPU-bound scenes, costs heat
* and battery. Off by default, matching the RPCSX fork this came from. */
val gpuTurbo: Boolean = false,
/** Stops the core writing any log after startup. A real performance lever on games that
* log heavily, but it also destroys the only artifact a bug report can carry, so it is
* off by default and the UI says so plainly. */
val silenceAllLogs: Boolean = false,
val netEnabled: Boolean = false,
val psnStatus: Boolean = false,
val upnpEnabled: Boolean = false,
@@ -1017,6 +1027,7 @@ data class Settings(
// surface layout. Keeping them in sync stops "Stretch" looking inert.
put("PS3/Video", "Stretch To Display Area", "bool", (displayFitMode == 1).toString())
put("PS3/Video", "Display Aspect Override", "int", ps3.displayAspect.coerceIn(0, 4000).toString())
put("PS3/Misc", "Silence All Logs", "bool", ps3.silenceAllLogs.toString())
put("PS3/Overlay", "Enabled", "bool", ps3.overlayEnabled.toString())
put("PS3/Overlay", "Detail level", "enum", ps3.overlayDetail.toString())
put("PS3/Overlay", "Enable Framerate Graph", "bool", ps3.overlayFramerateGraph.toString())
@@ -1049,6 +1060,7 @@ data class Settings(
put("PS3/Audio", "Enable Time Stretching", "bool", ps3.audioTimeStretch.toString())
put("PS3/Audio", "Enable Buffering", "bool", ps3.audioBuffering.toString())
put("PS3/Audio", "Desired Audio Buffer Duration", "int", ps3.audioBufferMs.toString())
put("PS3/Audio", "Cubeb Backend", "int", ps3.audioCubebBackend.toString())
put("PS3/Net", "Internet enabled", "enum", ps3.netEnabled.toString())
put("PS3/Net", "PSN status", "enum", ps3.psnStatus.toString())
put("PS3/Net", "UPNP Enabled", "bool", ps3.upnpEnabled.toString())
@@ -1294,10 +1306,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
@@ -1986,8 +2006,11 @@ data class Settings(
put("ps3ClocksScale", ps3.clocksScale)
put("ps3ResolutionScale", ps3.resolutionScale)
put("ps3MsaaMode", ps3.msaaMode)
put("ps3AudioCubebBackend", ps3.audioCubebBackend)
put("ps3ShaderMode", ps3.shaderMode)
put("ps3WriteColorBuffers", ps3.writeColorBuffers)
put("ps3GpuTurbo", ps3.gpuTurbo)
put("ps3SilenceAllLogs", ps3.silenceAllLogs)
put("ps3WriteDepthBuffer", ps3.writeDepthBuffer)
put("ps3ReadColorBuffers", ps3.readColorBuffers)
put("ps3ReadDepthBuffer", ps3.readDepthBuffer)
@@ -2321,8 +2344,11 @@ data class Settings(
clocksScale = json.optInt("ps3ClocksScale", def.ps3.clocksScale),
resolutionScale = json.optInt("ps3ResolutionScale", def.ps3.resolutionScale),
msaaMode = json.optInt("ps3MsaaMode", def.ps3.msaaMode),
audioCubebBackend = json.optInt("ps3AudioCubebBackend", def.ps3.audioCubebBackend),
shaderMode = json.optInt("ps3ShaderMode", def.ps3.shaderMode),
writeColorBuffers = json.optBoolean("ps3WriteColorBuffers", def.ps3.writeColorBuffers),
gpuTurbo = json.optBoolean("ps3GpuTurbo", def.ps3.gpuTurbo),
silenceAllLogs = json.optBoolean("ps3SilenceAllLogs", def.ps3.silenceAllLogs),
writeDepthBuffer = json.optBoolean("ps3WriteDepthBuffer", def.ps3.writeDepthBuffer),
readColorBuffers = json.optBoolean("ps3ReadColorBuffers", def.ps3.readColorBuffers),
readDepthBuffer = json.optBoolean("ps3ReadDepthBuffer", def.ps3.readDepthBuffer),
@@ -2636,8 +2662,11 @@ data class Settings(
if (current.ps3.clocksScale != base.ps3.clocksScale) j.put("ps3ClocksScale", current.ps3.clocksScale)
if (current.ps3.resolutionScale != base.ps3.resolutionScale) j.put("ps3ResolutionScale", current.ps3.resolutionScale)
if (current.ps3.msaaMode != base.ps3.msaaMode) j.put("ps3MsaaMode", current.ps3.msaaMode)
if (current.ps3.audioCubebBackend != base.ps3.audioCubebBackend) j.put("ps3AudioCubebBackend", current.ps3.audioCubebBackend)
if (current.ps3.shaderMode != base.ps3.shaderMode) j.put("ps3ShaderMode", current.ps3.shaderMode)
if (current.ps3.writeColorBuffers != base.ps3.writeColorBuffers) j.put("ps3WriteColorBuffers", current.ps3.writeColorBuffers)
if (current.ps3.gpuTurbo != base.ps3.gpuTurbo) j.put("ps3GpuTurbo", current.ps3.gpuTurbo)
if (current.ps3.silenceAllLogs != base.ps3.silenceAllLogs) j.put("ps3SilenceAllLogs", current.ps3.silenceAllLogs)
if (current.ps3.writeDepthBuffer != base.ps3.writeDepthBuffer) j.put("ps3WriteDepthBuffer", current.ps3.writeDepthBuffer)
if (current.ps3.readColorBuffers != base.ps3.readColorBuffers) j.put("ps3ReadColorBuffers", current.ps3.readColorBuffers)
if (current.ps3.readDepthBuffer != base.ps3.readDepthBuffer) j.put("ps3ReadDepthBuffer", current.ps3.readDepthBuffer)
@@ -2932,8 +2961,11 @@ data class Settings(
clocksScale = if (overrides.has("ps3ClocksScale")) overrides.getInt("ps3ClocksScale") else base.ps3.clocksScale,
resolutionScale = if (overrides.has("ps3ResolutionScale")) overrides.getInt("ps3ResolutionScale") else base.ps3.resolutionScale,
msaaMode = if (overrides.has("ps3MsaaMode")) overrides.getInt("ps3MsaaMode") else base.ps3.msaaMode,
audioCubebBackend = if (overrides.has("ps3AudioCubebBackend")) overrides.getInt("ps3AudioCubebBackend") else base.ps3.audioCubebBackend,
shaderMode = if (overrides.has("ps3ShaderMode")) overrides.getInt("ps3ShaderMode") else base.ps3.shaderMode,
writeColorBuffers = if (overrides.has("ps3WriteColorBuffers")) overrides.getBoolean("ps3WriteColorBuffers") else base.ps3.writeColorBuffers,
gpuTurbo = if (overrides.has("ps3GpuTurbo")) overrides.getBoolean("ps3GpuTurbo") else base.ps3.gpuTurbo,
silenceAllLogs = if (overrides.has("ps3SilenceAllLogs")) overrides.getBoolean("ps3SilenceAllLogs") else base.ps3.silenceAllLogs,
writeDepthBuffer = if (overrides.has("ps3WriteDepthBuffer")) overrides.getBoolean("ps3WriteDepthBuffer") else base.ps3.writeDepthBuffer,
readColorBuffers = if (overrides.has("ps3ReadColorBuffers")) overrides.getBoolean("ps3ReadColorBuffers") else base.ps3.readColorBuffers,
readDepthBuffer = if (overrides.has("ps3ReadDepthBuffer")) overrides.getBoolean("ps3ReadDepthBuffer") else base.ps3.readDepthBuffer,
@@ -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.",
@@ -433,6 +468,8 @@ val EN: Map<String, String> = mapOf(
"audio.synchronization.label" to "Time Stretching",
"audio.synchronization.description" to "Keeps audio pitch correct when the emulator runs below full speed, instead of letting sound slow and drop in pitch along with it. Costs a little CPU; turn it off if audio stutters more with it on.",
"audio.buffer.label" to "Audio Buffer",
"audio.cubebBackend.label" to "Audio Backend (advanced)",
"audio.cubebBackend.description" to "Which system audio path the emulator uses. Auto picks AAudio, which has the lowest latency but the smallest buffers. OpenSL adds latency and is much harder to starve — try it if audio crackles or stutters on a slower device.",
"audio.buffer.description" to "Bigger buffer = fewer crackles/dropouts but more latency. Raise this if audio stutters on low-end devices.",
// --- Recompiler (JIT) tab ---
"jit.recompiler.warning" to "Disabling a recompiler drops that CPU/COP onto its interpreter — much slower, for debugging only. Changes apply to the running game.",
@@ -490,6 +527,8 @@ val EN: Map<String, String> = mapOf(
"adv.ppuRsvPriority.description" to "Gives the main CPU priority over the SPUs when competing for the same memory. Can help games that stall waiting on the PPU.",
"adv.spuVerification.label" to "SPU Verification",
"adv.spuVerification.description" to "Verifies compiled SPU code against the original. Catches miscompiles; turning it off is faster but makes bad codegen silent.",
"adv.preciseSpuVerification.label" to "Precise SPU Verification",
"adv.preciseSpuVerification.description" to "Compares cached SPU code byte for byte instead of by checksum. Slower, but games stream different SPU jobs through the same memory, so a checksum collision can run one job's code on another's data. Try this for physics or logic that goes wrong without crashing, such as falling through the ground.",
"adv.section.fpu" to "Floating Point",
"adv.section.fpu.help" to "PowerPC floating-point corner cases. Defaults are correct for almost every game \u2014 these exist for the handful that depend on exact NaN or rounding behaviour.",
"adv.ppuNan.label" to "PPU Vector NaN Handling",
@@ -561,19 +600,27 @@ val EN: Map<String, String> = mapOf(
"renderer.consoleAspect.description" to "The aspect the emulated PS3 reports to the game. The console only ever signalled 4:3 or 16:9, so those are the only real options \u2014 Auto leaves it to the game. This is what the game renders for; how it is fitted to YOUR screen is the setting below.",
"renderer.fit.auto" to "Fit",
"renderer.fit.stretch" to "Stretch",
"perf.silenceLogs.label" to "Silence all logs",
"perf.silenceLogs.description" to "\u26a0 We cannot help you with a bug if logs are disabled \u2014 a report without a log is usually unfixable. That said, a few games log so heavily that this is worth several FPS, so it can be the difference for a game sitting just under playable. Turn it back on before reporting anything.",
"renderer.gpuTurbo.label" to "GPU Turbo (Adreno)",
"renderer.gpuTurbo.description" to "Pin the GPU to its maximum clocks instead of letting it scale on demand. Removes the stutter when a scene suddenly gets heavy, at the cost of noticeably more heat and battery. Adreno only \u2014 ignored on other GPUs.",
"renderer.upscale.label" to "Internal Resolution",
"audio.renderer.label" to "Audio Backend",
"audio.renderer.description" to "Cubeb is the only working backend on Android. Off disables audio entirely, which can claw back a little CPU on a struggling device.",
"audio.renderer.description" to "Cubeb is the general-purpose backend. Oboe is Android-specific and carries per-device workarounds plus automatic recovery when the audio route changes, so try it if you get crackling or dropouts. Off disables audio entirely, which claws back a little CPU on a struggling device.",
"patches.ps3.header" to "Community patches and graphics mods from RPCS3. These can unlock frame rates, change resolution or LOD, add widescreen, and fix game bugs. Download once, then enable per game.",
"patches.ps3.download" to "Download / update patch database",
"patches.ps3.search" to "Search patches",
"patches.ps3.showing" to "%1 games, %2 patches \u2014 tap a game to see its patches, or search.",
"patches.ps3.downloading" to "Downloading patches\u2026",
"patches.ps3.imported" to "patches imported",
"patches.ps3.importFile" to "Import a patch file",
"patches.ps3.importFailed" to "Could not read that file. Pick a patch.yml exported from RPCS3.",
"patches.ps3.downloadFailed" to "Could not download the patch database. Check your connection.",
"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 +1147,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)) },
)

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