81 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
jpolo1224 dca685b67d Android: 0.9.4.3 (versionCode 23) 2026-08-23 02:51:07 -04:00
jpolo1224 78650f32d8 Savers: always run gl1_init(), never trust a stale g_started
port_new opened with "if (g_started) return 1". That was defensible while
gl1's state was whatever the previous saver left behind, but nativeInit now
calls gl1_lost() before every create, so gl1 is guaranteed DOWN on entry.
Reporting success there would hand the caller a saver with no shim under it.

So the gl1_lost() added alongside the leak fix did not just close the leak,
it turned that early return from redundant into wrong -- its safety rested
on a call-graph property that same change removed. All six ports now tear
down a stale run through port_free and always run gl1_init(). Each needs a
forward declaration of port_free, which is defined below port_new.

No behaviour change on any path reachable today: port_free always clears
g_started, so the guard never fires. The point is that a saver added later,
or an upstream cleanup that returns early, should fail in its own saver
rather than poison the next one -- the same reason port_free tears gl1 down
unconditionally.

Adapted from the ARMSX2 change (be674a64c5).
2026-08-23 02:44:04 -04:00
jpolo1224 24951bb863 Fix animated background locking users out of the app
Ported from the ARMSX2 fix; ARMSX3 had the same bug, plus a sixth saver.

A saver that dies natively made the app unlaunchable. The choice is a
persisted pref read on the library screen -- the first screen -- so the
crash repeated on every launch and Settings was never reachable to turn it
off. The only escape was clearing app data, which takes memory cards and
save states with it.

Cause: gl1's state is a file-scope global holding GL object names, and
gl1_init() early-returns on g.ready. Skyrocket and Lattice defer initSaver()
to port_resize, so a create-then-teardown with no surface size left
g_started false and their port_free returned BEFORE gl1_shutdown(); flux,
plasma and solarwinds leaked it the other way, since returning 0 when
initSaver() leaves readyToDraw clear means port_free is never called at all.
Either way g.ready stayed set with names from a destroyed EGL context, and
the next saver -- new view, new context -- drew against them. Drivers answer
that with anything from a black screen to a segfault.

Every port now gives gl1 back on every exit, unconditionally, so the rule
holds by construction rather than by arguing about which paths are
reachable. gl1_lost() already existed here but was never called; nativeInit
calls it now as the standing invariant, so a new context cannot inherit old
GL names even if a saver added later forgets.

Containment, independent of the fix: native GL can always find a new way to
die, so the setting arms itself with a synchronous commit() before the
render thread starts and disarms when that thread exits in an orderly way.
Still armed at startup means the last run died with a saver up -- the
background switches off and a toast names it. A crash or a kill cannot reach
the disarm; that is the entire signal.

Also guards Thread.start(): it asks for a 16MB stack (Skyrocket declares a
3MB starmap as a local) and an OutOfMemoryError there is an uncaught throw
on the main thread -- the same lockout with no native crash involved.
2026-08-23 02:15:41 -04:00
jpolo1224 620eaf5066 Oboe: correct a device that opens at the wrong sample rate
The backend asked for the guest's rate with sample rate conversion
disabled, then recorded the rate it ASKED for rather than the one the
stream actually opened at. A device that cannot run at the guest rate
opens at its own, and with conversion off nothing resamples -- so the
emulator feeds samples at one rate into a device consuming them at
another. That is a permanent rate mismatch, not a one-off pitch error:
the gap between what is heard and what is on screen grows for as long as
playback continues, and nothing downstream can see it, because every
later calculation (including the buffering algorithm's idea of how much
audio is queued) uses the rate we requested.

Check the opened rate and, on a mismatch, reopen letting Oboe convert.
Converting is a worse signal path than matching rates outright and a far
better one than not converting at all; Cubeb resamples for the same
reason. If the rate is still wrong after that, say so in the log rather
than drifting in silence.

Refs #87
2026-08-23 01:52:24 -04:00
jpolo1224 5d91f8c56b Say why an ISO failed to boot instead of a bare error
load_iso() cannot report failure: it mounts the virtual device whether or
not the archive actually opened. An image that is unreadable, truncated,
or encrypted without a usable disc key therefore sailed past the mount and
died much further down as a generic 'invalid file or folder', with nothing
in the log distinguishing it from a dozen other causes. A bug report of
that failure carries no information at all.

Check the mount where the reason is still knowable. An image whose
filesystem did not parse now reports whether a disc key was missing or
rejected -- returning decryption_error, and naming both the key locations
searched -- or, failing that, says the image looks truncated. When the
archive is readable but has no EBOOT.BIN at the expected path, log what the
disc root did contain; install discs legitimately lack one, so that stays a
warning rather than a failure.

Also realign BootResult with game_boot_result. It was missing
firmware_version and database_config_missing, so every code from ordinal 10
down was reported as its neighbour -- still_running surfaced as
'AlreadyAdded' -- and the last two had no entry at all, making fromInt
throw rather than return. fromInt is now total.

Refs #88
2026-08-23 01:47:38 -04:00
jpolo1224 06b33abf27 Send rumble to the controller, not the phone
Rumble always went to the phone's own motor: the vibrator lookup asked
the system service and never considered the connected pad. On a handheld
that is the wrong motor outright, and on a phone-plus-controller setup it
buzzes the device sitting in a dock while the pad in hand stays still.

Prefer the first connected gamepad or joystick reporting a working motor,
falling back to the phone. The target is resolved per state change rather
than cached for the pump's lifetime, so connecting or disconnecting a pad
mid-session moves rumble with it; the motor that was last started is
tracked separately so unplugging mid-rumble cannot leave one buzzing.

Add a 'Vibrate the phone' toggle gating only that fallback, so playing on
a pad need not mean the phone rumbles along with it. Touch haptics stay
on the phone deliberately -- the finger is on the phone's screen.

Closes #89
2026-08-23 01:39:03 -04:00
Gabriel Costa 3aac7d776d rsx/fp: Improve DP3 instruction precision 2026-08-22 14:17:01 +00:00
jpolo1224 daed55c427 Release: 0.9.4.2 2026-08-22 10:09:25 -04:00
RipleyTom 8bbcd5936d Bugfixes 2026-08-22 14:56:31 +03:00
jpolo1224 43d26f3720 FrameGen: flush the generated blit before presenting it, and refuse multithreaded RSX
present_generated_frame submitted its blit and presented immediately.
command_buffer::submit goes through queue_submit, which DEFERS to the offloader
thread when multithreaded RSX is on -- so the present waited on a semaphore whose
signal operation had not been submitted, which the spec forbids and which hangs
the present without returning the acquired image. The real present path flushes
first for exactly this reason; this now does the same.

Frame generation also refuses to run at all with multithreaded RSX enabled.
generate() submits inline on the RSX thread while the frame's own command buffer
is merely enqueued to the offloader, so the interpolation can reach the queue
before the frame it reads -- and two threads then submit to one VkQueue without
the external synchronisation vkQueueSubmit requires, which is what g_submit_mutex
exists for and what generate() bypasses by calling vkQueueSubmit directly.

Refusing is honest. Supporting it means routing through vk::queue_submit and
reasoning about deferred submission, which is not a change to make untested.
MTRSX defaults off, so this affects only users who enabled it -- and it is the
profile that produces intermittent DEVICE_LOST under load.
2026-08-22 06:27:43 -04:00
jpolo1224 30f4d93733 FrameGen: read the presented image in the layout it is actually in
capture_presented_frame() was handed target_layout -- COLOR_ATTACHMENT_OPTIMAL or
TRANSFER_DST_OPTIMAL -- and stored it for generate() to use. But generate()
records into its own command buffer, submitted after the frame's, and the frame's
own transition to present_layout runs in between. So by the time the barrier
executed, its oldLayout named a layout the image had already left, and the
matching barrier on the way out restored that same wrong layout -- leaving the
image outside PRESENT_SRC_KHR when it was presented.

Both are violations: oldLayout must be the current layout or UNDEFINED
(VUID-VkImageMemoryBarrier-oldLayout-01197), and a presented image must be in
PRESENT_SRC_KHR (VUID-VkPresentInfoKHR-pImageIndices-01430). It also desynced the
renderer's own layout bookkeeping for that swapchain image on every subsequent
frame.

Passing present_layout instead describes what the image will actually be in, so
the barrier is a no-op transition that reads it where it lies and leaves it there.

Found by a Vulkan lifetime and layout audit. Not verified on device.
2026-08-22 06:26:10 -04:00
jpolo1224 1e9f8c70da FrameGen: give the capture barrier a real source scope, and ask for TRANSFER_SRC
Two findings from the Vulkan audit, both of which let the interpolation read an
image it had no valid dependency on.

The barrier before Process used TOP_OF_PIPE with MEMORY_READ. TOP_OF_PIPE in
srcStageMask specifies no stage of execution, so the first synchronization scope
was empty and no availability operation happened for the colour-attachment and
transfer writes the frame made into that image in the PREVIOUS submission.
Submission order on a queue orders execution; it is not a memory dependency. So
the interpolation could read a partially-flushed frame -- which presents as
smearing during motion and does not respond to any interpolation setting, since
the interpolation is not what is wrong. Source scope is now
COLOR_ATTACHMENT_OUTPUT|TRANSFER with the matching write access, and the mirror
barrier's empty BOTTOM_OF_PIPE second scope becomes ALL_COMMANDS.

Separately, the swapchain was created COLOR_ATTACHMENT|TRANSFER_DST, but
FrameGen::Process copies FROM the presented image as TRANSFER_SRC_OPTIMAL. Both
that layout and vkCmdCopyImage require TRANSFER_SRC usage. Nothing else in the
renderer reads a WSI image, so it had never been needed. Requested only where the
surface reports it, with a warning otherwise, since it is not guaranteed on
Android.
2026-08-22 06:24:56 -04:00
jpolo1224 e7ab163b54 FrameGen: release its Vulkan objects before the device that owns them
Frame generation's state is file-scope global and had no teardown path.
shutdown() only finalised the old dlopen'd library, and release_shared_images()
was never called at all, so at emulation stop the fences, command pool, command
buffers, allocator, queue and the whole pass chain stayed live as children of a
destroyed VkDevice.

The reachable consequence is worse than the leak. On the NEXT boot in the same
process g_native.valid() was still true, so build_native_stack() was skipped and
generate() waited on a fence from the dead device, reset freed command buffers
and submitted to its queue. g_shared_w/h still matched too, so the output images
were not recreated either.

That is boot -> stop -> boot, and it is deterministic rather than intermittent.
It is the likeliest explanation for frame generation working in one game and then
failing immediately in the next one launched without restarting the app, which is
exactly how it was reported.

release_device_resources() destroys the stack and the images and resets the state
that gates rebuilding, including g_disabled -- a new device may not fail where the
last one did. Called from ~VKGSRender after vkDeviceWaitIdle, while the device is
still alive.

Found by a Vulkan lifetime audit. Not verified on device; the hardware was
unavailable.
2026-08-22 06:22:39 -04:00
jpolo1224 e66ce8328a FrameGen: stop reading the shader cache twice per frame, and fix the output image layouts
shader_count() read the ENTIRE SPIR-V cache off disk and rebuilt every module,
then threw the result away -- and it ran twice per frame on the RSX thread, from
capture_presented_frame() and generated_frame_count(). At ~322KB that is roughly
644KB of file IO, four stats, two opens and hundreds of allocations per frame, on
the thread driving the whole present path.

That is CPU cost, which is why halving the GPU shader cost with fp16 changed
nothing, and why ~3ms of measured interpolation was costing ~20ms of frame time.
It arrived with the fix for shaders not loading at all: making the probe ask the
cache was right, doing it per frame was not. The verdict can only change on
import, so it is memoised and cleared there, as ARMSX2 does for the same reason
(GSLsfg.cpp:53-58, whose comment names this exact hazard).

Also: GenerateInto writes through a storage image view, which requires
VK_IMAGE_LAYOUT_GENERAL, and the blit that follows declares TRANSFER_SRC. Nothing
transitioned those images either way -- a storage write outside GENERAL is
undefined, and on Adreno a mismatched layout on a UBWC image forces a
conservative full-surface decompress. Both transitions added, with the layout
tracked across frames.

And the generated-frame blit is source-size to destination-size, so LINEAR
filtering bought nothing and cost a filtered sampling pass instead of the fast
copy path. NEAREST.

Found by a comparative audit against the ARMSX2 driver. Not yet verified on
device -- the hardware was unavailable when this was written.
2026-08-22 06:19:06 -04:00
jpolo1224 a378f38ba6 FrameGen: never stall a frame waiting for the previous interpolation
The fence wait at the head of generate() blocked for up to a second on the
previous slot's work. When it actually waited, the long frame interval it
produced went straight into the pacer -- whose clock is sampled after this point
-- and any interval over 100ms trips MINIMUM_BASE_RATE and stops generation for a
full second (FrameGenPacer.cpp:29/40/97-100).

So a stall here made the pacer stand down, and it was measuring a rate we had
slowed ourselves. Measured against that: the pacer asked for a generated frame on
only ~40% of real frames, which matches one >100ms interval every couple of
seconds costing a second of generation each time.

A zero timeout asks whether the previous frame's work is done and skips this
frame if it is not. Skipping costs one generated frame; stalling cost a second of
them. With three rotating slots the work has three frames to finish, and it
measures ~3ms, so skips should be rare.

VK_TIMEOUT is now a normal outcome rather than an error, so only real failures --
VK_ERROR_DEVICE_LOST being the one actually suspected here -- reach the counter
that disables the feature.
2026-08-22 06:14:00 -04:00
jpolo1224 f2bce78ba1 FrameGen: report what the fence wait actually returned
Every non-success result from vkWaitForFences was reported as a timeout, which
hid the one detail that mattered: 60 of them arrived in 3 seconds against a
1-second timeout, so the wait was failing immediately rather than expiring.
VK_ERROR_DEVICE_LOST is the obvious candidate and means something entirely
different from a slow frame -- the device is gone and frame generation is the
messenger, not the cause.

The failure is intermittent, so this logs the result code once per stall rather
than trying to fix a cause that has not been identified.
2026-08-22 06:00:40 -04:00
jpolo1224 f55a246f22 FrameGen: revert the single-submit restructure, keep the resolution and timeout fixes
The single-submit path broke two games -- one reported "failed", another stopped
progressing past its logo -- and reverting it restores both. It was chasing ~8 ms
of measured per-submit overhead, and the idea may still be right, but it changed
swapchain image ownership incrementally and got it wrong four separate ways:
acquiring before the count was known (leaving semaphores signalled and every
later acquire failing), sharing semaphores across in-flight frames, leaking
acquired images that were never presented, and writing storage images outside
GENERAL. That is a design that needs settling before it is written, not patched
into place against a device.

Kept, all measured:

- The acquire semaphore. Generated frames were acquired with VK_NULL_HANDLE and
  written immediately -- a swapchain image used before the presentation engine
  had finished with it. That was the judder: invariant to every interpolation
  setting because the interpolation was never at fault, present at any frame
  rate because a race has no cadence, and smearing rather than stutter because a
  torn image is parts of two frames at once. Gone entirely at x2.

- The guest extent. The passes size their optical flow from the ratio between
  what the game rendered and what is presented; passing the presented size for
  both made that ratio 1.0 and ran the flow at full 1080p for a title that
  rendered 720p. 4.8 ms -> 3.0 ms of GPU time.

- fp16 shaders, where the device reports and enables them.

- Fence timeouts are transient. disable() is permanent for the session, so one
  stall during a load spike left frame generation reading "failed" until the
  game was restarted with nothing wrong.

Frame generation costs ~3 ms of GPU time per frame. On a title already at the
refresh rate that is enough to cross a vblank boundary, so it trades real frames
for interpolated ones and gains nothing; it is worth having where a game has
headroom below the panel rate, not where it is already at the cliff edge.
2026-08-22 05:54:58 -04:00
jpolo1224 8759a648f7 FrameGen: one command buffer and one submit, as ARMSX2 does
Measured, not guessed: a GPU timer around the passes says they cost 4.1 ms per
frame, steady, while frame time went from ~17 ms to ~30 ms with generation on.
Roughly 8 ms per frame was therefore not interpolation -- it was the cost of
submitting it.

ARMSX2 records Process, every GenerateInto, and every copy into the acquired
swapchain images into ONE command buffer and issues ONE vkQueueSubmit that waits
on each acquire and signals each present. ARMSX3 submitted framegen's work and
then a separate command buffer and submit per generated frame, each its own GPU
batch on the same queue with its own scheduling gap.

generate_into_targets() takes the acquired images and does the whole frame in one
buffer and one submit; the present path acquires them up front and issues the
presents afterwards. present_generated_frame is left in place for the pipelined
path, which is still disabled, and no longer runs on this one.

Follows the acquire-semaphore fix in the previous commit -- that was the judder
(a swapchain image written before the presentation engine had finished with it),
this is the cost.
2026-08-22 05:20:43 -04:00
jpolo1224 5dc669bc7f FrameGen: acquire generated frames with a semaphore instead of nothing
present_generated_frame acquired its swapchain image with VK_NULL_HANDLE -- no
semaphore, no fence -- and then blitted into it and presented immediately.
Vulkan requires waiting on the acquire before touching the image, so this was
writing into one the presentation engine could still be scanning out.

That accounts for what nothing else did. The artifact was invariant to flow
scale, shader family, fp16 and multiplier, because all of those change how a
pair is interpolated and the interpolation was never at fault. It appeared at
every frame rate, because a race has no cadence. It looked like smearing rather
than stutter, because a torn image is parts of two frames at once, and it only
showed during motion, because that is when consecutive frames differ.

The real frame has always had an acquire/present semaphore pair in
frame_context_t. Only the generated path went without, which is why nothing
inside frame generation could reveal it.

ARMSX2 acquires with a fence and blocks on it before submitting, and says so.
This waits on the GPU instead: the blit waits on the acquire, the present waits
on the blit, one pair per swapchain image so none is reused while in flight.

Also restores the fp16 probe consistency in shader_count(), which a git checkout
during this session reverted -- three callers ask which shader variant to use and
any two disagreeing makes a freshly imported cache read as "no shaders".
2026-08-22 05:12:48 -04:00
jpolo1224 11f960fde9 FrameGen: use the fp16 shaders, which this device has and ARMSX2's hardcode denied it
Frame generation was running the fp32 shader family on hardware that supports
fp16. Measured on an Adreno 740: real frame rate fell from ~60 to ~25 with
generation on, so the output was worse than not using it at all.

The cause was copying a hardcode without its reason. ARMSX2 sets allow_fp16 and
prefer_fp16 false and says why: PCSX2's Vulkan backend never asks for
shaderFloat16 when it creates its logical device, so a module declaring the
Float16 capability would be invalid usage there -- and its own comment ends
"restore Eden's two lines if PCSX2 ever enables the feature". Eden does gate on
the device, and Eden is the one that performs. RPCS3 enables shaderFloat16 where
the driver is trusted, and clears its own flag on the Adreno drivers whose
compiler rejects native float16, so asking the device is both possible and safe.
The device in question logs "GPU/driver supports float16 data types natively".

The compat Device answers a third query now, as Eden's does. BuildShaderCache
takes the flags rather than choosing its own, because the cache header stores
them and rejects an entry whose flags differ -- the same mismatch that made a
freshly written cache read as "no shaders".

Found by measuring instead of reasoning: the CPU side of the passes costs
0.000-0.061 ms/frame, which said the cost was GPU-side and not in how the work
was sequenced, after four attempts at the sequencing.
2026-08-22 04:19:21 -04:00
jpolo1224 c537563d14 FrameGen: rotate command buffers so a frame stops waiting on the one before it
One command buffer and one fence meant every frame waited for the immediately
previous interpolation to finish before it could reset the buffer. The GPU
pipeline drained once per frame and the CPU could never get ahead, which is a
stall no amount of moving the work around removes -- it is the depth that is
wrong, not the placement.

ARMSX2 keeps one slot per swapchain image, each with its own command buffer and
fence, and waits on the fence belonging to the slot it is about to reuse. That
fence is several frames old, so in the normal case it is already signalled and
the wait costs nothing. Three slots here, rotated per frame.

This is the last of the differences found by reading the ARMSX2 driver rather
than reasoning about my own: the copy, then the pacer's clock, then the frame's
own command buffer, and now the depth.

Discord: the emulator logo now appears beside the cover art, as it does in
ARMSX2. By URL rather than asset key -- a key that is not in the portal makes
Discord reject the entire presence update, which is what left the card blank
before. It is the launcher icon out of this repository, so it cannot drift from
the app. With no cover the logo takes the large slot instead of sending no assets
at all.
2026-08-22 04:14:13 -04:00
jpolo1224 f8b4d63323 FrameGen: keep the interpolation off the frame's own command buffer
The previous commit removed the capture copy by recording Process() into the
frame's command buffer. That traded one problem for a worse one: the present path
waits on that buffer, so the interpolation's GPU cost came straight off the real
frame rate. Measured on device, 1920x1080 content ran at 24-28 real fps with
generation on, while the CPU side of the same call cost 0.013-0.061 ms -- the
work was not expensive to record, it was expensive to be waited on.

ARMSX2 runs Process in framegen's OWN command buffer, after the frame, against
the same presented image. That costs neither the copy nor the frame, and it is
what this now does: the capture side only remembers the image and lets the pacer
plan, and generate() transitions it, runs Process, runs GenerateInto per planned
frame, and submits -- one buffer, one submission, off the critical path.

Defaults were checked against ARMSX2 first and are identical (flow scale 100,
performance shaders on, target rate 0, multiplier 2), so this was never a settings
difference.
2026-08-22 04:06:54 -04:00
jpolo1224 eda6870a47 FrameGen: process the real frame in the frame's own command buffer, like ARMSX2 does
Two defects, both from grafting the passes onto the existing shape instead of
adopting the one they were written for.

A copy per frame that ARMSX2 never makes. Process() copies the source into its
chain itself (FrameGen.cpp, CopyPresentedFrame), and ARMSX2 hands it the real
swapchain image. This handed it a capture copy made by a separate full-frame blit,
so every frame paid for two 1920x1080 copies instead of one. The capture existed
only because generation happened later, from another submission, by which time the
swapchain image was gone -- so the copy was paying for a problem the ordering
created.

And the pacer planned against a clock that jittered. Plan() derives the base frame
rate from the interval between its own calls; it sat inside generate(), behind
three early returns and a conditional call site, so it measured an interval the
game never had and planned against it. That is judder by construction, and no
amount of tuning the multiplier reaches it.

Process now runs in the frame's OWN command buffer, against the presented image,
where the frame is finished and the image is still ours -- which removes the copy
and the reason for it together. The pacer plans there too, once per frame,
unconditionally. generate() keeps only the generation half: record GenerateInto
per planned frame, submit, and let queue order carry it into the blits.

The input capture images are gone entirely.

Reported as judder and lower performance against ARMSX2 and Eden running the same
passes, which was the right comparison to make.
2026-08-22 04:00:33 -04:00
jpolo1224 b4a552d697 FrameGen: take the interpolation off the critical path, and fix the shader cache flags
The cache was written with one flag set and read with another. BuildShaderCache
asked for allow_fp16 = true while LsfgShaders, the only consumer, asks for
(false, false), and the header stores those flags and is rejected when they
differ -- so the cache written at import could never satisfy the read after it.
On a desktop that is invisible: the source DLL is still there, LoadShaderModules
falls through to re-parsing the executable, and the cache is dead weight rewritten
every launch. Android has nothing to fall through to, because the picked file is
a copy in app cache that the system may clear, so it surfaced as "no shaders"
with a valid cache sitting next to it. Worth telling Camille: Eden and ARMSX2 are
both re-parsing Lossless.dll every launch rather than using their cache.

The cache also survives a missing source now. It is validated against the source's
size and mtime, which is right where an install stays put and wrong here; passing
0 skips those checks, matching what source_hash and variant already do. A replaced
Lossless Scaling is no longer noticed automatically, which is the trade -- against
losing the cache to a routine cache sweep, and re-importing is explicit.

Performance: the fence wait moved from after the submit to before the next one.
Waiting at the end put the whole interpolation on the critical path -- the thread
sat idle until the GPU finished, every frame -- when nothing required it: frame
generation and the present blits share one queue, so submission order already
orders them. Waiting at the START only blocks when the previous frame's passes
have not finished in time. The queue is now taken from the device rather than a
second vkGetDeviceQueue, so that sharing is explicit rather than incidental.

Settings: motion detail is a slider rather than three stops, and performance
shaders is a switch rather than a pair of buttons.
2026-08-22 03:51:34 -04:00
jpolo1224 caaadbe266 FrameGen: fix the allocator crash and the shader gate, and expose the rest of the settings in-game
Three defects from the switchover, all mine.

vmaCreateAllocator crashed at pc 0 the first time frame generation was switched
on. The allocator was created with physicalDevice and device but no instance and
no vulkanApiVersion; Android builds with VK_NO_PROTOTYPES, so VMA resolves its
table dynamically through vkGetInstanceProcAddr, which answers only for global
functions when the instance is null. It stored nulls and called one. memory.cpp
documents this exact failure at its own vmaCreateAllocator -- the two getters were
supplied and the instance beside them was not.

shader_count() then answered for the wrong cache, twice. First it still counted
g_shaders, which import_shaders stopped filling when it moved to the ported
extractor: generated_frame_count() multiplies by it, so generate() returned on its
first line and frame generation sat on "starting" with nothing logged, because it
never got far enough to fail. Replacing that with GetInstalledLosslessStatus() was
also wrong -- that validates the DLL PATH, and the UI copies the picked file into
app cache, which Android may purge. A re-import wrote a good 322KB shader cache and
it still said "no shaders". It now asks whether the cache LOADS, which is the
actual question: the shaders are extracted once and the source is not needed again.

The dead initialize() call in generate() is gone with it. It built the dlopen'd
library's context, nothing uses it, and a failure there could still disable the
feature.

Settings: the in-game menu offered the multiplier alone. Someone opening it
mid-game is there to answer "it is generating but the picture is unsteady" or
"it is generating but it is too expensive", and neither is the multiplier. Target
rate, flow scale and performance mode now sit beside it. Target rate is new --
0 keeps the fixed multiplier, non-zero is Camille's adaptive pacing.
2026-08-22 03:39:59 -04:00
jpolo1224 b5f449ccb6 FrameGen: run the ported passes, and stop going through the separate device
Wires up the previous commit. Frame generation now runs on OUR VkDevice, in our
own present path, instead of inside a dlopen'd library with a VkDevice of its own.

What that removes: every frame used to cross between devices as an
AHardwareBuffer -- allocated, imported as a VkImage on our device so the renderer
could blit into it, handed over as a raw buffer, imported again on theirs. The
capture images are plain device-local VkImages now, because the passes read the
very images the renderer already wrote. The AHardwareBuffer external-memory
extension was also the narrowest gate on the whole feature and is no longer
required.

Kept deliberately:

- The capture itself. The swapchain image is presented and reused, so the passes
  still need a stable copy; only its backing changed.
- commit_capture(), now near-vestigial. It existed because a second VkDevice had
  no semaphore joining it to ours. One device and one queue means submission
  order already says this, but the call site is where the capture becomes
  readable and that is worth keeping named.
- The fence wait in generate(). The present path blits the generated images
  immediately after, so the old contract -- return only when they are ready --
  still holds. A semaphore would do it without stalling the thread and is the
  obvious next step, on a path that has broken three times from partial fixes.

Frame generation records into its OWN command buffer, as the ARMSX2 driver does:
the passes have to run after the captured frame is complete and before the
generated images are blitted, and the renderer's buffer is already closed by then.

import_shaders now drives the ported extractor rather than the library. Both kept
their own copy of the shaders, and importing through the library would have looked
like it worked -- a count comes back, the settings screen agrees -- while the cache
the passes actually read stayed empty. Its failures are now phrased for the person
who chose the file.

VKPresent.cpp is untouched: generate(), generated_frame_count(), generated_image(),
capture_presented_frame() and commit_capture() all kept their signatures and their
meaning, which is the whole reason the switchover is one commit and not five.

Builds clean. NOT yet run on hardware -- neither this nor the device-feature change
under it has drawn a frame.
2026-08-22 03:19:55 -04:00
jpolo1224 e05ea4d219 FrameGen: port Camille's native LSFG implementation from ARMSX2
Compiles; not yet wired. VKFrameGen still drives the old dlopen path and nothing
calls these passes -- that is the next commit. Landing it here keeps the port and
the switchover separable, because the present path is the part that has broken
before.

The implementation is Camille LaVey's, from eden-emu PR #4263, by way of ARMSX2
2.6.6.8. She licensed it GPL-2.0-or-later for use here: ARMSX3 derives from
RPCS3, which is GPL-2.0-ONLY with no "or later" clause, so the original
GPL-3.0-or-later terms could not be carried across. Every file records that.
Eden is unaffected -- "or later" leaves its own use exactly as it was.

Why it is worth having: the current implementation runs behind a dlopen'd .so
with its OWN VkDevice, so every frame crosses devices as an AHardwareBuffer --
allocated, imported on our device, handed over, imported again on theirs. These
passes run on our device in our own present path and that round-trip disappears.
It also brings adaptive pacing: instead of a fixed x2, generate as many frames as
it takes to hold a target refresh rate, which is the answer to a game oscillating
between 60 and 30 on a 60Hz panel.

Porting notes, all of it concentrated in three seams rather than spread:

- LsfgVkCompat's Device adapter wrapped GSDeviceVK and now wraps vk::render_device.
  28 of the 31 files never mention either and were not touched.
- GSConfig lives in FrameGenConfig.h, mapping RPCS3's mode enum and cfg fields to
  the names the passes read, so a later merge from Eden touches passes not wiring.
- LosslessDll's platform calls (FileSystem/Path/GSXXH) became fs:: and an inline
  FNV-1a; the hash only has to notice the DLL changed since the cache was written.

Two changes outside the tree were prerequisites, not cleanups:

- vkCreateDevice now ENABLES vulkanMemoryModel and robustness2's nullDescriptor
  when the device reports them. Both were already probed and never enabled, and a
  shader that declares the memory model on a device where it was not enabled is
  invalid usage rather than a soft fallback -- desktop drivers shrug it off while
  Adreno takes the device down mid-frame, so it would have looked fine until it
  was not. Only nullDescriptor is turned on, not robustness2's expensive half.
- The Android Vulkan loader gained vkFreeDescriptorSets, which it never loaded.

New settings: Frame Generation Target Rate (0 = fixed multiplier) and Frame
Generation Lossless Path. Nothing ships the shaders; they are read from the
user's own legitimately purchased copy, exactly as before.
2026-08-22 03:03:36 -04: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
138 changed files with 8345 additions and 712 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 = 21
versionName = "0.9.4.1"
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.
@@ -360,16 +360,33 @@ void applyPresence(const std::string& serial, const std::string& title,
// So: use the cover URL when we have one (the API accepts an https URL here,
// no upload required), and otherwise send no assets rather than a key we
// cannot guarantee exists.
const bool has_art = !cover.empty();
if (has_art) {
discordpp::ActivityAssets assets{};
// The app icon, by URL rather than by asset key, for the reason above: a key that is not in
// the portal makes Discord reject the whole update, while an https URL always resolves. It is
// the launcher icon out of this repository, so it cannot drift from the app it represents.
static constexpr const char* kLogoUrl =
"https://raw.githubusercontent.com/ARMSX2/ARMSX3/master/android/armsx3-ui/app/src/main/"
"res/mipmap-xxxhdpi/ic_launcher.png";
discordpp::ActivityAssets assets{};
if (!cover.empty()) {
// Cover art large, emulator small -- the shape ARMSX2 uses, and the one that reads as
// "playing this, on this" rather than just naming a game.
assets.SetLargeImage(cover);
if (!title.empty()) {
assets.SetLargeText(title);
}
activity.SetAssets(assets);
assets.SetSmallImage(kLogoUrl);
assets.SetSmallText("ARMSX3");
} else {
// No cover: the logo takes the large slot instead of sending no assets at all, which is
// what used to happen and left the card blank.
assets.SetLargeImage(kLogoUrl);
assets.SetLargeText("ARMSX3");
}
activity.SetAssets(assets);
LOGI("presence: title='%s' serial='%s' cover=%s ra=%s", title.c_str(), serial.c_str(),
cover.empty() ? "none" : "yes", ra.empty() ? "none" : "yes");
@@ -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) {
@@ -23,10 +23,21 @@ namespace { bool g_started = false; }
extern "C" {
/* Defined below. port_new tears a stale run down through it rather than trusting
* g_started, so the declaration has to come first. */
void flux_port_free();
/* preset is 1..6, matching the saver's own DEFAULTS1..DEFAULTS6. */
int flux_port_new(int preset)
{
if (g_started) return 1;
/* NOT "if (g_started) return 1": nativeInit calls gl1_lost() before every create, so
* gl1 is guaranteed DOWN on entry now. Reporting success here would hand the caller a
* saver with no shim under it. That guard was only ever safe because gl1 state
* survived between savers -- which is exactly the property gl1_lost() removed, so it
* went from redundant to wrong. Unreachable today (port_free always clears g_started),
* but the rule is that a stale run is torn down and gl1_init() always runs, rather
* than that every caller gets the ordering right. */
if (g_started) flux_port_free();
if (!gl1_init()) return 0;
if (preset < 1 || preset > 6) preset = 1;
@@ -40,7 +51,14 @@ int flux_port_new(int preset)
saver_flux::initSaver();
g_started = saver_flux::readyToDraw != 0;
return g_started ? 1 : 0;
if (!g_started) {
/* Returning 0 means the JNI never calls port_free, so this is the only chance to give
* gl1 back. Leaving it up would strand g.ready with names from a context that is about
* to die, and gl1_init() early-returns on g.ready -- poisoning the NEXT saver. */
gl1_shutdown();
return 0;
}
return 1;
}
void flux_port_resize(int width, int height)
@@ -58,11 +76,20 @@ void flux_port_draw()
void flux_port_free()
{
if (!g_started) return;
saver_flux::cleanUp();
/* NOT "if (!g_started) return": port_new releases gl1 itself on the path where it
* returns 0, so this is unreachable with g_started false today. It is written this
* way so that stays true by construction rather than by that argument -- the rule is
* that gl1 goes back on every exit, in every port, without a caller having to reason
* about which ones can be skipped. */
if (g_started) {
saver_flux::cleanUp();
saver_flux::readyToDraw = 0;
g_started = false;
}
/* Belongs to the EGL context that is about to be destroyed; leaving g.ready set means
* gl1_init() early-returns for the NEXT saver and hands it dead GL names. Idempotent,
* and a no-op if gl1 was never up. */
gl1_shutdown();
saver_flux::readyToDraw = 0;
g_started = false;
}
} /* extern "C" */
@@ -72,7 +72,13 @@
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "Savers", __VA_ARGS__)
#define STACK_DEPTH 32
#define GL1_MAX_LISTS 16
/* Sixteen was not enough for two of the savers, and the failure was silent-ish: glGenLists
* returned 0, every subsequent glNewList/glCallList logged "bad list", and the geometry those
* lists held simply never drew. Lattice asks for 20 in one range (NUMOBJECTS), and Skyrocket
* accumulates 16 across separate calls -- flare 4, smoke 5, and seven singles in world.cpp.
* Names are 1-based, so the old ceiling allowed a longest run of 15 and neither could ever
* succeed. Sixty-four is a slot table of a pointer and two ints apiece: about a kilobyte. */
#define GL1_MAX_LISTS 64
typedef struct {
float pos[3];
@@ -24,10 +24,21 @@ namespace { bool g_started = false; }
extern "C" {
/* Defined below. port_new tears a stale run down through it rather than trusting
* g_started, so the declaration has to come first. */
void hyperspace_port_free();
int hyperspace_port_new(int preset)
{
(void) preset; /* No presets upstream; every knob was a registry value. */
if (g_started) return 1;
/* NOT "if (g_started) return 1": nativeInit calls gl1_lost() before every create, so
* gl1 is guaranteed DOWN on entry now. Reporting success here would hand the caller a
* saver with no shim under it. That guard was only ever safe because gl1 state
* survived between savers -- which is exactly the property gl1_lost() removed, so it
* went from redundant to wrong. Unreachable today (port_free always clears g_started),
* but the rule is that a stale run is torn down and gl1_init() always runs, rather
* than that every caller gets the ordering right. */
if (g_started) hyperspace_port_free();
if (!gl1_init()) return 0;
saver_hyperspace::setDefaults();
@@ -59,11 +70,21 @@ void hyperspace_port_draw()
void hyperspace_port_free()
{
if (!g_started) return;
saver_hyperspace::cleanUp();
/* NOT "if (!g_started) return": port_new sets g_started on every path that returns 1 today, so this
* is defensive -- but the point is that gl1 is released regardless of it. See the note below. */
if (g_started) {
saver_hyperspace::cleanUp();
saver_hyperspace::readyToDraw = 0;
g_started = false;
}
/* gl1_init() ran in port_new, and everything it holds -- the shader program, the vertex
* buffers -- belongs to the EGL context that is about to be destroyed. Returning without
* gl1_shutdown() leaves gl1's g.ready set with GL names from a DEAD context, and gl1_init()
* early-returns on g.ready. The next saver, in a NEW context, would then run against those
* dead names: undefined behaviour that some drivers answer with a segfault rather than a GL
* error, which takes the whole app down. So gl1 is torn down whether or not this saver's own
* init ever got as far as running. gl1_shutdown() is idempotent. */
gl1_shutdown();
saver_hyperspace::readyToDraw = 0;
g_started = false;
}
} /* extern "C" */
@@ -23,9 +23,20 @@ int g_preset = 1;
extern "C" {
/* Defined below. port_new tears a stale run down through it rather than trusting
* g_started, so the declaration has to come first. */
void lattice_port_free();
int lattice_port_new(int preset)
{
if (g_started) return 1;
/* NOT "if (g_started) return 1": nativeInit calls gl1_lost() before every create, so
* gl1 is guaranteed DOWN on entry now. Reporting success here would hand the caller a
* saver with no shim under it. That guard was only ever safe because gl1 state
* survived between savers -- which is exactly the property gl1_lost() removed, so it
* went from redundant to wrong. Unreachable today (port_free always clears g_started),
* but the rule is that a stale run is torn down and gl1_init() always runs, rather
* than that every caller gets the ordering right. */
if (g_started) lattice_port_free();
if (!gl1_init()) return 0;
g_preset = (preset >= 1 && preset <= 6) ? preset : 1;
@@ -53,11 +64,21 @@ void lattice_port_draw()
void lattice_port_free()
{
if (!g_started) return;
saver_lattice::cleanUp();
/* NOT "if (!g_started) return": this saver waits for a surface size before it initialises,
* so it can be created and torn down having never started. See the note below. */
if (g_started) {
saver_lattice::cleanUp();
saver_lattice::readyToDraw = 0;
g_started = false;
}
/* gl1_init() ran in port_new, and everything it holds -- the shader program, the vertex
* buffers -- belongs to the EGL context that is about to be destroyed. Returning without
* gl1_shutdown() leaves gl1's g.ready set with GL names from a DEAD context, and gl1_init()
* early-returns on g.ready. The next saver, in a NEW context, would then run against those
* dead names: undefined behaviour that some drivers answer with a segfault rather than a GL
* error, which takes the whole app down. So gl1 is torn down whether or not this saver's own
* init ever got as far as running. gl1_shutdown() is idempotent. */
gl1_shutdown();
saver_lattice::readyToDraw = 0;
g_started = false;
}
} /* extern "C" */
@@ -16,9 +16,20 @@ namespace { bool g_started = false; }
extern "C" {
/* Defined below. port_new tears a stale run down through it rather than trusting
* g_started, so the declaration has to come first. */
void plasma_port_free();
int plasma_port_new(int preset)
{
if (g_started) return 1;
/* NOT "if (g_started) return 1": nativeInit calls gl1_lost() before every create, so
* gl1 is guaranteed DOWN on entry now. Reporting success here would hand the caller a
* saver with no shim under it. That guard was only ever safe because gl1 state
* survived between savers -- which is exactly the property gl1_lost() removed, so it
* went from redundant to wrong. Unreachable today (port_free always clears g_started),
* but the rule is that a stale run is torn down and gl1_init() always runs, rather
* than that every caller gets the ordering right. */
if (g_started) plasma_port_free();
if (!gl1_init()) return 0;
saver_plasma::setDefaults();
@@ -42,7 +53,14 @@ int plasma_port_new(int preset)
saver_plasma::initSaver();
g_started = saver_plasma::readyToDraw != 0;
return g_started ? 1 : 0;
if (!g_started) {
/* Returning 0 means the JNI never calls port_free, so this is the only chance to give
* gl1 back. Leaving it up would strand g.ready with names from a context that is about
* to die, and gl1_init() early-returns on g.ready -- poisoning the NEXT saver. */
gl1_shutdown();
return 0;
}
return 1;
}
void plasma_port_resize(int width, int height)
@@ -59,11 +77,20 @@ void plasma_port_draw()
void plasma_port_free()
{
if (!g_started) return;
saver_plasma::cleanUp();
/* NOT "if (!g_started) return": port_new releases gl1 itself on the path where it
* returns 0, so this is unreachable with g_started false today. It is written this
* way so that stays true by construction rather than by that argument -- the rule is
* that gl1 goes back on every exit, in every port, without a caller having to reason
* about which ones can be skipped. */
if (g_started) {
saver_plasma::cleanUp();
saver_plasma::readyToDraw = 0;
g_started = false;
}
/* Belongs to the EGL context that is about to be destroyed; leaving g.ready set means
* gl1_init() early-returns for the NEXT saver and hands it dead GL names. Idempotent,
* and a no-op if gl1 was never up. */
gl1_shutdown();
saver_plasma::readyToDraw = 0;
g_started = false;
}
}
@@ -10,6 +10,8 @@
#include <cstddef>
#include <mutex>
#include "gl1.h"
#define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, "Savers", __VA_ARGS__)
#define SAVER_DECL(name) \
@@ -82,6 +84,21 @@ Java_com_armsx2_ui_home_SaverNative_nativeInit(JNIEnv *, jobject, jint effect, j
/* A previous saver may still be up if the outgoing view has not torn down yet. */
if (g_active) g_active->destroy();
/* Every nativeInit arrives on a freshly created EGL context -- one view, one render thread,
* one context, one create. So whatever GL names gl1 is still holding belong to a context
* that no longer exists, and gl1_init() early-returns on g.ready, which would hand those
* dead names to the saver we are about to start. gl1_lost() drops them without calling GL
* on them (gl1_shutdown() would try to delete them, in the wrong context).
*
* The ports each give gl1 back on their own failure and teardown paths, so this should
* already be a no-op. It is here because it is the invariant that actually matters -- a
* saver added later that forgets, or an upstream cleanup that returns early, would
* otherwise poison the NEXT saver rather than fail visibly in its own. Drivers answer a
* draw against a dead program name with anything from a black screen to a segfault, and a
* segfault here is unrecoverable: the library is the first screen, so the app would crash
* on every launch. */
gl1_lost();
g_active = &k_savers[effect];
if (!g_active->create(preset)) {
LOGE("%s failed to start (preset %d)", g_active->name, preset);
@@ -26,10 +26,21 @@ bool g_started = false;
extern "C" {
/* Defined below. port_new tears a stale run down through it rather than trusting
* g_started, so the declaration has to come first. */
void skyrocket_port_free();
int skyrocket_port_new(int preset)
{
(void) preset; /* No presets upstream; every knob was a registry value. */
if (g_started) return 1;
/* NOT "if (g_started) return 1": nativeInit calls gl1_lost() before every create, so
* gl1 is guaranteed DOWN on entry now. Reporting success here would hand the caller a
* saver with no shim under it. That guard was only ever safe because gl1 state
* survived between savers -- which is exactly the property gl1_lost() removed, so it
* went from redundant to wrong. Unreachable today (port_free always clears g_started),
* but the rule is that a stale run is torn down and gl1_init() always runs, rather
* than that every caller gets the ordering right. */
if (g_started) skyrocket_port_free();
if (!gl1_init()) return 0;
saver_skyrocket::setDefaults();
@@ -69,11 +80,21 @@ void skyrocket_port_draw()
void skyrocket_port_free()
{
if (!g_started) return;
saver_skyrocket::cleanup();
/* NOT "if (!g_started) return": this saver waits for a surface size before it initialises,
* so it can be created and torn down having never started. See the note below. */
if (g_started) {
saver_skyrocket::cleanup();
saver_skyrocket::readyToDraw = 0;
g_started = false;
}
/* gl1_init() ran in port_new, and everything it holds -- the shader program, the vertex
* buffers -- belongs to the EGL context that is about to be destroyed. Returning without
* gl1_shutdown() leaves gl1's g.ready set with GL names from a DEAD context, and gl1_init()
* early-returns on g.ready. The next saver, in a NEW context, would then run against those
* dead names: undefined behaviour that some drivers answer with a segfault rather than a GL
* error, which takes the whole app down. So gl1 is torn down whether or not this saver's own
* init ever got as far as running. gl1_shutdown() is idempotent. */
gl1_shutdown();
saver_skyrocket::readyToDraw = 0;
g_started = false;
}
} /* extern "C" */
@@ -15,11 +15,22 @@ namespace { bool g_started = false; }
extern "C" {
/* Defined below. port_new tears a stale run down through it rather than trusting
* g_started, so the declaration has to come first. */
void solarwinds_port_free();
/* preset is 1..6 from the UI. Upstream's DEFAULTS1..DEFAULTS6 is a zero-based ENUM here, not
* the 1-based #defines Flux uses, so the UI value is shifted down. */
int solarwinds_port_new(int preset)
{
if (g_started) return 1;
/* NOT "if (g_started) return 1": nativeInit calls gl1_lost() before every create, so
* gl1 is guaranteed DOWN on entry now. Reporting success here would hand the caller a
* saver with no shim under it. That guard was only ever safe because gl1 state
* survived between savers -- which is exactly the property gl1_lost() removed, so it
* went from redundant to wrong. Unreachable today (port_free always clears g_started),
* but the rule is that a stale run is torn down and gl1_init() always runs, rather
* than that every caller gets the ordering right. */
if (g_started) solarwinds_port_free();
if (!gl1_init()) return 0;
if (preset < 1 || preset > 6) preset = 1;
@@ -27,7 +38,14 @@ int solarwinds_port_new(int preset)
saver_solarwinds::initSaver();
g_started = saver_solarwinds::readyToDraw != 0;
return g_started ? 1 : 0;
if (!g_started) {
/* Returning 0 means the JNI never calls port_free, so this is the only chance to give
* gl1 back. Leaving it up would strand g.ready with names from a context that is about
* to die, and gl1_init() early-returns on g.ready -- poisoning the NEXT saver. */
gl1_shutdown();
return 0;
}
return 1;
}
void solarwinds_port_resize(int width, int height)
@@ -45,11 +63,20 @@ void solarwinds_port_draw()
void solarwinds_port_free()
{
if (!g_started) return;
saver_solarwinds::cleanUp();
/* NOT "if (!g_started) return": port_new releases gl1 itself on the path where it
* returns 0, so this is unreachable with g_started false today. It is written this
* way so that stays true by construction rather than by that argument -- the rule is
* that gl1 goes back on every exit, in every port, without a caller having to reason
* about which ones can be skipped. */
if (g_started) {
saver_solarwinds::cleanUp();
saver_solarwinds::readyToDraw = 0;
g_started = false;
}
/* Belongs to the EGL context that is about to be destroyed; leaving g.ready set means
* gl1_init() early-returns for the NEXT saver and hands it dead GL names. Idempotent,
* and a no-op if gl1 was never up. */
gl1_shutdown();
saver_solarwinds::readyToDraw = 0;
g_started = false;
}
}
@@ -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()}°"
}
@@ -134,6 +134,8 @@ data class Ps3Settings(
val frameGenPerformance: Boolean = true,
// Optical-flow resolution as a percentage of full; lower is cheaper and blurrier in motion.
val frameGenFlowScale: Int = 100,
/** Hz to hold, or 0 for the fixed multiplier. Non-zero selects adaptive pacing. */
val frameGenTargetRate: Int = 0,
val writeColorBuffers: Boolean = false,
val writeDepthBuffer: Boolean = false,
val readColorBuffers: Boolean = false,
@@ -1109,6 +1111,10 @@ data class Settings(
put("PS3/Video", "Frame Generation", "enum", ps3.frameGeneration.toString())
put("PS3/Video", "Frame Generation Performance Mode", "bool", ps3.frameGenPerformance.toString())
put("PS3/Video", "Frame Generation Flow Scale", "int", ps3.frameGenFlowScale.toString())
put("PS3/Video", "Frame Generation Target Rate", "int", ps3.frameGenTargetRate.toString())
// Temporary: the value reaches the core as 0 whatever the UI is set to, and all six
// plumbing sites read correctly. This says what the object being applied actually holds.
android.util.Log.i("FRAMEGEN", "applyTo: targetRate=${ps3.frameGenTargetRate} mult=${ps3.frameGeneration}")
put("PS3/Video", "Write Color Buffers", "bool", ps3.writeColorBuffers.toString())
put("PS3/Video", "Write Depth Buffer", "bool", ps3.writeDepthBuffer.toString())
put("PS3/Video", "Read Color Buffers", "bool", ps3.readColorBuffers.toString())
@@ -1838,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)
@@ -2088,6 +2094,7 @@ data class Settings(
put("ps3FrameGeneration", ps3.frameGeneration)
put("ps3FrameGenPerformance", ps3.frameGenPerformance)
put("ps3FrameGenFlowScale", ps3.frameGenFlowScale)
put("ps3FrameGenTargetRate", ps3.frameGenTargetRate)
put("ps3WriteColorBuffers", ps3.writeColorBuffers)
put("ps3GpuTurbo", ps3.gpuTurbo)
put("ps3SilenceAllLogs", ps3.silenceAllLogs)
@@ -2442,6 +2449,7 @@ data class Settings(
frameGeneration = json.optInt("ps3FrameGeneration", def.ps3.frameGeneration),
frameGenPerformance = json.optBoolean("ps3FrameGenPerformance", def.ps3.frameGenPerformance),
frameGenFlowScale = json.optInt("ps3FrameGenFlowScale", def.ps3.frameGenFlowScale),
frameGenTargetRate = json.optInt("ps3FrameGenTargetRate", def.ps3.frameGenTargetRate),
writeColorBuffers = json.optBoolean("ps3WriteColorBuffers", def.ps3.writeColorBuffers),
gpuTurbo = json.optBoolean("ps3GpuTurbo", def.ps3.gpuTurbo),
silenceAllLogs = json.optBoolean("ps3SilenceAllLogs", def.ps3.silenceAllLogs),
@@ -2781,6 +2789,7 @@ data class Settings(
if (current.ps3.frameGeneration != base.ps3.frameGeneration) j.put("ps3FrameGeneration", current.ps3.frameGeneration)
if (current.ps3.frameGenPerformance != base.ps3.frameGenPerformance) j.put("ps3FrameGenPerformance", current.ps3.frameGenPerformance)
if (current.ps3.frameGenFlowScale != base.ps3.frameGenFlowScale) j.put("ps3FrameGenFlowScale", current.ps3.frameGenFlowScale)
if (current.ps3.frameGenTargetRate != base.ps3.frameGenTargetRate) j.put("ps3FrameGenTargetRate", current.ps3.frameGenTargetRate)
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)
@@ -3096,6 +3105,7 @@ data class Settings(
frameGeneration = if (overrides.has("ps3FrameGeneration")) overrides.getInt("ps3FrameGeneration") else base.ps3.frameGeneration,
frameGenPerformance = if (overrides.has("ps3FrameGenPerformance")) overrides.getBoolean("ps3FrameGenPerformance") else base.ps3.frameGenPerformance,
frameGenFlowScale = if (overrides.has("ps3FrameGenFlowScale")) overrides.getInt("ps3FrameGenFlowScale") else base.ps3.frameGenFlowScale,
frameGenTargetRate = if (overrides.has("ps3FrameGenTargetRate")) overrides.getInt("ps3FrameGenTargetRate") else base.ps3.frameGenTargetRate,
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,
@@ -905,7 +905,15 @@ 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
"Use the phone's own motor when no controller has one. Turn this off to keep rumble on the controller only.",
"pad.hapticStrength.description" to "Scales all vibration — controller rumble and on-screen touch haptics alike. Below 100% tames a strong motor; above 100% boosts a weak one.",
"pad.hapticStrength.label" to "Vibration Strength",
"pad.scopeHint.global" to "○ Editing GLOBAL controls (all games).",
@@ -1052,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.",
@@ -1155,6 +1165,8 @@ val EN: Map<String, String> = mapOf(
"perf.framegen.performance.label" to "Performance shaders",
"perf.framegen.performance.description" to "Use Lossless Scaling's lighter 3.1p shaders instead of the full-quality 3.1 set. Cheaper to run and slightly softer in motion \u2014 on by default, because the quality set usually costs more than the frames it buys on a phone. Both come from the file you imported, so switching does not need another import.\n\nTakes effect when frame generation next starts: turn it off and on again, or restart the game.",
"perf.framegen.flowScale.label" to "Motion detail",
"perf.framegen.targetRate.label" to "Target refresh rate",
"perf.framegen.targetRate.description" to "Generate as many frames as it takes to hold this rate, instead of a fixed multiplier. Steadies the picture when the game's own frame rate moves. Off uses the multiplier above.",
"perf.framegen.flowScale.description" to "How finely motion is measured between frames, as a percentage of full resolution. Lower is faster and blurrier around moving edges. Drop this before dropping the multiplier if frame generation is costing more than it gives.\n\nTakes effect when frame generation next starts.",
"perf.framegen.off" to "Off",
"perf.framegen.x2" to "x2",
@@ -393,6 +393,17 @@ object ControllerMappings {
com.armsx3.NativeApp.sRumbleEnabled = on
}
// Whether the PHONE's motor may be used. Rumble prefers a connected controller's motor and
// falls back to the phone; this gates only that fallback, so playing on a pad need not mean
// the phone buzzes too. Mirrored into NativeApp.sPhoneRumbleEnabled the same way KEY_RUMBLE
// is — live on change and at app start. Default on, so a phone-only player is unaffected.
private const val KEY_RUMBLE_PHONE = "pad.rumble.phone"
fun phoneRumbleEnabled(): Boolean = MainActivityRuntime.prefs.getBoolean(KEY_RUMBLE_PHONE, true)
fun setPhoneRumbleEnabled(on: Boolean) {
MainActivityRuntime.prefs.edit { putBoolean(KEY_RUMBLE_PHONE, on) }
com.armsx3.NativeApp.sPhoneRumbleEnabled = on
}
// Haptic strength: one multiplier scaling ALL vibration — controller rumble AND on-screen
// touch ticks both funnel through NativeApp.rumbleOne. 0..200 % (100 = as the game/UI
// authored it), so it tames a too-strong motor or boosts a weak one. Persisted and mirrored
@@ -2157,6 +2157,10 @@ open class MainActivityRuntime : ComponentActivity() {
startAutosaveIntervalJob()
// 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()

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