Commit Graph
24403 Commits
Author SHA1 Message Date
Brian Degenhardt fe32ef2c27 EE: flush the source pins before QFSRV's raw adjacent-source load
recQFSRV has a fast path for Rs == Rt+1 that reads the contiguous 256-bit
{Rt:Rs} window straight out of cpuRegs.GPR with an unaligned raw Ldr. Its
comment claimed the window was "memory-coherent after the flushes above",
but those flushes are mmiFlushReg -> _deleteEEreg, which reconciles
const-prop and the scalar/NEON slots and never touches the pins.

Under lazy-dirty the pin is authoritative for UD[0] and armStoreEERegPtrRaw
elides the canonical store entirely for a pinned lane-0 write, so a pinned
source's lower half in memory is routinely stale mid-block. Nine GPRs are
pinned, which makes four adjacent pairs both-pinned -- ($at,$v0) ($v0,$v1)
($v1,$a0) ($a0,$a1) -- plus eight more with one pinned operand: exactly the
register range a funnel-shift memcpy loop uses. Failure mode is wrong data,
not a fault.

Every other raw quad-load site fixes this by merging the pin into lane 0
after the load, which cannot work here because the read straddles two guest
registers. Flush the two pins the window covers instead -- it covers exactly
r[Rt] and r[Rt+1], since sa <= 15 over their 32 bytes -- via a new
armFlushEEGPRPin. That keeps the fast path (0-2 extra Str) rather than
falling back to the ~10-instruction temp-buffer path, and the flushed pins
stay authoritative.

This was the last raw address-of-GPR read in pcsx2/arm64/; the GE-M2e sweep
in 3bc64ac11a covered the mergeable sites and missed this one. Also fixes
the comment, which is what made the hole look deliberate.

Tests: two red-on-unfixed cases dirtying a pinned Rt and a pinned Rs, plus a
non-adjacent green control that proves the divergence belongs to the fast
path. recompiler_tests 1442/1442.
2026-07-26 15:01:08 -07:00
Brian Degenhardt 77a4a2366a Merge pull request #435 from pstef/tests
Add more tests

Console-conformance suites for EE MMI / FPU control registers / loads and
stores / SA and the performance counters / the data and instruction
caches, IOP loads, stores and branches, VU0 COP2 macro mode, VU1 EFU, and
VU sticky flags. Each case is scored against a PS2 hardware capture on the
interpreter and the JIT separately rather than against the other engine,
so a defect the two share is still visible. 1439 -> 1509 cases.

Two fixes ride along, each confirmed load-bearing by reverting it:

  * psxJALR read its branch target out of Rs after writing the link, so
    `jalr $t0, $t0` jumped to the link address instead of the old Rs.
    Reverting fails BranchDelaySlotOrderingMatchesConsole alone.
  * MTSA masks to four bits. The console says `mtsa 0x10` leaves SA at 0
    and `mtsa 0xFFFFFFFF` leaves 0xF, and the x86 recompiler already
    masked on both of its paths, so this aligns the interpreter with what
    the JIT had been doing. Reverting fails four cases.

Twenty-seven DISABLED cases record console divergences PCSX2 has not
closed yet, each a tripwire that starts passing when the gap does. None of
them disables a case that used to pass. One is ours: cop2EmitFlagUpdate
builds the MAC flag from sign and zero only and clears U/O outright, so
arm64 COP2 macro mode raises no underflow or overflow flag.
2026-07-26 14:23:28 -07:00
Brian Degenhardt 539f4f7247 Tests: guard the EE cache2 host mapping behind MAP_FIXED_NOREPLACE
MAP_FIXED_NOREPLACE is Linux 4.17+; Darwin's <sys/mman.h> has no such
macro and Windows has no such header, so the unguarded include plus bare
use broke the macOS CI job outright — macos_build.yml builds `unittests`
and hard-fails when the recompiler_tests binary is missing, making this a
compile error there rather than a skipped test.

__has_include for the header, #if defined for the flag, and MapAt returns
nullptr when neither is available. Both callers already GTEST_SKIP on a
null return, so the two tests that need a page at a chosen host address
skip off Linux and nothing else moves. Verified by compiling this TU with
the macro #undef'd: clean build, those two skip, the other eight pass.

Also records why they skip on a 16K-page kernel: all four candidate
addresses are 4K-aligned but none is 16K-aligned, so Asahi, Apple Silicon
and some Android reject every one. That is not the loader collision the
comment assumed.
2026-07-26 14:22:41 -07:00
jpolo1224 94d2e3f618 Android: note that background recolouring skips the fallback background
The animated library background is a GL displaced-grid mesh, and where
EGL/GLES3 init fails (older Mali without float-texture filtering) the app
drops to a simpler backdrop that carries no colour uniform. Recolouring
therefore does nothing on those devices, silently, which reads as a broken
setting rather than an unsupported one.

Said so in both descriptions rather than one: RGB Cycle is its own row, and
somebody who only enables that would never see a warning attached to the
fixed-colour row above it.
2026-07-26 15:49:48 -04:00
jpolo1224 dd5235d74b Android: let the texture catalog collapse, and make it searchable
The catalog is 113 packs today and only grows, so the whole list folds away
once someone has what they came for - the header shows the pack count and
toggles, and the state is saveable so a rotation does not spring it back
open.

Search covers pack name, game title, serial and author, since any of those
is a plausible thing to type. It uses the in-app keyboard like the library
search rather than a raw TextField, so it works on a handheld with no
touchscreen keyboard and still honours the use-system-IME preference.

Requested by jpolo1224 after seeing the list at full length.
2.6.5.4
2026-07-26 15:21:13 -04:00
jpolo1224 9a6db38395 GS: stop injecting replacement textures whose upload failed
CreateReplacementTexture discarded the bool from every tex->Update(). The
texture object exists either way, so a failed upload was reported as a
successful replacement and the game was handed a texture with UNDEFINED
contents. On screen that is indistinguishable from a replacement that never
loaded — missing cursors, letters cut in half — which is exactly how #442
presents.

Update can genuinely fail. On Vulkan an upload needs either room in the
shared streaming buffer or a dedicated staging allocation
(GSTextureVK::DoUpdate), and under memory pressure either can fail; OpenGL
has no equivalent staging step, which is consistent with #442 reproducing on
Vulkan and not on OpenGL.

It is worse here than upstream because of our CPU BC decode: a BC7 texture
becomes RGBA8 at four times the size, so the allocation that has to succeed
is four times larger, on exactly the packs most likely to be huge already.

Drop the texture on failure so the game falls back to its original, and log
once with the settings that actually help (Precache off, lower render
resolution). A pack that does not fit now degrades to "not replaced" rather
than "corrupt", and the failure is visible in the log instead of silent.

Mip failures are treated the same way: a garbage mip is still garbage, it
just only shows at distance.

Defect identified by bmdhacks.
2026-07-26 15:18:51 -04:00
jpolo1224 ffe161a576 Android: download texture packs from the shared catalog
The community texture catalog maintained by sashkinbro, browsable and
installable in the Texture Packs screen, shared with us at his suggestion.

No game needs to be running. Each pack names the serials it belongs to, so
the install target comes from the pack itself rather than from whatever
happens to be loaded - packs for a game you own sort to the top, the rest
stay visible so you can fetch one before you own the disc. Files land in
textures/<SERIAL>/replacements, which is where the core already looks.

Deliberately not a port of upstream's download service. That buys pause and
resume across a reboot at the cost of WorkManager, kotlinx-serialization,
three manifest permissions, a receiver and a notification channel; the
streaming download and zip handling here follow ShaderRepo, which already
does this shape, and add no dependency at all. The catalog parser is
rewritten against org.json for the same reason.

Sizes drive the rest. The largest published pack is 2 GB where a controller
skin is a few hundred KB, so: free space is checked as 2x the archive plus
slack (peak is the zip plus its expansion), the SHA-256 is computed while
streaming rather than in a second pass over gigabytes, and the install
swaps into place atomically with the previous pack retained until the new
one lands. Texture replacement is switched on after a successful install,
since it defaults off and the files would otherwise sit on disk changing
nothing - the most confusing possible outcome.

The panel links the catalog repository so people know where the packs come
from and where to contribute their own.

Verified against the live catalog: all 113 entries parse, and the archive
layout handling covers flat, <SERIAL>/, <SERIAL>/replacements/ and GitHub's
own archive root.
2026-07-26 14:53:48 -04:00
jpolo1224 4d8701a49d GS: driver-bug database and per-driver shader workarounds
Ported from EmuCoreX with sashkinbro's approval. The GPU family alone was
never enough to decide behaviour: the same Mali part behaves differently
under ARM's proprietary driver than under Mesa PanVK, a lesson this tree
learned twice the expensive way - the r44p1 DEVICE_LOST fix had to be
gated on driverID rather than vendorID, and the 8 Elite push-descriptor
disable likewise. Driver identity, version and a bug set are now recorded
in a table, so the next device quirk is an entry rather than another
bespoke branch. Twenty-five rules, with match confidence so a vendor-wide
rule never overrides a driver-version-specific one.

The shaders gain gpu_bitwise_and / gpu_bitwise_not / gpu_boolean_not,
whose bodies the device emits: plain a & b normally, scalarized where the
database says the driver miscompiles vector bitwise ops. A device matching
no rule renders exactly as before.

opengl/tfx_fs.glsl already carried this fix as IAND3/UAND2/UAND4 macros
gated on GPU_PROFILE_MALI. Those are replaced by the shared helpers, but
the GL macro is deliberately (workaround || IsMaliGPUProfile()): Mali
reached through ANGLE or Panfrost resolves a non-ARM driver and matches no
rule, so a database-only gate would have silently removed a fix those
users have today. Widening only.

SHADER_CACHE_VERSION 108 -> 109. Every TFX and convert shader's source
text changed, so a blob cached from 108 no longer matches the source that
produced it; without the bump users would get stale binaries and garbage
rendering after updating.

RewriteConstantLoads is deliberately absent - the database records
BrokenConstantLoad with no workaround bits, so the macro would be
permanently zero and the shader code dead.

Shaders validated offline with glslc across 136 Vulkan and 296 GL/GLES
permutations, every macro on and off; the failure set is byte-identical to
the pre-change baseline.
2026-07-26 14:53:48 -04:00
jpolo1224 7c05cfd117 Android: make the settings usable again, and declutter the library menus
CollapsibleSection had stopped collapsing. Its own comment describes "a
tappable header that shows or hides its content ... to de-bloat long
settings lists", but the body rendered a plain title and called content()
unconditionally, with initiallyExpanded marked UNUSED_PARAMETER. All 20
sections across Pad, Renderer, Performance and Fixes were therefore
permanently open, which is why the settings became unusable in landscape:
not too many options, but every option in a tab on screen at once.
Restoring it shortens four tabs at a stroke. State is rememberSaveable so
a rotation no longer re-collapses what was just opened, landscape being
where the long lists hurt most; Renderer opens on Display & Resolution
and Performance on Speedhacks so neither looks empty.

The Fixes tab is now Advanced, and absorbed the GameDB fixes from
Performance and the whole Recompiler tab. Both are debugging controls that
the GameDB already applies per game, so they do not belong in the tab
people open to change speed. Fourteen tabs become twelve; the search index
and the per-tab reset ownership moved with the settings, so searching
"Skip MPEG" lands on Advanced and resetting Performance no longer resets
fixes it does not show.

Skip BIOS now defaults on. "How do I skip the boot animation" is one of
the most-asked questions in the Discord and desktop PCSX2 fast-boots too.
Only fresh installs change: the saved JSON always carries the key, so
nobody's boot behaviour is altered by an update.

The library overflow menu drops from eleven rows to eight with separators.
Open navigation goes entirely (the hamburger is on the same toolbar) and
Game names in grid moves to App settings beside cover size and opacity,
where every other library-appearance preference already lives. Setup /
Change Folders and Exit MOVE to the drawer rather than being deleted -
each had exactly one entry point, so removing them would have stranded
anyone needing to change their ROM folder and left no in-app quit. Exit
keeps its confirmation and gets a real power-symbol drawable: the "⏻"
glyph is absent from most Android system fonts and rendered as tofu.

About leaves the settings tab strip for the drawer, next to the other
links. It is a read-only page that was costing a tab slot on every visit.

Installed patches and cheats can be collapsed, so a pnach carrying
hundreds of downloaded codes can be folded away.

Reported by a community member on Discord.
2026-07-26 14:53:48 -04:00
J1coding 2c4f5500c7 iOS: give bottom-anchored OSD content the bottom inset
RenderOverlays fetched the safe-area insets with a null pointer for the bottom
one, so it was written and never read. Only a single margin gets threaded down
to the draw functions, and it carries the horizontal clearance, so anything
anchored to the bottom edge — the settings string, the shader-compile spinner,
the inputs overlay, the Bottom* performance positions — was being spaced off the
screen edge by the notch inset instead of the home indicator one. In portrait
the horizontal insets are zero, so in practice they got nothing at all and sat
under the indicator.

Threads a bottom margin alongside the existing one. Off iOS every inset is zero
and the two are the same number, so nothing changes there.

The message toasts were skipping the insets entirely while the performance
overlay honoured them, which looked odd given they share the same four corners.
They read them now too.
2026-07-26 20:27:11 +02:00
J1coding 0bb396793c iOS: take the OSD safe-area insets from the view, and actually deliver them
Two things were wrong with the clearance the OSD gets on iOS.

It was pushed through Host::RunOnGSThread, which quietly does nothing when the
GS thread isn't open. Every layout pass before a game boots hits that, and UIKit
doesn't call -layoutSubviews again unless the bounds change, so the insets sat
at zero for the whole session until the first rotation finally landed one. That
is why two portrait screenshots of the same size showed the OSD block 36px
apart: one was drawn before the push got through and one after. Nothing else
re-pushes, so there was no way back. The insets are four floats read by the GS
thread while it draws, so they're atomics now and -layoutSubviews sets them
directly; only the window resize still needs the hop.

And the value itself was a flat 18pt on all four edges of every device. A 750px
iPhone has square corners and no cut-out, so that was 36 physical pixels of
nothing; a phone with a notch wants better than twice that at the top in
portrait. UIKit already tracks this per orientation, so use safeAreaInsets. It
also gets the split portrait layout right for free — the Metal surface there
only covers the top of the screen, so it reports the notch inset but no home
indicator inset, which is what you want.
2026-07-26 20:27:11 +02:00
J1coding 2163f71671 iOS: stop the performance overlay resizing itself every frame
The shrink-to-fit added for portrait recomputed its factor on every frame from
whatever the widest line happened to measure last frame. That makes the font
size a function of the live text, and the live text never stops moving: the GS
stats line gains and loses digits constantly, the resolution string flips from
0x0 Unknown to 512x448 NTSC during startup, the EE/GS/VU percentages churn. Each
of those nudged the factor across an integer font-size boundary, and since the
overlay is right-aligned, every row's left edge then moved by its own share of
the rescale. Rows that hadn't changed a character were sliding around by ten to
twenty pixels.

Now the size is only reconsidered when the space it has to fit into changes, or
when the set of enabled lines changes. In between it ratchets down once, far
enough for the widest line to fit, and holds there. Rotating gets you the full
size back because the available width changed; switching presets does too. With
the Full preset in portrait on a 750px-wide screen it steps 24 -> 23 about three
frames after boot and then never moves again.

The frame-time graph was also scaling itself by the factor we asked for while
reporting its width normalised by the size the text actually got. Those two
disagree by up to a whole step, so with the graph as the widest element the
whole thing never converged at all — it oscillated forever. Both now come off
the realised size.

Also folds the eleven-way flag test at the top into a bitfield, since the fit
needs to compare line sets rather than just check for an empty one.
2026-07-26 20:27:11 +02:00
J1coding ab4d9f9a16 iOS: bring back the device stats overlay line
The battery / heat / RAM line used to sit under FPS and Speed and went
missing when the frontend moved onto the shared core: that commit dropped
the fork's private copy of ImGuiOverlays.cpp for upstream's, and the draw
went with it. The producer in ios_main.mm survived intact, so this is a
missing consumer rather than a missing feature. The Should- getter still
had its logging caller; it was the line and severity getters that were
left with nothing reading them.

Draws in both the refresh and the cached branch. The cache only rebuilds
every tenth of a second, so leaving the cached branch out would flash the
line once and drop it for the next nine frames. Severity 2 is the same
red as a sub-95% speed reading and severity 1 an amber; a healthy device
uses the normal OSD colour rather than a hardcoded white, so the line
follows OsdColor like everything else does now.

Two things had to be fixed to make it work. The early return at the top
of the performance overlay bails out when all eleven perf flags are off,
which is exactly what a Custom preset with only Device Stats ticked looks
like, so the line would never have drawn there. And the line getter was
returning a pointer into the cached std::string after the lock had gone,
which another thread could reallocate mid-read; it copies into
thread-local storage now.

The C++ side also defaulted the flag to on when the INI key is missing,
so a fresh install at preset Off showed the line anyway. It reads the
preset the same way Swift does instead.
2026-07-26 18:35:29 +02:00
J1coding 434cbdda6f iOS: honour the OSD safe-area insets
The view has been computing a corner clearance on every rotation and
handing it to ImGuiManager::SetOSDSafeAreaInsets since the Metal layer
went in, and the setter has been an empty body with a "Phase 5" note on
it the whole time. So the OSD only ever cleared OsdMargin, ten logical
points, which is less than the rounded-corner radius on any recent iPhone
and nowhere near the Dynamic Island. Top-Right is the default position on
iOS and is exactly where the cut-out sits in portrait.

Store the four values and let the overlay read them back. RenderOverlays
now keeps the plain margin and the inset one separate: the horizontal
margin picks up the worse of the left and right inset, and the first line
starts below the top one. Only one horizontal margin is threaded through
the six draw functions and widening that signature for this is not worth
it, so taking the larger side is the honest approximation.

Nothing on desktop calls the setter, so the insets stay zero there and
this is a no-op.
2026-07-26 18:35:29 +02:00
J1coding 0f48358fda iOS: shrink the performance overlay to fit the window
Every line in the performance overlay is right-aligned as
x = window_width - text_size.x - margin, with nothing stopping x going
negative. A line wider than the window therefore starts off-screen and
runs off the left edge instead of being clipped on the right, which is
why the OSD looked chopped rather than cut off. Portrait on a phone is
less than half the width of landscape, so anything that only just fitted
in landscape breaks the moment you rotate.

Measure the widest line each frame and, if it does not fit, draw the
whole block at a smaller font next frame. The factor is clamped to 1.0 at
the top so it can only ever shrink below the size the user picked with
OsdScale, never grow past it, and it is floored to 0.5 so a very long
line does not disappear. The accumulated width is normalised back to what
it would have been unshrunk, otherwise each frame measures the previous
frame's shrink and the overlay flips between two sizes forever. The drawn
font size is floored to an integer so ImGui only ever has a couple of
bakes alive.

The frame-time graph gets the same factor for its padding, plot size and
label gutters, or it would overhang a shrunken text block. The settings
string is a different problem: at a couple of hundred characters it would
need about a quarter size to fit on one line, so it shrinks a little and
then wraps, and its baseline is anchored off the wrapped height so a
two-line string does not hang off the bottom.

There is also a hard floor on the x position now, which catches anything
still too wide while the factor is catching up.

None of this is behind a platform guard. It is plain layout correctness,
and a desktop window narrow enough to overflow today gets the same fix;
an iOS-guarded copy of identical logic would just read as a fork hack.
2026-07-26 18:35:29 +02:00
jpolo1224 caedd7d9ae Achievements: link the RA control panel from the library-progress setting
The web API key lives under "Keys" on the RetroAchievements control panel,
which the old copy described in prose and left the user to find. Link it
instead, with the site's own wording for where to look.

The link is activatable from a gamepad as well as by touch: this screen is
fully pad-navigable, so a link that only answered to a finger would be
unreachable on a handheld. The label is a separate translatable string
substituted into the sentence, and a translation that loses the placeholder
still renders its own text with the link appended rather than dropping it.

Requested by jpolo1224.
2.6.5.3
2026-07-26 12:23:34 -04:00
jpolo1224 05b52de95e Achievements: library-wide RA progress, including games never played
Progress could only ever be shown for a game that had been loaded, because
the core is only able to report on the game it currently has: set sizes for
everything else are not on the device at all. So a game you own but have
never launched showed nothing, which is most of a library.

Fetch it from RetroAchievements instead — and this needs two requests for
an entire library, not one per game:

  - API_GetGameList (i=21 PS2, f=1, h=1) returns every PS2 set's size
    together with its MD5 hashes. Cached on disk for a week; set sizes
    change on the order of months.
  - API_GetUserCompletionProgress returns NumAwarded / NumAwardedHardcore
    / MaxPossible per game id, paginated 500 at a time.

Matching is by disc hash, the only reliable key: RA carries no PS2 serials
and title matching would confuse regional variants and multi-disc sets.
Achievements::GetGameHashForImage computes it without booting, mirroring
the game-list scanner's open/detect/read/close over CDVDapi_Iso. It refuses
while a VM is valid — CDVD is a global, so repointing it mid-session would
swap the disc out from under the running game.

A game with a set and no unlocks now correctly reads 0/N rather than
nothing. Automatic syncs are limited to one a day and only run once a web
API key is present; the RA panel has a manual "Sync library" button that
ignores the interval. The web API key is a separate credential from the
login token, so it is entered once in the panel and trimmed on the way in
(the site's copy button brings whitespace, which would otherwise look like
the feature silently failing).

Also captures progress on every RA sound, so the figure moves as
achievements are earned instead of waiting for the slow poll.

Requested by Isshin.
2026-07-26 12:23:34 -04:00
jpolo1224 1f949b6b0a Android: record achievement progress without the panel, split hardcore
The library showed no achievement progress for anything, because the only
code that ever recorded it was AchievementsViewModel.refresh() — open the
in-game achievements panel and the numbers were stored, never open it and
nothing was. That is every game, for most people.

Capture it from the play-session effect instead, where a live VM is
guaranteed: once ~15s after the game starts running (RetroAchievements
resolves the set over the network, so it is not there at boot), then on a
two-minute poll so a killed process still leaves a recent figure behind,
and once more on the way out of RUNNING to bank whatever was earned. The
core can only be asked about the game it currently has loaded, so these
are the only moments the numbers exist.

Hardcore and softcore are now tracked and shown separately. They are
different accomplishments — hardcore forbids save states, cheats and slow
motion — so merging them would overstate what was earned. rc_client
already reports both in the "unlockedMask" bits that Achievements.cpp
emits; nothing on the Kotlin side was reading them. The list row leads
with the hardcore count and a gold HC marker when any hardcore unlock
exists, appends "+n SC" when softcore is ahead, and colours a completed
set gold for hardcore against silver for softcore.

Two traps worth noting for anyone touching the mask: -1 (field absent)
has every bit set, so an unguarded test reports an unearned hardcore
unlock, and Kotlin's infix `and` binds looser than `!=`, so the bit test
needs its own parentheses or it does not compile.

Reported by Isshin.
2026-07-26 12:23:34 -04:00
jpolo1224 e0e523dc98 Android: data backup, screenshot hotkey, disc CRC without booting
Backup/restore (App tab): one .zip carrying save states, memory cards,
artwork, per-game settings, controller profiles and patches, plus the
preference file — everything a reinstall destroys. Games and BIOS are
left out (they live outside the app and survive on their own), as are
texture packs, shader caches and logs: gigabytes of regenerable data
would make the archive impossible to hand around. Archives are portable
between the sideload and Play builds, since the preference file has a
fixed name and data-root entries are stored relative to whatever the
asset-copy root resolves to. Restore merges, then restarts, because
preferences are read once at startup and a live process would overwrite
what was just restored. Requested by Rei Ayanami; AetherSX2 has the
equivalent feature.

Screenshot hotkey: bindable to any controller button or stick direction,
capturing the emulated frame at internal resolution with no touch
overlay or OSD — which is precisely what the system screenshot button
cannot do. The PNG is then published to Pictures/ARMSX2 via MediaStore,
because snaps/ is app-private and Android 11+ hides Android/data from
the Files app, so a screenshot the user cannot find may as well not
exist. Requested by Rei Ayanami. Video recording is deliberately absent:
GSCapture dlopens versioned FFmpeg libraries that do not exist on
Android, and MediaProjection forces a consent dialog on every capture
start, so it could never be a one-press hotkey.

Disc CRC without booting: GameList::PopulateEntryFromPath already
computes serial and CRC from the image, so the Game Properties info tab
and the patch installer no longer demand the game be launched first.
Reported by GBSUPREMO, confirmed by Rei Ayanami.

Patch import naming: an imported "F0A6D880.txt" became
"F0A6D880.txt.pnach", which matches neither glob the core searches, so
the file listed as installed and could never load. Build the name from
the stem and uppercase the canonical prefix — the template is formatted
with {:08X} and WildcardMatch is case-sensitive, so a lowercase CRC is
just as invisible. Reported by Rei Ayanami.

Library list view now shows play time and last-known achievement
progress per game, keyed on a revision counter so the figures refresh
after a session instead of showing what they showed on the way in.
Requested by Isshin.
2026-07-26 12:23:34 -04:00
J1coding 7fc83fd8cd iOS: flesh out the skin browser rows and states
The rows only ever redrew because the installer's single installingName was
published and went nil at the end of an install. That was luck, not design, and
it goes away now that the tracker is a set. The browser holds the skin library in
@State like every other store consumer in the app, and the installed set is read
once per body pass rather than from inside a lazily evaluated row closure.

Pull-to-refresh forces a network fetch and an "Updated ... ago" line above the
list makes it obvious whether one landed.

The spinner and the fetch error were both gated on the list being empty, so once
a first load succeeded neither could ever show again: a refresh looked like
nothing was happening and a failed one looked like it worked. Both are ungated,
and there are proper lines for an empty catalog, a search that matches nothing,
and no connection.

Rows carry the author and the download size, which were already in the manifest
and unused, and search matches the author too. Installed skins sort first. Skins
without a recommended layout say so rather than being hidden.

An installed row's checkmark is a menu now with Reinstall and Remove, and Remove
asks first — swiping was the only way to remove and there was no way at all to
replace a skin with a newer copy. Both the swipe and the menu go through the same
confirmation. The preview thumbnail and the error triangle have accessibility
labels; they were unlabelled buttons before.
2026-07-26 17:18:21 +02:00
J1coding 0d6ac5c689 iOS: make the skin catalog refresh and the installer match on identity
Pull-to-refresh went through URLSession's default cache policy, and the manifest
is served from raw.githubusercontent.com with max-age=300. Refreshing inside that
window returned the cached copy without touching the network, so a skin published
in the last five minutes simply was not there however many times you pulled.
fetch takes a force flag now and the refresh gesture passes it, which is the
literal "the menu doesn't update the skins" the report was about. A lastUpdated
date comes back with it so the screen can say whether a refresh landed.

The .task load and a refresh could also overlap: both raise the spinner, the
first to finish lowers it while the other is still running, and a slow stale
answer could land on top of a fresh one. Fetches are held in one task now, a new
one cancels the old, and a cancelled load neither writes its result nor touches
the spinner.

Half the catalog was hidden. The list was filtered to skins carrying an iosLayout,
which is 13 of the 26 published. The other 13 import fine, they just bring no
recommended layout, so they are listed and the row says so.

The installer decided a skin was installed by comparing the catalog name against
the descriptor's displayName. It matches on catalogID now, in isInstalled and in
uninstall, and uninstall stops swallowing its error with try?. Errors are keyed
the same way and clear when the skin installs or goes away, instead of leaving a
warning triangle next to a skin that installed fine. The single installingName
became a set: with one slot, two taps meant the first to finish cleared the
tracker while the second was still downloading, reverting that row to Get and
inviting another copy.

Uninstall and reinstall both push the library's selection back into
SettingsStore.virtualPadSkin. Removing the selected skin used to leave that at
.custom, which sends the pad at the old ControllerSkins/Custom folder rather than
the stock art.

zipURL and previewURL are static, return an Optional, and refuse a path that is
not a safe relative reference — .. and / both survive percent-encoding, so a
hostile manifest entry could point the download off the repo. The force-unwrap
they had is gone with them, and so is the installer's catalog reference.
2026-07-26 17:18:21 +02:00
J1coding 6e0797da13 iOS: give imported skins a catalog id and reinstall in place
The downloader had no way to tell that a skin it installed is the one already in
the library. It compared the catalog name against the descriptor's displayName,
but importSkin takes the name from the package's own manifest.json and then runs
it through uniqueDisplayName, so the two drift apart on any package whose
embedded name differs from the catalog entry -- and several in the live catalog
do. Descriptors carry a catalogID now, set to the repo-relative zip path rather
than the display name, since the name is copy and the path is the resource.

The field is Optional and the struct keeps its synthesized Codable. It has to
stay that way: the library decodes all-or-nothing, loadLibrary swallows a decode
failure with try?, and the next persist() would then write an empty library over
the user's skins.

importSkin takes a replacingSkinID so the downloader can offer Reinstall without
piling up "Neon 2", "Neon 3". Order matters -- the new copy is imported first and
the old one deleted only once that succeeded, so a dead download or a corrupt zip
leaves the install the user has alone. The old id is passed to uniqueDisplayName
as the name to ignore, so the reinstall keeps the plain name.

Per-game skin picks are re-pointed at the new descriptor, not cleared.
deleteImportedSkin nils them, which is right when a skin goes away for good and
wrong when it is being replaced, so the repoint runs first and the clear then
finds nothing to do. The old skin's linked layout preset is removed on reinstall
too, because createPreset does not uniquify and every reinstall of Black would
otherwise leave another preset called "Black Layout" in the picker. Anything
still pointing at that preset -- the global one included -- moves to the new
skin's layout before the delete.

Installing a skin also stopped freezing the pad settings screen. importSkin
decoded and re-encoded every PNG inline, 18 to 21 of them for a real skin, on
whatever thread called it, which for the downloader is the main one. That loop
now runs detached and hands back finished bytes; the writes stay put. Callers
become async as a result.
2026-07-26 17:18:21 +02:00
J1coding d4296f9fd3 iOS: repair the dead guard tests and stop two UI-thread config writes
Three follow-ups from the graphics live-apply work.

The two iOS guard tests had been failing with FileNotFoundError since the core
moved out of platforms/ios -- both still pointed at app/src/main/cpp/pcsx2 and
app/src/main/assets. Dead tests read as coverage, so repoint them.

Repointing the True Crime one turned up a real regression. It guards
"mvuFlag: 0 # Fixes texture flickering caused by the VU Flag Hack" on the six
New York City serials, and that fix is gone: the iOS build used to bundle its own
GameIndex.yaml carrying it, and when we converged on the overlay only the
gameFixes came across. The speedHack has been missing ever since, unnoticed
because the test that would have caught it broke in the same move. Restored in
the overlay, which is where our GameDB changes live now, and the test follows it
there.

The renderer picker stayed live with a game loaded. Returning to the menu only
pauses the VM, so you could switch renderer, change any other graphics setting,
and have the next apply take GSUpdateConfig down the reopen path -- a full Metal
teardown under a loaded game. The picker already said "Requires restart"; now it
means it.

applyOsdPreset and setPerformanceOverlayVisible wrote GSConfig and EmuConfig.GS
from the UI thread. Those flags are bitfield members sharing storage with the GS
device-restart flags, so a read-modify-write off-thread can write back a stale
neighbour and flip RestartOptionsAreEqual -- the same teardown, from a stats
toggle. Both compute their values up front and hand the writes to the CPU thread.
isPerformanceOverlayVisible reads the INI now rather than GSConfig, which the
setter no longer updates synchronously.
2026-07-26 15:47:05 +02:00
J1coding 538421f65b iOS: fix graphics settings that never reach the running game
Align Sprite and Merge Sprite look stuck: turn one on and it stays on after you
turn it off. The INI write is fine in both directions -- nothing ever tells the
VM about it.

A graphics setting only live-applies if its Setting<T> declares an onSet closure
calling requestGraphicsApplyGuarded(), and most declare nothing. The C++ fallback
in setINIBool/setINIInt covers a hardcoded allow-list with none of the user hacks
on it. 33 keys are in neither and only take effect once some unrelated setting
forces a VMManager::ApplySettings, or on reboot.

The allow-listed ones were not really working either. The poke writes GSConfig
from the UI thread, so when GSUpdateConfig runs it takes old_config from that
already-updated GSConfig and every new != old guard is false: hw_mipmap and
texture_preloading skip the texture-cache purge they are listed for,
LoadTextureReplacements skips UpdateConfig and the hash purge, OsdShowGPU never
reaches SetGPUTimingEnabled.

So make it structural rather than extend the list, since the list is what rotted.
Setting<T> now gives every EmuCore/GS key the hook by default and the 23
hand-written copies are gone; both poke helpers go with them, leaving one apply
per key and nothing writing GS state off the CPU thread. Fourteen keys are not
Setting<T> instances -- five plain didSets, and the nine homogeneous hardware
fixes that live in a [String: Bool] dictionary -- so those call it from their
write funnel, and resetGraphicsDefaults goes through that funnel instead of
writing the INI beside it.

Renderer keeps no hook: it is a restart option, so applying it live tears the
Metal device down under the running game. Native scaling joins the texture-cache
purge, being the one geometry hack that outlives the draw -- it swaps a target's
texture for a downscaled one and pins m_scale to 1, so those targets stay
downscaled after it is off.

Also: MaskUpscalingHacks zeroes the sprite hacks below 2x while the UI only gated
them on Manual Advanced Hacks, so at 1x they read on and did nothing. Say so. The
Skipdraw footer claimed its changes need a reset; they do not.
2026-07-26 15:47:05 +02:00
J1coding 5031a4d2ba iOS: gate the JIT keepalive canary on the VM being parked
The canary flip from the keepalive fix drops execute on the arena's
first page, but the didBecomeActive prewarm re-runs it on every app
switch while the CPU thread is executing the dispatcher on that exact
page -> Instruction Abort. The old 'every caller runs parked' claim was
only a comment; now ValidateJITAlive asks the scene layer for real
VM/worker state and skips the probe (canary=skipped-vm-active) while
anything JIT is running or still initializing. A live VM is its own
proof the grant works.
2026-07-26 13:48:45 +02:00