56 Commits
Author SHA1 Message Date
jpolo1224 14079425fa Credit CamilleLaVey in the SGSR implementation, not just the shader
Git authorship covers the commits; the feature's own source should say where
it came from too, since that is what someone reading the code later actually
sees.
2026-08-24 17:06:23 -04:00
jpolo1224 4bf82b6df0 Touch editor: dock the panel away from the widget being edited
I left this out when porting the collapse toggle, on the grounds that ARMSX2
found docking alone did not answer the complaint. That was right about docking
being insufficient and wrong about it being unnecessary -- the two cover
different halves of the same problem. Collapse answers 'I cannot select what
is under you in the first place'; docking answers 'I have selected it and now
you are sitting on top of it'.

Selecting a widget in the top half moves the panel to the bottom, and back
again otherwise. Halves rather than real overlap maths, on purpose: a panel
that darts about as rectangles graze each other is less predictable than one
that is simply never on the side being worked on.

The stored drag offset means 'away from the anchored edge', so it flips sign
with the anchor. ARMSX3 anchors the panel top-centre and offsets downward, so
while docked to the bottom a +dy the user had nudged in would otherwise push
the panel straight off the screen.

Note for testing: this is only visible when selecting a widget in the TOP
half. Most on-screen controls live in the lower half, so selecting a face
button correctly leaves the panel where it is -- which is indistinguishable
from nothing happening. Use a shoulder button.
2026-08-24 17:03:07 -04:00
jpolo1224 03b5bde716 Sharpening in the in-game menu, named for the upscaler in use
The slider was missing from the in-game menu entirely -- it only existed in
the settings screen. That is the wrong way round: the quick menu is where the
upscaler gets changed mid-game, and having to leave the game to tune what you
just switched to defeats the point of it being there.

Shown only for FSR and SGSR, since nearest and bilinear have nothing to
sharpen.

The label follows the selection in both places, because the number does not
mean the same thing to each: it is an RCAS stop to FSR and an edge factor to
SGSR, and a slider named for the upscaler that is not running is simply
wrong. One control rather than two, deliberately -- both upscalers want the
same thing from the user, and a second slider only lets them disagree.
2026-08-24 16:55:56 -04:00
jpolo1224 81346ebb8b Touch editor: let the panel get out of the way
Ported from ARMSX2, which built it against a user complaint worth restating:
the panel covers the thing you are trying to edit, and the only remedy on
offer was to drag it away, every time.

ARMSX2's first answer was auto-docking the panel to the opposite half of the
screen from the selected widget. It works, and it did NOT fix the complaint --
selecting a widget under the panel means touching through the panel first, so
a reactive fix cannot help with an obstruction that happens strictly before
there is anything to react to.

What fixed it was a collapse toggle: one tap leaves the grip strip and
uncovers everything beneath it, one tap brings the controls back. It is first
in the grip row so it sits in the same place whether the panel is open or
shut, and it is neither persisted nor carried between sessions -- it is a
momentary 'let me see under this', and an editor that opened to a panel with
no controls on it would look broken.

Column is an inline composable, so the collapsed path returns out of it and
genuinely stops emitting the rest rather than drawing it invisibly.

Auto-dock is not ported. It is the half that demonstrably did not solve the
problem, it needs an anchor flip this panel does not have (top-anchored with a
downward offset, where ARMSX2 switches edges), and the offset sign trap that
comes with it is real. Worth revisiting only if collapse turns out to be
insufficient.
2026-08-24 16:48:02 -04:00
jpolo1224 f53a76c0fc SGSR upscaling
Snapdragon Game Super Resolution 1.0, mobile variant: a single-pass
edge-directed spatial upscaler Qualcomm wrote for Adreno. Against FSR1 it is
one dispatch instead of two and one target instead of two, which is what makes
it worth having on a phone -- cheaper, not better.

Licensing is the reason this is a reimplementation rather than a port.
Suggested by CamilleLaVey, who made the same filter work in Eden, but Eden's
glue is GPL-3.0-or-later and RPCS3 is GPL-2.0-ONLY, so none of it is used --
the same blocker that stopped the LSFG adoption, and permission cannot fix it
because Eden has other contributors. What IS used is Qualcomm's BSD-3-Clause
release, which is GPL-2.0 compatible, with the copyright notice kept. The crop
mapping and the widened sharpness range are reimplemented from a description
of what they do, which is not copyrightable.

Qualcomm ship it as a fragment shader over a fullscreen triangle; this is a
compute pass because that is what the VK device layer already schedules. The
interpolated texcoord becomes a UV from the invocation id and the fragment
output becomes an imageStore, with a bounds check because a dispatch rounds up
to whole workgroups.

The push constant offsets were read out of the compiled SPIR-V rather than
derived from the struct -- 0/8/16/24/32/40, 44 bytes -- because a mismatch
there produces garbage that looks exactly like a shader bug. glslc also
type-checks the GLSL, which the native build cannot: shaders here are compiled
at runtime, so a broken one builds fine and fails on device.

No vendor gate, deliberately: its requirements are a strict subset of FSR1's
(textureGather with a constant component and no offset, an rgba8 storage
image, one descriptor set), so anywhere FSR1 runs, this runs. It no-ops when
the frame is already at or above output resolution, which is correct and
indistinguishable from broken, so the setting text says so.

Wired at all five places the mode is represented: the enum (appended, never
inserted -- it is serialised by ordinal in savestates), the fmt_class_string
case (a missing one serialises as 'unknown' and the mode is silently never
selectable), the VK dispatch, both Kotlin pickers, and the persisted clamp
that would otherwise rewrite the new value straight back to FSR.
2026-08-24 16:46:30 -04:00
jpolo1224 13dcec9e66 Merge RPCS3 upstream, excluding the ISO timestamp change
14 of the 15 upstream commits since bab81aa23. The fifteenth, 3aea3b15d 'Fix
ISO timestamps', is deliberately left out: it touches rpcs3/Loader/ISO.cpp,
and the Aug-2026 upstream ISO refactor is already reverted here because it
breaks some images (region_count reads 0 and the disc will not mount). It is
the tip commit, so merging its parent excluded it exactly, with no surgery.

Four conflicts, all of them ours-and-theirs rather than either-or:

nv4097.cpp conflicted whole-file. Took ours and applied upstream 071c9f10f's
set_shading_mode by hand -- an earlier merge of this same file lost two hunks
by resolving it wholesale, and that is recorded in c6a0878a9.

VKPipelineCompiler.cpp: ours has the mobile dynamic-state work (topology-class
collapsing, normalize_dynamic_pipeline_state, compiler thread affinity),
upstream adds a provoking-vertex chain for flat shading. They are independent,
so both are in, with the rasterization state rebased as upstream needs.

device.cpp: three hunks, all parallel feature queries -- extended dynamic
state and the Android LSFG feature bits on our side, provoking vertex on
theirs. All kept; ours' extension push needed its own closing brace.

BUILDING.md stays deleted: 30fc4e566 folded it into the README, and upstream
merely edited it.

Core builds and links.
2026-08-24 16:13:52 -04:00
jpolo1224 2fc3566a7b Add the Thermals source file itself
It was written but never staged: 'commit -a' does not pick up an untracked
file, so the temperature feature went in as its wiring only -- the settings
row, the startup hook and the native push all referencing a class that is not
in the repository. Builds here kept working because the file exists on disk;
a fresh clone would not have compiled.
2026-08-24 16:09:04 -04:00
jpolo1224 8bc2da9d3f Android: 0.9.4.4 (versionCode 38)
Resumes the public version line after 0.9.4.3. The 0.9.4.4 through 0.9.5.7
bumps were internal test builds for the Xillia 2 hang hunt and were never
released; versionCode keeps climbing past them so a tester carrying one can
still update rather than having to uninstall.
2026-08-24 16:01:28 -04:00
jpolo1224 d65a603547 Remove the Xillia 2 hang instrumentation
Seven detectors over as many reproductions, none of which caught it. The
final one settles why: with cia sampled once a second, the guest threads are
found at a DIFFERENT address every time -- 0x011f63ac, 0x00278268, 0x008ac3a4,
0x0141512c -- so the best repeat count never exceeded 1.

The thread is not parked anywhere. It executes a great deal of varied guest
code at over 100% of a core while making no progress, consistent with
sys_ppu_thread_yield at ~100 million. A busy-wait that does real work each
iteration cannot be found by watching for something to stop, which is what
every one of these tried, in a different place each time.

What was learned and is worth keeping is recorded in the commits: frames keep
flipping throughout (so no frame-based check can see it), lock traffic never
ramps up because the hang precedes any workload, rsx::thread spins in
NV406E_SEMAPHORE_ACQUIRE, and PPU[0x1000000] burns 4.36s of CPU per 4s of wall
clock. The next attempt should start from a guest-side breakpoint or an
instruction trace, not from another liveness heuristic.

The structural fixes found along the way stay: on_frame_end no longer counts
forced frames as guest progress, and check_frame_stall dumps guest threads
rather than only reporting. Both are correct independently of this hunt.
2026-08-24 15:57:34 -04:00
jpolo1224 cd1fd0e585 Android: 0.9.5.7 (versionCode 37) 2026-08-24 15:37:35 -04:00
jpolo1224 54f354c6ef Spin detector: count repeats of one cia, and decay instead of resetting
The range-window version reset on every excursion, and its own report made
that look like success. widest_range=0x0 does not mean an identical cia -- it
means the entry had just been reset, so lo==hi. I read it the other way and
concluded the threshold was fine.

The thread mostly sits in a small loop (one sample caught it inside 0x500) and
occasionally wanders far enough -- a helper, a syscall handler -- to blow any
fixed window. So every all-or-nothing scheme measured nothing: hard reset on
an out-of-range sample threw away all the evidence collected before it, every
time.

Count how often each thread is found at the same cia and decay by one on a
miss instead. An occasional excursion now costs a point rather than the whole
history, so a thread parked at one address 90% of the time still accumulates,
while a thread genuinely making progress still falls to zero. Threads parked
in a syscall are skipped, never counted against, so idle still cannot look
like spin.

The state line reports the best count and the address it is stuck at, so a
miss says how close it got and where.
2026-08-24 15:37:35 -04:00
jpolo1224 bbf7520fc9 Android: 0.9.5.6 (versionCode 36) 2026-08-24 15:31:31 -04:00
jpolo1224 123be4da84 Spin detector: a wait sample skips, it does not reset
Erasing the tracked state whenever cpu_flag::wait was set is what stopped
this firing. The loop dips into a syscall regularly -- almost certainly
sys_ppu_thread_yield, seen at ~100 million -- and every dip cleared the
accumulated seconds, so the counter never got past 2.

The device reported it precisely once the range was instrumented:
widest_range=0x0, meaning an identical cia on every single sample. As tight a
spin as can exist, and invisible purely because of that erase.

A wait sample is now skipped rather than treated as evidence against a spin.
A thread genuinely parked in a syscall never accumulates running samples at
all, so idle threads still cannot trip it.
2026-08-24 15:31:31 -04:00
jpolo1224 aa04822135 Android: 0.9.5.5 (versionCode 35) 2026-08-24 15:26:30 -04:00
jpolo1224 df1cb84789 Spin detector: measure the cia range, and report it
The 1 KiB base+offset window measured nothing. On the hung device it reported
tracked=1 longest=0s: one running thread, whose cia left the window every
second, so the window reset on every tick and the counter never advanced past
zero. The loop is wider than a handful of instructions -- it polls and calls
helpers -- which the previous shape could not represent at all.

Track the RANGE cia has covered instead, and allow 64 KiB of it. A polling
loop that calls helpers stays within tens of KiB; ordinary execution covers
megabytes in a second.

The state line now reports the widest range being tracked. If this still does
not fire, that number is itself the answer -- it says how large the loop
really is, and therefore what the threshold must be, instead of costing
another reproduction to find out.
2026-08-24 15:26:30 -04:00
jpolo1224 59dc158841 Android: 0.9.5.4 (versionCode 34) 2026-08-24 15:19:49 -04:00
jpolo1224 ea52e9bef4 Detect a spinning guest thread, not a stopped one
Five earlier attempts at catching this hang keyed on something STOPPING --
frames, then lock traffic -- and every one missed it, because nothing stops.

Measured on the hung device: PPU[0x1000000] burning 4.36s of CPU across 4s of
wall clock, more than a full core, while rsx::thread spun on
NV406E_SEMAPHORE_ACQUIRE. Tales of Xillia 2 white-screens when its Bandai logo
is skipped, and the guest's main thread is not blocked at all -- it sits in a
tight guest-side wait loop, making no syscalls, taking no locks and writing no
log lines. A frame-based detector saw frames still flipping, a lock-based one
saw a peak of 10 locks/sec to fall from, and neither was wrong about what it
measured. They were measuring the wrong thing.

So look for the opposite of a stall: a thread that is RUNNING -- no
cpu_flag::wait, meaning not parked in a syscall -- whose cia has not left a
1 KiB window for 30 seconds. A spin loop is a few instructions branching to
themselves; ordinary execution walks cia across the binary many times a
second. Threads waiting in a syscall are skipped, so an idle game cannot trip
it.

Dumps twice, 15s apart, so the second shows whether cia moved at all between
them, and reports its own state every 10s: threads tracked, longest spin,
dumps taken.
2026-08-24 15:19:35 -04:00
jpolo1224 d1f7060c19 Android: 0.9.5.3 (versionCode 33) 2026-08-24 15:09:33 -04:00
jpolo1224 b8cc008d96 Hang detector: sys_mutex_lock only, and a relative collapse
Two corrections to the previous attempt, both measured rather than reasoned.

Including _sys_lwmutex_lock broke it. That counter keeps climbing straight
through the hang at a flat ~375 per 10s -- the idle loops take lightweight
mutexes -- so the 'unchanged' test re-armed on every tick and the detector
never fired. sys_mutex_lock alone froze outright, at 22.

And 'exactly zero' only fits Xillia 2. Kane & Lynch collapsed from ~200,000
per 10s to ~300, which is just as dead and never reaches zero. So the test is
now relative: remember the busiest rate this game has reached, decay it
slowly, and call it a hang when the current rate stays under a fiftieth of
that for 30 seconds. A title that has never been busy has no peak to fall from
and cannot trip it; the 5000/s floor sits far below every busy rate measured
(20,000+) and far above anything idle.

The syscall code is resolved by name from g_ppu_syscall_table once, rather
than hardcoded, so it cannot silently come to mean a different syscall.

Also logs its own state every 10s -- rate, peak, quiet seconds, dumps taken.
Five attempts at detecting this hang have now failed, every one of them
silently, and each cost a reproduction to discover. The detector reporting
what it sees is worth more than the detector being clever.
2026-08-24 15:09:23 -04:00
jpolo1224 db38d6d737 Android: 0.9.5.2 (versionCode 32) 2026-08-24 15:02:59 -04:00
jpolo1224 5e49d77c76 Detect a hang by guest lock traffic, not by frames
The frame-based check cannot see this class of hang at all. Tales of Xillia 2
white-screens with its RENDER loop still running: it submits real, non-forced
flips every ~10ms forever, so 'no frame presented' is never true while the
game logic behind them is dead. Measured on device -- g_last_frame_time was
9-12ms old on every sample taken across the hang. Four fixes to the
frame-based detector were all fixing the wrong instrument.

What actually stops is lock traffic. Both hangs seen so far -- Xillia 2's
white screen and Kane & Lynch's freeze -- show mutex acquisition at exactly
zero for minutes while sys_timer_usleep and sys_event_queue_receive continue
at flat, identical rates, which is idle service loops and nothing else. Both
games were taking 100k+ locks per 10s until the moment they stopped.

Polled from the PPU syscall usage thread, which already holds the counters and
is independent of both the RSX thread and the guest. Bounded the same way as
the other path: two dumps, the second 15s after the first so a cia that has
not moved between them is distinguishable from slow progress, re-armed only
when lock traffic resumes.
2026-08-24 15:02:59 -04:00
jpolo1224 595f8009cc Android: 0.9.5.1 (versionCode 31) 2026-08-24 14:56:10 -04:00
jpolo1224 53f8ab219f Watchdog: IsStopped() is true while LOADING, so the guard skipped it
The watchdog was called under !Emu.IsPaused() && !Emu.IsStopped(). The
default IsStopped() overload is m_state <= system_state::stopping, and the
enum orders stopped, loading, stopping, running -- so it reports true for a
game that is LOADING.

A hang during a load is exactly what this watches for. Tales of Xillia 2
white-screens mid-load once its logos are skipped, so the guard skipped the
watchdog on every tick of the precise case it exists for, and skipped it
silently: not a declined decision anyone could read, just no call at all.
Nine minutes of held white screen produced no output whatsoever.

Use IsStopped(true), which is the fully-stopped test.

Also log the watchdog's decision once every 10s -- progress flag, last frame
timestamp, its age, dumps taken. Three attempts at this detector have failed
silently on a reproducible hang; the only evidence each time was an absence,
which cannot say which branch won. One line per ten seconds makes the next
failure a fact rather than another guess.
2026-08-24 14:55:58 -04:00
jpolo1224 bfc0b5d73e Android: 0.9.5.0 (versionCode 30) 2026-08-24 14:49:27 -04:00
jpolo1224 fae49e2b6c Watchdog: seed the stall clock instead of bailing on it
poll_frame_stall_watchdog returned early when g_last_frame_time was zero,
where the RSX-side check seeds it -- and seeding is what starts the clock.
Since the whole reason the watchdog exists is an RSX thread too stuck to run
that check, nothing ever seeded it: the value stayed zero and the watchdog
bailed on every tick forever, blocked in exactly the scenario it was written
for.

Reproduced on Tales of Xillia 2: white screen held for nearly nine minutes,
guest mutex traffic zero throughout, and not one dump.
2026-08-24 14:49:26 -04:00
jpolo1224 c3cc0c9687 Android: 0.9.4.9 (versionCode 29) 2026-08-24 14:35:17 -04:00
jpolo1224 03500a8da6 Poll the hang watchdog off the RSX thread
check_frame_stall() runs from do_local_task, on the RSX thread's own FIFO
loop. That works for a guest-side hang with the RSX idle -- every hang chased
so far -- and is useless for the opposite case, where the RSX thread is the
one stuck. It never returns to do_local_task, so the detector that would
report the hang is starved by the hang.

Tales of Xillia 2 (BLUS31397) is exactly that. Reproduced on device: guest
mutex traffic at zero for minutes while sys_event_queue_receive and
sys_timer_usleep tick at flat identical rates, rsx::thread accumulating 4.99s
of CPU per 5s of wall clock, and NV406E_SEMAPHORE_ACQUIRE its costliest method
at 1.59ms a call. The RSX is spinning on a guest semaphore the stopped guest
will never write, and nothing reported any of it.

The same condition is now polled once a second from the PPU syscall usage
thread, which is independent and keeps running. That half only dumps; the
on-screen message and the native-UI flip stay on the RSX side, because the
overlay is not safe to drive from another thread. Both share one dump budget
so they cannot produce four dumps between them.
2026-08-24 14:30:19 -04:00
jpolo1224 f130141eb3 Android: 0.9.4.8 (versionCode 28) 2026-08-24 14:22:27 -04:00
jpolo1224 3727c000bc Stop the hang detector disarming itself after the first stall
check_frame_stall() arms native-UI flipping when it reports a stall, so the
guest has something on screen while it is hung. Those synthetic flips reach
flip(), which finds nothing queued and calls on_frame_end(buffer, true), and
on_frame_end refreshed g_last_frame_time unconditionally. So the first hang of
a session switched on a flip source that then refreshed the timestamp forever,
and no later hang in that session could be detected at all.

Only a frame the guest actually produced counts as guest progress now.

Found on Tales of Xillia 2 (BLUS31397), which reproduces the same white screen
as Xillia 1: a stall was reported at 0:29:06, that game was closed, another was
booted, and when it hung 90 seconds later nothing fired. Guest mutex traffic
sat at exactly zero for over two minutes -- sys_event_queue_receive and
sys_timer_usleep continuing at flat, identical rates every interval, which is
idle service loops and nothing else -- while VKGSRender::flip kept running and
the detector kept believing frames were landing.

That is the one case this was written for: a hang where something still flips
looks perfectly healthy to a detector that only watches frames.
2026-08-24 14:22:12 -04:00
jpolo1224 8142af1faa Count audio underruns instead of inferring them
A callback the backend cannot fill completely is a hole in the output: the
device asked for N frames, the emulator did not have them, and the gap is
filled by repeating the last sample. That is what crackling IS, and nothing
counted it. The buffer-level report samples every ten seconds while a starved
callback lasts milliseconds, so every transient underrun passed between
samples unseen -- a Call of Duty: World at War capture shows a perfectly
healthy buffer (queued 22.7-48ms against a 36.7ms target, never near dry)
through a session where the audio was audibly breaking up.

Counted in AudioBackend rather than per backend so Cubeb and Oboe report the
same number the same way, one count per starved callback rather than per
padded frame -- the audible event is the gap, and its length is already
implied by how much of the callback had to be invented.

Reported on the existing audio line as a delta since the previous one, not a
running total: what matters is whether the output is breaking up now, and a
total from a rough patch minutes ago hides that.
2026-08-24 14:05:19 -04:00
jpolo1224 df14be53f7 Android: 0.9.4.7 (versionCode 27) 2026-08-24 13:33:52 -04:00
jpolo1224 46c37dc1d6 Stop log rotation destroying the log people are trying to send
There was exactly one previous log: RPCSX.log became RPCSX.old.log and the
previous old was deleted. That loses the capture reliably, because of how
people send it -- play, stop, relaunch the app to reach the file, and the
relaunch rotates the wanted session into .old; relaunch once more, to find it
or to share it or because the launcher restored the app, and it is gone.

Three captures have been lost this way (#87, #91, and the Call of Duty audio
one). In two of them what arrived was a 47-line log ending before the game had
booted, which is the replacement session rather than the one played.

Two defences, because generations alone would not have saved those:

1. A session that produced almost nothing does not get a slot. The logs that
   did the damage were boot-only -- a few KB, stopping within a second of
   launch -- and pushing one of those down the chain is what evicted the real
   capture. Below the threshold the file is discarded instead. A session with
   'Silence All Logs' on still writes far more than this (~190 KB for a
   29-minute one), so that setting does not trip it.

2. Three generations instead of one, so an ordinary mistake costs nothing.
2026-08-24 13:33:52 -04:00
jpolo1224 6ab1a4b456 Show device temperatures on the performance overlay
Ported from ARMSX2. Android has no supported API for SoC temperatures --
HardwarePropertiesManager is gated behind the signature-level DEVICE_POWER --
so the only route is the thermal sysfs, which is readable without permission
on essentially every device but is not a contract: zone count, order, naming
and even the unit are all vendor-specific. So the app discovers zones once by
name, tolerates every failure by having no reading, and never displays a value
it could not actually read. A device that exposes nothing shows nothing, which
is a normal outcome rather than an error.

The core cannot read these and should not learn how, so the app pushes them in
through _rpcsx_setThermals into atomics beside the overlay. The overlay line is
appended after the detail-level switch, the same way the frame generation line
is, so all four levels get it without their format strings and positional
arguments having to agree.

The sentinel is -1000.0f on both sides. ARMSX2 spells the Kotlin half
Float.MIN_VALUE, which in Kotlin is the smallest POSITIVE float (1.4e-45) and
so arrives as a real temperature of 0 degrees rather than as absent; worth
correcting there too.

Default on, poll interval configurable 1-5s (default 2s) since sensor overhead
was the concern raised when this was asked for. No realtime option: a
temperature that moves slower than a second is not worth the syscalls.
2026-08-24 13:27:11 -04:00
jpolo1224 b9ed11da20 Drop fast-forward from the in-game menu and the second screen
It stays a feature -- the hotkey, the touch button and the OSD indicator are
untouched -- it just no longer occupies a tile in either panel.

Removing it also repairs the Session grid's controller dispatch, which was
broken. EmulationMenuViewModel indexes three things by the same number: the
grid in SessionPane, actionCount(), and activateSelection(). Fast-forward sat
at index 1 in the grid and had no entry in activateSelection, so every index
from 1 up dispatched to its neighbour -- a pad press on Fast Forward restarted
the game, Restart swapped the disc, Swap Disc closed the game, and Close could
not be activated at all. actionCount had been corrected to 5 to let the pad
reach the last tile, which made the misalignment reachable rather than fixing
it. With the tile gone the grid is resume/restart/swap/close, exactly what
activateSelection already dispatched, and actionCount goes to 4.

Touch users are unaffected: the grid is tapped by index through onSelect, so
it was always correct there. This only ever misfired for a controller.
2026-08-24 13:24:10 -04:00
jpolo1224 58ac171af5 Android: 0.9.4.6 (versionCode 26) 2026-08-24 12:38:37 -04:00
jpolo1224 a7ec28f7a8 SPU: always notify reservation waiters after a successful store
Upstream bc22df8ba skips waking waiters after a SUCCESSFUL conditional store
when the SPU sits at pc 0x11e4 with the SPURS control block reserved, unless
byte 0x73 of that block shows this thread going running->idle. It is a
throughput optimisation: SPURS kernels store to that block constantly and
waking every waiter each time is a thundering herd.

Both constants are assumptions about one specific SPURS kernel build. 0x11e4
is a guest code address and 0x73 an offset inside the guest's control block,
and SPURS ships in many versions across titles. On a kernel whose layout
differs, the running->idle test reads the wrong byte, answers no forever, and
the store succeeds while every waiter stays asleep -- reported by nothing.

Tales of Xillia (BLUS31006) hangs with all five graphics SPUs reserving that
block, each suppressing ~100,000 notifications, ~4.85M conditional stores at
a 16.5% failure rate. Its SPURS kernel then executes its own HALT at
pc=0x00f00: r16 & r17 = 0x40000000, two workload masks that must be disjoint
both claiming workload bit 30. The group never joins, no frame is ever
presented again, and every PPU thread parks.

Whether the missed wakeups cause that inconsistency or merely accompany it is
NOT established. What is established is that notifying is correct and
suppressing is the optimisation, so the optimisation goes. Expect a
throughput cost on SPURS-heavy titles; it is measurable and revertable.

The counter is kept, now recording how often the heuristic would have
suppressed, so that cost shows up in a log instead of being guessed at.
2026-08-24 12:38:25 -04:00
jpolo1224 dd373795bb Android: 0.9.4.5 (versionCode 25) 2026-08-24 11:43:52 -04:00
jpolo1224 45abc35dab Dump the SPU that stopped, not an idle one, and all of its registers
The stall report picked its detailed SPU by raddr == spurs_addr -- a kernel
waiting on its own control block, i.e. an IDLE one -- on the assumption that
every kernel parks at the same pc so any of them would do. Tales of Xillia
is the counter-example: one graphics kernel executed a guest HALT and sits
stopped at pc=0x00f00 while the other four idle normally at 0x011a8, so the
rule picked an idle SPU. The only thread in the process with anything to say
printed no registers at all.

A stopped, halted or exited SPU now wins outright, chosen in a pass before
the walk. Nothing else in a SPURS group stops on its own, so if one has, it
is why the group never joined and the rest are only waiting on it.

Also dump all 128 GPRs rather than the first 16. Xillia's assertion is a
validity check over r12/r16/r17/r19/r33/r34, so the sixteen that were printed
did not include a single operand of the test the log had just disassembled.
2026-08-24 11:35:49 -04:00
jpolo1224 851c3191f2 Android: 0.9.4.4 (versionCode 24) 2026-08-24 10:23:00 -04:00
jpolo1224 3a7c4f2061 cellAudio: fix the buffer-target ratio comparing microseconds to samples
average_playtime_ratio divided m_average_playtime -- a rolling average of
get_enqueued_playtime(), in microseconds -- by audio_buffer_length, which is
a sample count (AUDIO_BUFFER_SAMPLES * channels, 512 for stereo). The result
was microseconds per sample: roughly 78 on a healthy stereo buffer and never
below 1, so the 'not as full as desired' branch it gates has not executed
once since it was written. The buffer target has therefore always been the
fixed desired_buffer_duration + half a block, with the adaptive widening
silently inert.

Wrong from introduction rather than drifted: m_average_playtime has been a
duration since before that line existed. audio_buffer_length had no other
reader anywhere in the tree, which is why nothing caught it; it is deleted
here so it cannot be picked up again by mistake.

The denominator is now desired_buffer_duration, which is what the comment
above it names and the only choice that makes the branch meaningful --
audio_block_period would leave the ratio near 7 and the branch just as dead.
Guarded against a zero denominator, and the existing max(ratio, 0.25) clamp
still bounds the widening at 4x.

This arms a path that has effectively never run, so it changes behaviour for
every game: a consistently under-filled buffer now raises the target instead
of being ignored, trading latency for fewer dropouts. That is what it was
written to do. The audio buffer report added alongside prints the ratio, so
the effect is visible in a log rather than inferred.
2026-08-24 10:08:52 -04:00
jpolo1224 762bf323de cellAudio: report how much audio is queued
Progressive audio delay keeps being reported (#87: Guitar Hero titles, where
audio starts synchronised and falls further behind the notes the longer a
song runs, and pausing resets it). It reproduces on both Cubeb and Oboe, so
it is not the backend -- it is this ring, and how full this ring is IS the
delay the player hears. It was recorded nowhere, so every theory about it is
unfalsifiable from the logs we receive.

One line per 10s: queued depth, the target the algorithm is aiming for, and
the dynamic period as a percentage of nominal, which says how hard it is
correcting. Enough to see the curve across a song; few enough that the log
volume cannot become a stall in its own right.

Noted while reading the algorithm, deliberately NOT changed here:
average_playtime_ratio divides m_average_playtime (microseconds) by
audio_buffer_length (samples times channels, 512 for stereo), so it reads
about 78 when healthy and its 'not as full as desired' branch has never once
executed. The intended denominator is a duration. Correcting it would arm a
widening path that has effectively never run, which raises latency in exactly
the under-buffered case -- the opposite of the symptom being chased. It wants
its own change, with the numbers this report will provide.
2026-08-24 10:03:02 -04:00
jpolo1224 08d290f701 Dump guest thread state when the game stops drawing
check_frame_stall() already detects a hang reliably and always-on: it
reports "No frame presented in 30s" and puts a message on screen. But the
one thing that says WHERE the guest is parked -- dump_guest_threads_stalled(),
which prints every PPU thread's registers, guest call stack and the
instructions around its cia, plus SPU state -- was only ever reached from the
RSX profiler's poll_stall(), and that returns false immediately unless the
profiler is switched on. Testers do not switch it on.

So every freeze report arrives with the detection line and nothing behind
it. Confirmed against a Kane & Lynch (BLUS30102) capture: guest execution
collapsed at 0:04:15 -- sys_mutex_lock fell from ~200,000 per 10s to ~300,
CPU from 43% to 5.5% -- and this fired at 0:04:45 having recorded none of
it. Three such reports across two different SoCs, none reproducible locally,
all equally undiagnosable.

Call it from the detector instead. Twice, ~15s apart, because one sample
cannot tell a thread spinning from one making very slow progress -- a cia
that has not moved between two samples is itself the finding. Twice and no
more, and re-armed only when a frame actually lands: the detail runs to
hundreds of lines per thread, and log volume alone is enough to stall the
emulator on Android. The profiler path is unchanged.
2026-08-24 09:52:39 -04:00
kd-11 071c9f10f1 rsx: Relax checking of NV4097_SET_SHADE_MODE inputs to match hw 2026-08-24 03:22:05 +03:00
Megamouse 68f260a698 Update Qt to 6.11.2 2026-08-23 19:11:06 +02:00
Megamouse 261b467b83 Qt: Allow to select ps move hue by mouse click 2026-08-23 15:24:13 +02:00
Megamouse 9bcd45d70c opencv: simplify includes. there's no need for photo 2026-08-23 15:24:13 +02:00
Megamouse 8bbdd3715a psmove: Add minor optimization for non diagnostic mode 2026-08-23 15:24:13 +02:00
Megamouse b8816a1e0e Update opencv to 4.14.0 2026-08-23 15:24:13 +02:00
Ani 5bc2928588 gui: Fix firmware install through drag-and-drop
Some browsers such as Firefox download the PS3UPDAT.pup file with a lowercase extension
2026-08-23 14:12:24 +02:00
kd-11 e678a8e572 rsx: Fix tiled region matching 2026-08-23 13:28:50 +02:00
Gabriel Costa 3aac7d776d rsx/fp: Improve DP3 instruction precision 2026-08-22 14:17:01 +00:00
RipleyTom 8bbcd5936d Bugfixes 2026-08-22 14:56:31 +03:00
Tagflag2d b97f4bd8da rsx: Implement flat shading semantics 2026-08-21 17:50:34 +00:00
RipleyTom 8fd2ae954d Filter UnlockTrophy reply 2026-08-21 07:35:12 +02:00
Megamouse 243d7db5b5 vfs: avoid double slash 2026-08-21 02:26:59 +03:00
Megamouse 809359cb20 sys_fs: simplify get_name 2026-08-21 02:26:59 +03:00
77 changed files with 1594 additions and 160 deletions
+3 -3
View File
@@ -134,7 +134,7 @@ jobs:
runs-on: ${{ matrix.runs-on }}
env:
CCACHE_DIR: /tmp/ccache_dir
QT_VER: '6.11.1'
QT_VER: '6.11.2'
QT_VER_MAIN: '6'
LLVM_COMPILER_VER: '22'
RELEASE_MESSAGE: ../GitHubReleaseMessage.txt
@@ -212,9 +212,9 @@ jobs:
env:
COMPILER: msvc
QT_VER_MAIN: '6'
QT_VER: '6.11.1'
QT_VER: '6.11.2'
QT_VER_MSVC: 'msvc2022'
QT_DATE: '202605090529'
QT_DATE: '202608131017'
LLVM_VER: '22.1.8'
VULKAN_VER: '1.4.341.1'
VULKAN_SDK_SHA: 'bcf2d75aa9556889ab974858666e20b3655b6055a0db704ccb47279ff33b5bfe'
+2 -2
View File
@@ -34,8 +34,8 @@ android {
// agree -- an APK that installs below its core's target is a dlopen failure at boot.
minSdk = (project.findProperty("armsx3.minSdk") as String?)?.toInt() ?: 33
targetSdk = 37
versionCode = 23
versionName = "0.9.4.3"
versionCode = 38
versionName = "0.9.4.4"
// 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.
@@ -47,6 +47,7 @@ struct RPCSXApi {
void (*surfaceSizeChanged)(int width, int height);
void (*setPadSensor)(int port, int x, int y, int z, int g);
int (*getPadRumble)(int port);
void (*setThermals)(float cpu, float gpu, float battery, bool show);
bool (*usbDeviceEvent)(int fd, int vendorId, int productId, int event);
bool (*installFw)(JNIEnv *env, int fd, long progressId);
bool (*isInstallableFile)(jint fd);
@@ -161,6 +162,7 @@ struct RPCSXLibrary : RPCSXApi {
result.surfaceSizeChanged = reinterpret_cast<decltype(surfaceSizeChanged)>(dlsym(handle, "_rpcsx_surfaceSizeChanged"));
result.setPadSensor = reinterpret_cast<decltype(setPadSensor)>(dlsym(handle, "_rpcsx_setPadSensor"));
result.getPadRumble = reinterpret_cast<decltype(getPadRumble)>(dlsym(handle, "_rpcsx_getPadRumble"));
result.setThermals = reinterpret_cast<decltype(setThermals)>(dlsym(handle, "_rpcsx_setThermals"));
result.usbDeviceEvent = reinterpret_cast<decltype(usbDeviceEvent)>(dlsym(handle, "_rpcsx_usbDeviceEvent"));
result.installFw = reinterpret_cast<decltype(installFw)>(dlsym(handle, "_rpcsx_installFw"));
result.isInstallableFile = reinterpret_cast<decltype(isInstallableFile)>(dlsym(handle, "_rpcsx_isInstallableFile"));
@@ -506,6 +508,18 @@ extern "C" JNIEXPORT jint JNICALL Java_net_rpcsx_RPCSX_getPadRumble(
return rpcsxLib.getPadRumble(port);
}
// Device temperatures for the perf overlay. Discovery is the app's job -- Android exposes no
// supported API for SoC temperatures, so it reads the thermal sysfs, whose zone naming and units
// are vendor-specific -- and this only carries the result across.
extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_setThermals(
JNIEnv *, jobject, jfloat cpu, jfloat gpu, jfloat battery, jboolean show) {
if (rpcsxLib.setThermals == nullptr) {
return;
}
rpcsxLib.setThermals(cpu, gpu, battery, show == JNI_TRUE);
}
extern "C" JNIEXPORT void JNICALL Java_net_rpcsx_RPCSX_surfaceSizeChanged(
JNIEnv *, jobject, jint width, jint height) {
if (rpcsxLib.surfaceSizeChanged == nullptr) {
@@ -216,9 +216,6 @@ object SecondScreen {
row1.addView(action(I18n.get("touch.stateAction.load")) {
MainActivityRuntime.instance?.loadState()
}, rowLp())
row1.addView(action(I18n.get("secondScreen.fastForward")) {
MainActivityRuntime.instance?.toggleFastForward()
}, rowLp())
row2.addView(action(I18n.get("secondScreen.pause")) {
// Same toggle the on-screen pause button uses.
if (MainActivityRuntime.eState.value == EmuState.PAUSED) MainActivityRuntime.resume()
@@ -0,0 +1,196 @@
package com.armsx2
import android.content.Context
import android.os.SystemClock
import com.armsx2.runtime.MainActivityRuntime
import java.io.File
/**
* CPU / GPU / battery temperatures for the performance overlay.
*
* Android has no supported API for this. HardwarePropertiesManager exists but is gated behind
* DEVICE_POWER, which is signature-level, so an app cannot use it. What is left is the thermal
* sysfs, which is readable without permission on essentially every device but is not a contract:
* zone COUNT, zone ORDER, zone NAMING and even the UNIT are all vendor-specific. So this
* discovers zones once by name, tolerates every failure by simply having no reading, and never
* claims a value it could not actually read.
*
* "Not available on this device" is a normal outcome here, not an error.
*
* Ported from ARMSX2's Thermals.kt. The core cannot read these and should not learn how -- see
* the note on rsx::overlays::thermals in overlay_perf_metrics.h for the other half.
*/
object Thermals {
/**
* No reading.
*
* Must match rsx::overlays::thermals::none, because the native side decides "absent" with a
* single `<= none` comparison rather than carrying a second flag per value. ARMSX2 spells
* this Float.MIN_VALUE, which in Kotlin is the smallest POSITIVE float (1.4e-45) and so
* reads as a real temperature of 0 degrees on the native side rather than as absent.
*/
const val NONE = -1000.0f
private const val ZONES = "/sys/class/thermal"
/**
* Substrings that identify a zone, in preference order. Qualcomm, MediaTek, Exynos and
* Tensor all name theirs differently, and several expose a dozen CPU zones (one per cluster
* or core); the first match is taken because one representative reading is what the overlay
* wants, not hottest-of-twelve.
*/
private val CPU_HINTS = listOf("cpu-0-0", "cpuss", "mtktscpu", "cpu_thermal", "cpu")
private val GPU_HINTS = listOf("gpuss", "mtktsgpu", "gpu_thermal", "gpu")
private var scanned = false
private var cpuZone: File? = null
private var gpuZone: File? = null
@Volatile var cpu: Float = NONE; private set
@Volatile var gpu: Float = NONE; private set
@Volatile var battery: Float = NONE; private set
private var lastPollMs = 0L
/** True once a scan has happened and found something. */
val available: Boolean get() = cpu != NONE || gpu != NONE || battery != NONE
private fun scan() {
if (scanned) return
scanned = true
val zones = runCatching {
File(ZONES).listFiles { f -> f.name.startsWith("thermal_zone") }?.sortedBy { it.name }
}.getOrNull().orEmpty()
// type -> zone dir, read once. A zone whose type is unreadable is simply skipped.
val named = zones.mapNotNull { z ->
val type = runCatching { File(z, "type").readText().trim().lowercase() }.getOrNull()
if (type.isNullOrEmpty()) null else type to z
}
fun pick(hints: List<String>): File? {
for (h in hints) named.firstOrNull { it.first.contains(h) }?.let { return it.second }
return null
}
cpuZone = pick(CPU_HINTS)
gpuZone = pick(GPU_HINTS)
}
/**
* Convert whatever the kernel wrote into degrees Celsius.
*
* The unit is genuinely not standard: most zones report millidegrees (45000), some tenths
* (450), a few plain degrees (45). Rather than guess per vendor, the magnitude decides -- no
* phone runs at 1000C and none idles at 0.045C, so the ranges cannot overlap.
*/
private fun toCelsius(raw: Long): Float = when {
raw > 10_000 -> raw / 1000f
raw > 1_000 -> raw / 100f
raw > 200 -> raw / 10f
else -> raw.toFloat()
}
private fun read(zone: File?): Float {
val f = zone ?: return NONE
val raw = runCatching { File(f, "temp").readText().trim().toLong() }.getOrNull() ?: return NONE
val c = toCelsius(raw)
// A plausibility gate. Some zones are not temperatures at all (fan RPM, a cooling-device
// state), and an overlay reading "912C" is worse than one reading nothing.
return if (c in -20f..150f) c else NONE
}
/**
* Refresh if [intervalMs] has passed. Cheap to call often -- the rate limit is the point,
* since these are file reads.
*/
fun poll(context: Context, intervalMs: Long) {
val now = SystemClock.elapsedRealtime()
if (now - lastPollMs < intervalMs) return
lastPollMs = now
scan()
cpu = read(cpuZone)
gpu = read(gpuZone)
// Battery is the one with a real API. Tenths of a degree, per the documented extra.
battery = runCatching {
val i = context.registerReceiver(
null,
android.content.IntentFilter(android.content.Intent.ACTION_BATTERY_CHANGED),
)
val tenths = i?.getIntExtra(android.os.BatteryManager.EXTRA_TEMPERATURE, Int.MIN_VALUE)
?: Int.MIN_VALUE
if (tenths == Int.MIN_VALUE) NONE else (tenths / 10f).takeIf { it in -20f..150f } ?: NONE
}.getOrDefault(NONE)
}
// ---- Feeding the in-game overlay -----------------------------------------------------
private const val PREF_OSD = "osd.showTemps"
private const val PREF_INTERVAL = "osd.tempIntervalSec"
private val handler = android.os.Handler(android.os.Looper.getMainLooper())
private var feeding = false
/**
* Default ON. It reads as a normal part of the perf overlay next to CPU/GPU load, the poll
* is one file read every couple of seconds, and a device with no readable zone shows nothing
* rather than something wrong -- so there is no device this is worse for. (ARMSX2 shipped it
* OFF first and the user immediately asked for it on.)
*/
val osdEnabled = androidx.compose.runtime.mutableStateOf(true)
/**
* Seconds between polls. Offered as a setting because a user asked for exactly this as the
* mitigation for sensor overhead. Deliberately no "realtime": a temperature that moves
* slower than a second is not worth the syscalls.
*/
val intervalSec = androidx.compose.runtime.mutableStateOf(2)
fun load(context: Context) {
osdEnabled.value = runCatching {
MainActivityRuntime.prefs.getBoolean(PREF_OSD, true)
}.getOrDefault(true)
intervalSec.value = runCatching {
MainActivityRuntime.prefs.getInt(PREF_INTERVAL, 2)
}.getOrDefault(2).coerceIn(1, 5)
apply(context)
}
fun setOsdEnabled(context: Context, on: Boolean) {
osdEnabled.value = on
runCatching { MainActivityRuntime.prefs.edit().putBoolean(PREF_OSD, on).apply() }
apply(context)
}
fun setIntervalSec(sec: Int) {
intervalSec.value = sec.coerceIn(1, 5)
runCatching { MainActivityRuntime.prefs.edit().putInt(PREF_INTERVAL, intervalSec.value).apply() }
}
private fun apply(context: Context) {
if (osdEnabled.value) start(context) else stop()
}
private fun start(context: Context) {
if (feeding) return
feeding = true
val app = context.applicationContext
val pump = object : Runnable {
override fun run() {
if (!feeding) return
val interval = intervalSec.value * 1000L
poll(app, interval)
runCatching { net.rpcsx.RPCSX.instance.setThermals(cpu, gpu, battery, true) }
handler.postDelayed(this, interval)
}
}
handler.post(pump)
}
private fun stop() {
feeding = false
handler.removeCallbacksAndMessages(null)
// Tell the overlay to stop drawing them rather than leaving the last values frozen there.
runCatching { net.rpcsx.RPCSX.instance.setThermals(NONE, NONE, NONE, false) }
}
/** "48" degrees, or null when there is no reading. */
fun format(c: Float): String? = if (c == NONE) null else "${c.toInt()}°"
}
@@ -1844,7 +1844,7 @@ data class Settings(
put("EmuCore/GS", "fxaa", "bool", fxaa.toString())
// Scaling Mode writes Output Scaling Mode unconditionally, the shader chain only
// when it is on, so CAS has to go first for the chain to keep the last word.
put("EmuCore/GS", "CASMode", "int", casMode.coerceIn(0, 2).toString())
put("EmuCore/GS", "CASMode", "int", casMode.coerceIn(0, 3).toString())
put("EmuCore/GS", "CASSharpness", "int", casSharpness.coerceIn(0, 100).toString())
put("EmuCore/GS", "ShaderChainEnabled", "bool", shaderChainEnabled.toString())
put("EmuCore/GS", "ShaderChainPreset", "string", shaderChainPreset)
@@ -905,6 +905,11 @@ val EN: Map<String, String> = mapOf(
"pad.rightStickFeel.title" to "Right Stick Feel",
"pad.players.help" to "The PS3 has seven controller ports and no multitap, so up to seven pads work with no setup. Connect them before launching — the order they first press a button in is the order they are assigned.",
"pad.rumble.description" to "Master switch for controller rumble and the device's built-in vibration. Turn off to silence all haptics.",
"overlay.toggle.temps" to "Device temperatures",
"overlay.toggle.temps.description" to
"Show CPU, GPU and battery temperature on the performance overlay. Not every device exposes these \u2014 one that doesn't simply shows nothing.",
"overlay.tempInterval.label" to "Temperature poll interval",
"renderer.outputScaling.sgsr" to "SGSR",
"pad.rumble.label" to "Rumble / Vibration",
"pad.rumblePhone.label" to "Vibrate the phone",
"pad.rumblePhone.description" to
@@ -1055,6 +1060,8 @@ val EN: Map<String, String> = mapOf(
"renderer.clearShaderCache.description" to "Wipes the compiled Vulkan + GL shader/pipeline caches. Use if a game renders corrupt after a driver swap or update — the next launch rebuilds them clean.",
"renderer.clearShaderCache.label" to "Clear Shader Cache",
"renderer.cas.sharpness.label" to "CAS Sharpness",
"renderer.cas.sharpness.fsr" to "FSR Sharpness",
"renderer.cas.sharpness.sgsr" to "SGSR Edge Sharpness",
"renderer.displayMode.description" to "How the PS3 picture fills your screen. Fit keeps the correct shape and adds bars where needed; Stretch fills the whole screen and distorts the image. The picture's shape itself is set by Console Aspect Ratio.",
"renderer.displayMode.label" to "Display Mode",
"renderer.loadTexturePacks.description" to "Loads replacement textures from the active game's texture folder.",
@@ -2158,6 +2158,9 @@ open class MainActivityRuntime : ComponentActivity() {
// Restore the saved rumble master toggle into the native gate (NativeApp.onPadRumble).
NativeApp.sRumbleEnabled = ControllerMappings.rumbleEnabled()
NativeApp.sPhoneRumbleEnabled = ControllerMappings.phoneRumbleEnabled()
// Starts the temperature poll if the overlay wants it. Costs one file read every couple
// of seconds and stops entirely when the option is off.
runCatching { com.armsx2.Thermals.load(this) }
// Push the saved haptic strength + achievement-sound volume into their native gates before
// any rumble or unlock sound can fire (both default to 1.0 = as authored until set here).
ControllerMappings.syncHapticIntensity()
@@ -636,12 +636,6 @@ private fun SessionPane(state: EmulationMenuUiState, viewModel: EmulationMenuVie
ActionGrid(
actions = listOf(
MenuAction(str("action.resume"), str("action.play"), "â–¶", Success, viewModel::resume),
MenuAction(
str("action.fastForward"),
if (MainActivityRuntime.fastForwardToggleActive) str("action.fastForward.on") else str("action.fastForward.detail"),
"⏩",
if (MainActivityRuntime.fastForwardToggleActive) Success else null,
) { MainActivityRuntime.instance?.toggleFastForward(); viewModel.resume() },
MenuAction(str("memcard.restart"), str("action.reset"), "↻", null, MainActivityRuntime::restart),
MenuAction(str("action.swapDisc"), str("action.swapDisc.detail"), "⏏", null, MainActivityRuntime::promptSwapDisc),
MenuAction(str("action.close"), MainActivityRuntime.currentGame.value?.title.orEmpty(), "â– ", Danger) {
@@ -890,11 +884,29 @@ private fun GraphicsPane(state: EmulationMenuUiState, viewModel: EmulationMenuVi
title = str("renderer.outputScaling.label"),
options = listOf(
str("renderer.outputScaling.nearest"), str("renderer.outputScaling.bilinear"),
str("renderer.outputScaling.fsr"),
str("renderer.outputScaling.fsr"), str("renderer.outputScaling.sgsr"),
).mapIndexed { index, label -> index to label },
selected = settings.casMode,
onSelect = { v -> viewModel.updateSettings { it.copy(casMode = v) } },
)
// Sharpening, shown only for the two upscalers that have any. It was missing from this
// menu entirely, which is the one people reach mid-game -- changing upscaler here and
// then having to leave the game to tune it defeats the point of the quick menu.
//
// The label follows the selection because the number does not mean the same thing to
// both: it is an RCAS stop to FSR and an edge factor to SGSR, and a slider named for the
// upscaler that is not running is simply wrong.
if (settings.casMode == 2 || settings.casMode == 3) {
Spacer(Modifier.height(6.dp))
com.armsx2.ui.settings.IntSliderRow(
label = str(if (settings.casMode == 3) "renderer.cas.sharpness.sgsr" else "renderer.cas.sharpness.fsr"),
value = settings.casSharpness.coerceIn(0, 100),
min = 0,
max = 100,
valueFormatter = { "$it%" },
onChange = { v -> viewModel.updateSettings { it.copy(casSharpness = v) } },
)
}
Spacer(Modifier.height(6.dp))
MenuSwitchRow(str("renderer.relaxedZcull.label"), settings.ps3.relaxedZcull) { v ->
viewModel.updateSettings { it.copy(ps3 = it.ps3.copy(relaxedZcull = v)) }
@@ -381,9 +381,12 @@ class EmulationMenuViewModel(application: Application) : AndroidViewModel(applic
}
private fun actionCount(tab: EmulationMenuTab): Int = when (tab) {
// MUST match SessionPane's action list length. This was 4 against a list of 5, so the pad
// could never reach Close at all.
EmulationMenuTab.Session -> 5
// MUST match SessionPane's action list length, AND activateSelection's Session branch
// below -- all three are indexed by the same number and nothing checks they agree.
// Fast-forward used to sit at index 1 in the grid but had no entry in activateSelection,
// so every index from 1 up dispatched to its neighbour: a pad press on Fast Forward
// restarted the game, and Close could not be activated at all.
EmulationMenuTab.Session -> 4
EmulationMenuTab.Graphics -> 2
EmulationMenuTab.Fixes -> 0
EmulationMenuTab.Performance -> 3
@@ -244,6 +244,31 @@ fun OverlayTab(state: MutableState<Settings>) {
ffToasts.value = it
com.armsx2.runtime.MainActivityRuntime.prefs.edit { putBoolean("ui.hotkeyToasts", it) }
}
SettingsDivider()
// Device temperatures on the perf overlay. Android exposes no supported API for SoC
// temperatures, so these come from the thermal sysfs, whose zone naming and units are
// vendor-specific -- a device with no readable zone simply shows nothing here.
val ctx = androidx.compose.ui.platform.LocalContext.current
ToggleRow(
str("overlay.toggle.temps"),
com.armsx2.Thermals.osdEnabled.value,
description = str("overlay.toggle.temps.description"),
) {
com.armsx2.Thermals.setOsdEnabled(ctx, it)
}
// Poll interval. Asked for explicitly as the mitigation for sensor overhead. No
// "realtime" option: a temperature that moves slower than a second is not worth the
// syscalls.
if (com.armsx2.Thermals.osdEnabled.value) {
IntSliderRow(
label = str("overlay.tempInterval.label"),
value = com.armsx2.Thermals.intervalSec.value,
min = 1,
max = 5,
valueFormatter = { "${it}s" },
onChange = { com.armsx2.Thermals.setIntervalSec(it) },
)
}
}
}
@@ -338,15 +338,27 @@ fun RendererTab(state: MutableState<Settings>) {
str("renderer.outputScaling.nearest"),
str("renderer.outputScaling.bilinear"),
str("renderer.outputScaling.fsr"),
str("renderer.outputScaling.sgsr"),
),
selectedIndex = s.casMode.coerceIn(0, 2),
columns = 3,
// Bound raised with the option. A clamp left at the old maximum silently rewrites
// the new choice back to the previous one, which reads as the setting refusing to
// take.
selectedIndex = s.casMode.coerceIn(0, 3),
columns = 2,
description = str("renderer.outputScaling.description"),
onChange = { apply(s.copy(casMode = it)) },
)
SettingsDivider()
IntSliderRow(
label = str("renderer.cas.sharpness.label"),
// Named for whichever upscaler is actually selected: the value is an RCAS stop to
// FSR and an edge factor to SGSR.
label = str(
when (s.casMode) {
2 -> "renderer.cas.sharpness.fsr"
3 -> "renderer.cas.sharpness.sgsr"
else -> "renderer.cas.sharpness.label"
}
),
value = s.casSharpness.coerceIn(0, 100),
min = 0,
max = 100,
@@ -122,6 +122,21 @@ object TouchControls {
* backdrop to deselect. */
val selectedButton = mutableStateOf<TouchButtonId?>(null)
/**
* Whether the editor panel is collapsed to just its grip strip.
*
* The complaint this answers is not that the panel is ugly, it is that the panel covers the
* thing you are trying to edit -- and the only remedy was to drag it out of the way, every
* time. Auto-docking the panel away from the selected widget does not fix it either, because
* selecting a widget under the panel means touching through the panel first. One tap here
* uncovers everything beneath it and needs no drag.
*
* Deliberately NOT persisted, and reset on leaving edit mode: it is a momentary "let me see
* under this", and a session that opened the editor to a panel with no controls on it would
* just look broken.
*/
val editorPanelCollapsed = mutableStateOf(false)
/** Profile picker / save-as dialog shown over the editor. */
val profileDialogOpen = mutableStateOf(false)
@@ -410,6 +410,10 @@ fun TouchControlsOverlay() {
}
}
// Momentary state: a panel that stayed collapsed into the next editing session would look
// like the controls had gone missing.
LaunchedEffect(edit) { if (!edit) TouchControls.editorPanelCollapsed.value = false }
if (edit) {
// The editor panel is draggable + pinch-resizable (grip handle at its top) so it can be
// moved off the buttons being edited. Offset is applied on the outer Box (real px); resize
@@ -420,12 +424,33 @@ fun TouchControlsOverlay() {
val dxState = TouchControls.editorPanelDx(isLandscape)
val dyState = TouchControls.editorPanelDy(isLandscape)
val panelScale = TouchControls.editorPanelScale(isLandscape).floatValue
// Auto-dock: never sit on the same half of the screen as the widget being edited.
//
// Halves rather than real overlap maths, on purpose. A panel that darts about as
// rectangles graze each other is less predictable than one that is simply never on
// the side you are working on, and predictability is what makes it stop being
// annoying.
//
// This complements the collapse toggle rather than replacing it: collapse answers "I
// cannot select what is under you in the first place", docking answers "I have
// selected it and now you are on top of it".
val selectedId = TouchControls.selectedButton.value
val selectedY = if (selectedId != null) {
TouchControls.activeLayout.value.buttons.firstOrNull { it.id == selectedId }?.yFrac
} else null
val dockBottom = selectedY != null && selectedY < 0.5f
Box(
Modifier
.align(Alignment.TopCenter)
.padding(top = 12.dp)
.align(if (dockBottom) Alignment.BottomCenter else Alignment.TopCenter)
.padding(top = if (dockBottom) 0.dp else 12.dp, bottom = if (dockBottom) 12.dp else 0.dp)
.offset {
IntOffset(dxState.floatValue.roundToInt(), dyState.floatValue.roundToInt())
// The stored drag offset means "away from the anchored edge", so it has to
// flip sign with the anchor. Applied unchanged while docked to the bottom,
// a +dy the user had nudged in would push the panel straight off-screen.
val dy = if (dockBottom) -dyState.floatValue else dyState.floatValue
IntOffset(dxState.floatValue.roundToInt(), dy.roundToInt())
},
) {
CompositionLocalProvider(
@@ -1907,6 +1932,11 @@ private fun EditToolbar(modifier: Modifier = Modifier) {
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// First in the row on purpose: it must be in the same place whether the panel is
// open or collapsed, or the way back is somewhere the user has to hunt for.
PanelSizeButton(if (TouchControls.editorPanelCollapsed.value) "â–Ľ" else "â–˛") {
TouchControls.editorPanelCollapsed.value = !TouchControls.editorPanelCollapsed.value
}
PanelSizeButton("-") {
val ls = OverlayDims.last?.let { it.widthPx > it.heightPx } ?: true
TouchControls.editorPanelScale(ls).floatValue =
@@ -1952,6 +1982,10 @@ private fun EditToolbar(modifier: Modifier = Modifier) {
(TouchControls.editorPanelScale(ls).floatValue + 0.1f).coerceIn(0.6f, 1.35f)
}
}
// Everything below is what the collapse toggle hides. Column is an inline composable,
// so this genuinely skips emitting the rest rather than drawing it invisibly.
if (TouchControls.editorPanelCollapsed.value) return@Column
// Scope hint: with no game running the editor edits the GLOBAL Default
// layout (per-game layouts need a running disc).
Text(
@@ -528,6 +528,9 @@ object Rpcs3Bridge {
when (asInt(value)) {
0 -> "Nearest"
2 -> "FidelityFX Super Resolution"
// 3 skips the librashader chain, which is ordinal 3 in the native enum
// but is driven by its own toggle rather than this picker.
3 -> "Snapdragon Game Super Resolution"
else -> "Bilinear"
},
)
@@ -111,6 +111,13 @@ class RPCSX {
/** What the game is asking the rumble motors to do: (large shl 8) or small, each 0..255. */
external fun getPadRumble(port: Int): Int
/**
* Device temperatures for the perf overlay, in degrees Celsius, or Thermals.NONE for a
* reading that could not be taken. Discovery is the app's job -- Android has no supported
* API for SoC temperatures -- so the core is only ever told the answer.
*/
external fun setThermals(cpu: Float, gpu: Float, battery: Float, show: Boolean)
external fun usbDeviceEvent(fd: Int, vendorId: Int, productId: Int, event: Int): Boolean
external fun processCompilationQueue(): Boolean
external fun startMainThreadProcessor(): Boolean
+49 -5
View File
@@ -19,6 +19,7 @@
#include "Emu/Io/pad_config_types.h"
#include "Emu/RSX/Null/NullGSRender.h"
#include "Emu/RSX/Overlays/overlay_manager.h"
#include "Emu/RSX/Overlays/overlay_perf_metrics.h"
#include "Emu/RSX/Overlays/overlay_save_dialog.h"
#include "Emu/RSX/Overlays/overlay_trophy_notification.h"
#include "Emu/RSX/Overlays/overlay_utils.h"
@@ -3080,12 +3081,46 @@ extern "C" bool _rpcsx_initialize(std::string_view rootDir,
stats.avail_free / 1000000.);
}
// preserve old log file
if (std::filesystem::exists(fs::get_log_dir() + "RPCSX.log")) {
// Preserve previous logs.
//
// There used to be exactly one: RPCSX.log became RPCSX.old.log and the previous old was
// deleted. That loses the log people are trying to send, reliably, because of how they send
// it -- play, stop, relaunch the app to reach the file, and the relaunch rotates the session
// they wanted into .old; relaunch once more (to find it, to share it, because the launcher
// restored the app) and it is gone. Three separate captures have been lost this way, and in
// two of them what arrived was a 47-line log that ended before the game had booted.
//
// Two defences, because generations alone would not have saved those:
//
// 1. A session that produced almost nothing does not get a slot. The logs that did the
// damage were boot-only -- a few KB, stopping within a second of launch -- and pushing
// one of those down the chain is what evicted the real capture. Anything below the
// threshold is simply discarded. A silenced-logging session still writes far more than
// this (~190 KB for a 29-minute one), so "Silence All Logs" does not trip it.
//
// 2. Three generations rather than one, so an ordinary mistake costs nothing.
{
std::error_code ec;
std::filesystem::remove(fs::get_log_dir() + "RPCSX.old.log", ec);
std::filesystem::rename(fs::get_log_dir() + "RPCSX.log",
fs::get_log_dir() + "RPCSX.old.log", ec);
const std::string dir = fs::get_log_dir();
const std::string current = dir + "RPCSX.log";
// Boot-only logs stop within a second of launch and run to a few KB. A real session --
// even one with logging silenced immediately -- is orders of magnitude larger.
constexpr std::uintmax_t k_worth_keeping = 32u * 1024u;
const bool exists = std::filesystem::exists(current, ec);
const std::uintmax_t size = exists ? std::filesystem::file_size(current, ec) : 0u;
if (exists && size >= k_worth_keeping) {
// Oldest out, everything down one.
std::filesystem::remove(dir + "RPCSX.old3.log", ec);
std::filesystem::rename(dir + "RPCSX.old2.log", dir + "RPCSX.old3.log", ec);
std::filesystem::rename(dir + "RPCSX.old.log", dir + "RPCSX.old2.log", ec);
std::filesystem::rename(current, dir + "RPCSX.old.log", ec);
} else if (exists) {
// Nothing in it worth a slot, and keeping it would cost the oldest real log.
std::filesystem::remove(current, ec);
}
}
// Limit log size to ~25% of free space
@@ -4307,6 +4342,15 @@ extern "C" void _rpcsx_setPadSensor(int port, int x, int y, int z, int g) {
// whenever it likes, and there is no notification to hook. The caller reads it on a
// timer and drives the phone's vibrator. Returns 0 when nothing is running, so a
// caller that keeps polling after the game stops simply sees silence.
// Device temperatures, pushed from the app layer -- see the note in overlay_perf_metrics.h for
// why discovery lives there and not here. Values are degrees Celsius, or the 'none' sentinel.
extern "C" void _rpcsx_setThermals(float cpu, float gpu, float battery, bool show) {
rsx::overlays::thermals::g_cpu = cpu;
rsx::overlays::thermals::g_gpu = gpu;
rsx::overlays::thermals::g_battery = battery;
rsx::overlays::thermals::g_show = show;
}
extern "C" int _rpcsx_getPadRumble(int port) {
std::lock_guard lock(g_virtual_pad_mutex);
+2 -2
View File
@@ -9,8 +9,8 @@
<IntDir>$(SolutionDir)build\tmp\$(ProjectName)-$(Configuration)-$(Platform)\</IntDir>
<GTestPath>$(SolutionDir)packages\Microsoft.googletest.v140.windesktop.msvcstl.static.rt-static.1.8.1.8\build\native\Microsoft.googletest.v140.windesktop.msvcstl.static.rt-static.targets</GTestPath>
<GTestInstalled Condition="Exists('$(GTestPath)')">true</GTestInstalled>
<OpenCvBuildDir>$(SolutionDir)3rdparty\opencv\opencv\opencv413\build</OpenCvBuildDir>
<OpenCvWorld>opencv_world4130</OpenCvWorld>
<OpenCvBuildDir>$(SolutionDir)3rdparty\opencv\opencv\opencv414\build</OpenCvBuildDir>
<OpenCvWorld>opencv_world4140</OpenCvWorld>
</PropertyGroup>
<ItemDefinitionGroup>
<Lib>
+1 -1
View File
@@ -839,7 +839,7 @@ bool EDATADecrypter::ReadHeader()
// Type 2: Use key from RAP file (RIF key). (also used for type 1 at the moment)
else
{
const std::string rap_path = rpcs3::utils::get_rap_file_path(npdHeader.content_id);
const std::string rap_path = rpcs3::utils::get_rap_file_path(npdHeader.get_content_id());
if (fs::file rap{rap_path}; rap && rap.size() >= sizeof(dec_key))
{
+6
View File
@@ -49,6 +49,12 @@ struct NPD_HEADER
u8 dev_hash[0x10];
s64 activate_time;
s64 expire_time;
std::string get_content_id() const
{
const std::string_view id{content_id, sizeof(content_id)};
return std::string{id.substr(0, id.find_first_of('\0'))};
}
};
struct EDAT_HEADER

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