The slider is shared, and that is deliberate -- the two upscalers want the
same thing from the user and a second control would only let them disagree.
But the NUMBER means different things to each: RCAS stops for FSR1, a 0..2
edge-sharpness factor for SGSR. Labelling it "FSR Sharpness" while SGSR is
selected says the setting belongs to something that is not running.
It now names whichever is selected, in both pickers.
The upscaler control exists TWICE -- once in the Renderer settings tab and
once in the in-game menu -- and only the first one learned about SGSR, so the
option was unreachable from the place people actually change these mid-game.
Both are pickers now rather than an FSR1 on/off switch. The in-game one was a
MenuSwitchRow, which was fine while FSR1 was the only upscaler and is the
wrong shape for three mutually exclusive ones.
The settings search index pointed at renderer.fsr1.label, which is no longer
a row anywhere; searching for it would have landed on nothing.
A third display upscaler beside FSR1, and the first one written for the
hardware this app actually runs on: FSR1's two compute passes are the
expensive outlier on mobile, while SGSR is a single pass Qualcomm designed
for Adreno.
The filter is Qualcomm's, BSD-3-Clause, unchanged in substance. What differs
is the shape around it -- theirs is a fragment shader over a fullscreen
triangle, this is a compute pass, because that is what GSDevice already knows
how to schedule. So the interpolated texcoord becomes a UV computed from the
invocation id and the fragment output becomes an imageStore. The crop
handling and the widened 0..2 sharpness range come from the Eden Emulator
Project's port (GPL-3.0-or-later, compatible with PCSX2's GPL-3.0+); PCSX2
hands the pass a display rectangle inside a larger target, which is the same
problem FsrEasuConOffset solves for FSR1.
Deliberately a strict subset of what FSR1 already requires -- same descriptor
types, same rgba8 storage image, textureGather with a constant component and
no offset, which is core Vulkan 1.0 and needs no optional feature. So any
driver already running FSR1 can run this, Turnip included, and there is no
vendor gate on either. A driver that cannot compile the pipeline clears
Features().sgsr and the renderer falls back to plain bilinear with an OSD
notice, rather than failing.
The Android upscaler control becomes a picker rather than an on/off toggle:
three mutually exclusive upscalers expressed as two toggles that silently
switch each other off is a worse way to say it than one list. FSR1 and SGSR
share the existing sharpness slider -- the number means different things to
each, but it is the same intent, and a second slider would only invite the
two to disagree.
★ The Settings.kt clamp on the persisted enum was still bounded at
UPSCALER_FSR1, and would have silently rewritten any SGSR selection back to
Off. That clamp's own comment warns about exactly this failure; it still had
to be updated by hand. Worth remembering the next time the enum grows.
Suggested by CamilleLaVey, who authored the upstream changes (eden-emu #4293).
Float.MIN_VALUE is the smallest POSITIVE float in Kotlin and Java (1.4e-45);
the most negative is -Float.MAX_VALUE. The overlay hides a figure by testing
`value > ARMSX2_THERMAL_NONE` against -1000.0f, so the sentinel sailed
through as a real reading and printed as "0°".
Both sides are -1000.0f now. Every use is an exact assignment or comparison
against the constant, and the plausibility gate (-20..150) means a real
reading can never collide with it.
Worth noting when this was visible: never, on a device whose zones all
resolve, because the sentinel is then never sent. It shows up only on the
devices the fallback exists for -- a readable CPU zone and no GPU zone would
print "CPU 47° GPU 0°". Found by reading the code during the ARMSX3 port,
not by testing, which is the only way it could have been found.
Giving the cover cell the art's own 1.4 ratio at the column width produced
869px on a 1080px-tall panel: one tile taking most of the screen and shoving
everything below it off the bottom. The panel is landscape and the art is
portrait, so deriving the height from the width was the wrong axis entirely
-- it made the cell taller the WIDER the panel got.
FIT_CENTER already guarantees the whole cover is visible in a box of any
shape, so the box only has to be a reasonable size rather than the art's
shape. A third of the panel height reads as a cover without crowding out the
tiles people actually press, with a floor for very short displays, and the
tile-height slider still overrides it.
FIT_CENTER only helps when there is a box to fit into. The cover cell was
WRAP_CONTENT around an image, which gives the cell no definite height, so the
art sized itself and spilled past the cell to be clipped by the tile outline
-- the same cut-off cover, arrived at a different way. Box art is about 1.4
times as tall as it is wide, so the cell is now given that shape at the
column width and the whole cover fits inside it. adjustViewBounds went with
it; it fights a definite box rather than helping.
The achievement tile showed a hardcore count and a casual count above the
total, which reads as three unrelated numbers -- "0 next to the trophy, then
another 0/64" -- and says nothing about which mode is actually active, which
was the one thing it needed to say. It now names the mode, with the trophy
for hardcore and the medal for casual, over unlocked-of-total.
The cover was CENTER_CROP in a grid cell. Box art is portrait and a cell is
not, so filling the cell ate the top and bottom of the cover -- the parts
carrying the logo and the title. It fits inside the tile now and letterboxes
against the tile background.
The panel root could not scroll, which was survivable while the tile list was
short and stopped being so the moment four more tiles were added to it. The
panel has no say in its own height -- the user chooses how many tiles and how
tall each one is -- so anything that did not fit was simply clipped off the
bottom of the display with nothing to say it was there.
"12/40" did not say whether those were earned in hardcore or casual, which is
the distinction RetroAchievements cares most about. The counts now carry
marks for both. rc_client reports a per-achievement mask where a hardcore
unlock also sets the softcore bit, so the casual figure is the softcore-ONLY
count rather than the total -- otherwise every hardcore unlock would be
counted twice.
The first migration only upgraded a layout that was byte-for-byte the old
default, reasoning that anything else was a deliberate arrangement to leave
alone. That fails for the ordinary case: toggle one tile on and off and the
layout is no longer the default, so someone who had barely touched it never
saw the new tiles at all. Which is exactly what happened -- a panel that had
been poked at during testing kept its old contents and looked like the
feature had not shipped.
Now version-stamped and additive: whatever the user arranged stays arranged,
anything missing from the new set is appended, and the stamp means it runs
exactly once -- so removing a tile afterwards sticks.
Shipping them as chips nobody would find was the wrong call -- a tile that
has to be discovered in a settings screen may as well not exist. Cover,
Achievements, RA points and rich presence are now part of the default panel.
A saved layout still wins, because that is what saving it is for. But a
layout that is byte-for-byte the OLD default is not a choice anyone made,
it is just what they were handed, so those upgrade once to the new default.
Anything actually arranged is left alone.
The cover tile also gets a visible fallback. With no game, or before the art
downloads, it was an empty box -- and an empty box is indistinguishable from
a broken tile. It now shows "Cover" with no game and the game's title once
there is one, with the picture drawn over the top when it arrives. A custom
cover file that cannot be decoded falls through to the fetched URL instead of
leaving the tile blank.
getAchievementsJSON is VM-scoped -- the JNI entry calls
Achievements::GetAchievementsAsJSON() with no guard at all, unlike the rich
presence getter directly below it which checks HasRichPresence() first. With
no game loaded it dereferences null and takes the process down.
The previous commit hoisted the unlock tracking out of the Achievements
tile's text builder so the new Latest-unlock tile would work on its own. That
was right, but it moved the call onto the panel tick, which runs whether or
not a game is running -- so the panel crashed the app the moment it appeared,
on launch, before anything could be touched. Every other caller of this
getter is inside a VM-scoped context by construction; this one was not.
Now polled only with a live VM, and the read-outs fall back to a dash
otherwise. runCatching was never going to help: a SIGSEGV is not a Throwable.
The native getter is still a landmine for any future caller and should get
the same guard its neighbour has.
Cover art tile. The panel had the game as a line of text, and on a screen
sitting beside you the cover is what makes it read as "this game" at a
glance. It resolves through the same two sources the library uses -- a
user-set custom cover first, then the fetched URL -- so the panel shows what
the library shows, including a cover picked by hand. The only tile that is a
picture rather than text, and it re-resolves on the tick keyed on the game,
so switching game changes it without rebuilding the panel.
Three more RetroAchievements read-outs: points earned against the total,
which is the figure RA itself leads with; the latest unlock on its own; and
the rich presence line, which is the one thing here that says something a
number cannot.
Two things fixed while adding them. The unlock tracking lived inside the
Achievements tile's own text builder, so the new Latest-unlock tile would
have read "—" forever unless the Achievements tile happened to be placed as
well -- it is now a per-tick step that runs regardless of which tiles exist.
And the achievements JSON was parsed per tile, so placing all three would
have parsed the same string three times a tick for identical results; it is
parsed once now and shared.
The panel reads its colours when it BUILDS its views, so publishing a new
scheme changed nothing for a panel that was already open -- it kept the
colours it was born with, which is why switching theme appeared to do
nothing to it. It now rebuilds when the theme changes, keyed on the
user-facing switches rather than on the scheme object: the RGB mode produces
a new scheme every hue step, and tearing the panel down and back up sixty
times a cycle would be absurd.
The editor's auto-dock addressed the wrong half of the problem. Moving the
panel out of the way once a widget is selected cannot help you SELECT a
widget under the panel, because the obstruction happens before there is
anything to react to. The grip row now has a collapse toggle: one tap leaves
just the grip and uncovers everything beneath it, one tap brings the controls
back. Not persisted, and reset on leaving the editor -- it is a momentary
"let me see under this", and opening the editor to a panel with no controls
on it would look broken. Auto-dock stays; it still helps once a widget is
picked.
Device temperatures on the overlay now default ON, confirmed reading real
values. It sits with CPU/GPU load, the poll is one file read every couple of
seconds, and a device with no readable zone shows nothing rather than
something wrong.
Custom panel background. Theme / library / black covered three of the four
asks; "an own background" needed a picker. Takes the persistable read grant
like the library's own picker -- without it the URI works until the process
restarts and then resolves to nothing, which reads as the background
disappearing on its own. Darkened by the same scrim as the library backdrop,
because an arbitrary photo has no obligation to be dark and tile text still
has to be readable.
Clock and battery move into a status bar across the top instead of being two
grid cells. Same information, but it stops the clock competing for space with
the things you actually press, and the grid gets two cells back.
The rest of the in-game OSD's figures reach the panel: VPS, EE / GS / GPU
load and frame time. These were not missing by choice -- getFPS() was the
only figure with a way across the JNI boundary, so the panel could show
frames and a percentage of nominal and nothing else. PerformanceMetrics
already computed all of it for the overlay. Each getter returns 0 with no VM
rather than the last value, so an idle panel reads as idle instead of frozen
on whatever the last game was doing.
Tile height is now settable. Columns already decided width -- tiles split the
row equally, so choosing columns IS choosing width, and a second width
control would only be a way to disagree with it. Height had no control at
all, which is why a panel could only ever be as tall as its text.
A display can be told to stay out of it. "The second screen also still
appears on the external monitor when connected via usbc" is not a bug by the
display-picking rule -- a USB-C monitor is a perfectly good second display --
so this records a preference instead of guessing: the panel's own Not-this-
screen tile drops the display it is on, and settings can re-enable them.
Keyed by display NAME, since ids are reassigned across replugs. Guessing from
internal-vs-external would have been wrong anyway; Android has no stable
public display type before API 34.
Device temperatures on the performance overlay, which is where they were
asked for. The core cannot read a temperature -- there is no portable API,
and on Android the only route is a vendor-specific sysfs the app layer
already discovers for the panel -- so the app pushes the values in and the
overlay draws what it was given. Atomics because the writer is a UI-thread
poll and the reader is the GS thread. A sensor that could not be read is
omitted rather than drawn as a zero.
The editor panel was a floating window pinned to the top of the screen, so
every button underneath it had to be uncovered by hand before it could be
touched. Dragging it was not an occasional adjustment, it was the price of
editing anything in the top half of the layout.
It now docks to the opposite half from whatever is selected: pick a button up
top and the panel goes to the bottom, pick one at the bottom and it returns.
Halves rather than real overlap maths, deliberately -- a panel that darts
around as rectangles graze each other is less predictable than one that is
simply never on the same side as the thing you are working on.
Manual dragging stays for anything unusual. The stored offset means "away
from the anchored edge" and so flips with the anchor; without that, a panel
the user had nudged down would be nudged straight off the bottom of the
screen the moment it docked there.
The panel carried a hand-written palette of neutral greys, on the reasoning
that a Presentation sits outside the Compose tree and reading MaterialTheme
from a plain View would mean holding a composition alive for six colours. The
reasoning was right and the conclusion was not: ARMSX2's night theme is BLUE,
so grey was not a neutral choice, it was a different app on the second
screen. That is what "still looks quite unpleasant... more like stock android
instead of armsx2" was describing.
Armsx2Theme now publishes the RESOLVED scheme for code that cannot be a
composable, and the panel reads that. No composition is held and there is no
second copy of the theme logic to drift, so the panel follows Blue, Purple,
OLED, Custom, Material You and the animated RGB mode without knowing any of
them exist.
Action tiles get a glyph over the label, because a tile has to be recognised
from across a desk. Geometric Unicode rather than emoji: emoji bring their
own colours and their own house style, which is the stock-Android look this
is moving away from, while a glyph takes the accent like everything else.
The two tiles that carry state SWAP their glyph rather than appending a line
-- Pause shows what the tap will do, and Fast Forward no longer grows when
you use it, which was half of the ragged-row problem.
Panel background is now a choice: the theme's own ground, the library's
backdrop darkened so it reads as the same app as the screen beside it, or
solid black for an OLED second display.
CPU, GPU and battery temperature tiles, asked for by two people. Android has
no supported API -- HardwarePropertiesManager is signature-gated -- so this
reads the thermal sysfs, which is permissionless but is not a contract: zone
count, naming and even the UNIT are vendor-specific. Zones are discovered
once by name, the unit is inferred by magnitude (no phone runs at 1000C and
none idles at 0.045C, so the ranges cannot overlap), implausible values are
dropped rather than displayed, and a device that exposes nothing shows a dash
instead of a wrong number. Polling is on its own interval, 1 to 5 seconds --
that interval is the mitigation asked about, and it is why the panel tick can
call it every frame for free.
Pause did not stick. The stuck-paused backstop resumes a VM that is paused
with nothing covering the screen, reasoning that such a state can only be a
lost resume. That held while every pause came with a frontend over it --
pauseForOverlay() is what the quick menu, the library and backgrounding all
use. The second screen's Pause tile is the only caller of plain pause(), and
it deliberately leaves the game on screen, so the backstop undid it 700ms
later. That is the "pause immediately unpauses itself" two people reported.
A pause the user asked for is now marked as such and the backstop leaves it
alone; resume() clears the mark.
Tiles were different heights. An active tile appends a state line ("\n❚❚" on
Pause, likewise Fast Forward), and with maxLines alone a tile grew the moment
you used it, so the row went ragged -- which is what made Fast Forward the
one people noticed. Every tile now reserves both lines whether or not it is
showing state, which also scales with the text size instead of a fixed
height, and row children stretch to the tallest so a Button's padding cannot
show as a ragged edge against a TextView's.
On-screen buttons get rapid-fire (#619). Physical buttons have had turbo and
the on-screen ones never did, which is the asymmetry the request was about --
and it is the same asymmetry tap-to-hold had in the other direction. Per
button, off by default, cycled from the editor toolbar. It runs on the macro
Frequency timer rather than a second one of its own, so it inherits the
sampling floor that stops the fastest settings from emitting presses the VM
never samples, and it composes with tap-to-hold: set both and a tap starts
the autofire and the next tap stops it.
Feature requested by shinobumaehara (#619)
connect() resolves the Discord app through
DiscordSocialSdkInit.getEngineActivity() and hands the result straight to
Context.getPackageManager() with no null check. That static was only ever
set by DiscordAuthActivity, which runs during sign-in -- so every launch
with a cached token started the SDK with it null, and :discord died with an
NPE. Android restarted the service, which started again and died again. The
app process never saw any of it, because the protocol is poll-only: the UI
just sat on "Connecting" forever.
Binding the SDK only during sign-in was wrong independently of that. The
SDK's statics are per-process and Android restarts :discord whenever it
likes, so the binding has to be re-established on demand rather than assumed
to survive.
Handing the Activity over is now separate from opening the browser:
DiscordAuthActivity takes an EXTRA_AUTHORIZE flag and always binds, and the
service will not call start() until a binding exists, launching the
invisible Activity itself when there is none.
Follow-up to 7aa4547e60. Four ports -- flux, plasma, solarwinds and
hyperspace -- still opened port_free with "if (!g_started) return;". That is
unreachable today, since the JNI only calls destroy after a successful
create, but unreachability is a property of the current call graph rather
than of the function, and it contradicts the rule the fix established: gl1
is given back on every path out, because whatever it holds belongs to an
EGL context that is about to die. All six are now unconditional.
Same argument one level up. port_new opened with "if (g_started) return 1",
which was defensible when gl1's state was whatever the last saver left, but
nativeInit now calls gl1_lost() first -- so gl1 is guaranteed DOWN on entry
and reporting success there would hand the caller a saver with no shim under
it. A stale run is torn down instead, and gl1_init always runs.
No behaviour change on any path reachable today. The point is that a saver
added later, or an upstream cleanup that returns early, fails in its own
saver instead of poisoning the next one.
The on-screen controls have had tap-to-hold since they existed; physical
buttons always followed the button exactly. A game that wants one held while
another control is worked -- MGS2 holding R1 to aim -- is then unplayable for
anyone who cannot hold two controls at once, which is what the request was
about.
Modelled as a transform on the event stream rather than a branch beside
turbo: a tap becomes a synthetic KeyDown, the next tap a synthetic KeyUp,
and everything between is swallowed. Turbo composes with it for free -- flag
a button both and a tap toggles autofire on and off.
Keyed on the physical code for "is this a fresh press", because ACTION_DOWN
auto-repeats while a key is held and each repeat would otherwise toggle, and
on the PS2 target for "is it latched", so two physical buttons bound to the
same button cannot desync.
The state lives in the companion because the boot path that clears it runs
there -- a latch must not outlive the game it was set in -- while the
dispatch that sets it is an instance method. Changing the setting releases
whatever is held: turning it off for a button that is latched down would
otherwise strand it pressed, with no second tap left to release it.
Stored per action per player like turbo, and off by default.
Feature requested by bobo123g (#612)
A saver that dies natively made ARMSX2 unlaunchable. The choice is a
persisted pref read on the library screen -- the first screen -- so the
crash repeated on every launch and Settings was never reachable to turn it
off. The only escape was clearing app data, which takes memory cards and
save states with it. A user lost their saves that way.
Cause: gl1's state is a file-scope global holding GL object names, and
gl1_init() early-returns on g.ready. Skyrocket and Lattice defer initSaver()
to port_resize, so a create-then-teardown with no surface size left
g_started false and their port_free returned BEFORE gl1_shutdown(); flux,
plasma and solarwinds leaked it the same way when initSaver() left
readyToDraw clear, since returning 0 means port_free is never called. Either
way g.ready stayed set with names from a destroyed EGL context, and the next
saver -- new view, new context -- drew against them. Drivers answer that
with anything from a black screen to a segfault.
Each port now gives gl1 back on every path out, and nativeInit calls
gl1_lost() as the invariant: a new context never inherits old GL names.
Contained separately, because native GL can always find a new way to die:
the setting arms itself with a synchronous commit() before the render thread
starts and disarms when that thread exits in an orderly way. Still armed at
startup means the last run died with a saver up, so the background switches
off and the user is told which one. runCatching was never going to catch a
SIGSEGV.
Also guards Thread.start(): it asks for a 16MB stack (Skyrocket declares a
3MB starmap as a local) and an OutOfMemoryError there is an uncaught throw
on the main thread -- the same lockout with no native crash involved.
It fired on its very first run against an APK that DID contain the library. `unzip -l | grep -q`
under `set -o pipefail` is a false-failure generator: grep exits on the first match, SIGPIPEs
unzip, and pipefail then reports the pipeline as failed. Capture the listing and match it with
`case` instead, which is what the existing notes on this already say to do.
2.6.6.8 shipped without Discord and it was only noticed after publication. The SDK is resolved
from $DISCORD_SDK_DIR at configure time and gated on include/discordpp.h existing, so with the
variable unset the build quietly omits it -- no error, no warning, nothing in the log to read
afterwards. Every check that already runs on these artifacts (both cores, alignment, signing,
package, MANAGE_EXTERNAL_STORAGE) would have caught this class of mistake if one had existed.
Both scripts now verify libdiscord_partner_sdk.so in the output. With DISCORD_SDK_DIR set and
the library absent, that is FATAL -- it means the staged directory was wrong, which is easy to
get wrong given the raw SDK download ships an x86-64 .so and only the .aar carries arm64. With
the variable unset it warns loudly instead of failing, because a Discord-less build is still a
legitimate thing to produce on purpose.
All of it was instrumentation for the Persona 3 FES investigation, and that turned
out not to be a texture problem at all -- three named patch groups were failing to
enable, and the texture pack stopped matching because the mod they gate patches the
game's font data.
Removed: the TCPROBE cross-version probe in HashCacheKey::Create, the hit/miss
counters and their geometric reporter, the per-miss detail listing what the pack
holds for a TEX0, the pack filename samples at map load, the duplicate/shadowed
accounting, and the mtime sidecar the palette fallback needed.
Kept the pre-existing "N indexed for '<serial>' (scanned <dir>)" line, which
predates this and is genuinely useful: a zero there with a path that does not match
the user's pack folder is still the fastest read on a pack that does nothing.
The cache budget work is untouched.
"I enabled HostFS and it is still off", with the switch sitting on. His game INI
proves it: [Cheats] present but empty, and no [Patches] section at all -- the
enable-list write never ran.
toggleLocalCheat flips the switch optimistically, then bails early when
setBodyEnabled produced an identical body:
if (newBody == target.body) return // pushEnableList never reached
setBodyEnabled only comments or uncomments patch= lines, and community pnach files
ship UNCOMMENTED. So for exactly those files enabling is a no-op on the body, the
early return skips the enable list, and Patch.cpp applies a NAMED group only when
its name is in [Patches]/[Cheats] Enable -- unlabelled groups are the only ones
that auto-enable. Switch on, file already correct, patch never applied, nothing
anywhere saying so.
The two writes are independent. Rewrite the body only when it differs; always push
the enable list.
This is what actually blocked the Persona 3 FES mods. The HostFS loader group could
not be enabled at all, so the mod never loaded, and the texture pack keyed to the
modded font stopped matching as a consequence -- which is how it arrived as "my
texture mods broke".
"I enabled HostFS and it is still off." The Patch Manager reads the toggle back as
on, and the patch loader never sees it.
ReloadEnabledLists reads through LayeredSettingsInterface, which returns the FIRST
NON-EMPTY layer with LAYER_GAME ahead of LAYER_BASE. So the moment a game's INI
carries any [Patches] Enable entry, the base list is invisible. setEnabledPatches
targeted the game INI only when a VM was running -- opened from the library it had
nowhere to go but base, where a shadowing game layer swallowed it. The UI then
reads the value straight back, which is why it looks like it took.
For this reporter that patch is the HostFS loader their Persona 3 FES mod runs on,
so it stayed off through repeated attempts to switch it on, the mod never loaded,
and the texture pack keyed to the modded font kept missing.
Take the serial from the caller -- PatchManagerViewModel already resolves one via
bestSerial() -- and resolve the game INI by the same glob gameIniBeginWriteForSerial
uses. With no VM and no serial, or no INI for that serial yet, it still falls
through to base, which is correct for a game that has no per-game file at all.
This is the "wants a serial/CRC parameter" the old comment left as the real fix.
It was a one-time repair for lists poisoned by patches arming themselves, and it
has done more harm than the thing it repaired. It deleted every [Patches]/[Cheats]
Enable entry, not just the self-armed ones, so anyone who had turned a patch on
deliberately lost it silently on update -- and when the patch in question is the
HostFS loader a mod depends on, the mod stops loading, the texture pack keyed to
the modded data stops matching with it, and the whole thing presents as "my
texture mods broke". That cost eleven builds to chase from the wrong end.
Restricting it to lists that look auto-armed was the first attempt at a fix, and
it was still wrong: the threshold is a guess, and someone with thirty cheats
enabled on one game would have been wiped by it just the same. A narrower
silent-data-loss bug is not a fix.
Removing it outright instead, because its job is finished:
- It is a migration, and it has already run for everyone who updated in the past
month. What remains is a shrinking population and a permanent hazard.
- Nothing re-poisons a list now. 10f4f73fe8 removed the unconditional
syncAllEnableLists on Patch Manager open, stopped EnumeratePnachFiles falling
back to the bundled zip over on-disk files, made delete() drop names from the
enable list, and stopped D-pad Right arming the row under the cursor.
- Anyone still carrying a poisoned list can clear it themselves, because
disabling a patch sticks now. That was the original complaint.
Nothing writes these lists except the player, which is how it should have been.
Both existed to work around the wrong diagnosis. The Persona 3 FES mods were not
failing in the texture cache at all -- a PNACH mod was switched off by the
enable-list purge, and because PNACH mods patch game DATA the texture pack keyed
to the patched font stopped matching as a side effect.
The palette fallback substituted a different colour variant of a glyph when the
exact palette was missing. It raised the hit count but could not be right: the
palette IS the colour, so substituted art carries the wrong shade, and it never
addressed why the hashes moved. Gone; the replacement path behaves exactly as it
always did.
The one-time notice goes too, per jpolo1224: the purge no longer takes deliberate
lists, so nothing needs announcing going forward.
Kept: the diagnostics that actually found this -- files scanned versus indexed
versus shadowed, hit and miss counts, and the per-miss detail naming what the pack
holds for that texture. Those are what turned "mods do not apply" into a
measurement, and they cost nothing when nothing is wrong.
This is what broke Persona 3 FES mods, and it was never the texture cache.
10f4f73fe8 fixed a real bug -- opening the Patch Manager armed every uncommented
group of every on-disk .pnach -- and cleaned up the damage with a one-time purge
of [Patches]/[Cheats] Enable. The purge deleted every entry unconditionally,
including the ones people had turned on deliberately, so updating past 2.6.6
silently switched off everyone's patches and cheats.
It surfaces in the worst possible way. The reporter's mod is a PNACH,
94A82AAA_Modern_Persona_Switch_aem.pnach, and PNACH mods patch game DATA. With it
disabled the font glyphs in GS memory differ, so the texture pack keyed to the
patched font stops matching as well -- and the whole thing reads as "my texture
mods stopped applying". Their logs say it plainly: 18,791 patch lines applied on
2.6.6 against 76 now, same .pnach found in both.
Two changes:
- The purge now only fires on lists carrying the auto-armed signature.
syncAllEnableLists persisted every uncommented group across ~4000 bundled
files, so a poisoned list runs to hundreds of names; a hand-built one is a
handful. At or under 25 entries it is treated as deliberate and left alone.
- A one-time notice for the people v1/v2 already hit. Nothing can give them back
what was deleted, and they have no way to know it happened -- the .pnach is
still on disk and still found in the log, it is simply no longer enabled.
Saying so once is the only repair available.
The immediate fix for anyone affected is to re-enable their patches in Patches &
Cheats; the purge is one-shot and will not take them again.
A paletted replacement is keyed on TEX0 hash AND palette hash, so a pack only
applies while the game asks for a palette its packer happened to dump. Persona 3
FES lands exactly there: the pack carries several palette variants of each glyph
and the game asks for one that is not among them, so every glyph misses while the
unpaletted art around it replaces fine -- 189 hits against 323 misses, which is
why the scene looked right and the text did not. With this, 441 against 71, and
none of the remainder are palette mismatches.
Lowest palette hash, deliberately. It is arbitrary but STABLE: the same glyph
resolves to the same file on every draw and every run, so text renders in one
consistent colour. Choosing by file mtime was tried and was worse in a way worth
recording -- different glyphs won different variants and the text came out
multicoloured.
The colour can still be wrong, since the replacement image has the packer's
palette baked in and there is no recolouring it. That is the trade, and it is the
right way round: a mod that applies in the wrong shade beats one that does not
apply at all.
This does NOT explain why the pack matched on 2.6.6. That is a separate finding
and still open: the same glyph at the same address with the same palette hashes
differently now (2.6.6 asks 85076d2a533c0128, current asks f0576dc2f0bb17d5),
with TBP0, TBW, PSM, TW/TH, region and lod all identical between them. The bytes
in GS local memory differ, which is upstream of texture replacement entirely.
Both builds report identical TBP0, TBW, PSM, TW/TH, region and lod for the same
font glyph, and identical leading bytes -- yet different TEX0 hashes. Since
HashTextureLevel reads straight out of GS local memory, the bytes at that address
differ; the shipped hash folds the base level and every mip into one value, so it
cannot say which of them moved.
Recompute the base level alone for the probed textures and log it beside the
combined hash, plus the actual mip range rather than the bool the first probe
recorded. Base matching with the combined differing means the mip chain moved;
base differing means the glyph data itself did. Those are different bugs.
The two builds ask for disjoint TEX0 hashes for the same on-screen font -- nine
values on 2.6.6, eight now, no overlap, same palette hash, same PSMT4. Every
function feeding the hash is byte-identical between them, so what differs is the
DATA, and HashCacheKey records only the region's width and height: never where it
starts, never which address it came from, never whether mips were folded in.
Print exactly what the key discards -- TBP0, TBW, TW/TH, the full region rect
including its origin, whether lod was present, and the first bytes actually
hashed -- so two logs can be diffed instead of theorised about.
A paletted glyph's palette IS its colour, so when the game asks for a palette the
pack does not carry there is no "close enough" file to stand in for it.
Substituting produced multicoloured text: the pack holds eight colour variants of
each glyph, all from one extraction seconds apart, and different glyphs won
different variants. Both selection rules were wrong for the same reason -- lowest
hash preferred the base pack, newest was noise, and neither can be right when the
thing being chosen IS the colour.
Keep the diagnostic half, which is what actually advanced this: on a paletted
miss, list every colour of that TEX0 the pack does hold. That distinguishes "the
pack lacks this texture" from "the pack lacks this COLOUR of it", and the second
is the real finding here -- the modder dumped eight colours and the game is asking
for a ninth.
Leaves the replacement path behaving exactly as it did before any of this, with
better logging. The open question is unchanged and now isolated to one value: why
the game asks for a palette that is not among the dumped eight.
Shadowing was not it: 8386 files, 8339 indexed, only 4 collisions -- and the
remaining misses are 640x384 PSMCT32 with no palette, i.e. FMV frames, which hash
uniquely per frame and can never match. So the layered mods index fine and the
game does ask for them.
What actually loses is the choice BETWEEN palette variants. A mod over a base
pack supplies the same glyph under a different palette, so the two never collide
during indexing -- and the first cut of this fallback took the lowest palette
hash, which is arbitrary, and silently preferred the base pack. The player ends
up seeing the pack they installed FIRST, which is the opposite of what layering a
mod means, and matches the report exactly: base HD text applied, slim font did
not.
Prefer the newest file instead. Modification time is the one signal that
separates "the pack added last" from "the pack it was layered over", and the
directory walk already reports it, so this costs no extra I/O. Ties break on the
lower hash purely to stay reproducible.
Also log every candidate -- palette hash, mtime, full path -- for the first few
fallbacks, so the choice is auditable rather than asserted. If the newest file is
still the wrong one, that log says so immediately instead of costing another
round.
Still not shippable: substituted art carries the packer's palette, so tints can
be wrong, and mtime is a heuristic rather than an expression of intent. A real
fix wants explicit pack precedence.
The palette fallback moved Persona 3 FES from 189 hits / 323 misses to 441 / 71,
with ZERO of the remaining misses matching a name under another palette -- so the
palette hash is settled, and what is left is textures the map does not hold under
that name at all. The base HD pack now applies; the layered mods still do not.
emplace does not overwrite, and this scan is RECURSIVE. Two files in different
subdirectories that decode to the same texture name collide, and whichever the
directory walk reaches first wins -- silently, with nothing to say which of them
ended up on screen. That is exactly how a mod layered over a base pack loses: not
on intent, on traversal order.
Count the collisions, name the first six with both paths, and report files
scanned alongside textures indexed. "My mod is not applying" and "my mod is being
shadowed by another pack" are indistinguishable to the person reporting it, and
until now they were indistinguishable from the log too.
Nothing about which file wins changes here. Measure first: if the count is zero,
the layered mods are absent rather than shadowed, and that is a different fix.
A paletted texture is looked up by TEX0 hash AND palette hash, so a pack only
applies while the game asks for a palette its packer happened to dump. The
Persona 3 FES logs land exactly there: the pack carries several palette variants
per glyph (ea34cb7b, df582335, b6eebe9e, 99b15967 ...) and the game asks for
cf4df1c018862d85, which is none of them. Every glyph misses while the unpaletted
art around it replaces fine -- 189 hits alongside 323 misses -- which is why the
scene looks right and the font does not.
Worth recording what this ISN'T, since each cost a round to eliminate: the cache
budget (already present in the working version), the pre-ELF hack strip
(transient, ApplyCoreSettings restores it), paltex (it relaxes the hash cache,
never the replacement lookup -- CreateTextureName always uses the real palette
hash for paletted formats), and name parsing, the hash function and the paltex
defaults, all byte-identical to 2.6.6.
So when the exact palette is missing but the pack holds this TEX0 under others,
take one -- lowest palette hash, so the choice is reproducible rather than
dependent on map iteration order.
NOT correct as shipped, and deliberately labelled so: the replacement image has
the packer's palette baked in, so substituted art can carry the wrong tint. It
exists to answer one question. If the mods appear, the palette hash was the only
obstacle and the real fix is about WHICH palette to prefer, not about loading at
all. If they still do not appear, the pack was never the whole story.
The previous diagnostic could say "the pack holds this TEX0 under a different
palette" but not which, and the difference is the entire diagnosis:
<tex0>-<clut>-<w>x<h>-<bits>.ext a real, different palette hash -- the palette
contents differ at run time from dump time
<tex0>-<w>x<h>-<bits>.ext no palette field, so it indexed with
CLUTHash=0 and can only match under paltex
Answering that meant asking someone to list a directory on their own device. The
people who hit this are players; that is not a reasonable thing to ask, and it is
information the emulator already has in memory.
So print it: up to two matching entries per miss, with their CLUT hash and
filename, plus three sample names at map load so the pack's naming style is on
the record even when nothing misses.
Bounded the same way as the rest of this logging -- at most eight misses reported,
at most two entries each -- because it walks the filename map, and this game's
pack has 8339 entries in it.
The first version could not. LookupReplacementTexture runs once per NEWLY HASHED
texture, not per draw, so a whole session can be a few hundred calls -- and a
report threshold of 20k therefore printed once, at the very first lookup, and
never again. Two tester logs came back reading "0 hits, 1 misses" after 139 and
195 seconds of play, which cannot distinguish one lookup from twenty thousand.
That ambiguity was the whole answer they were supposed to give.
Three changes:
- Report on a geometric schedule (1, 2, 4, 8 ... then every 4096) rather than a
fixed threshold. Bounded whatever the rate, and dense at the start, which is
where the answer usually is.
- Print the first eight misses in full: the TEX0 hash, CLUT hash, dimensions
and PSM actually asked for, plus whether the pack holds that same TEX0 under
a DIFFERENT CLUT hash. That is the difference between "the pack does not have
this texture" and "it has it, under another palette hash" -- and the latter is
the usual answer for paletted UI art, which is what a Persona 3 FES font and
menu panel are.
- Log preloading, paltex, async and upscale once at map load. Those decide
whether a lookup is ever ATTEMPTED rather than whether it matches; a pack that
indexes thousands of files and is then never consulted looks identical from
outside to one that misses every time.
"My texture pack does nothing" has two failure stages and the log only covered
the first. The indexed count proves the FILES were found and their names parsed;
it says nothing about whether any draw ever asks for one. A pack that indexes
thousands of textures and misses every lookup is a hash problem -- wrong dump
settings, paltex vs CLUT, wrong upscale -- and from outside it looks exactly
like a pack that never loaded at all. Both read as "the mods are not applying".
Count hits and misses in LookupReplacementTexture and summarise one line per
20k lookups. Summarised rather than logged per lookup because this runs per
draw, and log volume on its own is enough to stall the emulator.
Also counts how many misses WOULD have matched with the CLUT hash zeroed. That
separates "the pack does not contain this texture" from "it does, but the
palette hash differs", which is the usual answer for paletted UI art -- fonts
and menu panels, the exact things a Persona 3 FES mod replaces.
Counters reset in ReloadReplacementMap: carried over from a previous game, a
stale hit count reads as a healthy pack.
Also -Wno-missing-braces on the savers target. Welsh's sources initialise nested
aggregates without inner braces throughout, which -Wall diagnoses once per site
per TU: ~5 million lines and an 842 MB build log, which is how it was noticed.
Upstream code we do not restyle, so the diagnostic has nothing to tell us. Purely
a diagnostic flag; no codegen change.
ARMSX2 had Flurry alone. This brings over the Really Slick Screensavers tree
ARMSX3 already runs -- Flux, Plasma, SolarWinds, Hyperspace, Lattice and
Skyrocket -- as one libsavers.so beside libflurry.so.
Terry Welsh's savers, GPL-2.0-OR-LATER, which this tree may take under the "or
later" clause. Nothing here comes from rss-glx: that Linux port is GPL-2.0-only
and cannot go into ARMSX2 at all, which is why Lattice and Skyrocket were
hand-ported from the Windows sources upstream rather than taken from the neutral
versions rss-glx already has. The per-file header is what decides this, not the
repository's LICENSE file; they disagree.
The saver sources build with RS_XSCREENSAVER, selecting their platform-neutral
path, against the GLES2 shim in savers/compat and savers/gl1.c rather than being
rewritten -- so they stay re-pullable and recognisably his code.
Four things the tree carries that are not obvious from the sources:
- The GL thread takes a 16MB stack. Skyrocket's World constructor declares a
1024x1024x3 starmap as a local, and a default stack is a SIGSEGV inside
memset before the first frame draws.
- Each saver is compiled through a *_unit.cpp that wraps it in a namespace.
They all declare draw/idleProc/cleanUp/readyToDraw, because each was built as
its own executable, and two in one .so collide at link. The shared libraries
must be pre-included OUTSIDE the namespace or the declarations never match
their definitions -- which compiles clean and fails at link.
- The lifecycle is serialised by a mutex and a generation token: gl1 keeps its
state in one global, so a view may only free the run it started. Without it
the first selection works and every later one is black.
- gl1_frame_begin() masks alpha off after the first frames. These savers fade
the previous frame rather than clearing, and that fade writes destination
alpha, dragging a composited surface transparent -- which reaches the screen
as full-screen TV static.
Hyperspace runs with dShaders = 0 (its ARB shader path needs objects GLES2 does
not have; upstream exposes this as -shaders 0 and falls back to it itself), and
Skyrocket with dSound = 0 (upstream drives OpenAL and bakes ~7MB of samples into
headers; soundEngine.h is a stub here). Hyperspace and Skyrocket ship no presets
upstream, so neither shows a preset picker -- invented ranges are what made
earlier ports fail to start.
SaverGlView replaces FlurryGlView, which it supersedes: it hosts Flurry and the
Really Slick savers behind one SaverSpec, and keeping both would have declared
FlurryNative twice in one package. Helios is deliberately absent -- it rendered
but animated wrongly and the cause was never found.
Merging preserves authorship in git history, but nobody reads git log to find
out who wrote a file. The tree already had the convention -- "Feature
contributed by misantronic (PR #391)" on exportRecentGamesPublic -- it just was
not applied to the three PRs merged here.
MemoryCardBackup.kt bmdhacks (PR #608)
PerGameOverrides.{h,cpp} bmdhacks (PR #593)
RecentGamesContentProvider.kt misantronic (PR #566)
The provider's line also records that the opt-in gate was added on merge, so the
gate is not mistaken for part of the contribution.
RecentGamesContentProvider (PR #566) ships android:exported="true" with no
android:permission, no readPermission, and no caller check in query(). That is
required for the feature to work -- a signature-level permission would only admit
apps we sign, and the companion app it exists for is third-party -- but as merged
it means every app on the device, holding no permissions at all, can read the
recently-played list: titles, serials, last-played times, and the file URIs,
which carry the user's folder layout and frequently their real name. No prompt,
no way to turn it off.
Gate query() on a preference that defaults to OFF, and surface it in App
settings, matching how the second-screen panel and Discord presence are handled:
anything that exposes data outside the app is the user's decision and starts
disabled.
The flag lives in the same "ARMSX2" SharedPreferences file the provider already
reads for the library itself, so the toggle and the gate are one value rather
than two that can drift. Written with commit() and not apply(): the reader is a
different process and can be queried the moment the switch returns, and apply()
only promises the in-memory value.
Returns an empty cursor rather than null when sharing is off -- null is the
failure signal a ContentResolver caller has to special-case, and "the user has
not enabled this" is a legitimate answer rather than an error.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
The per-second LSFG branch counters and the FSR gate line were added to find two
specific bugs and both did their job — the counters proved generated frames were
reaching the screen uncounted (VK_SUBOPTIMAL_KHR treated as failure), and the
gate proved all three FSR conditions passed while a misplaced log made the pass
look dead. Neither belongs in a release: one printed every second, the other on
every state change.
What stays is event-driven and diagnostic in the ordinary sense: LSFG's
initialise line, shader-cache hits and misses, load and ABI failures, and one
FSR line per output-size change.
Two bugs of mine, both found on-device with everything else working.
★ The zero-timeout acquire disabled frame generation entirely.
Forcing FIFO fixed the MAILBOX discard, and the display rate still equalled the
real rate. Under FIFO the presentation engine returns an image at a vblank, so
at steady state nothing is ever free INSTANTLY — vkAcquireNextImageKHR with a
zero timeout returns VK_NOT_READY every frame, the loop breaks, and every
interpolated frame is dropped. Silently, because a dropped generated frame is a
legitimate outcome and nothing logs it.
The reasoning behind the zero was that an interpolated frame is a bonus not
worth stalling for. That is backwards: presenting two frames per rendered frame
MEANS waiting for the second display slot. Waiting is the mechanism, not the
cost. Now a 50ms bound — six vblanks at 120Hz, so it expires only when something
is genuinely wrong, while still keeping a lost surface from wedging the GS
thread the way an unbounded wait would.
★ The in-game FSR row was gated on renderer == "vulkan", and the default is
"auto".
"auto" resolves to Vulkan on Android, so the row was hidden from anyone who had
not explicitly pinned the renderer — which is nearly everyone, and was the
reporter. Gated on the two backends that genuinely cannot run it instead.
★ Frame generation produced nothing on a MAILBOX swapchain, silently.
Reported on an Adreno 740: LSFG logged 'active: 1920x1080 x2 frames, 3.1p',
cached its 52 shaders, never logged a single failure — and both the FPS and the
LSFG display counters read 59. The interpolator was working perfectly and its
output was being thrown away.
MAILBOX keeps only the most recent image queued for a given refresh. Presenting
an interpolated frame and then the real frame immediately after replaces the
interpolated one, so it is generated, costs its full GPU time, and is never
displayed. IMMEDIATE discards the same way. Nothing errors anywhere along that
path, which is why the only symptom is a display rate identical to the real one.
The device landed on MAILBOX because vsync was off — 'Immediate not supported
for vsync-disabled, using mailbox'. SelectPresentMode now forces FIFO while
frame generation is enabled. Eden reached the same conclusion; their setting
text reads 'Forces FIFO presentation while enabled'.
Gated on the setting rather than GSLsfg::IsAvailable(), which cannot answer at
swapchain-creation time: the DLL path only reaches GSLsfg from EndPresent.
Also adds the FSR rows to the in-game GraphicsPane. In full settings FSR sits
under Display Effects beside CAS, which is the right shelf for a post-effect and
the wrong one for finding it — it is an upscaler, so in the quick menu it goes
with the internal-resolution controls, which is where you reach while watching
the framerate. Vulkan-only, so it is never a dead toggle on OpenGL.
Adds FSR1 (EASU upscale + RCAS sharpen, two compute passes) to the Vulkan
backend, so a game rendered below display size can be upscaled properly instead
of bilinear-stretched at present. Slots in beside the existing MetalFX branch in
GSRenderer rather than introducing a parallel abstraction: GSUpscaler and the
non-pure DoXxx virtuals already occupy that design space, and OpenGL and Metal
inherit a false return and need no change.
FSR1 is MIT (AMD, 2021) and the tree already ships ffx_a.h and ffx_cas.h under
the identical grant, so the headers are vendored verbatim with their licence
blocks intact.
★ ffx_a.h is NOT replaced. FSR1 wants the 2021 header, ours is 2019, and the
2019 one has been locally patched for Metal Shading Language (A16, A_MSL,
A_MAYBE_UNUSED) with ffx_cas.h depending on those. Swapping it would break the
Metal backend. The 2021 copy ships alongside as ffx_a_fsr1.h, used only for
GPU-side string substitution. The CPU-side FsrEasuConOffset/FsrRcasCon compile
against the existing 2019 header — verified by compiling a probe, not by
grepping, because AU1_AF1 and AU1_AH2_AF2 are functions and a grep for a #define
reports a false negative.
Two shader modules, not two specializations. FSR_EASU_F and FSR_RCAS_F are
preprocessor gates deciding which function bodies ffx_fsr1.h emits at all, and
specialization constants resolve after preprocessing, so CAS's constant_id trick
would produce a shader calling undefined functions. Confirmed distinct:
disassembly shows EASU with three OpImageGather and RCAS with none.
Both passes push the full 80-byte constant block. With all five uvec4 declared
so one layout serves both, Sample decorates to byte offset 64 — pushing the 32
bytes RCAS nominally needs would leave it undefined, and Sample gates a
gamma-squaring branch, so garbage there squares the image.
Binding 0 is a combined image sampler, unlike CAS's plain sampled image, because
EASU uses textureGather.
The EASU intermediate stays in GENERAL with explicit compute-to-compute
barriers. Layout::ShaderReadOnly targets the FRAGMENT stage and
TransitionToLayout early-outs when the layout already matches, so neither of the
usual tools makes a compute write visible to a compute read. The barrier also
covers frame N+1's EASU write against frame N's RCAS read, since the image is
parked across frames.
FSR and CAS are alternatives, not a chain: RCAS is itself a sharpener. Selecting
FSR hides the CAS rows. Pipeline compilation failure is non-fatal and leaves
Features().fsr1 false, matching the CAS path that exists because of an Adreno
650 crash.
GSUpscaler::FSR1 is appended, not inserted, since the enum is persisted as an
integer. Android clamps to the enum's own maximum rather than the count of
options its picker shows — clamping to the picker would have rewritten FSR1 back
to Off on every save, because MetalFX occupies value 1 and is never displayed.
Verified: build clean, no C++ or Kotlin errors; all three resource files
packaged into the APK; FSR code present in the core. NOT verified: anything on a
GPU. No visual check, no perf numbers, and in particular no confirmation that
textureGather in a compute shader works on the Adreno drivers this targets.
Five changes, one of which is a plain bug in what shipped.
★ LSFG settings were never persisted. lsfgEnabled, lsfgMultiplier and
lsfgDllPath were absent from toJson/fromJson, and that pair IS the persistence
format — ConfigStore stores toJson().toString(). So every choice, including the
Lossless.dll the user went and found, was discarded on restart. Added to the
round-trip, the per-game override diff/merge, and gsDiffersFrom.
Translated SPIR-V is now cached to disk. Extraction used to keep raw DXBC and
translate inside the shader callback, so all 26 translations re-ran on the GS
thread inside EndPresent after every enable, resize or multiplier change. Now
ExtractShaders translates eagerly, drops the DXBC, and writes
<cache>/lsfg_shaders.bin. The DLL's size and mtime go in the header and a
mismatch re-extracts — ARMSX3's equivalent has no invalidation at all.
Frame generation can no longer fail invisibly. GetStatusText() feeds one line
to the performance overlay, empty ONLY when the user has not enabled it:
unavailable / failed / no shaders / starting / a display rate. That rate counts
frames actually PRESENTED, real plus generated, because the acquire loop can
break early and assuming the multiplier would overstate it. FPS alone cannot
show this — frame generation deliberately does not change the emulator's frame
rate, so without a separate line 'working', 'broken' and 'unsupported' are all
the same absent line.
Flow scale and the 3.1p pipeline are exposed. flowScale is a DIVISOR — framegen
computes flowExtent = inputExtent / flowScale — so the UI percentage is passed
as clamp(100/percent, 1, 4). ARMSX3 passes percent/100, where only the default
is right because 1.0 is its own reciprocal and every lower position makes it
slower; that inversion is not copied here. 3.1p is a separate shader family with
separate device state, so the shim fixes the choice at initialise and dispatches
every entry point on it, and the name table gains the p_* resource IDs.
Frames the game did not draw are no longer interpolated. PresentWithGeneration
captured unconditionally, so pause menus and boot screens got interpolated at
full GPU cost. It now takes frame_has_new_content, sourced from the condition
GSRenderer already computes (current && !blank_frame) rather than a new
heuristic, and consumed with std::exchange because RenderBlankFrame presents
without going through BeginPresent. A false also resets the frame history, so
the pair either side of a gap is never stitched into one bogus in-between frame.
Verified in the built APK: four status states and lsfg_shaders.bin in the core,
26 p_* names in its table, 3.1p linked into the shim. Build clean, no C++ or
Kotlin errors. NOT verified: any of it at runtime — no Adreno 7xx here, so the
flow-scale direction, the cache round-trip and the capture gate are reasoned,
not observed.
Three defects in the frame-generation present path, found comparing it against
ARMSX3's.
1. Binary semaphores signalled without being waited, and waited twice.
The loop reassigned the real present's wait to the last post-copy semaphore.
That left s_pre_copy_sem signalled and never waited, so the next frame
signalled an already-signalled binary semaphore; and it made the last
post-copy semaphore the target of two waits, its own present and the real
one, when a binary semaphore's signal can be consumed exactly once. Both are
spec violations, and the kind that work until a driver decides otherwise.
The fix is to delete the reassignment, not to add semaphores. The real
present must wait on s_pre_copy_sem specifically, because the pre-copy reads
the real image as TRANSFER_SRC and returns it to PRESENT_SRC — presenting
before that lands would present an image still being read. The generated
presents are independent: different swapchain images, each gated by its own
post-copy. Presents issued on one queue are processed in call order, which is
what keeps them on screen ahead of the real frame. Every semaphore is now
signalled once and waited once.
2. vkAcquireNextImageKHR with UINT64_MAX, on the present path.
Two problems at once. It bypassed the bounded ACQUIRE_TIMEOUT_NS that
VKSwapChain::AcquireNextImage deliberately adopted so a surface destroyed
under the GS thread — background, rotate, fold — does not leave every thread
asleep at 0% CPU with nothing in any log. And the swapchain is 2 or 3 images
while this loop holds the real one and asks for multiplier-1 more, so at
x3/x4 it serialised on a vblank per generated frame, spending exactly the
time the feature exists to save.
Now a zero timeout: an interpolated frame is a bonus, so if nothing is free
the right move is to drop it and get the real frame out. VK_NOT_READY leaves
the semaphore unsignalled, so the slot stays clean for the next frame.
3. The fetch was not actually pinned.
LSFG_PIN was "release" — a branch — under a comment explaining that an
unpinned fetch would let a remote push change what our core links. Now the
commit we have actually built and verified against, with GIT_SHALLOW off
because a shallow clone carries only branch tips and cannot resolve a SHA.
Also caches the structural PE check. GetUnavailableReason() runs once per frame
from EndPresent while the feature is on, and it was doing a full
fopen/fread/fseek/fread/fclose on the GS thread every frame; the verdict can
only change when the path does, which is where it is now invalidated.
The legacy APK claimed Android 8 (minSdk 26) while being compiled
-march=armv8.1-a, which lets clang emit LSE atomics inline. Android 8 means
Cortex-A53/A72/A73 — ARMv8.0, no LSE — so the one tier whose entire purpose is
reach did not reach them.
This is not a theoretical concern. BuildParameters.cmake:145 already records it:
'proven by a casal SIGILL on a real A53 device'. The guard written in response
only applies the safe default when nobody passes an -march, and this script
always passes one, so the tier defeated the protection added for it.
Legacy now builds -march=armv8-a -moutline-atomics, which is exactly what that
comment prescribes. Outline atomics keep LSE on cores that have it via a
runtime HWCAP dispatch, so a modern phone loses nothing.
Verified on the built core rather than assumed. The flags reach 3990 and 2036
compile lines respectively, and disassembling an LSE site shows the dispatch:
bti c
adrp x16, ... ; __aarch64_have_lse_atomics
ldrb w16, [x16, #0xc10]
cbz w16, 0x10b7128 ; no LSE -> fall through to LL/SC
cas w0, w1, [x2]
0x10b7128:
ldxr w0, [x2] / cmp / stxr / cbnz
An A53 takes the branch and never reaches the cas. APK minSdk confirmed 26.
Also moves a11/a13/a15 onto ARMSX3's SDK/NDK pairs and pins all four to NDK 29.
The NDK is not a device-compatibility knob — API level and -march gate devices,
and nothing on the device can tell which toolchain built the binary — so one
toolchain across the matrix is what makes a cross-tier comparison mean anything,
and there is no reason to withhold the measured gain from the weakest tier.
Needs a new armsx2.marchExtra gradle property: -moutline-atomics has to be its
own token, and BuildParameters.cmake's escape hatch keys on CMAKE_CXX_FLAGS
matching '-march='.
Artifact renamed to ARMSX2-<VN>-legacy-armv8.0-sdk26.apk. The updater keys on
the -sdkNN suffix, which is unchanged, so no updater change is needed. The Play
AAB is untouched: build.gradle.kts defaults still say minSdk 26 / NDK 28, and
only the APK script ever passes the tier properties.
Drives Lossless Scaling's interpolation from our own Vulkan present path.
Upstream's consumer app captures the screen with MediaProjection and
composites over the target process, because Android 12+ forbids injecting
code into a non-debuggable app. That constraint is not ours: ARMSX2 owns its
swapchain, so it hands the library its own images through the AHardwareBuffer
entry points. No screen capture, no overlay, no accessibility service.
NOTHING PROPRIETARY SHIPS. The interpolation shaders are read at runtime out
of the user's own Lossless.dll, supplied through SAF exactly as a PS2 BIOS is.
The requirements dialog says so before the toggle commits, not after it
silently fails. Only the MIT-licensed lsfg-vk-android framegen library is
fetched; its sibling app carries a no-commercial-use licence and is not.
framegen is ISOLATED IN ITS OWN .so BEHIND A C ABI. It links volk, which
defines 759 globals named vkCreateImage, vkQueueSubmit and so on -- precisely
the names VKLoader.cpp defines. In one library that is a duplicate-symbol
error at best; at worst the linker merges them and framegen's volkLoadDevice()
call, made against its OWN VkDevice, silently repoints every entry point the
GS renderer uses, which would present as a driver crash with nothing pointing
back at frame generation. libarmsx2_lsfg.so gives volk its own copies, and
nm confirms only the eight armsx2_lsfg_* entry points are exported. The
interface is C because the CMake project builds ANDROID_STL=c++_static, so an
std::vector crossing that boundary would be two unrelated types sharing a
name; errors come back as codes, never exceptions.
The shader chain (pe-parse over the PE resources, then upstream's DXBC to
SPIR-V translator) stays in the core -- neither half touches Vulkan symbols.
GSLsfg.cpp is the one PCSX2 translation unit built with exceptions, because
that translator throws and the alternative is std::terminate on exactly the
paths a wrong DLL takes.
Present path mirrors upstream's Android sequence: copy the rendered frame
into shared storage, idle, interpolate, idle, present each generated frame,
then the real one. The idles are not laziness -- Turnip rejects OPAQUE_FD on
AHB-imported memory, so there is no cross-device semaphore and a device idle
is the only barrier that exists. Every failure degrades to an ordinary
present rather than taking the GS thread down.
Gated on Vulkan + Adreno 7xx and newer, asked of the resolved driver profile
rather than a GL_RENDERER substring. The UI reports WHY it is unavailable,
since 'needs an Adreno 7xx' and 'you have not picked a DLL yet' are the same
greyed row otherwise and only one is actionable.
Rows live in All Settings > Performance and the in-game performance tab, from
one shared section, wired to each host's own settings tier the same way
ShaderChainSection is. Play builds compile the whole thing out -- gradle sets
ARMSX2_ENABLE_LSFG=OFF, BuildConfig.LSFG is false, and build-play-aab.sh now
fails closed if libarmsx2_lsfg.so ever appears in a bundle.
Verified: github APK carries libarmsx2_lsfg.so and 13 live @@ANDROID_LSFG@@
strings in the core; the play variant configures with zero references to
either. NOT verified: the present path itself, which needs an Adreno 7xx
device, a real Lossless.dll and a running game.
Splits the ARMv8.2 build into three platform tiers instead of two, so the
Android 11 floor gets FP16 + DotProd as well:
legacy minSdk 26 NDK 28 armv8.1-a
a11 minSdk 30 NDK 28 armv8.2-a+fp16+dotprod
a13 minSdk 33 NDK 28 armv8.2-a+fp16+dotprod
a15 minSdk 35 NDK 29 armv8.2-a+fp16+dotprod
Artifacts are now ARMSX2-<VN>-{legacy-armv8.1-sdk26,a11-armv8.2-sdk30,
a13-armv8.2-sdk33,a15-armv8.2-sdk35}.apk, and the updater classifies on the
-sdkNN suffix alone. The old markers were -v82 and -v82-sdk35, where one was a
substring of the other and only a carefully ordered when-branch kept Android 15
devices off the standard build; that hazard grows with every tier. The four sdk
suffixes cannot overlap.
An asset with no recognised marker still counts as legacy, so releases published
before tiering keep resolving.
The release-shape check now requires all four and verifies each name carries
exactly one, distinct sdk marker, and prints the upload-order warning: every
updater up to 2.6.6.6 takes the first .apk asset in a release regardless of
name, so the legacy build has to go up first or those installs are handed an
APK that SIGILLs on its first hot path.
Pressure modifier now applies to buttons that are ALREADY held: the range was only
read when a press was emitted, so the gesture these games actually use — hold the
button, then ease off — did nothing (MGS2 cancels a shot on a half-pressed Square).
Macro turbo holds each state for at least 24ms. The pad is sampled on the VM's own
schedule, so the fastest frequencies were emitting presses that fell between two
samples and never registered, which read as the turbo being dead.
Extra button on the on-screen left stick, for sprint/jump. The stick locks the
gesture onto the pointer that started on it, so a separate widget could never be
reached by a finger gliding up off the stick; the stick hit-tests the zone itself
and keeps emitting deflection, making run-and-sprint one thumb motion.
Landscape render position (Center/Top), for foldables and clamshell controllers
whose screens open downward. Reuses the vertical-align switch that was gated to
portrait.
Custom internal resolution as a percentage of native, for steps the presets miss.
A value matching no preset also stops displaying as "0.25x" while the GS runs
something else.
RetroArch overlay artwork: import a pack and draw it between the game frame and
the touch controls, so it layers with a shader preset and never covers a button.
Second-display panel (Ayn Thor, Retroid dual screen): FPS, battery, clock and
buttons for save/load state, fast-forward, pause and screenshot.
Battery low and temperature warnings, off the sticky battery broadcast.
Delete a save state from the in-game picker by long-pressing its slot.
Point the PCSX2 CheatDB source at its current home; the old address is gone.
Swiping out with the pause menu up left the track playing on the OS home
screen. Two paths caused it: backgrounding a running game calls
InGameOverlay.open() from onPause, which sets overlayVisible = true and
re-fires the pause-music LaunchedEffect — that effect then ran start()
after onPause had returned, beginning playback while backgrounded — and
more generally nothing stopped a late start() from a background thread (a
MediaPlayer with USAGE_MEDIA is not auto-paused by the system).
Add a foreground guard: onResume sets it true, onPause sets it false
(early, before open() flips the overlay state) and pauses any current
playback. start() no-ops whenever it is false, so no effect, resume, or
toggle path can begin playback behind a backgrounded app. Coming back,
onResume clears the guard and restarts the track if a menu is still up.
Swap the bundled pause-menu track for the slower, more ambient edit
(res/raw/pause_music.ogg -> .mp3; R.raw.pause_music is unchanged), and
ease it in instead of starting at full volume — the abrupt onset was the
complaint.
start() now begins the player silent and fadeIn() ramps to the set volume
over ~1.6s, reading the volume each step so a live slider change tracks.
stop() mirrors it with a ~0.5s fade-out then release, so resuming the game
isn't a hard cut; ownership of the player is handed to the fade coroutine
(player = null) up front and released in a finally, so a reopen mid-fade
builds a fresh player, the two cross-fade, and nothing leaks. A manual
volume change cancels an in-progress fade so the slider never fights it.
The pause-menu track never played. It deferred to active audio the way
LibraryMusic does (to stay out of Spotify's way), but that check is wrong
here: on an overlay pause the game keeps its audio DEVICE open and just
underruns to silence — pauseForOverlay calls setOutputPauseSuppressed(true)
so Android does not reclaim the idle stream and stall the resume (#333).
So AudioManager reports the game's own stream as active the whole time the
menu is up, even though nothing is audible, and start() deferred forever.
Drop the isMusicActive() guard: play a second stream over the silent game
one. No audio focus is requested either, so the game's stream and its
resume are left untouched. Also default the toggle on — the menu was
silent and this fills it; the switch turns it off. The effect's retry loop
shrinks accordingly (start now plays immediately; a couple of light
retries only cover a transient MediaPlayer prepare hiccup).
RetroAchievements toasts (and other OSD) were invisible on first boot
with Skip BIOS on, until you opened the pause menu — at which point the
toast appeared, and vanished again when you backed out.
Cause is the Android startup blank-frame suppression (GSPresentationPolicy).
With Skip BIOS on there is no boot animation, so the game shows a black
screen with no GS output for a while, and RA posts its "achievements
loaded" summary toast into exactly that window. ShouldSkipAndroidBlankFrame
returns true for every one of those frames (Vulkan + blank + no current
output), so the present is skipped — BeginPresentFrame(true) reports
FrameSkipped and EndPresentFrame() never runs, which means neither
RenderOSD() nor FullscreenUI::Render() (where notifications are drawn)
executes. The toast just sits queued in s_notifications. Opening the pause
menu forces real presents so it finally draws; closing it resumes the
skip. With BIOS on, the boot animation produces GS output before RA
posts, so has_current_output is already true and the toast presents
normally — which is why the bug only showed with Skip BIOS.
Gate the skip on new ImGuiManager::HasPresentableOverlayContent(): an OSD
message (pending or active) or an open FullscreenUI window or a queued
toast now forces the blank frame down the normal present path, where
EndPresentFrame() draws the overlay over black and presents it — exactly
what the pause menu already does during boot. Once the game produces its
first frame the suppression is moot anyway (has_current_output true), so
this only ever presents the few black boot frames that actually have
something to show, and is a no-op when there is nothing queued.
The pause menu was silent, and people sit in it for minutes browsing
settings, achievements or memory cards mid-game.
New PauseMusic object, deliberately separate from LibraryMusic rather
than a second gate on it: LibraryMusic refuses to play unless
eState == STOPPED, which is the exact opposite condition, and the two
have opposite lifetimes. One player serving both would spend its life
fighting the other's start conditions.
Driven off WindowImpl.overlayVisible || inGameScreen != null rather than
from InGameOverlay.open()/close(), for the same reason as the effects
around it: many paths reach each state (back, menu button, hotkey,
dismissInGameScreen, a boot that force-closes the overlay) and hooking
them one by one always misses one. Including inGameScreen matters
because openInGameScreen() closes the menu as it opens Settings, and
sitting in Settings is the long silence this exists to fill.
Start retries for ~3s like the library track: pausing suspends SPU2 but
Oboe takes a moment to actually go idle, and start() politely defers
while AudioManager still reports audio active, so a single attempt would
lose that race every time. Audio focus is deliberately NOT requested —
the game's own audio is already suspended, so there is nothing to duck,
and grabbing focus for a menu track would stop whatever the player has
going in another app.
Toggleable and volume-adjustable in App settings like the library music,
off by default (audio starting when you open a menu is startling if you
didn't ask for it), and a custom track can be imported the same way.
onPause/onResume mirror the library track's handling so it never plays
behind a backgrounded app; onResume restarts explicitly because the
overlay states don't change while backgrounded, so the effect won't
re-fire on its own.
GunCon 2 alone was half the answer. The core registers eighteen USB devices — Buzz
buzzers, a Rock Band drum kit, Keyboardmania, BeatMania, a DJ turntable, the Printer,
EyeToy, Gametrak, RealPlay, Train controller, mic, headset, HID keyboard/mouse — and
none of them were reachable on Android.
The list is enumerated FROM RegisterDevice rather than hardcoded, so it cannot drift
from what a given build actually supports, and subtypes (different wheels, different
turntables) come along with it.
Making them USABLE is the real work, and it did not need a second binding editor.
Every InputBindingInfo already declares a generic_mapping (Cross, DPadUp, L1, ...), so
on attach native builds GenericInputBinding -> bind_index for the device and
applyPadButton forwards each press to the matching bind. The player's existing controls
— physical pad, on-screen buttons, macros, anything that funnels through that one
chokepoint — drive the device with nothing extra to configure. Bindings with no generic
equivalent (Gametrak's axes, the printer) get nothing, which is correct: there is no
sensible pad button for them.
Aiming stays special-cased, because a pointer is not a button: Lightgun owns it, and
UsbDevices.setType now tells it when a port changes so the aim layer cannot stay live
over a port that has become a drum kit — otherwise every touch would be swallowed as a
shot at a device that is no longer attached.
Lightgun: the core already emulates the device (usb_lightgun::GunCon2Device,
DEVTYPE_GUNCON2). What Android lacked were the three things that feed it, because our
input path is bespoke rather than InputManager/SDL:
* device selection -> USB{n}/Type via USB::SetConfigDevice
* aiming -> InputManager::UpdatePointerAbsolutePosition, which is what
GunCon2State reads through GetPointerAbsolutePosition(0) when
it has no relative binds. Window pixels, and our SurfaceView
spans the window, so touch coordinates pass straight through.
* buttons -> USB::SetDeviceBindValue(port, BID_*, 0/1)
Touch aims continuously rather than only on tap, so you can lead a target before
firing, and the aim is pushed BEFORE the trigger because the core samples the pointer
when the trigger goes down. A touch within 6% of a screen edge fires OFF-SCREEN
instead: that is how these games reload, and without it they are unplayable past the
first magazine. A/B/C, Start, Select and Cal (recalibrate) sit down the right edge,
composed above the aim layer so pressing one is a button press and not a shot;
recalibrate matters because several of these games open with a calibration step.
The aim layer consumes its pointers, unlike the gesture layer — with a gun attached a
touch on empty screen IS the shot, so there is nothing else it could belong to. It
still ignores a DOWN a widget already claimed, so the gun buttons and pause still work.
Device type is restart-required and says so: swapping a USB device on a live VM is the
emulated equivalent of yanking the plug from a port the game has already probed.
Gestures also reachable in-game: swipe distance and the Tap/Hold mode are values you
only find by playing, and walking out to the settings tree to nudge them loses the
moment. The six button assignments stay in All Settings — they are set once, and six
pickers would swamp that pane.
Gesture control (PPSSPP-style): swipes and a double-tap on empty screen area fire a
PS2 button. The double-tap takes a Tap/Hold mode — Tap pulses (NFS nitro), Hold
latches until you double-tap again (ARPG camera lock). The layer composes below every
widget, rejects a DOWN a control already consumed, and never consumes anything itself,
so it cannot swallow a press. Pulses hold 40ms because the emulated pad drops an
instant down+up.
Clock + battery readout in the library toolbar and the in-game menu header. The glyph
drains with charge (green/amber/red) and shows a bolt while charging. Time-remaining
appears only while charging: Android has computeChargeTimeRemaining() but no public
discharge-time API, and inventing an estimate would be worse than omitting it.
Texture packs:
- write() did delete()+renameTo(); if the rename failed the whole install record was
gone, and runCatching swallowed it while still bumping revision. Now an atomic
Files.move with a .bak fallback. This is "packs forget being installed after ~57
downloads", and very likely also "delete does nothing" / "still says Installed".
- reconcile() refuses to wipe records when a scan reports zero packs but records
exist — that is a failed listFiles(), not sixty simultaneous deletions.
- refresh() walked every file of every pack on every open (~250k stats at 60 packs).
Sizes now come from an mtime-keyed cache.
- The catalogue paged 20 rows at a time with a "Show N more" that names the
remainder; it cannot be a LazyColumn inside the existing verticalScroll.
- Installed packs show game names, and the catalogue sorts by game or serial.
Controls reset now exists: resetTunables() hand-listed keys, drifted behind every
setting added after it, and had ZERO call sites. Replaced with resetAllControls(),
which sweeps by key prefix so it cannot rot, scoped global or per-game.
Confirmations are inline overlays claiming an exclusive nav layer, not Compose
dialogs — a dialog is its own window and swallows controller keys, so those prompts
were touch-only. Adds Clear All for Recently Played and a full app reset; the reset
also purges the in-folder settings mirror and gamesettings INIs, without which the
next launch would silently restore everything it just wiped.
Motion control falls back to the accelerometer where there is no gyroscope, with the
tilt limitation stated in the UI (gravity cannot observe yaw). Adds a Motion Recenter
hotkey — recenter() previously had no call site at all.
Aspect ratios: added 20:9, 19.5:9 and a user-entered Custom ratio
(GSOptions::CustomAspectRatio, clamped 0.5..5.0). All APPENDED, never inserted —
these values are persisted as raw ints in the ini and in the Android prefs, so
slotting one in mid-enum would silently repoint every saved config at a different
ratio. Also filled in the two ultrawide cases RequestDisplaySize was missing.
Interlace/presentation: ported sashkinbro's EmuCoreX 30799e4. SelectGSInterlaceMode
centralises the mode choice and keeps shader_mode -1 for automatic full-frame output
(a deinterlace pass must not run over progressive output during a video-mode
transition); our formula already agreed, so this is centralisation plus
static_asserts rather than a behaviour change. ShouldSkipAndroidBlankFrame is new
behaviour: Vulkan now suppresses only the startup blank, so a mid-game fade reaches
the normal present path and its recorded command buffer is submitted.
Vulkan: declare the attachment feedback loops on the PIPELINE, not just on the image
layout and render pass. We put attachments into FEEDBACK_LOOP_OPTIMAL without ever
setting VK_PIPELINE_CREATE_{COLOR,DEPTH_STENCIL}_ATTACHMENT_FEEDBACK_LOOP_BIT_EXT,
which the spec requires — undefined behaviour rather than a missed optimisation, and
strict mobile drivers are where undefined shows up as stale attachment reads.
Reported as "patches apply with every patch setting off, and won't turn off" —
SOTC/KH2/GOW2. One chain of defects, verified on device:
- PatchManagerViewModel.refresh() called syncAllEnableLists() unconditionally, so
merely OPENING the Patch Manager persisted every uncommented group of every
on-disk .pnach as enabled. Community pnach files ship uncommented, and patches
are matched by NAME, so a name like "60 FPS" then armed the same-named group in
any of the ~4000 bundled files, for games never opened. Removed; import still
registers its own file, which was the only legitimate use.
- EnumeratePnachFiles fell back to the bundled zip even when disk files existed,
contradicting its own "prefer files on disk" comment. Deleting a pnach silently
promoted the identically-named bundled group in its place.
- delete() removed the file but never dropped its names from the enable list, so
they stayed armed forever.
- ReloadPatchAffectingOptions never reset CurrentCustomAspectRatio, which only
ever gets set, so 16:9 survived disabling widescreen.
- LocalCheatRow and OnlineEntryRow armed the row under the cursor on D-pad Right,
so scrolling a cheat list enabled everything you passed. Confirm only now.
Patches cannot be un-applied without a reboot: PatchCommand has no original-value
field and UnloadPatches never touches guest RAM, so disabling one mid-session only
stops it being re-written.
The note-highway render target is never sampled back, so the GPU->CPU download is
pure cost on a tiler. Covers GH2 (SLES-54442, SLUS-21447 — the latter is also the
serial GH2 Deluxe ships under) and GH3 (SLES-54962, SLES-54974, SLKA-25363,
SLKA-25414, SLUS-21672).
gsHWFixes is a clear-then-replace map in this overlay, so each GH3 entry restates
every upstream key. Dropping one would have silently undone the crowd-texture,
bloom and post-processing fixes those entries already carry.
Exit returns to the library's overflow menu. It had moved to the navigation
drawer, which put it below every other destination -- so quitting, one of the
most frequent things anyone does from that screen, meant opening the drawer and
scrolling to the bottom every time. Reported as issue #460 by shinobumaehara,
whose point is simply that frequency should decide placement. It stays in the
drawer as well; this is the short path, not a replacement.
Smaller than it looked: onExitApp was still a parameter and its confirmation
dialog was still wired up. Only the row that reached them had been deleted.
LibraryOverflowItem gained optional iconRes/iconTint for it, because the power
symbol (U+23FB) is not in the bundled font and rendered as a tofu box -- it now
uses the same ic_power drawable and red as the drawer's row, so the two entries
match. Every other row keeps the text-glyph path untouched.
Skins moves to sit after Shortcuts and before Network. It is controller artwork,
so people look for it beside Controls and Shortcuts rather than past On-Screen.
Suggested by Isshin.
Finishing a game submits every leaderboard in the same frame. AddNotification
gave each one start_time = current_time, so they all began at once -- Final
Fantasy XII posted six, which covered the screen and pushed the mastery unlock
out of sight before it could be read. Reported with a screenshot showing exactly
that.
Three on screen at most now, at least 0.4s apart, and anything past the limit
waits its turn. A QUEUED notification does not age while it waits: its duration
starts when it actually appears, so nothing expires unseen in the backlog.
Also guards the same-key replacement path. It recomputes start_time from elapsed
time, which is NEGATIVE for a notification that has not appeared yet -- and
Timer::Value is unsigned, so subtracting it would have wrapped and flung the
notification years into the future. It now keeps the scheduled start instead.
Ordering is still insertion order, so an unlock posted after a batch of
leaderboards still comes last. Visible rather than buried, but not prioritised.