25057 Commits
Author SHA1 Message Date
jpolo1224 19335b2e54 LSFG: adaptive pacing captured the wrong target rate
Turning adaptive pacing on silently turned frame generation off: the OSD read
FPS 60 / LSFG 60 on a 120Hz panel.

The target was captured from Display.getRefreshRate(), which reports the rate
the app's window is being driven at, not what the panel can do. Android leaves a
window at 60 until something asks for more, so the stored target became 60 -- and
the pacer then correctly asked for zero interpolated frames, since a 60fps game
at a 60Hz target already satisfies it (desired_outputs = interval * target = 1.0,
generations = outputs - 1 = 0).

Derive from the highest supported mode at the CURRENT resolution instead, which
is what the switch always claimed to mean. Same resolution filter used elsewhere,
so the target can never imply a mode switch of its own.

Existing installs need the switch toggled off and on to re-capture: the value is
stored, not recomputed.

Also swap the credit-card glyph on the library's Memory Cards row.
2026-08-21 14:26:59 -04:00
jpolo1224 d9b961eb36 Community batch: seven feature requests
Second screen (BrainBeat, NiceRon):
- The panel picked its display by "not DEFAULT_DISPLAY", so launching ARMSX2 on
  the second panel put the panel on top of the running game. Anchor on the
  display the activity is actually on, and re-pick it on every resume rather
  than only on a foreground change.
- Restyle the panel: it inherits the system dialog theme, not the app's, which
  is why it looked like a stock Android dialog. Dark ground, rounded tiles, one
  accent, painted in code since a Presentation is outside the Compose tree.
- Customisable grid: SecondScreenTiles declares the tiles (stats, actions,
  macros, achievements, hide-panel), SecondScreenLayout stores which and in what
  order, and App settings edits both plus the column count. Stored by tile id,
  not ordinal. Achievement tile shows collection progress plus whatever unlocked
  this session -- the snapshot carries no timestamp, so "recent" is the
  locked-to-unlocked edge on the panel's own tick.

Library (Isshin, GBSUPREMO):
- Cover region per game, overriding the library-wide choice; "Library" is the
  absence of a pin, not a fifth region. Both cover components now subscribe to
  the region state -- game.coverUrl resolves it inside a plain getter, which
  Compose could not see, so cards kept their old art.
- Memory cards reachable from the long-press menu. The card picker already did
  per-game assignment whenever handed a game; only the in-game menu ever handed
  it one.

In-game (Sizor, Grayy):
- Quick menu docks left or right. Alignment, slide direction, rounded corners
  and inset all move together.
- Stats position (four corners), driving PCSX2's own OsdPerformancePos. Stored
  as the core's enum ordinal so there is no translation table to keep in sync.
- Cycle-display-refresh hotkey, using preferredDisplayModeId -- the frame-rate
  vote in EmulationSurface is a hint the compositor may ignore, which is right
  for latency and useless as a user-facing toggle.
- Analog Sticks section in the quick menu, extracted from PadTab the same way
  Gyro and Macros already were.

Also drop the -fexceptions/no-PCH carve-out on GSLsfg.cpp: it existed because
lsfg-vk-android reported failure by throwing, and the Eden port has no throw
sites.
2026-08-21 13:43:40 -04:00
jpolo1224 b8f80aee02 Fix Reset leaving per-game settings, and Fast Forward (Toggle) on analog triggers
Two reported bugs, unrelated to each other.

RESET (takanome9104, confirmed by lugnel): per-game settings such as affinity and
GS multithreading survived a full reset.

purgeAllSettingsFiles deleted PCSX2-Android.ini and gamesettings/ from
currentInitDataRoot — one root. A device with a configured system directory has
TWO, and on a device that has been moved between them gamesettings/ exists under
both; the surviving copy is re-read on the next launch. Verified on the test
device: gamesettings/ present under BOTH the SD root and app-private storage.
Every known root is purged now. Deleting a file that is already gone is free, so
casting wide costs nothing.

This is the same single-root assumption that hid save states from the library's
long-press menu earlier today. Worth suspecting wherever this codebase resolves
'the' data directory.

The prefs clear also moved from apply() to commit(). restartApp calls
Runtime.exit(0) on the next line, and apply() only guarantees the in-memory
update — its disk write is asynchronous and an abrupt exit can beat it. A reset
that survives the restart is the entire point of the button.

FAST FORWARD (SKrazy on an AYN pad, Shmoda12 on a Thor): Fast Forward (Toggle)
bound to L2/R2 came on for a frame and then reported OFF. It worked as Hold, it
worked on a non-trigger button like R3, and it worked once the pad was switched
to digital triggers.

Those three facts together say it: some pads report a trigger BOTH as an axis and
as a key event, so one pull reaches the hotkey dispatcher twice — once from
sendTrigger, once from the key path. For a HOLD that is harmless, since both
compute the same state from the same edge. For a TOGGLE the first flips it on and
the second immediately flips it back. Digital triggers send only key events, which
is why that setting 'fixed' it.

The axis path now claims the press and the key path skips its own edge. Scoped to
L2/R2 alone so nothing else changes, and cleared on release so the next pull
re-arms. sendTrigger already carried a comment about pads that report triggers
both ways — the hold path had been made safe against them, the hotkey path had
not.
2026-08-21 13:19:18 -04:00
jpolo1224 cc9e58cf4e Library: port the Flurry animated background from ARMSX3
Calum Robinson's Flurry screensaver (2002, BSD-3-clause) as a live library
backdrop, already shipping in ARMSX3. Source port, not the Windows .scr — every
upstream copyright header is intact.

Its own SHARED target rather than folded into the core, for two reasons: it is
BSD next to GPL and that boundary should be visible, and it is plain C from 2002
that wants none of the C++20 the emulator is built with. gl_compat.c answers the
GL 1.x calls the sources make — client-side vertex arrays, a fixed-function
ortho, GL_QUADS — with a GLES2 shader, so the renderer stays unmodified.

★ The integration differs from the brief, which surveyed the refresh-experimental
checkout. That tree has ui/GamesList.kt and no background system, so it needed a
new full-bleed host. This one already has one: Flurry slots in beside XmbGlView
under the same libraryBg == null branch and inherits its fallback — if GL cannot
come up we get LibraryWaveBackground rather than a hole. onRelease stops the
render thread, without which the EGL thread outlives the composition and keeps
drawing to a dead surface.

★ add_dependencies had to move BELOW the emucore target. Stated next to the
flurry target — which is defined earlier in the file — configure fails outright,
because add_dependencies requires a target that already exists. The dependency
itself is required: libflurry.so is loaded by System.loadLibrary and never
linked, so nothing else in the build would make it, and a missing .so stays
invisible until someone switches the background on and the view throws
UnsatisfiedLinkError.

Off by default, and said plainly in the description. It is a particle simulation
rather than a still, and this library shipped a looping video once and lost it in
2.5.9 when the continuous decode turned out to cost real performance. Preset
picker included, with random as the default choice.

A UI preference, so prefs rather than the twelve-site Settings.kt path — it does
not touch the emulator config.

Verified: libflurry.so is packaged and exports all six JNI entry points. NOT yet
run — Flurry's frame cost has never been measured on either project, and Water
spawns nine flurries, so that number is still owed before it is recommended.
2026-08-21 12:59:05 -04:00
jpolo1224 8c0ae12398 Patch: stop Hardcore blocking presentation patches
Reported by EddyOP (60 FPS and Widescreen disabled under RetroAchievements
Hardcore) and diagnosed by Jetup, who found that moving the same lines under a
widescreen heading re-enabled them — ARMSX2 issue #541.

The Hardcore gate from 2f3f28faae allows a group through only if it DECLARES
gsaspectratio or gsinterlacemode. That rule was picked to separate a Skip
Cutscenes cheat from a Widescreen patch living in the same pnach, and it does
separate those two. What it does not survive is the rest of the database.

Measured across the shipped 4437 files, the groups it blocked were overwhelmingly
not cheats:

    305  50 FPS
    177  60 FPS
    168  50/60 FPS
    102  Remove Blackbars      <- a widescreen patch
    100  NTSC Mode
     31  480p Mode

Almost every real presentation patch changes the picture by writing EE memory and
declares nothing, so the declaration test caught the tail and missed the bulk.
The original commit sampled Car select and Auto-activate analogs and concluded
the blocked set read like cheats; across the whole database it does not.

So the name is now consulted too, matched case-insensitively against the labels
the database actually uses. Matched on the EFFECT rather than the verb — Remove
Blur, Disable Blur and No Blur are the same patch, and listing verbs caught one
spelling while blocking the others.

After: 4673 groups stay by declaration, 1206 more stay by name, 495 remain
blocked. What still blocks reads the way it should — Adjusted triggers
sensitivity, Trigger control mappings, GT3 Chase Camera, and Skip Cutscenes
itself, which is the case the gate was written for.

An unrecognised name still fails CLOSED, which is the safe direction: a new cheat
is blocked by default and only an understood presentation class is let through.

★ Frame-rate patches are the judgement call here. They are in the allowlist
because they are what was reported broken and desktop PCSX2 permits them, but
they are the entry RetroAchievements is most likely to object to. If RA rules
them out, deleting three strings from ALLOWED is the whole change.
2026-08-21 12:16:27 -04:00
jpolo1224 66aeaaeb96 Patches: cache the repository trees on disk
The other half of the online-browser complaint. Stopping the scan when you leave
addressed the heat; this addresses the minutes.

Every search downloads four GitHub '?recursive=1' listings — multi-megabyte JSON
for repositories holding tens of thousands of pnach files — and regex-scans each
for paths. The existing caches are in-memory only, so the first search after
every launch paid the full price again, which is why it reads as broken rather
than slow.

The EXTRACTED PATH LIST is what gets cached, not the JSON: a fraction of the
size, and coming back in it skips the expensive regex entirely, which is the CPU
half of the cost rather than the network half.

Seven-day TTL. These repositories gain files occasionally and the cost of being
stale is one newly-added cheat not appearing, against re-downloading megabytes
on every cold start. Keyed by a hash of the tree URL rather than a sanitised
name, since two repositories can differ only in characters a filesystem folds.

The cache directory is handed in rather than discovered: PatchRepo is a
context-less object, so until setCacheDir runs it stays memory-only and behaves
exactly as before.
2026-08-21 12:04:09 -04:00
jpolo1224 48094495a0 Patches: stop the online scan when you leave the browser
Reported by SNAKEATEROP (Helio G99): after using the online cheats/patches
browser, going back to the game left the device heating severely and a game that
had held full speed no longer did. Nothing in the emulator explained it.

The scan was UNSTOPPABLE, not merely slow. PatchRepo's fetch functions were
plain blocking calls with no isActive check, no ensureActive and not even
suspend. Kotlin cancellation is cooperative, so cancelling the scope did
nothing: the work ran to completion no matter what the user did. It walks four
community repositories, each a multi-megabyte GitHub tree that is downloaded and
then regex-scanned for paths — that is the CPU the game was competing with, and
it kept going long after anyone was looking at it.

Three parts:

  · PatchRepo's entry points are suspend and check for cancellation between
    every repository and every file. Between SOURCES is the one that matters —
    that is where the time goes.
  · The scan's Job is tracked, so a second search cannot stack on the first, and
    the browser cancels it in onDispose. viewModelScope alone was not enough:
    the ViewModel is Activity-scoped and shared with the settings tab, so it
    does not clear merely because the user went back to the game — which is
    exactly the case that was reported.
  · The progress text now says it takes a minute or two AND that leaving is
    safe. Users assumed it had hung, and several were told to just wait; nobody
    should have to sit through it to protect their device.

This does not make the scan faster. It makes it stop, which is the part that was
damaging. Caching the repository trees on disk is the fix for the duration —
they are re-downloaded and re-parsed on every cold start today — and is worth
doing next.
2026-08-21 11:51:32 -04:00
jpolo1224 879d07209c GS: size the texture cache as RAM minus a reserve, not a fraction of it
Third attempt at this budget, so the reasoning is written down properly.

Uncapped OOM-killed Android on a 5 GB uncompressed Persona 3 FES pack. Capping
at RAM/4, then RAM/2, stopped that and broke the same pack on 8 GB devices where
it had been working — 5 GB against a 4 GB budget evicts continuously, each load
dropping the previous one. That is the Persona 3 FES report: corruption first
(a failed upload injected with undefined contents, before 9a6db38395), then
'mods do not apply at all' once failed uploads correctly fell back. Removing the
cap entirely just traded the crash back.

A FRACTION of RAM is the wrong shape, and that is the actual mistake. It scales
the RESERVE with total memory, when what has to be reserved is roughly constant:
the EE/GS allocations, the JIT, Android itself. On 8 GB, RAM/2 holds back 4 GB
to protect something needing about 1.5 GB.

A fixed 5 GB is wrong too, and specifically so: the pack measures 5.0 GB, so a
5 GB cap sits exactly on the boundary and evicts anyway.

So reserve a constant 2.5 GB and give the rest to textures:

   6 GB ->  3.5 GB   (was 3.0)   GoW1 HD 2.97 GB fits
   8 GB ->  5.5 GB   (was 4.0)   P3 FES HD 5.0 GB fits
  12 GB ->  9.5 GB   (was 6.0)

The 8 GB row is the reported case, and it now holds the pack whole with headroom
rather than missing by a gigabyte.

The budget is logged at startup with the physical RAM it came from, and the
over-budget warning now says the pack does not fit rather than only quoting the
budget — the actionable question is which is bigger, and the old wording did not
let anyone answer it.
2026-08-21 11:36:43 -04:00
jpolo1224 7bbe5b2fc1 GS: remove the texture-replacement cache cap
Reverses the 2026-07-20 policy. Reported by JustVibin247 for Persona 3 FES:
mods worked on 2.6.6 and stopped on 2.6.6.1, first showing as corruption and
later as simply not applying.

The cap was added to stop a 5 GB uncompressed Persona 3 FES pack OOM-killing
Android mid-load, and it did stop that. It also broke every setup where an
oversized pack had been working. Budget was RAM/2, so on an 8 GB device that
same 5 GB pack sat permanently about 1 GB over and evicted continuously — each
load immediately dropping the previous one.

That produced both reported symptoms in the order they were reported. The churn
means constant re-upload; before 9a6db38395 a failed upload was injected anyway
with UNDEFINED contents, which is the green blocky corruption in the original
screenshots. After 9a6db38395 a failed upload correctly drops the texture, so
the same cause changed appearance to 'the mods do not apply at all'.

No budget satisfies both cases: 5 GB does not fit inside any fraction of 8 GB
that also leaves the emulator room. So this is a choice about which failure the
user gets, not a bug with a correct answer. An oversized pack now behaves as it
did before — it loads, and the device is allowed to run out of memory if it
genuinely cannot hold it. Half a pack applied with nothing on screen explaining
why is the worse outcome; it reads as broken rather than as too big.

The byte accounting is kept and now warns ONCE past physical RAM/2, naming the
footprint. It is a breadcrumb, not a limit: nothing is refused and nothing is
evicted, but if the process is killed the log says what was resident. The LRU
ordering is kept dormant for the same reason — if OOM reports return,
reinstating eviction is one line in GetReplacementCacheBudget.

The genuine fix is on the pack side: block-compressed (BC7/BC3) it would be
several times smaller and fit comfortably.
2026-08-21 11:26:36 -04:00
jpolo1224 aba5f201e6 Saves: hiding Save must not hide Load
Save and Load shared a single guard, so gating Save on a running VM hid Load with
it — and Load is the entire reason the library's long-press menu opens this
screen.

They have different conditions and now have different guards. Load needs only
that the state belongs to the game in context: with no VM it boots the game and
loads into it. Save additionally needs a live VM, because there is nothing to
snapshot without one.
2026-08-21 11:07:04 -04:00
jpolo1224 2443ad72cf Saves: only offer Save when a VM is actually running
Opened from the library's long-press menu the Save Manager showed a Save button
next to Load, which cannot mean anything: nothing is booted, so there is no
running game to snapshot.

The button was gated on canUseWithActiveGame, which means 'this state belongs to
the game in context' — a different question, and one that only started answering
true here when contextGame was wired up in the previous commit. Saving needs a
live VM; loading does not, because it can boot the game first. The two
conditions were conflated and the fallback fix exposed it.

Now gated on hasActiveVm, read from NativeApp.hasActiveVM() during refresh. Load
and Delete are untouched: both are meaningful without a running game.
2026-08-21 11:04:52 -04:00
jpolo1224 208bf68f1b Saves: honour contextGame when listing, not just when launching
Long-press -> Load save state opened the Save Manager but nothing could be
loaded from it.

load() already fell back to contextGame; the LISTING did not. It read
currentGame alone, which is null when nothing is booted — so every entry came
back canUseWithActiveGame = false, which is what greys out Load, and the serial
filter also stopped applying so the screen showed every game's saves at once
rather than the one that was long-pressed.

contextGame was added for exactly this case and the launch path already used it.
The read here had simply never been updated, because until now the only way into
this screen was from a running game or the drawer.

importSaveStateToNextFreeSlot deliberately still requires currentGame: it
resolves destination paths through NativeApp.getGamePathSlot, which answers for
the running VM, so a context game would give it nowhere to write.
2026-08-21 11:00:55 -04:00
jpolo1224 146da3d2ef Library: open the real Save Manager, and make swipe-to-dismiss actually work
Both from testing feedback. The save-state feature itself worked — slots listed
and booting into one loaded correctly — but two things around it did not.

★ The swipe did nothing, and the reason is worth writing down: Compose delivers
pointer events to CHILDREN first. Every row in the sheet is clickable, so they
consumed the drag in the Main pass and a detector on the parent Box never saw it.
Watching PointerEventPass.Initial is the only way a parent wins that. Winning it
everywhere would be worse than not having it — it would eat scrolling inside any
modal that scrolls — so the gesture is claimed only when it STARTS in the top
64dp, where the drag handle is and where a sheet is grabbed anyway, and only once
it has clearly travelled downward. The absorb-taps clickable also moved AFTER the
detector; having it first gave it the events.

This mattered more than a missing nicety: the game menu is nearly full-height, so
there is almost no scrim left to tap, and without the swipe the only way out was
the controller. A touch user was stuck.

★ 'Load save state' now opens the Save Manager rather than a bespoke list. That
was the request, and it is also the better implementation: the Save Manager
already renders slots as a grid with preview thumbnails and carries its own back
button, so it cannot trap anyone. contextGame exists for precisely this — it is
how the Save Manager already operates on a game that is not currently running —
so this is wiring, not new UI. The bespoke picker and its modal are deleted.

SaveSlotLookup stays: the menu still needs to know whether a game has any states
at all, to decide whether to show the row.
2026-08-21 10:56:53 -04:00
jpolo1224 caaf80749e Library: find save states in both data roots, and restore swipe-to-dismiss
Two follow-ups from testing the long-press menu.

★ SaveSlotLookup only searched ONE root. A device with a configured system
directory has two — assetCopyRoot resolves to that one (typically the SD card,
where ROMs and most saves live) while others stay under getExternalFilesDir. On
the test device 13 states sat in one and 5 in the other, so either root alone
under-reports, and the failure is silent: it reads as 'this game has no save
states' rather than as a bug. This is the same two-root trap that once made a
patches investigation report a false 'clean'. Both are searched now, and when a
slot exists in both the newer file wins.

Swipe-to-dismiss is back for bottom-aligned modals. PadModal replaced
ModalBottomSheet because that is its own focused Android window and every row
inside it was unreachable by pad; the swipe was the one thing given up in the
trade. But a panel that rises from the bottom edge with a rounded top and a drag
handle is PROMISING a swipe, so its absence reads as broken rather than as a
deliberate omission. The panel now follows the finger and dismisses past a
threshold, without giving up focus ownership.

Downward only, and only for BottomCenter: dragging a bottom sheet up should not
lift it off the edge it is anchored to, and on centred or anchored menus a
vertical drag means nothing and would fight scrolling inside them.

Worth recording that the originally reported symptom was NOT a bug: God of War
II has no save states on the test device, so an absent row was correct. The root
bug was real but found by inspection while checking that.
2026-08-21 10:47:35 -04:00
jpolo1224 0f0f719bce Library: fix the selection highlight contrast, and offer save states on long-press
bmdhacks' two.

Selection highlight was blue on blue. It drew a single ring in the theme's
primary, and the library background is themed from the same palette — so on a
blue theme the highlight was invisible, which matters because controller
navigation is the only way that selection is moved. Now two rings: an outer one
derived from inverseSurface, which contrasts with the background whatever hue
the user picked, and the accent ring inside it. Whichever the background happens
to match, the other still reads. The list rows had the same problem and get a
thicker stroke blended toward inverseSurface.

Long-press already opened the game menu; it now offers the game's save states
and boots straight into one.

Most of that already existed and only needed connecting: pendingSlotLoadOnBoot
and launchCurrentGameFromSaveSlot have been driving the Save Manager's
'relaunch and load' for a while. The one thing that did not work from the
library is that it resolved the game from currentGame/contextGame, and in the
library nothing is booted so both are null — it could never have fired there.
Split into launchGameFromSaveSlot(game, slot) which names the game explicitly.

Enumerating the slots needed new code for the same reason: the in-game picker
asks NativeApp.getGamePathSlot, which resolves against the RUNNING VM's serial.
SaveSlotLookup reads the files instead, using the layout the save manager
already walks — '<serial> (title).NN.p2s' under sstates/ or savestates/.

The row only appears when states exist. A 'Load save state' entry that opens
onto an empty list is worse than no entry.

The picker is a second PadModal rather than a submenu inside the game menu:
PadModal owns focus, so nesting one inside another leaves the inner rows
unreachable by controller — the same trap that made the game menu a PadModal
instead of a ModalBottomSheet in the first place.

Both flavours compile. Not yet exercised on device.
2026-08-21 10:38:07 -04:00
jpolo1224 f6ddff79bc LSFG: expose adaptive frame pacing, and remove the diagnostic tracing
The pacer shipped in the previous commit but was inert: GSConfig.LsfgTargetRate
defaulted to 0, which means "hold the multiplier fixed", and nothing in the
Android settings could change it. So the port ran, but the specific problem it
was brought over to solve — games that oscillate between 60 and 30fps on a 60Hz
panel, where a fixed multiplier presents 120 then 60 and judders at every
transition — was still there. Working but inert is the failure mode worth naming:
nothing errors, the feature simply does not do the thing it was for.

Plumbed through the usual twelve places (the field, INI read and write, the
differs chain, toJson/fromJson, both per-game override paths, the reset list, the
search index, the strings, and the two call sites), plus the two C++ ones in
Pcsx2Config.

Presented as a switch rather than a number. The pacer needs a concrete Hz, but
picking one by hand is not a decision anyone can make usefully and the only
sensible answer is the panel's own refresh rate — so the UI writes that when the
toggle goes on, and 0 when it goes off. Off remains the default, so behaviour is
unchanged until it is asked for.

Also removes the step tracing added while chasing the Turnip crash. It did its
job: five rounds of reading the code produced three wrong theories, and the trace
produced the answer in two. The reasoning it uncovered is in the comments, which
is where it belongs — the instrumentation is not.

The new strings live in the github-only table, so the Play split still holds:
playDebug has zero class files containing 'Lossless' or 'perf.lsfg', githubDebug
has 2 and 4.
2026-08-21 10:30:15 -04:00
jpolo1224 2080bd1c44 LSFG: make frame generation actually run on device
Verified working on an Adreno 740, on BOTH the stock Qualcomm driver and Turnip:
an interpolated frame presented for every rendered frame, no crash.

Six real defects between 'compiles' and 'runs', all mine. Recording them because
every one of them compiled cleanly and several looked like somebody else's bug.

★ THE LAST ONE, and the least guessable. Waiting on a semaphore signalled by a
SECOND vkAcquireNextImageKHR within one frame segfaults inside Turnip at
vkQueueSubmit. Stock Qualcomm accepts it; Turnip does not. The extra acquire now
signals a FENCE which we block on before recording, so the submit waits only on
the caller's render-finished semaphore — exactly what a non-generating frame
does, and that shape always worked. It costs a short CPU stall per generated
frame, still far cheaper than the two full device idles per frame the old
implementation paid.

The tell was in the trace, not in the code: working frames submitted
waits=1 signals=1, the frame that died submitted waits=2 signals=2. Everything
else about that frame — the dispatch, the copy, the fences, the presents — was
identical.

The other five:

  · __fi on a free function in a header. PCSX2's __forceinline is
    __attribute__((always_inline, unused)) with NO inline keyword, so every
    including TU emitted its own copy: duplicate symbol at link, from a header
    that compiles perfectly alone. Pcsx2Defs.h provides __forceinline_odr for
    exactly this and the rest of the renderer only uses __fi inside class
    bodies, where members are implicitly inline.
  · Acquire budget. Vulkan allows imageCount - minImageCount + 1 images held at
    once and the presented frame already holds one, so with min=3 and 3 images
    the budget was ZERO. Acquiring anyway is undefined behaviour, not a failed
    call. GetImageCount() - 1 was simply the wrong bound.
  · Swap chain image count. Asking for base + 1 does nothing: on FIFO the base
    is 2, so it clamps straight back up to minImageCount. The request has to be
    anchored to minImageCount, or the budget stays zero and frame generation
    silently never runs with nothing reporting an error anywhere.
  · One command buffer, one semaphore set, no fence. Resetting a buffer that is
    still executing and resubmitting one that is still pending are both
    undefined. The OLD implementation had the same single-slot arrangement and
    got away with it because it called vkQueueWaitIdle twice a frame — removing
    those idles is the entire point of this port, and it removed the accidental
    serialisation that made reuse legal. Now one slot per swap chain image, each
    with its own fence.
  · Initialisation order. Moving image allocation into CreateResources without
    moving the allocator ahead of it dereferenced an empty std::optional and
    killed the GS thread during BIOS boot — before frame generation would ever
    have produced a frame, so it presented as an entirely unrelated crash.

Generated frames also now go into images WE own and are copied into the acquired
swap chain image, rather than being dispatched straight into it through a
storage view. That theory did NOT fix the crash — but it is what both Eden and
the old implementation do, it asks nothing unusual of the WSI, and it let the
swap chain drop VK_IMAGE_USAGE_STORAGE_BIT entirely, which removed the
'enable it, then restart the renderer' wart along with it.

Diagnosis was step-tracing the present path, not reading it: five rounds of
reading produced three wrong theories, and the trace produced the answer in two.
The instrumentation is removed; the reasoning is in the comments.
2026-08-21 10:23:26 -04:00
jpolo1224 0bfefd4b69 LSFG: run frame generation on our own device, and delete the old path
Completes the switch to the Eden port. GSLsfg keeps its entire public surface —
availability, status text, display FPS, the settings and OSD plumbing all
untouched — and only its internals change, so nothing above the renderer had to
move.

What actually changed on screen: the old implementation ran the interpolator on
a SECOND VkDevice and shared images as AHardwareBuffers, and because Android
offers no cross-device semaphore (Turnip rejects OPAQUE_FD export on AHB memory)
the only barrier available was a full device idle — twice per frame, every
frame. That is gone. Generation is now ordinary compute recorded into a command
buffer on the device we already have, and interpolated frames are written
STRAIGHT into an acquired swap chain image through a storage view, so the
intermediate copy is gone too.

The pacer comes with it, which is the fix for games that oscillate between 60
and 30fps on a 60Hz panel: the generation count now varies to hold the presented
rate near a target instead of blindly multiplying whatever the game produced.

★ ONE submit, N+1 semaphores. All the generation work goes into a single
command buffer, submitted once, waiting on the caller's render-finished
semaphore plus every acquire, and signalling one semaphore per present that
follows. The obvious alternative — a submit per generated frame — walks straight
back into the binary-semaphore bug this file was bitten by before, where the
real present and the first generated present both want to wait on the semaphore
that says the source has been read. A binary semaphore may be waited exactly
once.

★ The hook fires AFTER vkQueueSubmit, so FrameGen had to take its command
buffer as a parameter. It was written against GSDeviceVK::GetCurrentCommandBuffer(),
which at that point is in flight or already belongs to the next frame; recording
into it is undefined and the symptom would have been interpolation running a
frame late rather than anything resembling an error.

Layout bracketing is ours: the ported passes speak Eden's convention where a
presentable image lives in GENERAL, and PCSX2 hands them over in PRESENT_SRC_KHR
and needs them back in it.

The swap chain now requests VK_IMAGE_USAGE_STORAGE_BIT — but only when frame
generation is on AND both the surface and the chosen format allow it. Asking
unconditionally fails swap chain creation outright on drivers that do not, which
would take the whole renderer down for a feature that is switched off. The
format half is the easy one to miss: a surface can report STORAGE support while
the sRGB format picked for it has no STORAGE_IMAGE feature bit, and that only
shows up later as a validation error at image-view creation. Because usage is
fixed at creation, switching the feature on mid-session needs a renderer
restart; Initialize says so rather than failing silently.

DELETED: platforms/android/app/src/main/cpp/3rdparty/lsfg in full — the
lsfg-vk-android framegen library, the DXVK dxbc compiler, pe-parse, volk and its
759-symbol collision with VKLoader, the C ABI shim, the version script, the
separate .so and the dlopen that found it, and the -fexceptions carve-out they
needed. GSLsfg.cpp went from 1259 lines to 654. The ~130 MB configure-time fetch
goes with it.

build-play-aab.sh's guard was rewritten rather than dropped: it checked for a
file that can no longer exist either way, so it would have passed forever
without proving anything. It now looks inside the core for a symbol only the
ported implementation defines.

Verified: all 18 affected translation units compile without errors, with
ARMSX2_HAS_LSFG on AND off (the play flavour still compiles the feature out
entirely). Not yet run on hardware.
2026-08-21 01:14:30 -04:00
jpolo1224 5e1d979b4e LSFG: port Eden's frame generation (passes, pacer, DLL reader)
Ports the frame-generation implementation from Eden (eden-emu PR #4263), which
is a substantially better design than the lsfg-vk-android one we currently ship.

Why it is better, concretely. Ours runs framegen on its OWN VkDevice, shares
images through AHardwareBuffer, and — because Android gives no cross-device
semaphore, Turnip rejecting OPAQUE_FD on AHB memory — uses full device idles as
its only barrier. Eden's runs as ordinary compute on the device we already have.
It also needs none of what ours drags in: no DXVK dxbc compiler (its shader
translate is a SPIR-V validate plus a descriptor-binding renumber, because
current Lossless.dll ships SPIR-V in its RCDATA resources), no pe-parse, no volk
and its 759-symbol collision with VKLoader, no separate .so, no C ABI, no dlopen,
and no -fexceptions carve-out.

It also brings a real frame PACER, which is the answer to games that oscillate
between 60 and 30fps on a 60Hz panel. A fixed multiplier presents 120 then 60
there and judders at every transition; the pacer varies the generation count to
hold the OUTPUT near a target instead. New GSConfig.LsfgTargetRate drives it,
defaulting to 0 = the existing fixed-multiplier behaviour, so this is opt-in.

Nothing is wired up yet — GSLsfg still drives the old path. This commit is the
ported library only.

★ The load-bearing decision is LsfgVkCompat. The pass code is written against
yuzu's RAII wrapper and its Device/MemoryAllocator, which PCSX2 has no analogue
for. Rather than rewrite ~2000 lines of call sites, the slice of that API the
code actually uses is reimplemented over PCSX2's raw handles and VMA — it came
to five command-buffer methods, three Device queries, two allocator entry points
and eight handle types. The result is that every pass body is BYTE-IDENTICAL to
Eden's, so upstream fixes stay a readable diff instead of a merge puzzle.

Deliberate departures, each commented at the site:
  · paths are std::string, not std::filesystem — the GS backend uses neither
  · CityHash -> GSXXH3_64bits, already used elsewhere in GS
  · the shader cache gained mtime + a flags field so a hit costs a stat() rather
    than a full read, hash and PE walk of the DLL on every launch; Eden keys on
    a content hash and so must read the whole file before it may look at the
    cache. GSLsfg.cpp already validates on size+mtime, so this matches the tree.
  · Eden's RemoveInstalledLosslessDll() is NOT ported. It deletes the DLL, which
    is safe there because Eden owns that file; here the path is whatever
    GSConfig.LsfgDllPath says and nothing checks it points inside our storage.
    Only the cache half is kept, as ClearShaderCache().
  · vk::Buffer gained Flush(). The port initially dropped Eden's flush because
    the shim had nothing to flush through. That write is the shader's entire
    uniform block, and the failure mode is not a crash — it is interpolation
    reading stale constants, which reads as a motion artefact, not a bug.

Verified: all 14 translation units compile clean against the real PCSX2 headers
under -Wall -Wextra. The reconstructed util.cpp helpers were diffed against the
genuine Eden source fetched from the merge commit — the extracted diff hunks in
the working copy are PARTIAL, added lines only, so they were not safe to trust.
2026-08-21 00:53:19 -04:00
jpolo1224 517fa69c4c OSD: stop any settings change from wiping the active OSD mode
Changing any setting at all — brightness, a speedhack, a controller binding —
made the on-screen display disappear.

The OSD has two independent controls that both write the same native flags. The
per-stat selection in settings, and the MODE picked from the in-game menu or the
hotkey (Full / Minimal / Custom / Off). Settings.applyTo() pushes the per-stat
osdShow* flags unconditionally, and applyTo runs on EVERY settings change, so it
was overwriting whatever mode was active with the Custom flag set. On most
setups the Custom set is mostly off, which is why the symptom reads as the OSD
vanishing rather than as it changing.

The mode STATE was never lost — InGameOverlay.osdMode still said Full, and the
in-game menu still showed Full. Only the native flags had been replaced, so the
UI and the screen disagreed and nothing looked wrong from the app's side.

Fixed at the applyTo choke point rather than at its five call sites: a re-assert
that reapplies the mode when it is anything other than Custom. Custom is left
alone deliberately — applyTo has just written exactly what Custom means, and
re-applying would be a redundant round trip through the CPU thread.

This is the same shape as the boot-time applyStoredOsdMode() and the
second-display reapplyOsdMode(), which already restore the mode after something
else has pushed flags underneath it. applyTo was the third place that needed it
and the only one that had no such guard.
2026-08-21 00:25:08 -04:00
jpolo1224 54d2850295 LSFG: keep it out of the Play build entirely, not just switched off
Play builds cannot carry LSFG at all, and they did. The gating was a
BuildConfig.LSFG check inside shared files, which is a weaker claim than it
reads as: the rows were never drawn, and all 22 frame-generation strings still
shipped in the Play dex in plain text — including "Lossless Scaling",
"Lossless.dll" and the requirements dialog naming the product, which is exactly
what a text search over the artifact finds. The native half was already
genuinely compiled out (-DARMSX2_ENABLE_LSFG=OFF); only the Kotlin half looked
like it was.

Moved to source sets, which is the arrangement that actually excludes:

  LsfgSection.kt      main -> github, with a no-op stub in play
  the 22 EN strings   -> I18nLsfg.kt, real in github and an EMPTY MAP in play
  the 5 search rows   -> SettingsSearchLsfg.kt, likewise
  LsfgEmulationCard   new, so the shared pause-menu file no longer even names
                      the section's string key (SectionCard became internal)

EN is now BASE_EN + LSFG_EN and the search index BASE + LSFG, so whichever
flavour is in scope supplies its half and no caller knows which build it is in.
Splitting the search rows is a behaviour fix as well: in the play build they were
indexed while the section they pointed at was compiled out, so searching would
offer a result that rendered its own key as its title and led nowhere.

The settings FIELDS stay shared on purpose — identifiers rather than product
names, and an identical config schema across flavours is what lets a config move
between builds without losing data.

Verified on compiled output rather than source: playDebug has zero class files
containing 'Lossless' and zero containing 'perf.lsfg'; githubDebug has 2 and 4.
I18nLsfgKt.class is 3633 bytes in github and 833 in play. build-play-aab.sh now
greps the AAB's dex for both strings and fails the build if either appears, so a
later edit to a shared file cannot quietly undo this.

★ That verification first came back clean for BOTH flavours, which was a false
negative: Xcode's strings(1) parses a .class as a Mach-O fat binary, errors, and
prints nothing — indistinguishable from a pass. LC_ALL=C grep -a is what the
check uses, and what the comment in the script warns about.
2026-08-21 00:22:31 -04:00
Brian Degenhardt 112bc73c4c Android: take a card snapshot at launch, and offer the restore
Wires MemoryCardBackup into the app.

The snapshot is taken immediately before the emulation thread starts, in
MainActivityRuntime.start() and startBios(). At that instant the card file is not
open, so the copy cannot catch a half-finished write and there is no thread
timing to reason about. It also means the copy holds the card as it stood when
the player last finished successfully -- if this session is the one that breaks
things, the snapshot is clean by construction. Restoring then loses the current
session's saves, which is the trade a save-state slot already makes.

The BIOS boot gets one too: its memory card manager can format a card or delete
saves off it, so that session is worth a copy for the same reason a game is.

Launch also checks the cards it is about to mount. If one will not read AND a
verified backup exists, the boot is HELD and the prompt offers to put it back
before the game starts. That ordering is not cosmetic: once the console has
mounted a card it caches its own picture of the directory in guest memory, and a
restore underneath would be written straight over. "Start anyway" stays available
-- some people will want to format fresh -- and is remembered only for the launch
it was answered for.

The memory card screen gets a per-card Backups panel: the snapshots with their
date, size and the game that was running, a verified-or-suspect badge, restore,
back up now, and the automatic-backups switch. That manual path is the one that
actually matters, because the automatic offer cannot fire for the failure players
hit most -- a card that verifies perfectly while the save inside it is damaged.
Recognising that would mean understanding each game's save format.

Restore is refused while a game is running, for the cache reason above, and says
so rather than failing quietly.

A suspect snapshot is listed rather than hidden: the pre-restore copy of a broken
card is exactly what someone may need back.
2026-08-20 09:24:01 -07:00
Brian Degenhardt 8e07613231 Android: rolling per-card memory card snapshots
Nothing today keeps a previous version of a memory card. A file card is written
straight through to disk as the game plays, so the card on storage is always the
only card there is; anything that interrupts a write leaves the real card
damaged, and the player finds out when they load.

MemoryCardBackup keeps a small rotation of snapshots per card so that is
recoverable. This commit is the engine only -- nothing calls it yet.

Two rules do the real work:

  - A card that fails verify() is never snapshotted. There is nothing worth
    saving and a rotation slot to lose. That same check is what detects the
    user's problem, so it is reported rather than swallowed.
  - prune() never deletes the newest snapshot that passed verification. Without
    that, the rotation is a shredder: the card breaks, the player relaunches five
    times trying to work out why, and every good copy has been overwritten with a
    copy of the broken card.

Retention is the newest 3, plus the newest from each of the preceding 4 distinct
days, plus always the newest verified copy however old. Three copies from one
afternoon only protect against that afternoon.

verify() is the same test the core trusts (FileMcd_IsMemoryCardFormatted): the
PS2 format signature at the head of the card, or the folder card's superblock
marker. Deliberately a size FLOOR rather than a table of exact sizes -- strict
about the signature, permissive about the size, so it refuses damaged cards
without refusing unusual ones that work.

Snapshots are content-hashed, so relaunching without saving costs nothing. They
are written to a .part file and renamed into place, so a process killed mid-write
leaves no truncated archive that would list as valid -- the same class of bug
this feature exists to undo. Restore stages and swaps through a sidelined copy,
outside the cards folder: a folder card is a directory, and the card list shows
every directory under memcards/ as a card, so staging there would flash a phantom
card mid-restore and leave one for good if the process died.

Restore takes a snapshot of the live card first, unconditionally, including a
broken one marked as such. Restoring the wrong copy must not be the act that
destroys the evidence.

Written against the Java file APIs rather than the core on purpose. The storage
memory cards live on is FUSE-emulated on some Android configurations, where
libc's file-creation call is denied outright -- a problem this tree has hit twice
and worked around both times from Java. A snapshot writer in the core would fail
silently on exactly the devices that need it.

The rotation joins the whole-app export: a card restored onto a new phone from
that archive is just as likely to be the broken one, so the history that can undo
it has to travel with it.
2026-08-20 09:22:21 -07:00
Brian Degenhardt cd20f6454d Android: flush memory card writes when the app is backgrounded
A memory card write does not necessarily reach the file system when it happens,
and on Android that is data loss rather than a detail.

A FOLDER card holds writes in an in-memory page cache and flushes two frames
after the last one, counted down by the per-frame tick that runs off vsync. So
pausing does not delay that flush, it stops it ever being reached -- the counter
does not advance at all while the VM is paused.

A FILE card writes through stdio with no flush anywhere in the path. Seeking on
an update stream pushes the previous write out, so a run of writes mostly
self-corrects, but the last write of a save sequence sits in the buffer until the
next card access or fclose.

Either way the pending write is lost if Android reclaims the process while it is
backgrounded, which it may do with no further callback. Save in-game, switch
apps, get reclaimed, and the save was never on disk.

The pause path already handles exactly this shape for the BIOS NVRAM
(cdvdSaveNVRAM, added because "the process is frequently killed while paused"),
so the card flush goes next to it, in both the Running and already-Paused
branches. It runs on the CPU thread, queued after SetPaused, so the console is
stopped and nothing can be written behind it, and it is fire-and-forget -- onPause
is on a deadline and blocking it risks an ANR.

  - FileMcd_Flush() / FileMemoryCard::Flush() / FolderMemoryCardAggregator::Flush()
    write out what is buffered without closing anything, so the console keeps
    playing afterwards.
  - FileMemoryCard::Flush deliberately does NOT stamp the running checksum the way
    Close() does. That value is a change-detector a savestate load compares to
    decide whether the card moved under the console, not an integrity check, and
    m_chkaddr is card data rather than a header field we own. A stale value costs
    one auto-eject on the next savestate load, which is the safe direction, so
    writing to the card on a path upstream never writes on buys nothing.
  - FolderMemoryCard::FlushNow clears the frame countdown so a resumed VM does not
    repeat the work. Flush() is already a no-op when nothing is cached, so calling
    this on a quiet card costs nothing.
  - Save() flushes each sector as it is written.

Also bounds the emulation-thread join in onDestroy. NativeApp.shutdown() already
gives up waiting after 5 s and returns anyway, so an unbounded join inherited a
wedged CPU thread and hung the destroy path until Android force-closed us.
2026-08-20 09:22:18 -07:00
Brian Degenhardt df17121cc1 SIO/Memcard: refuse a card write whose read-back failed
FileMemoryCard::Save is a read-modify-write: it reads the sector being written
into a scratch buffer, ANDs the new data into it (memory card bits only ever go
1->0 without an erase), and writes the result back.

When the read failed it reported the error and then carried straight on into the
merge. m_currentdata is only ever grown, never cleared, so the merge ran against
whatever an earlier -- and possibly completely unrelated -- write had left in the
buffer, and that was what got written to the card. A transient read failure
therefore did not lose a write, it corrupted a sector the console never asked to
change.

Refuse the write instead. The sector keeps its previous contents, which the
console can retry. The return value is discarded by the only caller (Sio.h), so
this reads as "don't write" rather than as an error report -- the same shape as
the two Seek failures already in this function.

Desktop hosts rarely see a read fail on a card file. Android does: the storage
memory cards live on is FUSE-emulated, where this tree already documents libc
calls being denied outright.
2026-08-20 08:26:31 -07:00