25 Commits
Author SHA1 Message Date
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
jpolo1224 daed55c427 Release: 0.9.4.2 2026-08-22 10:09:25 -04: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
74 changed files with 6753 additions and 554 deletions
+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 = 23
versionName = "0.9.4.3"
// 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");
@@ -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;
}
}
@@ -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())
@@ -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,
@@ -906,6 +906,9 @@ val EN: Map<String, String> = mapOf(
"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.",
"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).",
@@ -1155,6 +1158,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,7 @@ 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()
// 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()
@@ -1059,6 +1059,54 @@ private fun PerformancePane(state: EmulationMenuUiState, viewModel: EmulationMen
selected = settings.ps3.frameGeneration,
onSelect = { v -> viewModel.updateSettings { it.copy(ps3 = it.ps3.copy(frameGeneration = v)) } },
)
// The rest of frame generation, which until now only existed in the main settings
// screen. Someone who opens this menu mid-game is here to change exactly these:
// the multiplier alone cannot answer "it is generating, but the picture is unsteady"
// (target rate) or "it is generating, but too expensive" (flow scale, performance).
HorizontalOptions(
title = str("perf.framegen.targetRate.label"),
options = listOf(0 to str("perf.framegen.off"), 60 to "60 Hz", 90 to "90 Hz", 120 to "120 Hz"),
selected = settings.ps3.frameGenTargetRate,
onSelect = { v ->
android.util.Log.i("FRAMEGEN", "pause menu: target rate chip -> $v")
viewModel.updateSettings { it.copy(ps3 = it.ps3.copy(frameGenTargetRate = v)) }
},
)
// Motion detail is continuous, so it gets a slider rather than three stops -- the
// useful values are wherever the picture stops improving on a given game, not a set
// someone picked in advance. 25 is the floor the core clamps to.
Column(Modifier.fillMaxWidth().padding(horizontal = 4.dp, vertical = 6.dp)) {
Row(
Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
str("perf.framegen.flowScale.label"),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurface,
)
Text(
"${settings.ps3.frameGenFlowScale}%",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
}
Slider(
value = settings.ps3.frameGenFlowScale.coerceIn(25, 100).toFloat(),
onValueChange = { v ->
viewModel.updateSettings {
it.copy(ps3 = it.ps3.copy(frameGenFlowScale = Math.round(v).coerceIn(25, 100)))
}
},
valueRange = 25f..100f,
)
}
MenuSwitchRow(
str("perf.framegen.performance.label"),
settings.ps3.frameGenPerformance,
) { v -> viewModel.updateSettings { it.copy(ps3 = it.ps3.copy(frameGenPerformance = v)) } }
}
Spacer(Modifier.height(10.dp))
}
@@ -147,6 +147,19 @@ fun HomeScreen(
var showClearRecentsConfirm by remember { mutableStateOf(false) }
// #9 custom library background — inert until the user picks an image.
LaunchedEffect(Unit) { LibraryBackground.ensureLoaded(); CoverArtStyle.load() }
// The animated background switched itself off because the last run died with it on screen
// (LibraryBackground.armSaver). Say so -- silently reverting a setting the user chose reads
// as the setting being broken, and the name tells them which one to avoid.
LaunchedEffect(LibraryBackground.crashedSaver.value) {
LibraryBackground.crashedSaver.value?.let { kind ->
LibraryBackground.crashedSaver.value = null
Toast.makeText(
context,
"Animated background turned off: ${LibraryBackground.saverName(kind)} crashed last time.",
Toast.LENGTH_LONG,
).show()
}
}
val backgroundPicker = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { picked ->
picked?.let { LibraryBackground.set(context, it) }
}
@@ -21,6 +21,12 @@ object LibraryBackground {
private const val PREF_FLURRY_PRESET = "library.background.flurry.preset"
private const val PREF_SAVER_KIND = "library_saver_kind"
private const val PREF_RSS_PRESET = "library_rss_preset"
/**
* Which saver is CURRENTLY running, written synchronously before its GL thread starts and
* cleared when that thread exits in an orderly way. See [armSaver].
*/
private const val PREF_ARMED = "library.saver.armed"
val uri = mutableStateOf<String?>(null)
/**
@@ -60,6 +66,53 @@ object LibraryBackground {
/** Preset for whichever Really Slick saver is selected, 1..6. 99 = pick one each time. */
val rssPreset = mutableStateOf(99)
/**
* Set at startup when the previous run died with a saver on screen. Holds the [saverKind]
* that was running so the library can say which one, and so the user knows their background
* was turned off deliberately rather than forgotten. Read once and cleared by the reader.
*/
val crashedSaver = mutableStateOf<Int?>(null)
/** Display name for a [saverKind], for the message above. Matches the settings list. */
fun saverName(kind: Int): String = when (kind) {
1 -> "Flux"; 2 -> "Plasma"; 3 -> "SolarWinds"
4 -> "Hyperspace"; 5 -> "Lattice"; 6 -> "Skyrocket"
else -> "Flurry"
}
/**
* Crash-loop breaker.
*
* The savers are native GL code, and native GL code can take the process down in ways no
* `runCatching` can see -- a SIGSEGV in a driver, or a hang that Android resolves by killing
* us. Because the choice is persisted and the library is the FIRST screen, a saver that dies
* on startup dies again on every launch: the app never gets far enough for anyone to reach
* Settings and switch it off. The only escape is clearing app data, which on Android takes
* the memory cards and save states with it.
*
* So the setting arms itself before the GL thread starts and disarms when that thread exits
* normally. Finding it still armed at startup means last run ended while a saver was on
* screen -- the background is switched off and the user is told which one did it. The write
* must be commit() rather than apply(): apply() is asynchronous, and the whole point is that
* the process may be about to die.
*/
fun armSaver() {
runCatching {
MainActivityRuntime.prefs.edit().putInt(PREF_ARMED, saverKind.value).commit()
}
}
/**
* Orderly teardown -- the saver ran without taking the process with it. Idempotent.
*
* apply() rather than commit() on purpose, and the asymmetry with [armSaver] is the point:
* this one is not racing the process's death, and if it were lost the cost is a background
* switched off for no reason, which is recoverable from Settings. The arm must never be lost.
*/
fun disarmSaver() {
runCatching { MainActivityRuntime.prefs.edit().remove(PREF_ARMED).apply() }
}
private var loaded = false
fun ensureLoaded() {
@@ -71,6 +124,14 @@ object LibraryBackground {
flurryPreset.value = runCatching { MainActivityRuntime.prefs.getInt(PREF_FLURRY_PRESET, 99) }.getOrDefault(99)
saverKind.value = runCatching { MainActivityRuntime.prefs.getInt(PREF_SAVER_KIND, 0) }.getOrDefault(0)
rssPreset.value = runCatching { MainActivityRuntime.prefs.getInt(PREF_RSS_PRESET, 99) }.getOrDefault(99)
// Still armed = the previous run died with a saver up. Break the loop (see armSaver).
val armed = runCatching { MainActivityRuntime.prefs.getInt(PREF_ARMED, -1) }.getOrDefault(-1)
if (armed >= 0) {
crashedSaver.value = armed
setFlurry(false)
disarmSaver()
}
}
fun setAnimated2D(on: Boolean) {
@@ -97,8 +97,22 @@ class SaverGlView(context: Context, private val spec: SaverSpec) :
}
override fun onSurfaceTextureAvailable(st: SurfaceTexture, w: Int, h: Int) {
thread = RenderThread(st, w, h, spec) { ok -> post { onGlStatus?.invoke(ok) } }
.also { it.start() }
// Arm the crash-loop breaker for as long as native GL code is running on our behalf; the
// thread disarms it when it exits in an orderly way. See LibraryBackground.armSaver.
LibraryBackground.armSaver()
// start() asks for a 16MB stack (see STACK_BYTES) and can throw OutOfMemoryError on a
// constrained device. That would be an uncaught throw on the MAIN thread -- the process
// dies, and since this is the first screen the app would be unlaunchable. Fall back to
// the 2D backdrop instead, the same way an EGL failure does.
thread = runCatching {
RenderThread(st, w, h, spec) { ok -> post { onGlStatus?.invoke(ok) } }
.also { it.start() }
}.getOrElse {
Log.w(TAG, "saver thread failed to start", it)
LibraryBackground.disarmSaver()
onGlStatus?.invoke(false)
null
}
}
override fun onSurfaceTextureSizeChanged(st: SurfaceTexture, w: Int, h: Int) {
@@ -152,9 +166,26 @@ class SaverGlView(context: Context, private val spec: SaverSpec) :
private var saver: Saver? = null
fun resize(w: Int, h: Int) { width = w; height = h; sizeDirty = true }
fun finish() { running = false; runCatching { join(500) } }
fun finish() {
running = false
runCatching { join(500) }
// join() is bounded, so the thread's own finally may not have run yet. We asked it to
// stop and the process is still here, which is all the breaker needs to know.
LibraryBackground.disarmSaver()
}
override fun run() {
// Reaching the end of this function at all -- however the saver did -- means the
// process survived it, which is the only thing the breaker is asking about. A native
// crash or a kill never gets here, and that is what leaves the flag set.
try {
render()
} finally {
LibraryBackground.disarmSaver()
}
}
private fun render() {
if (!initEgl()) { onStatus(false); teardown(); return }
val s = spec.newSaver()

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