19 Commits
Author SHA1 Message Date
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
52 changed files with 6234 additions and 498 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 = 22
versionName = "0.9.4.2"
// 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");
@@ -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,
@@ -1155,6 +1155,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",
@@ -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))
}
@@ -442,6 +442,19 @@ fun PerformanceTab(state: MutableState<Settings>) {
onChange = { apply(s.copy(ps3 = s.ps3.copy(frameGenFlowScale = it))) },
)
SettingsDivider()
SettingsDivider()
SegmentedGridRow(
label = str("perf.framegen.targetRate.label"),
options = listOf(str("perf.framegen.off"), "60 Hz", "90 Hz", "120 Hz"),
selectedIndex = when (s.ps3.frameGenTargetRate) { 60 -> 1; 90 -> 2; 120 -> 3; else -> 0 },
columns = 4,
description = str("perf.framegen.targetRate.description"),
onChange = { idx ->
val hz = when (idx) { 1 -> 60; 2 -> 90; 3 -> 120; else -> 0 }
apply(s.copy(ps3 = s.ps3.copy(frameGenTargetRate = hz)))
},
)
SettingsDivider()
FrameGenShaderRow()
}
SettingsDivider()
@@ -621,6 +621,7 @@ object Rpcs3Bridge {
"Frame Generation" -> Rpcs3Settings.setFrameGeneration(asInt(value))
"Frame Generation Performance Mode" -> Rpcs3Settings.setFrameGenPerformance(asBool(value))
"Frame Generation Flow Scale" -> Rpcs3Settings.setFrameGenFlowScale(asInt(value))
"Frame Generation Target Rate" -> Rpcs3Settings.setFrameGenTargetRate(asInt(value))
"Write Color Buffers" -> Rpcs3Settings.setWriteColorBuffers(asBool(value))
"Write Depth Buffer" -> Rpcs3Settings.setWriteDepthBuffer(asBool(value))
"Read Color Buffers" -> Rpcs3Settings.setReadColorBuffers(asBool(value))
@@ -493,6 +493,10 @@ object Rpcs3Settings {
fun setFrameGenFlowScale(percent: Int) =
setInt("$VIDEO@@Frame Generation Flow Scale", percent.coerceIn(25, 100))
/** Hz to hold, or 0 for the fixed multiplier. Bounds match what the core accepts. */
fun setFrameGenTargetRate(hz: Int) =
setInt("$VIDEO@@Frame Generation Target Rate", hz.coerceIn(0, 480))
fun setFrameGeneration(index: Int) =
setEnum("$VIDEO@@Frame Generation", FRAME_GENERATION.getOrElse(index) { FRAME_GENERATION[0] })
+19
View File
@@ -648,6 +648,25 @@ if(TARGET 3rdparty_vulkan)
RSX/VK/vkutils/sampler.cpp
RSX/VK/vkutils/shared.cpp
RSX/VK/vkutils/unique_resource.cpp
# Frame generation, ported from Eden (CamilleLaVey, eden PR #4263) via ARMSX2, and
# relicensed GPL-2.0-or-later by its author for use here. Runs the passes on OUR device
# in our own present path -- the earlier implementation lived behind a dlopen'd .so with
# its own VkDevice, which forced every frame through an AHardwareBuffer round-trip.
RSX/VK/FrameGen/FrameGen.cpp
RSX/VK/FrameGen/FrameGenPacer.cpp
RSX/VK/FrameGen/LosslessDll.cpp
RSX/VK/FrameGen/LsfgAlpha.cpp
RSX/VK/FrameGen/LsfgBeta.cpp
RSX/VK/FrameGen/LsfgChain.cpp
RSX/VK/FrameGen/LsfgCommon.cpp
RSX/VK/FrameGen/LsfgDelta.cpp
RSX/VK/FrameGen/LsfgGamma.cpp
RSX/VK/FrameGen/LsfgGenerate.cpp
RSX/VK/FrameGen/LsfgMipmaps.cpp
RSX/VK/FrameGen/LsfgShaders.cpp
RSX/VK/FrameGen/LsfgTranslate.cpp
RSX/VK/FrameGen/LsfgUtil.cpp
RSX/VK/FrameGen/LsfgVkCompat.cpp
RSX/VK/VKAsyncScheduler.cpp
RSX/VK/VKCommandStream.cpp
RSX/VK/VKCommonDecompiler.cpp
+294
View File
@@ -0,0 +1,294 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-FileCopyrightText: Copyright 2026 Camille LaVey
// Relicensed to GPL-2.0-or-later for use in ARMSX3 by Camille LaVey, founder of the Eden
// Emulator Project and author of this implementation (eden-emu PR #4263), who granted
// permission for it to be used 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 --
// ARMSX2 is GPL-3.0 and ships it unchanged. Eden is unaffected: "or later" leaves its own use
// exactly as it was.
// SPDX-License-Identifier: GPL-2.0-or-later
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-2.0-or-later
//
// Ported from Eden (eden-emu PR #4263), src/video_core/renderer_vulkan/present/frame_gen.cpp.
// The orchestration is verbatim; see FrameGen.h for the three structural differences and for why
// the debug image dump is gone. Only the PORT-marked spots below deviate.
#include <algorithm>
#include <array>
#include <cmath>
#include "FrameGen.h"
#include "LsfgChain.h"
#include "LsfgCommon.h"
#include "LsfgShaders.h"
#include "LsfgVkCompat.h"
#include "../vkutils/device.h"
#include "FrameGenConfig.h"
namespace Vulkan {
namespace {
constexpr u64 LSFG_REQUIRED_FRAMES = 2;
constexpr u32 LSFG_RECURRENCE_FRAMES = 2;
[[nodiscard]] f32 ManualFlowScale() {
// The clamp is not Eden's: their setting type enforces 25..100 on the way in, ours is a plain
// u8 that a hand-edited INI can hold anything in, and LsfgResources feeds this straight into
// 1.0f / flow_scale. GSLsfg.cpp clamps the same way for the same reason.
return static_cast<f32>(std::clamp<u8>(::vk::lsfg::flow_scale(), 25, 100)) / 100.0f;
}
[[nodiscard]] f32 ConfiguredFlowScale(VkExtent2D guest_extent, VkExtent2D presented_extent) {
// PORT: Eden gates the automatic path on frame_gen_flow_scale_auto, a toggle that defaults on
// and that PCSX2 has no equivalent for. 100% — our default, and the top of the 25..100 range
// the UI offers — reads as "do not reduce the flow resolution", and the automatic result is
// clamped to 1.0 anyway, so treating it as Eden's auto mode preserves both projects' default
// behaviour. Any explicit value below 100 pins the scale exactly where the user put it.
if (::vk::lsfg::flow_scale() < 100) {
return ManualFlowScale();
}
if (guest_extent.width == 0 || presented_extent.width == 0) {
return 1.0f;
}
// PORT: Eden scales by resolution_info.up_factor because its guest_extent is the console's own
// resolution. Ours is the size the game was really rendered at, upscale already applied.
const f32 rendered_width = static_cast<f32>(guest_extent.width);
const f32 ratio = rendered_width / static_cast<f32>(presented_extent.width);
constexpr f32 FLOW_SCALE_STEPS = 20.0f;
const f32 stepped = std::ceil(ratio * FLOW_SCALE_STEPS) / FLOW_SCALE_STEPS;
return std::clamp(stepped, 0.25f, 1.0f);
}
VkImageMemoryBarrier MakeTransitionBarrier(VkImage image, VkAccessFlags src_access,
VkAccessFlags dst_access, VkImageLayout old_layout,
VkImageLayout new_layout) {
return VkImageMemoryBarrier{
.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER,
.pNext = nullptr,
.srcAccessMask = src_access,
.dstAccessMask = dst_access,
.oldLayout = old_layout,
.newLayout = new_layout,
.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED,
.image = image,
.subresourceRange{
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.baseMipLevel = 0,
.levelCount = 1,
.baseArrayLayer = 0,
.layerCount = 1,
},
};
}
VkImageCopy MakeCopyRegion(VkExtent2D extent) {
return VkImageCopy{
.srcSubresource{
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.mipLevel = 0,
.baseArrayLayer = 0,
.layerCount = 1,
},
.srcOffset = {},
.dstSubresource{
.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT,
.mipLevel = 0,
.baseArrayLayer = 0,
.layerCount = 1,
},
.dstOffset = {},
.extent = {.width = extent.width, .height = extent.height, .depth = 1},
};
}
void CopyPresentedFrame(vk::CommandBuffer cmdbuf, VkImage source, LsfgImage& destination,
VkExtent2D extent) {
const auto make_barrier = MakeTransitionBarrier;
const std::array before{
make_barrier(source, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT,
VK_IMAGE_LAYOUT_GENERAL, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL),
make_barrier(destination.Handle(), VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_WRITE_BIT,
destination.Layout(), VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL),
};
cmdbuf.PipelineBarrier(VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT |
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
VK_PIPELINE_STAGE_TRANSFER_BIT, 0, {}, {}, before);
// PORT: yuzu's vk::Span takes a lone VkImageCopy; the shim's std::span needs a range.
const std::array regions{MakeCopyRegion(extent)};
cmdbuf.CopyImage(source, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, destination.Handle(),
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, regions);
const std::array after{
make_barrier(source, VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT,
VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_IMAGE_LAYOUT_GENERAL),
make_barrier(destination.Handle(), VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT,
VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_GENERAL),
};
cmdbuf.PipelineBarrier(VK_PIPELINE_STAGE_TRANSFER_BIT,
VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT |
VK_PIPELINE_STAGE_COMPUTE_SHADER_BIT,
0, {}, {}, after);
destination.SetLayout(VK_IMAGE_LAYOUT_GENERAL);
}
} // Anonymous namespace
FrameGen::FrameGen(MemoryAllocator& memory_allocator_, const ::vk::render_device* device_)
: memory_allocator{memory_allocator_}, gs_device{device_} {}
FrameGen::~FrameGen() {
if (chain) {
// ★ Device idle before teardown — see WaitForIdle. The chain is about to be destroyed by
// the member destructor, and its images and pipelines go with it immediately.
WaitForIdle();
}
}
void FrameGen::Process(const Device& device, vk::CommandBuffer cmdbuf, VkImage image,
VkImageView storage_view, VkExtent2D extent, VkFormat format,
VkExtent2D guest_extent) {
generated = false;
if (unavailable || !::vk::lsfg::enabled()) {
if (chain) {
// ★ Device idle before teardown — see WaitForIdle. Stands in for Eden's
// scheduler.Finish() ahead of the same chain.reset().
WaitForIdle();
chain.reset();
}
warm_streak = 0;
return;
}
if (storage_view == VK_NULL_HANDLE) {
unavailable = true;
return;
}
if (!shaders) {
shaders.emplace(device);
if (!shaders->IsValid()) {
unavailable = true;
return;
}
}
peak_guest_extent.width = std::max(peak_guest_extent.width, guest_extent.width);
peak_guest_extent.height = std::max(peak_guest_extent.height, guest_extent.height);
const f32 flow_scale = ConfiguredFlowScale(peak_guest_extent, extent);
if (!chain || built_extent.width != extent.width || built_extent.height != extent.height ||
built_format != format || built_flow_scale != flow_scale) {
Rebuild(device, extent, format, flow_scale);
}
const u64 count = frame_count++;
last_count = count;
last_generations = plan.generations;
const bool warm = plan.warm && count + 1 >= LSFG_REQUIRED_FRAMES;
warm_streak = warm ? warm_streak + 1 : 0;
generated = warm && warm_streak >= LSFG_RECURRENCE_FRAMES && plan.generations > 0;
// PORT: Eden asks the scheduler for an outside-render-pass context and defers the recording
// into a callback. Here the caller is already outside any render pass and hands us the buffer
// to record into — see the note on Process() in the header for why it must not be the frame's.
CopyPresentedFrame(cmdbuf, image, chain->Input(count), extent);
if (warm) {
chain->DispatchShared(cmdbuf, count);
}
}
size_t FrameGen::WantedGenerations(size_t capacity) {
if (unavailable) {
plan = {};
return 0;
}
plan = pacer.Plan(capacity);
return plan.generations;
}
size_t FrameGen::GeneratedFrameCount() const {
return generated ? last_generations : 0;
}
void FrameGen::GenerateInto(const Device& device, vk::CommandBuffer cmdbuf, VkImage image,
VkImageView storage_view, size_t generation) {
const u32 target = TargetIndex(storage_view);
chain->SetTarget(device, last_generations, generation, target, storage_view);
// PORT: Eden reads the destination Frame's own size. Everything we generate into is a
// presented image, so it is the extent the chain was built for.
const VkExtent2D extent = built_extent;
// PORT: recorded into the caller's buffer, as in Process.
chain->DispatchGeneration(cmdbuf, last_count, last_generations, generation, target, image,
extent);
}
void FrameGen::Rebuild(const Device& device, VkExtent2D extent, VkFormat format, f32 flow_scale) {
// ★ Device idle before teardown — see WaitForIdle. This is Eden's scheduler.Finish().
WaitForIdle();
chain.reset();
// PORT: the slot table keys on VkImageView handles, and a rebuild is exactly when the
// presented images are recreated. Dropping the stale handles keeps a recycled one from
// matching an entry that belongs to a view that no longer exists.
targets.fill(VK_NULL_HANDLE);
target_count = 0;
built_flow_scale = flow_scale;
chain.emplace(device, memory_allocator, *shaders, extent, format, built_flow_scale);
built_extent = extent;
built_format = format;
frame_count = 0;
warm_streak = 0;
generated = false;
}
void FrameGen::WaitForIdle() {
// ★ LOAD-BEARING, and the reason every teardown path calls it first.
//
// LsfgVkCompat's wrappers call vkDestroy* the moment they go out of scope instead of routing
// through the renderer's deferred-destruction queue. Releasing the chain while a submitted
// command buffer still references its images, pipelines or descriptor pool is therefore a
// use-after-free, and one that surfaces as a random GPU fault rather than as an obvious bug.
// Eden gets the guarantee from Scheduler::Finish(); ours has to be explicit.
//
// vkDeviceWaitIdle covers everything submitted, which is sufficient here because every site
// that calls this runs before that frame's chain work is recorded, and the previous frame's
// command buffer was submitted by the present that ended it. Note it is deliberately not
// the renderer's own submit: that submits the frame's command buffer, which is the
// wrong thing to do halfway through a present.
// PCSX2 called GSDeviceVK::WaitForGPUIdle() here. RPCS3 has no equivalent wrapper, and the
// renderer's own teardown at VKGSRender.cpp:859 uses the plain Vulkan call, so use that.
vkDeviceWaitIdle(static_cast<VkDevice>(*gs_device));
}
u32 FrameGen::TargetIndex(VkImageView view) {
for (size_t i = 0; i < targets.size(); ++i) {
if (targets[i] == view) {
return static_cast<u32>(i);
}
}
// Unseen view: claim the next slot. Wrapping is not expected — a present path draws from a
// handful of images and there are LSFG_MAX_TARGETS of these — and costs only a descriptor
// rewrite if it ever happens.
const u32 index = static_cast<u32>(target_count++ % targets.size());
targets[index] = view;
return index;
}
} // namespace Vulkan
+104
View File
@@ -0,0 +1,104 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-FileCopyrightText: Copyright 2026 Camille LaVey
// Relicensed to GPL-2.0-or-later for use in ARMSX3 by Camille LaVey, founder of the Eden
// Emulator Project and author of this implementation (eden-emu PR #4263), who granted
// permission for it to be used 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 --
// ARMSX2 is GPL-3.0 and ships it unchanged. Eden is unaffected: "or later" leaves its own use
// exactly as it was.
// SPDX-License-Identifier: GPL-2.0-or-later
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-2.0-or-later
//
// Ported from Eden (eden-emu PR #4263), src/video_core/renderer_vulkan/present/frame_gen.h.
// The orchestration is unchanged — warm-up counting, the rebuild conditions, the flow-scale
// derivation and the pacer plumbing are all Eden's. What differs is structural:
//
// * Eden's methods take a `Frame*` out of PresentManager's pool. PCSX2 has no such pool, so the
// three fields they read (image, storage view, extent) arrive as explicit parameters instead.
// * Eden records through a `Scheduler`. We hold the render device and record into the command
// buffer directly.
// * Eden's debug image dump — `DumpDebugImages` plus the WritePortablePixmap / WriteGrayscalePgm
// / WriteRaw / WriteColorPpm writers and the `dumped` flag — is not carried over. It needs
// <filesystem>, which the GS backend does not pull in, and a buffer readback the compat shim
// does not implement.
//
// See FrameGenTypes.h and LsfgVkCompat.h.
#pragma once
namespace vk { class render_device; }
#include <array>
#include <optional>
#include "FrameGenPacer.h"
#include "FrameGenTypes.h"
#include "LsfgChain.h"
#include "LsfgShaders.h"
#include "LsfgVkCompat.h"
namespace Vulkan {
class FrameGen {
public:
explicit FrameGen(MemoryAllocator& memory_allocator, const ::vk::render_device* device);
~FrameGen();
/// Feeds the frame about to be presented into the chain.
///
/// ★ The caller supplies [cmdbuf]. It is NOT taken from the renderer's current buffer:
/// frame generation runs from the present hook, which fires AFTER the frame's command buffer
/// has been submitted, so that buffer is either in flight or already belongs to the next
/// frame. Recording into it is undefined, and the visible symptom would be interpolation
/// running a frame late rather than anything that looks like an error. GSLsfg owns its own
/// one-shot buffers and passes them in, which is also how Eden's scheduler supplies one.
///
/// `image` / `storage_view` / `extent` describe that presented image; `guest_extent` is the
/// size the game was actually rendered at, upscale included — the flow-scale heuristic
/// compares the two. Must be called outside a render pass.
void Process(const Device& device, vk::CommandBuffer cmdbuf, VkImage image,
VkImageView storage_view, VkExtent2D extent, VkFormat format,
VkExtent2D guest_extent);
[[nodiscard]] size_t WantedGenerations(size_t capacity);
[[nodiscard]] size_t GeneratedFrameCount() const;
/// Writes interpolated frame `generation` into `image`. Only valid while
/// GeneratedFrameCount() is non-zero, and for `generation` below it.
void GenerateInto(const Device& device, vk::CommandBuffer cmdbuf, VkImage image,
VkImageView storage_view, size_t generation);
private:
void Rebuild(const Device& device, VkExtent2D extent, VkFormat format, f32 flow_scale);
void WaitForIdle();
[[nodiscard]] u32 TargetIndex(VkImageView view);
MemoryAllocator& memory_allocator;
const ::vk::render_device* gs_device;
std::optional<LsfgShaders> shaders;
std::optional<LsfgChain> chain;
FrameGenPacer pacer;
FrameGenPlan plan{};
/// PORT: stands in for Eden's `Frame::index`. LsfgGenerate keys its descriptor sets by target
/// slot and only rewrites them when the view in that slot changes, so a destination image
/// needs a *stable* index — Eden gets one from its frame pool, we recover it by remembering
/// which view we handed to which slot.
std::array<VkImageView, LSFG_MAX_TARGETS> targets{};
size_t target_count{};
VkExtent2D peak_guest_extent{};
VkExtent2D built_extent{};
VkFormat built_format{VK_FORMAT_UNDEFINED};
f32 built_flow_scale{};
u64 frame_count{};
u64 last_count{};
size_t last_generations{};
u32 warm_streak{};
bool generated{};
bool unavailable{};
};
} // namespace Vulkan
@@ -0,0 +1,58 @@
// SPDX-License-Identifier: GPL-2.0-or-later
//
// The settings the ported passes read.
//
// The Eden/ARMSX2 sources read a global `GSConfig` with fields named LsfgEnabled, LsfgMultiplier
// and so on. RPCS3 keeps the same values in g_cfg under different names and different types --
// a mode enum rather than a multiplier, most obviously -- so this is the one place the two
// vocabularies meet. Keeping it in a single header means a future merge from Eden touches the
// passes and not the wiring.
#pragma once
#include "Emu/system_config.h"
namespace vk::lsfg
{
inline bool enabled()
{
return g_cfg.video.frame_generation != frame_generation_mode::off;
}
/// How many frames to show per rendered one. PCSX2 stored this directly; RPCS3 stores a mode.
inline u32 multiplier()
{
switch (g_cfg.video.frame_generation)
{
case frame_generation_mode::x3: return 3;
case frame_generation_mode::x4: return 4;
default: return 2;
}
}
/// 25..100. The optical flow runs at this fraction of full resolution.
inline u32 flow_scale()
{
return g_cfg.video.frame_generation_flow_scale.get();
}
/// Hz to hold, or 0 to use multiplier() unchanged. Non-zero selects adaptive pacing.
inline u32 target_rate()
{
// 0 keeps the fixed multiplier.
//
// Forcing a 60Hz target here as a test made things WORSE, and the reason is worth
// keeping: at ~36 real fps a 60Hz target wants 0.67 extra frames per frame, and since
// only whole frames can be made the pacer produced 1, 0, 0, 0, 0. Sporadic generation is
// less even than none, so the displayed rate barely moved and the judder got worse. A
// target only helps when it is reachable in whole frames from the rate the game is
// actually managing -- 120 against 36 wants two, 60 against 36 wants two thirds of one.
return g_cfg.video.frame_generation_target_rate.get();
}
/// The cheaper 3.1p shader family.
inline bool performance_mode()
{
return g_cfg.video.frame_generation_performance.get();
}
}
+251
View File
@@ -0,0 +1,251 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-FileCopyrightText: Copyright 2026 Camille LaVey
// Relicensed to GPL-2.0-or-later for use in ARMSX3 by Camille LaVey, founder of the Eden
// Emulator Project and author of this implementation (eden-emu PR #4263), who granted
// permission for it to be used 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 --
// ARMSX2 is GPL-3.0 and ships it unchanged. Eden is unaffected: "or later" leaves its own use
// exactly as it was.
// SPDX-License-Identifier: GPL-2.0-or-later
//
// Ported from Eden (eden-emu PR #4263). The pacing algorithm is unchanged — only the three
// settings reads are remapped onto GSConfig via FrameGenSettings. Keeping it verbatim is
// deliberate: this is subtle control logic and staying diffable against upstream is worth more
// than restyling it to PCSX2's brace conventions.
#include <algorithm>
#include <cmath>
#include <utility>
#include "FrameGenPacer.h"
namespace Vulkan {
namespace {
using Clock = std::chrono::steady_clock;
constexpr f32 INTERVAL_SMOOTHING = 0.25f;
constexpr f32 MINIMUM_BASE_RATE = 10.0f;
constexpr f32 BURST_CADENCE_RATIO = 3.0f;
constexpr f32 BURST_TARGET_RATIO = 2.0f;
constexpr f32 PROBE_THROUGHPUT_TOLERANCE = 0.95f;
constexpr f32 PROBE_BASE_COLLAPSE_RATIO = 0.70f;
constexpr f32 PROBE_MARGINAL_GAIN = 1.15f;
constexpr f32 TARGET_SATISFIED_RATIO = 0.95f;
constexpr f32 UNLOADED_BASE_RETENTION = 0.75f;
constexpr f32 CREDIT_EPSILON = 1.0e-4f;
constexpr u32 MAX_PROBE_FAILURES = 4;
constexpr auto STABILIZATION_DURATION = std::chrono::seconds(1);
constexpr auto PROBE_DURATION = std::chrono::seconds(1);
constexpr auto DEFICIT_DURATION = std::chrono::seconds(1);
constexpr auto PROBE_STEP_DELAY = std::chrono::milliseconds(250);
[[nodiscard]] Clock::duration ProbeBackoff(u32 failures) {
switch (failures) {
case 1:
return std::chrono::seconds(5);
case 2:
return std::chrono::seconds(15);
case 3:
return std::chrono::seconds(30);
default:
return std::chrono::seconds(60);
}
}
} // Anonymous namespace
FrameGenPlan FrameGenPacer::Plan(size_t capacity) {
const size_t ceiling = std::min(capacity, FrameGenSettings::MaxGenerations());
if (ceiling == 0) {
Reset();
return {};
}
const Clock::time_point now = Clock::now();
const size_t previous_generations = std::exchange(issued_generations, 0);
if (!last_frame) {
last_frame = now;
return {};
}
const Clock::duration interval = now - *last_frame;
const f32 interval_seconds = std::chrono::duration<f32>(interval).count();
last_frame = now;
if (interval_seconds <= 0.0f) {
Stabilize(now);
return {};
}
const f32 target_rate = FrameGenSettings::TargetRate();
if (smoothed_interval > 0.0f) {
f32 burst_threshold = BURST_CADENCE_RATIO / smoothed_interval;
if (target_rate > 0.0f) {
burst_threshold = std::max(burst_threshold, target_rate * BURST_TARGET_RATIO);
}
if (1.0f / interval_seconds > burst_threshold) {
DeferEvaluations(interval);
output_credit = 0.0f;
return {};
}
}
if (interval_seconds > 1.0f / MINIMUM_BASE_RATE) {
Stabilize(now);
return {};
}
smoothed_interval = smoothed_interval > 0.0f
? smoothed_interval +
(interval_seconds - smoothed_interval) * INTERVAL_SMOOTHING
: interval_seconds;
if (previous_generations == 0) {
const f32 measured = 1.0f / smoothed_interval;
unloaded_base_rate =
unloaded_base_rate > 0.0f
? unloaded_base_rate + (measured - unloaded_base_rate) * INTERVAL_SMOOTHING
: measured;
}
if (stable_until) {
if (now < *stable_until) {
return {};
}
stable_until.reset();
}
if (target_rate == 0.0f) {
limit = std::min(FrameGenSettings::Generations(), ceiling);
output_credit = 0.0f;
issued_generations = limit;
return {.generations = limit, .warm = limit > 0};
}
UpdateLimit(now, 1.0f / smoothed_interval, target_rate, ceiling);
const size_t allowed = std::min(limit, ceiling);
const f32 desired_outputs = smoothed_interval * target_rate;
if (allowed == 0 || desired_outputs <= 1.0f) {
output_credit = 0.0f;
return {};
}
output_credit += desired_outputs;
const size_t outputs =
std::max<size_t>(1, static_cast<size_t>(std::floor(output_credit + CREDIT_EPSILON)));
const size_t generations = std::min(outputs - 1, allowed);
output_credit -= static_cast<f32>(generations + 1);
if (output_credit < 0.0f) {
output_credit = 0.0f;
} else if (generations == allowed && output_credit >= 1.0f) {
output_credit = std::fmod(output_credit, 1.0f);
}
issued_generations = generations;
return {.generations = generations, .warm = true};
}
void FrameGenPacer::UpdateLimit(Clock::time_point now, f32 base_rate, f32 target_rate,
size_t ceiling) {
limit = std::min(limit, ceiling);
if (probe_until) {
if (now < *probe_until) {
return;
}
probe_until.reset();
output_credit = 0.0f;
const f32 previous_output =
std::min(target_rate, probe_base_rate * static_cast<f32>(probe_previous_limit + 1));
const f32 current_output =
std::min(target_rate, base_rate * static_cast<f32>(limit + 1));
const bool throughput_regressed =
current_output < previous_output * PROBE_THROUGHPUT_TOLERANCE;
const bool collapsed_for_marginal_gain =
base_rate < probe_base_rate * PROBE_BASE_COLLAPSE_RATIO &&
current_output < previous_output * PROBE_MARGINAL_GAIN;
const bool emulation_slowed = unloaded_base_rate > 0.0f &&
base_rate < unloaded_base_rate * UNLOADED_BASE_RETENTION;
if (throughput_regressed || collapsed_for_marginal_gain || emulation_slowed) {
limit = probe_previous_limit;
probe_failures = std::min(probe_failures + 1, MAX_PROBE_FAILURES);
next_probe = now + ProbeBackoff(probe_failures);
deficit_since.reset();
return;
}
probe_failures = 0;
next_probe = now + PROBE_STEP_DELAY;
}
if (base_rate * static_cast<f32>(limit + 1) >= target_rate * TARGET_SATISFIED_RATIO ||
limit >= ceiling) {
deficit_since.reset();
return;
}
if (!deficit_since) {
deficit_since = now;
return;
}
if (now - *deficit_since < DEFICIT_DURATION) {
return;
}
if (next_probe && now < *next_probe) {
return;
}
probe_previous_limit = limit;
probe_base_rate = base_rate;
++limit;
probe_until = now + PROBE_DURATION;
deficit_since.reset();
output_credit = 0.0f;
}
void FrameGenPacer::DeferEvaluations(Clock::duration amount) {
const auto defer = [amount](std::optional<Clock::time_point>& deadline) {
if (deadline) {
*deadline += amount;
}
};
defer(stable_until);
defer(probe_until);
defer(next_probe);
deficit_since.reset();
}
void FrameGenPacer::Stabilize(Clock::time_point now) {
stable_until = now + STABILIZATION_DURATION;
probe_until.reset();
deficit_since.reset();
smoothed_interval = 0.0f;
output_credit = 0.0f;
}
void FrameGenPacer::Reset() {
last_frame.reset();
stable_until.reset();
probe_until.reset();
next_probe.reset();
deficit_since.reset();
smoothed_interval = 0.0f;
output_credit = 0.0f;
probe_base_rate = 0.0f;
unloaded_base_rate = 0.0f;
issued_generations = 0;
probe_previous_limit = 0;
limit = 0;
probe_failures = 0;
}
} // namespace Vulkan
+64
View File
@@ -0,0 +1,64 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-FileCopyrightText: Copyright 2026 Camille LaVey
// Relicensed to GPL-2.0-or-later for use in ARMSX3 by Camille LaVey, founder of the Eden
// Emulator Project and author of this implementation (eden-emu PR #4263), who granted
// permission for it to be used 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 --
// ARMSX2 is GPL-3.0 and ships it unchanged. Eden is unaffected: "or later" leaves its own use
// exactly as it was.
// SPDX-License-Identifier: GPL-2.0-or-later
//
// Ported from Eden (eden-emu PR #4263), src/video_core/renderer_vulkan/present/frame_gen_pacer.h.
// Logic unchanged; only the scalar aliases and settings accessors are ours. See FrameGenTypes.h.
#pragma once
#include "FrameGenTypes.h"
#include <chrono>
#include <optional>
namespace Vulkan
{
struct FrameGenPlan
{
size_t generations{};
bool warm{};
};
/// Decides how many frames to interpolate for the frame about to be presented.
///
/// The naive alternative — always generate LsfgMultiplier-1 frames — is what makes frame
/// generation feel worse than no frame generation on a game whose rendered rate moves. This
/// watches the real cadence and adapts: it smooths the measured interval, ignores bursts,
/// stabilises after a stall, and probes the generation count upward only while the output is
/// short of target, backing off with an escalating delay when a probe makes things worse.
class FrameGenPacer
{
public:
[[nodiscard]] FrameGenPlan Plan(size_t capacity);
void Reset();
private:
using Clock = std::chrono::steady_clock;
void Stabilize(Clock::time_point now);
void DeferEvaluations(Clock::duration amount);
void UpdateLimit(Clock::time_point now, f32 base_rate, f32 target_rate, size_t ceiling);
std::optional<Clock::time_point> last_frame;
std::optional<Clock::time_point> stable_until;
std::optional<Clock::time_point> probe_until;
std::optional<Clock::time_point> next_probe;
std::optional<Clock::time_point> deficit_since;
f32 smoothed_interval{};
f32 output_credit{};
f32 probe_base_rate{};
f32 unloaded_base_rate{};
size_t issued_generations{};
size_t probe_previous_limit{};
size_t limit{};
u32 probe_failures{};
};
} // namespace Vulkan
+84
View File
@@ -0,0 +1,84 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-FileCopyrightText: Copyright 2026 Camille LaVey
// Relicensed to GPL-2.0-or-later for use in ARMSX3 by Camille LaVey, founder of the Eden
// Emulator Project and author of this implementation (eden-emu PR #4263), who granted
// permission for it to be used 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 --
// ARMSX2 is GPL-3.0 and ships it unchanged. Eden is unaffected: "or later" leaves its own use
// exactly as it was.
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-2.0-or-later
//
// Compatibility shim for code ported from Eden (eden-emu PR #4263).
//
// The port is deliberately kept close to the original so upstream fixes stay easy to follow.
// Eden is a yuzu descendant and leans on two things PCSX2 does not have: an `f32` scalar alias,
// and a `Settings::values.*` configuration object. Rather than rewriting every use of those
// across ~3000 lines, they are provided here once.
//
// LICENSING: Eden is GPL-3.0-or-later and PCSX2 is GPL-3.0+, so this code may be combined.
// Note this is NOT true of RPCS3, which is GPL-2.0-only — do not carry these files there.
#pragma once
#include "util/types.hpp"
#include "FrameGenConfig.h"
#include "Config.h"
#include <algorithm>
#include <cstddef>
#include "FrameGenConfig.h"
// yuzu/Eden spell the float aliases this way; PCSX2 only defines the integer ones.
using f32 = float;
using f64 = double;
namespace VideoCore::FrameGen
{
/// Hard ceiling on interpolated frames per rendered frame. Matches LSFG_MAX_GENERATIONS in
/// the pass code — the shader set only carries weights for three.
inline constexpr size_t MAX_GENERATIONS = 3;
} // namespace VideoCore::FrameGen
/// ★ __forceinline_odr, NOT __fi.
///
/// On GCC/Clang PCSX2's __forceinline expands to __attribute__((always_inline, unused)) with no
/// `inline` keyword, so a free function marked __fi in a header gets EXTERNAL linkage and every
/// translation unit that includes it emits its own copy — "duplicate symbol" at link, from a
/// header that compiles perfectly in isolation. __fi is fine on member functions defined inside a
/// class body, which are implicitly inline; that is what the rest of the renderer uses it for.
/// RPCS3 spells this FORCE_INLINE (util/types.hpp); it expands to always_inline + inline,
/// so these stay ODR-safe in a header exactly as FORCE_INLINE did.
namespace Vulkan::FrameGenSettings
{
/// Interpolated frames per rendered frame, as the user configured it.
///
/// GSConfig stores the MULTIPLIER (2 = one interpolated frame, 3 = two, ...) because that is
/// what the UI shows; the ported code counts GENERATIONS. The two differ by one, and mixing
/// them up shows as "x2 looks like x3", so the conversion lives only here.
FORCE_INLINE size_t Generations()
{
const u32 mult = std::max<u32>(::vk::lsfg::multiplier(), 2u);
return std::min<size_t>(mult - 1u, VideoCore::FrameGen::MAX_GENERATIONS);
}
/// Upper bound the pacer may probe up to.
FORCE_INLINE size_t MaxGenerations()
{
return ::vk::lsfg::enabled() ? Generations() : 0;
}
/// Target OUTPUT rate in Hz, or 0 to hold the multiplier fixed.
///
/// This is what makes the pacer adaptive rather than a blind multiplier, and it is the whole
/// answer to games that oscillate between 60 and 30fps on a 60Hz panel: at a fixed x2 such a
/// game presents 120 then 60, and the panel shows judder at every transition. Given a target
/// the pacer instead varies the generation count to hold the output near it — two interpolated
/// frames while the game runs at 30, one while it runs at 60.
///
/// Zero preserves the old fixed-multiplier behaviour exactly, so this is opt-in.
FORCE_INLINE f32 TargetRate()
{
return static_cast<f32>(::vk::lsfg::target_rate());
}
} // namespace Vulkan::FrameGenSettings
File diff suppressed because it is too large Load Diff
+93
View File
@@ -0,0 +1,93 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-FileCopyrightText: Copyright 2026 Camille LaVey
// Relicensed to GPL-2.0-or-later for use in ARMSX3 by Camille LaVey, founder of the Eden
// Emulator Project and author of this implementation (eden-emu PR #4263), who granted
// permission for it to be used 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 --
// ARMSX2 is GPL-3.0 and ships it unchanged. Eden is unaffected: "or later" leaves its own use
// exactly as it was.
// SPDX-License-Identifier: GPL-2.0-or-later
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-2.0-or-later
//
// Ported from Eden (eden-emu PR #4263), src/video_core/frame_gen/lossless_dll.h.
// Logic unchanged. The one substantive difference is the path type: Eden passes
// std::filesystem::path, PCSX2 uses std::string throughout with its own FileSystem/Path
// helpers, so every path here is a std::string. See FrameGenTypes.h.
#pragma once
#include "FrameGenTypes.h"
#include <array>
#include <map>
#include <string>
#include <vector>
namespace VideoCore::FrameGen {
enum class LosslessStatus : u32 {
Ok,
NotInstalled,
UnreadableFile,
NotPortableExecutable,
MissingShaders,
TranslationFailed,
CacheUnusable,
};
using ShaderResources = std::map<u32, std::vector<u8>>;
using ShaderModules = std::map<u32, std::vector<u32>>;
enum class ShaderVariant : u32 {
NativeFp32 = 1,
NativeFp16 = 2,
};
namespace PerformanceShader {
constexpr u32 MIPMAPS = 255;
constexpr u32 GENERATE = 256;
constexpr std::array<u32, 4> ALPHA{290, 291, 292, 293};
constexpr std::array<u32, 5> BETA{298, 299, 300, 301, 302};
constexpr std::array<u32, 5> GAMMA{280, 282, 283, 284, 285};
constexpr std::array<u32, 10> DELTA{280, 286, 287, 288, 289, 281, 294, 295, 296, 297};
constexpr u32 NATIVE_FP16_OFFSET = 49;
constexpr u32 NATIVE_FP32_OFFSET = 98;
} // namespace PerformanceShader
[[nodiscard]] std::string GetLosslessDllPath();
[[nodiscard]] std::string GetShaderCachePath();
[[nodiscard]] LosslessStatus ReadShaderResources(const std::string& path,
ShaderResources& out_resources);
[[nodiscard]] LosslessStatus ValidateLosslessDll(const std::string& path);
[[nodiscard]] LosslessStatus GetInstalledLosslessStatus();
/// Whether the fp16 family may be used, asked of the live render device.
///
/// THREE callers need the same answer -- the cache writer, LsfgShaders, and the availability
/// probe -- and the cache header stores the flags and rejects an entry whose flags differ. Any
/// two of them disagreeing means a cache that can never be read: it wrote fp16 and was probed
/// with fp32, which reads to a user as "no shaders" immediately after a successful import.
[[nodiscard]] bool Float16Allowed();
/// [allow_fp16]/[prefer_fp16] MUST match what LsfgShaders asks for: the cache header stores them
/// and rejects an entry whose flags differ, so a mismatch means the cache written at import can
/// never satisfy the read that follows it.
[[nodiscard]] LosslessStatus BuildShaderCache(bool allow_fp16, bool prefer_fp16);
[[nodiscard]] LosslessStatus LoadShaderModules(ShaderModules& out_modules,
bool allow_fp16 = false,
bool prefer_fp16 = false);
/// Delete the on-disk SPIR-V cache, forcing a rebuild from the DLL next time.
///
/// Eden's equivalent also deleted the DLL; see the note in the .cpp for why that half is gone.
void ClearShaderCache();
} // namespace VideoCore::FrameGen
+152
View File
@@ -0,0 +1,152 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-FileCopyrightText: Copyright 2026 Camille LaVey
// Relicensed to GPL-2.0-or-later for use in ARMSX3 by Camille LaVey, founder of the Eden
// Emulator Project and author of this implementation (eden-emu PR #4263), who granted
// permission for it to be used 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 --
// ARMSX2 is GPL-3.0 and ships it unchanged. Eden is unaffected: "or later" leaves its own use
// exactly as it was.
// SPDX-License-Identifier: GPL-2.0-or-later
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-2.0-or-later
//
// Ported from Eden (eden-emu PR #4263), src/video_core/renderer_vulkan/present/lsfg_alpha.cpp.
// The pass is unchanged: the descriptor layouts, the tile shift and the barrier ordering are the
// shader contract, not style, so this stays verbatim and diffable against upstream. Only the
// includes are remapped onto the PCSX2 shim. See LsfgVkCompat.h.
#include <vector>
#include "LosslessDll.h"
#include "LsfgAlpha.h"
#include "LsfgShaders.h"
#include "LsfgUtil.h"
#include "LsfgVkCompat.h"
namespace Vulkan {
namespace {
constexpr u32 DISPATCH_TILE_SHIFT = 3;
[[nodiscard]] u32 GroupCount(u32 size) {
return (size + (1u << DISPATCH_TILE_SHIFT) - 1) >> DISPATCH_TILE_SHIFT;
}
[[nodiscard]] VkExtent2D HalveExtent(VkExtent2D extent) {
return VkExtent2D{
.width = (extent.width + 1) >> 1,
.height = (extent.height + 1) >> 1,
};
}
} // Anonymous namespace
LsfgAlphaPasses::LsfgAlphaPasses(const Device& device, const LsfgShaders& shaders) {
using namespace VideoCore::FrameGen::PerformanceShader;
passes[0] = LsfgPass(device, shaders, ALPHA[0],
{{1, VK_DESCRIPTOR_TYPE_SAMPLER},
{1, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
{1, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
passes[1] = LsfgPass(device, shaders, ALPHA[1],
{{1, VK_DESCRIPTOR_TYPE_SAMPLER},
{1, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
{1, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
passes[2] = LsfgPass(device, shaders, ALPHA[2],
{{1, VK_DESCRIPTOR_TYPE_SAMPLER},
{1, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
{2, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
passes[3] = LsfgPass(device, shaders, ALPHA[3],
{{1, VK_DESCRIPTOR_TYPE_SAMPLER},
{2, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
{2, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
}
LsfgAlpha::LsfgAlpha(const Device& device, MemoryAllocator& memory_allocator,
const LsfgAlphaPasses& passes_, LsfgResources& resources,
vk::DescriptorPool& descriptor_pool, LsfgImage& input_)
: passes{&passes_}, input{&input_} {
const VkExtent2D half_extent = HalveExtent(input->Extent());
const VkExtent2D quarter_extent = HalveExtent(half_extent);
temp1 = LsfgImage(device, memory_allocator, half_extent);
temp2 = LsfgImage(device, memory_allocator, half_extent);
for (size_t i = 0; i < temp3.size(); ++i) {
temp3[i] = LsfgImage(device, memory_allocator, quarter_extent);
for (size_t j = 0; j < LSFG_HISTORY_SLOTS; ++j) {
out_images[j][i] = LsfgImage(device, memory_allocator, quarter_extent);
}
}
std::vector<VkDescriptorSetLayout> layouts;
for (size_t i = 0; i < LSFG_ALPHA_STAGES - 1; ++i) {
layouts.push_back(passes->Get(i).SetLayout());
}
for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
layouts.push_back(passes->Get(3).SetLayout());
}
owned_sets = CreateWrappedDescriptorSets(descriptor_pool, layouts);
for (size_t i = 0; i < LSFG_ALPHA_STAGES - 1; ++i) {
descriptor_sets[i] = owned_sets[i];
}
for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
last_descriptor_sets[i] = owned_sets[LSFG_ALPHA_STAGES - 1 + i];
}
const VkSampler sampler = resources.GetSampler();
LsfgDescriptorWriter(descriptor_sets[0])
.AddSampler(sampler)
.AddSampledImage(*input)
.AddStorageImage(temp1)
.Build(device);
LsfgDescriptorWriter(descriptor_sets[1])
.AddSampler(sampler)
.AddSampledImage(temp1)
.AddStorageImage(temp2)
.Build(device);
LsfgDescriptorWriter(descriptor_sets[2])
.AddSampler(sampler)
.AddSampledImage(temp2)
.AddStorageImages(temp3)
.Build(device);
for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
LsfgDescriptorWriter(last_descriptor_sets[i])
.AddSampler(sampler)
.AddSampledImages(temp3)
.AddStorageImages(out_images[i])
.Build(device);
}
}
void LsfgAlpha::PushBarriers(LsfgBarriers& barriers, u64 frame_count, size_t stage) {
switch (stage) {
case 0:
barriers.WriteToRead(*input).ReadToWrite(temp1);
break;
case 1:
barriers.WriteToRead(temp1).ReadToWrite(temp2);
break;
case 2:
barriers.WriteToRead(temp2).ReadToWriteAll(temp3);
break;
default:
barriers.WriteToReadAll(temp3).ReadToWriteAll(out_images[frame_count % LSFG_HISTORY_SLOTS]);
break;
}
}
void LsfgAlpha::DispatchStage(vk::CommandBuffer cmdbuf, u64 frame_count, size_t stage) {
const VkExtent2D extent = stage < 2 ? temp1.Extent() : temp3[0].Extent();
const VkDescriptorSet set = stage < LSFG_ALPHA_STAGES - 1
? descriptor_sets[stage]
: last_descriptor_sets[frame_count % LSFG_HISTORY_SLOTS];
passes->Get(stage).BindSet(cmdbuf, set);
cmdbuf.Dispatch(GroupCount(extent.width), GroupCount(extent.height), 1);
}
} // namespace Vulkan
+73
View File
@@ -0,0 +1,73 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-FileCopyrightText: Copyright 2026 Camille LaVey
// Relicensed to GPL-2.0-or-later for use in ARMSX3 by Camille LaVey, founder of the Eden
// Emulator Project and author of this implementation (eden-emu PR #4263), who granted
// permission for it to be used 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 --
// ARMSX2 is GPL-3.0 and ships it unchanged. Eden is unaffected: "or later" leaves its own use
// exactly as it was.
// SPDX-License-Identifier: GPL-2.0-or-later
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-2.0-or-later
//
// Ported from Eden (eden-emu PR #4263), src/video_core/renderer_vulkan/present/lsfg_alpha.h.
// Descriptor layouts, dispatch maths and barrier ordering are verbatim; only the includes are
// remapped onto the PCSX2 shim. See LsfgVkCompat.h.
#pragma once
#include <array>
#include "FrameGenTypes.h"
#include "LsfgCommon.h"
namespace Vulkan {
class Device;
class LsfgShaders;
constexpr size_t LSFG_ALPHA_STAGES = 4;
class LsfgAlphaPasses {
public:
LsfgAlphaPasses() = default;
LsfgAlphaPasses(const Device& device, const LsfgShaders& shaders);
[[nodiscard]] const LsfgPass& Get(size_t stage) const {
return passes[stage];
}
private:
std::array<LsfgPass, LSFG_ALPHA_STAGES> passes;
};
class LsfgAlpha {
public:
LsfgAlpha() = default;
LsfgAlpha(const Device& device, MemoryAllocator& memory_allocator,
const LsfgAlphaPasses& passes_, LsfgResources& resources,
vk::DescriptorPool& descriptor_pool, LsfgImage& input);
void PushBarriers(LsfgBarriers& barriers, u64 frame_count, size_t stage);
void DispatchStage(vk::CommandBuffer cmdbuf, u64 frame_count, size_t stage);
[[nodiscard]] LsfgImageHistory& Outputs() {
return out_images;
}
private:
const LsfgAlphaPasses* passes{};
LsfgImage* input{};
std::array<VkDescriptorSet, LSFG_ALPHA_STAGES - 1> descriptor_sets{};
std::array<VkDescriptorSet, LSFG_HISTORY_SLOTS> last_descriptor_sets{};
vk::DescriptorSets owned_sets;
LsfgImage temp1;
LsfgImage temp2;
LsfgImagePair temp3;
LsfgImageHistory out_images;
};
} // namespace Vulkan
+159
View File
@@ -0,0 +1,159 @@
// SPDX-FileCopyrightText: Copyright 2026 Eden Emulator Project
// SPDX-FileCopyrightText: Copyright 2026 Camille LaVey
// Relicensed to GPL-2.0-or-later for use in ARMSX3 by Camille LaVey, founder of the Eden
// Emulator Project and author of this implementation (eden-emu PR #4263), who granted
// permission for it to be used 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 --
// ARMSX2 is GPL-3.0 and ships it unchanged. Eden is unaffected: "or later" leaves its own use
// exactly as it was.
// SPDX-License-Identifier: GPL-2.0-or-later
// SPDX-FileCopyrightText: Copyright 2025 lsfg-vk
// SPDX-License-Identifier: GPL-2.0-or-later
//
// Ported from Eden (eden-emu PR #4263), src/video_core/renderer_vulkan/present/lsfg_beta.cpp.
// The pass is unchanged: the descriptor layouts, the two tile shifts and the barrier ordering are
// the shader contract, not style, so this stays verbatim and diffable against upstream. Only the
// includes are remapped onto the PCSX2 shim. See LsfgVkCompat.h.
#include <vector>
#include "LosslessDll.h"
#include "LsfgBeta.h"
#include "LsfgShaders.h"
#include "LsfgUtil.h"
#include "LsfgVkCompat.h"
namespace Vulkan {
namespace {
constexpr u32 DISPATCH_TILE_SHIFT = 3;
constexpr u32 OUTPUT_TILE_SHIFT = 5;
[[nodiscard]] u32 GroupCount(u32 size, u32 shift) {
return (size + (1u << shift) - 1) >> shift;
}
} // Anonymous namespace
LsfgBeta::LsfgBeta(const Device& device, MemoryAllocator& memory_allocator,
const LsfgShaders& shaders, LsfgResources& resources,
vk::DescriptorPool& descriptor_pool, LsfgImageHistory& inputs_)
: inputs{&inputs_} {
using namespace VideoCore::FrameGen::PerformanceShader;
passes[0] = LsfgPass(device, shaders, BETA[0],
{{1, VK_DESCRIPTOR_TYPE_SAMPLER},
{6, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
{2, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
for (size_t i = 1; i < LSFG_BETA_STAGES - 1; ++i) {
passes[i] = LsfgPass(device, shaders, BETA[i],
{{1, VK_DESCRIPTOR_TYPE_SAMPLER},
{2, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
{2, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
}
passes[4] = LsfgPass(device, shaders, BETA[4],
{{1, VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER},
{1, VK_DESCRIPTOR_TYPE_SAMPLER},
{2, VK_DESCRIPTOR_TYPE_SAMPLED_IMAGE},
{6, VK_DESCRIPTOR_TYPE_STORAGE_IMAGE}});
const VkExtent2D extent = (*inputs)[0][0].Extent();
for (size_t i = 0; i < temp1.size(); ++i) {
temp1[i] = LsfgImage(device, memory_allocator, extent);
temp2[i] = LsfgImage(device, memory_allocator, extent);
}
for (size_t i = 0; i < LSFG_BETA_OUTPUTS; ++i) {
const VkExtent2D level_extent{
.width = extent.width >> i,
.height = extent.height >> i,
};
out_images[i] = LsfgImage(device, memory_allocator, level_extent, LSFG_FLOW_FORMAT);
}
std::vector<VkDescriptorSetLayout> layouts;
for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
layouts.push_back(passes[0].SetLayout());
}
for (size_t i = 1; i < LSFG_BETA_STAGES; ++i) {
layouts.push_back(passes[i].SetLayout());
}
owned_sets = CreateWrappedDescriptorSets(descriptor_pool, layouts);
for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
first_descriptor_sets[i] = owned_sets[i];
}
for (size_t i = 0; i < LSFG_BETA_STAGES - 1; ++i) {
descriptor_sets[i] = owned_sets[LSFG_HISTORY_SLOTS + i];
}
const VkSampler sampler = resources.GetSampler();
const VkSampler border_sampler = resources.GetSampler(
VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_BORDER, VK_COMPARE_OP_NEVER, true);
for (size_t i = 0; i < LSFG_HISTORY_SLOTS; ++i) {
LsfgDescriptorWriter(first_descriptor_sets[i])
.AddSampler(border_sampler)
.AddSampledImages((*inputs)[(i + 1) % LSFG_HISTORY_SLOTS])
.AddSampledImages((*inputs)[(i + 2) % LSFG_HISTORY_SLOTS])
.AddSampledImages((*inputs)[i % LSFG_HISTORY_SLOTS])
.AddStorageImages(temp1)
.Build(device);
}
LsfgDescriptorWriter(descriptor_sets[0])
.AddSampler(sampler)
.AddSampledImages(temp1)
.AddStorageImages(temp2)
.Build(device);
LsfgDescriptorWriter(descriptor_sets[1])
.AddSampler(sampler)
.AddSampledImages(temp2)
.AddStorageImages(temp1)
.Build(device);
LsfgDescriptorWriter(descriptor_sets[2])
.AddSampler(sampler)
.AddSampledImages(temp1)
.AddStorageImages(temp2)
.Build(device);
LsfgDescriptorWriter(descriptor_sets[3])
.AddUniformBuffer(resources.GetBuffer(0.5f), LsfgResources::BufferSize())
.AddSampler(sampler)
.AddSampledImages(temp2)
.AddStorageImages(out_images)
.Build(device);
}
void LsfgBeta::Dispatch(vk::CommandBuffer cmdbuf, u64 frame_count) {
const VkExtent2D extent = temp1[0].Extent();
const u32 groups_x = GroupCount(extent.width, DISPATCH_TILE_SHIFT);
const u32 groups_y = GroupCount(extent.height, DISPATCH_TILE_SHIFT);
LsfgBarriers barriers(cmdbuf);
for (auto& slot : *inputs) {
barriers.WriteToReadAll(slot);
}
barriers.ReadToWriteAll(temp1).Build();
passes[0].Bind(cmdbuf, first_descriptor_sets[frame_count % LSFG_HISTORY_SLOTS]);
cmdbuf.Dispatch(groups_x, groups_y, 1);
LsfgBarriers(cmdbuf).WriteToReadAll(temp1).ReadToWriteAll(temp2).Build();
passes[1].Bind(cmdbuf, descriptor_sets[0]);
cmdbuf.Dispatch(groups_x, groups_y, 1);
LsfgBarriers(cmdbuf).WriteToReadAll(temp2).ReadToWriteAll(temp1).Build();
passes[2].Bind(cmdbuf, descriptor_sets[1]);
cmdbuf.Dispatch(groups_x, groups_y, 1);
LsfgBarriers(cmdbuf).WriteToReadAll(temp1).ReadToWriteAll(temp2).Build();
passes[3].Bind(cmdbuf, descriptor_sets[2]);
cmdbuf.Dispatch(groups_x, groups_y, 1);
LsfgBarriers(cmdbuf).WriteToReadAll(temp2).ReadToWriteAll(out_images).Build();
passes[4].Bind(cmdbuf, descriptor_sets[3]);
cmdbuf.Dispatch(GroupCount(extent.width, OUTPUT_TILE_SHIFT),
GroupCount(extent.height, OUTPUT_TILE_SHIFT), 1);
}
} // namespace Vulkan

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