Commit Graph
100 Commits
Author SHA1 Message Date
J1coding 7ae1bd29b3 iOS: drop the per game numeric option lists
Nothing reads them now that those rows are sliders and steppers. The
comment on them said as much: they existed because the global screens
were continuous and the per game side was not, which is the mismatch that
has just gone.

SettingsOptions is label tables only again, which is what its header
describes.
2026-08-02 23:15:59 +02:00
J1coding a49ad04d01 iOS: put per game sharpness and fast forward volume on sliders
The last two numeric pickers. CAS Sharpness offered 0, 25, 50, 75 and 100
against a global slider that writes any percentage, and Fast Forward
Volume offered five steps against a global slider covering 0 to 200.

Same defect as the pacing ones, just without a preset writing off list
values into them yet, so today you would need to edit the INI by hand to
see a blank row.
2026-08-02 23:15:59 +02:00
J1coding bca617b60c iOS: put the per game pacing numbers on real controls
FPS Target, VSync Queue Size, Buffer Size and Output Latency were pickers
offering five to nine values each, while the global screen takes any
value in the range. That was already breaking: the per game preset picker
writes the pacing table straight through, and Optimal's 15 ms latency and
Low Latency's 30 ms buffer are not in the lists, so choosing a preset and
reopening the panel left you looking at an empty row.

FPS Target is worse, since it comes back out of NominalScalar and can be
any whole number from 15 to 120.

Queue size is a stepper, the other three sliders. Each row names the
global it inherits, which none of them did before.

Loaded values get pinned to the control's range on the way in. Nothing
validated them before, so an INI edited by hand could hold anything.
2026-08-02 23:15:59 +02:00
J1coding 02f1477a7c iOS: add a per game number row that can fall back to global
The per game tabs express "inherit the global value" with a Use Global
entry on a picker, which is why the numeric ones only offer a handful of
values while the global screen takes any of them. Shade Boost already had
the shape that fixes it, a sentinel that swaps the whole row, so that is
now a type the other numeric settings can use.

Two things it does that the Shade Boost version did not. The inherit row
names the value being inherited instead of just saying Use Global, and
Override starts you at that value rather than a hardcoded 50, which was
wrong for every parameter whose global was not 50.

Style is passed rather than worked out from how wide the range is. A
reader should not have to know a threshold to predict which control a row
gets, and widening a range later should not silently change the UI.

Shade Boost is the first caller. Its old helper is gone, since leaving it
would mean two controls doing the same job.
2026-08-02 23:15:59 +02:00
J1coding 324372dced iOS: keep one copy of the duplicated pacing rows
VSync Queue Size and Sync to Host Refresh were on both the Graphics and
Frame Pacing tabs, and Buffer Size and Output Latency on both Audio and
Frame Pacing. Same state behind each pair, so changing one moved the
other, and they were identical rows by the time the shared lists work
finished with them.

Frame Pacing keeps all four. That tab's preset picker writes exactly
these keys and its reset clears exactly these keys, so a copy elsewhere
would change on its own because of something you did on another tab, with
no caption nearby to explain why. Globally the line is already drawn the
same way: neither of the first two appears anywhere but Frame Pacing.

Buffer and Output Latency are the awkward ones, since they do sit on the
global Audio screen and people will look for them on the Audio tab. There
is a line there now saying where they went.
2026-08-02 23:15:59 +02:00
J1coding e82689d257 iOS: give frame pacing the same audio sliders as the audio screen
Buffer Size and Output Latency were steppers here and sliders on the
Audio screen, for the same two INI keys. Nobody was stepping from 10 ms
to 200 ms one tap at a time, and the two screens disagreeing about what
kind of control a setting gets is the thing this run of work keeps
tidying up.

Queue Size stays a stepper. It is fifteen values and you usually want a
specific one.

The Audio screen's name for the key wins, so this now says Buffer Size
rather than Audio Buffer.
2026-08-02 23:15:59 +02:00
J1coding e9538ab867 iOS: share the global int slider row
The Audio screen had a private helper for its three numeric rows: value
in the header, bounds underneath, reset in the middle. Frame Pacing wants
the same rows for two of the same keys, so it is a type of its own now.

No visual change, the body is the helper as it stood.
2026-08-02 23:15:59 +02:00
J1coding f1b4fd4a52 iOS: name the ranges the numeric settings clamp to
The bounds for queue size, audio buffer, output latency and fast forward
volume were spelled out as literals in the writers, the load clamps and
again in the controls on each settings screen. That is four places per
setting that have to agree, which is how they come apart.

They are constants on SettingsStore now, next to textureOffsetRange and
skipDrawRange which already worked this way.

Left the two Double sliders alone. The global CAS slider runs 0 to 1 on a
normalised float and the shade boost one is a Double range, so pushing
the Int constants through either reads worse than the literal does.
2026-08-02 23:15:59 +02:00
J1coding 13af4ab88e iOS: make the default preset report itself active
Applying Default never put a checkmark on the Default row, and could not
have under any settings the app can reach. Its Configuration literal is
only ever read by isActive, because apply short-circuits straight into
resetAllDefaults and never looks at it, and the two had drifted apart on
two fields.

Queue size said 8 while the reset leaves 4, because the reset ends on the
Optimal pacing preset. And showBackgroundInSettings is a defaulted member
that the Default case never passed, so it claimed false while the reset
sets the background on.

The other nine already matched. Also corrected the Help entry for VSync
Queue Size, which still gave 8 as the default.
2026-08-02 21:46:30 +02:00
J1coding 07d294dcff iOS: stop the graphics and emulator resets clobbering frame pacing
Reset Graphics restored VSync Queue Size and Sync to Host Refresh, and
Reset Emulator restored the audio buffer and output latency. None of
those four appear anywhere on the screen doing the resetting: the queue
and host refresh live only on Frame Pacing, and the two audio ones live
on Audio. So a reset was reaching across into settings it does not show,
and dragging the pacing preset to Custom on the way.

The queue value it restored was 8, which is the upstream PCSX2 default
kept around as the migration comparator, not ours. Ours has been 4 since
the Optimal migration, so Reset Graphics left you on Custom holding a
queue size that matches no preset the Graphics screen owns.

Frame limiter and FPS target stay in the emulator reset, since that
screen does have controls for them.

All four are still restorable from Reset Frame Pacing, any preset row,
the two per row resets on the Audio screen, or a full reset. The full
reset now applies the Optimal values explicitly before setting the
preset, the way the Frame Pacing screen already does, instead of relying
on the preset's didSet to be the only thing restoring them.

Not claiming this as a rule the file follows. The emulator reset still
restores volume, time stretch, fast forward volume and channel swap,
which are all Audio screen settings too. They do not touch the pacing
preset, so they are not part of this bug and I have left them.
2026-08-02 21:46:30 +02:00
J1coding cf6b3c4264 iOS: only mark frame pacing custom when a value really changes
The six pacing settings flip the Frame Pacing preset to Custom from their
didSet, and they did it on any assignment at all. SettingsStore is
@Observable, so writing a value it already holds still runs the setter
body, and the preset moved even though nothing about the pacing had.

That is why pressing Reset Graphics, or the 60 FPS button while already
at 60, or Audio's own Buffer reset while already at 50, all quietly took
you off Optimal and onto Custom.

Each one now compares against oldValue before marking. The INI write and
the limiter apply stay unconditional; only the marking is gated.

targetFPS needs the awkward version. Its didSet re-enters after clamping,
so oldValue there is the unclamped intermediate rather than the previous
setting, and clampedTargetFPS rounds. Comparing against the clamped old
value keeps 59.94 landing on an existing 60 from counting as a change.
2026-08-02 21:46:30 +02:00
J1coding bc094bf2f3 iOS: break the xcode dependency cycle from the svnrev target
Reconfiguring and then building failed with a cycle:
PCSX2_LTO -> armsx2_svnrev -> ZERO_CHECK -> PCSX2_LTO. It only showed up
after a regeneration, so a build dir that had already settled kept
working and this went unnoticed when the target landed.

The BYPRODUCTS was the cause. svnrev.h lives in common/include, which is
on the include path of most of the core, so Xcode inferred a producer
edge from every consumer of that header and routed it back through
ZERO_CHECK. The target is ALL and always runs its command, and PCSX2
already depends on it explicitly, so the declaration was buying nothing.

Checked the header still gets rewritten on every build by deleting it and
building without reconfiguring. armsx2_git_hash keeps its BYPRODUCTS,
since that header sits in the target's own binary dir and only the app
reads it.
2026-08-02 21:46:30 +02:00
J1coding a28f010d79 iOS: correct what the Setting header says about init
Setting.swift claimed observers are suppressed during init so onSet never
runs there, while SettingsStore.init says the opposite and turns on
suppressINIWrites precisely because assignments do fire. The store is
right. @Observable rewrites these properties into computed ones, and a
computed setter runs whatever the context, so didSet reaches onSet during
init for the 88 settings that are not suppressible.

Nothing is broken by it today, because the graphics apply hook no-ops
while the INI is loading. Worth saying plainly though, since the old
wording reads as a guarantee and anyone touching the observer plumbing
would lean on it.
2026-08-02 21:46:30 +02:00
J1coding f87952e524 iOS: keep one copy of the per game general sections
Portrait puts the game identity, the overrides toggle and the status line
at the top of the panel's root form; landscape shows the same three as
their own General category. They were written out twice and had already
drifted apart: the landscape copy never picked up the localized warning
or the theme colour, so "Start this game once before saving its settings"
showed in English in a slightly different orange.

The sections live in GeneralTab.swift now and both layouts read them.

Also swapped the category rail's icon width for the theme token, since it
was already the same 22.
2026-08-02 21:46:30 +02:00
J1coding a3b887ef72 iOS: finish moving the per game pickers onto the shared lists
The shared option tables landed last time but only half the call sites
were converted, so several pickers were still carrying their own copy of
a list that already existed. GPU Target CLUT had drifted off the back of
it: the global screen calls option 1 "Enabled (Exact Match)" and the per
game tab called it "Enabled (Exact)".

Max Anisotropy, Hardware Download Mode, CPU CLUT Render, GPU Target CLUT
and Texture Inside RT now read the same table the global screen reads.
The numeric steps moved across too, since VSync Queue Size, Buffer Size
and Output Latency each had two per game copies spread between the
graphics, frame pacing and audio tabs.

The repeated picker block in the graphics tab is a small helper now, the
same shape as intPicker on the global side.
2026-08-02 21:46:30 +02:00
J1coding f1c553e45a iOS: share one option list between the global and per game screens
Every picker in the graphics settings was written out twice, once on the global
screen and once on the per game tab, and they had drifted.

The per game TV/CRT Shader stopped at Lottes while the global one went two further
to 4xRGSS and NxAGSS, so those two could not be chosen for a game at all and a
file already holding 6 or 7 showed an empty picker. The per game Renderer was
missing Null, and offered Software on Mac Catalyst where the global screen
deliberately hides it behind a build check.

UpscaleOptions already exists for this reason. Its header records that its own two
copies had drifted so the per game one stopped at 4x while the global went to 8x.
That got fixed once, for one setting, and never generalised. The other eleven
lists live next to it now and both screens read the same one. The Catalyst check
moved inside the renderer list so the two cannot disagree about it again.

PickerOption is gone; it only existed to hold the per game copies. Trilinear is
the one that cannot use the shared use global helper, since -1 is a real
TriFiltering value there, so its marker stays Int32.min and says so.

Left alone on purpose: CAS Sharpness, the queue and latency lists, and aspect
ratio. Those do differ, but as a slider against a picker, a stepper against a
picker, and an Int tag against a String tag. Each needs a decision about which
control is right rather than a list to move.
2026-08-02 18:58:24 +02:00
J1coding 5b0e4d63cd iOS: only write per game settings you actually changed
A tester spent two days working out why in game text went soft and got there
himself: turning on Use Per-Game Overrides did it, with nothing else changed.

Six EmuCore/GS keys were copied into the per game file the moment overrides were
enabled. Five happened to match the core defaults so nobody noticed.
deinterlace_mode did not. It fell back to 7, the picker called 7 "Adaptive
(Default)", and 7 is really BlendBFF. The global INI has never held that key on
iOS, so the core had been running Automatic and enabling overrides quietly moved
every game onto a blend deinterlace, which is a vertical low pass over the frame:
text and edges lose definition while geometry and colour sit still. It also
suppresses the game's own GameDB deinterlace fix, which only applies while the
mode is Automatic. A progressive title had been taking the no deinterlace path
entirely and gains a blur out of nowhere, while an already deinterlaced one just
swaps method, which is why it looked game specific.

The picker was the root of it and is fixed in both screens: tags are
GSInterlaceMode values, and the list was shifted by one from index 1 down, so
every label named the mode below it and Adaptive was unreachable. Existing per
game files get one pass to drop a deinterlace_mode of exactly 7.

Auditing the rest of that write path turned up three more. Flipping a stick
latched the master toggle on, because the pad tab writes the Invert keys without
consulting the toggle, the probe that decides whether overrides are on counts
them, and the block that clears everything did not. FastForwardVolume had two
owners in one save and whichever ran second won, and reading it back treated the
presence of that key as evidence of a main volume override. And the last six keys
were still written unconditionally; they are Bools on both sides of a very long
selector, so rather than turn them into tri state ints they compare against the
global and write nothing when they agree, the way vuThread already did.
2026-08-02 18:58:24 +02:00
J1coding 04adedd94e iOS: fix the reset settings wording and translate the new strings
"This will reset all settings to it's original values" wanted its, and reads
better as their, so it says that.

The string doubles as the dictionary key, so the corrected spelling is what goes
in the tables. Reset Settings, its title and warning, and the new rumble duration
toggle were all falling through to English in every language. They are in the ui
supplement table now, which covers zh, es, de, it and pt, and is where Reset
Emulator to Defaults already sits.

Arabic, French, Japanese and Korean only exist in the older table and are left
alone. The rest of the phone rumble section has never been translated either, so
those languages are no worse off than before.
2026-08-02 18:58:24 +02:00
J1coding 32966ad992 iOS: fix the phone rumble strength scale and release envelope
PhoneRumbleStrength kept its key but changed meaning. It used to be a plain 0..1
multiplier defaulting to 1.0; now 0.25 reproduces that old full strength and the
rest of the slider adds gain up to 3x. Nobody's stored value was touched, so
anyone who had ever moved that slider got louder on update, and someone who had
dragged it down to 0.5 to calm it came out at 1.67x, above the default they were
reducing from. There is a one shot rescale now, behind a flag in
ARMSX2iOS/Migrations.

The fiddly part is telling "never saved" from "saved zero". A fresh install has no
key at all and its new 0.25 default already means what 1.0 used to, so rescaling
that would quarter the default for every new user. It reads through a negative
sentinel and leaves the key alone when it is absent.

Separately, the release scale was picked from max(large, small) and then handed to
both channels. The PS2 small motor has no variable speed, the pad runs it flat out
or not at all, so any buzz pinned the classification to Hard and the heavy motor
inherited a long tail it never asked for. Splinter Cell holds the small motor on
almost constantly, which is where it showed worst. Each channel is classified from
its own level now and carries its own scale to its own player.
2026-08-02 18:58:24 +02:00
J1coding 09c1ebb73c iOS: make quick menu stop actually leave the game
Stop shut the VM down and then left you looking at live gameplay until the
shutdown notification came back, which is the whole of MTGS teardown, the memory
card close and the NVRAM write. The Now Running card in the library stayed up the
whole time too, because nothing cleared runningGameName.

Worse, if a Reset ROM or a disc restart was still in flight, AppState had a
pendingBootAction queued and the shutdown observer takes that branch in preference
to going back to the menu, so Stop rebooted the game instead of quitting it. That
was private with no way to clear it, hence cancelPendingBoot.

It leaves for the library up front now instead of waiting on the notification,
which is the order the library's own Stop already uses. That also drops
GameScreenView out of the hierarchy, which kills the onChange that was scheduling
an unpause on the way out. Draining the cpu thread tasks happens before the stop
check, so that unpause was landing first and resuming a frame after you had
already pressed Stop.

Back to Menu claimed to quit the game in its accessibility hint. It pauses.
2026-08-02 18:58:24 +02:00
J1coding 759889df1a iOS: line up the pause menu row titles
Change Disc sat about twelve points left of everything around it in Game Tools,
and Controller Skin did the same in This Game.

Every row in those cards puts its title 34 points in, from a 22 point icon frame
plus 12 points of spacing. Those two never went through the row components though.
They are Menus built over in GameScreenView, handed to the pause menu as opaque
AnyViews, and their labels are plain Labels, which bring their own narrower icon
column along with them.

There is one label style for that column now, applied where the menus get hosted
rather than at the two labels themselves, so anything injected later lines up
without the author having to know the rule.

The 22 and the 12 have names. The caption under the Virtual Pad toggle was
carrying a hardcoded 34 to hang under the title, which is exactly the sort of
number that quietly stops matching.
2026-08-02 18:58:24 +02:00
J1coding 7345304c9a iOS: resolve the git hash at build time
Both revision strings came from execute_process at configure time, so they only
updated when cmake happened to re-run. Commit something without touching a cmake
file and the next build still reported the old revision. That is how the 2.5.2
test build went out claiming d66a721df1 in both the @@BUILD_ID@@ line and the
PCSX2 banner.

There are two of them and they are unrelated: ARMSX2_GIT_HASH feeds the
@@BUILD_ID@@ line, and GIT_REV comes from svnrev.h and feeds the core banner.
Each now resolves into a generated header on every build.

Neither header is rewritten unless the hash actually moved. That matters more for
svnrev.h than it looks, because it reaches BuildVersion.cpp inside the core, so a
pointless rewrite would relink the whole thing through LTO every build.

WriteSvnRev.cmake deliberately reproduces write_svnrev_h byte for byte, including
the quirk that the tag, hash and date come out empty because the .git check is
against the ios project dir rather than the repo root. If the two disagreed they
would take turns rewriting the file and force a rebuild each way. Upstream's
functions in Pcsx2Utils.cmake are untouched and still do the configure time
write, this only refreshes it, so the merge surface stays small.

The version string stays a compile definition. It lives in the ios CMakeLists, so
changing it always reconfigures and cannot go stale the same way.
2026-08-01 18:07:17 +02:00
J1coding 31a691567d iOS: bump to 2.5.2
Cutting a patch release for the two crash fixes that landed after 2.5.1.

The page protection table bound is the important one. I wrote it as
hardening with no reproduction behind it, and it turns out to be the cause
of at least two reported crashes: the Namco Museum software renderer one
and Splinter Cell 3 dying when you start a level. Both are the same stray
write landing on the physical page map, so anyone who hit either of those
wants this build.

The other one stops the per game settings panel closing the disc of the
game you are playing, which showed up as a freeze a moment after saving
anything in that panel.

Version and build number both come from the two variables at the top of the
iOS CMakeLists and everything else reads them back out of the bundle, so
this is the only place that needs touching.
2026-08-01 18:07:17 +02:00
J1coding a494b70e64 iOS: one settings reload per save, not one per field
Pressing Save in the per-game panel produced seventy-one "Applying settings"
cycles, twice over in the logs from the disc bug. Each per-game setter writes
its one key and then queues a full reload, so a panel that writes every field
it owns gets a reload per field. Each of those re-reads the INI, re-runs the
GameDB fixups and rebuilds the GS config, which is most of why saving anything
took a visible moment.

The reload is now coalesced: a write schedules one shortly after, and if
another write lands first it hands the job over, so the last write in a burst
is the one that reloads. Seventy-one becomes one.

Left the reload in the bridge rather than adding a begin/end batch for the
panel to bracket its save with. The disc bug next door happened because a
caller did not know a rule it was supposed to follow, and a bracket is the same
shape: anything added later that forgets to wrap goes quietly back to the old
behaviour. This way there is no rule to remember.

The cost is that a change now applies about fifty milliseconds after the last
write instead of immediately, and if the app is backgrounded inside that window
the live apply is skipped, though the file is already written so it lands on the
next boot. Neither is noticeable for settings UI.

Only the ForCurrentGame setters are touched. The forISO ones never reloaded;
they exist precisely to write the file without applying it.
2026-08-01 16:48:01 +02:00
J1coding 0fbee8dc9c iOS: stop metadata scans closing the running game's disc
Saving anything in per-game settings left the game frozen a second or two
later. Not a crash, and not the savestate everyone including me assumed: the
emulator carried on at 59.9 fps while the game sat there starving.

There is one process-wide InputIsoFile shared between the running VM and every
metadata scan. Scanning opens an image into it, and Open closes whatever was
already open first, so a scan while a game is running leaves that game with a
closed disc. Every read after that comes back "past the end of file (N >= 0)",
which is the file reporting zero blocks. GTA:SA streams constantly, so it locks
up almost immediately; a game that streams less would just look fine until it
next needed the disc, which is why this was so confusing to pin down.

The way in was savePerGameCompatibility. It threads useCurrent through every
write bar one, the renderer, which deliberately uses the forISO variant so the
value lands in the INI without applying mid-game. That intent is right, but the
forISO variants resolve identity through GameList::PopulateEntryFromPath, and
GameList.h says directly above it not to call that while the system is running.
Since most people have no per-game renderer override it is the delete branch
that runs, so it fired on every press of Save no matter what was changed.

So the bridge no longer scans while a VM is up. Identity for the running game
comes from what the VM already knows, and anything wanting a full entry gets it
from the game list cache, with a miss failing rather than falling back to a scan.
Worst case is a missing cover or title while a game is up, against killing its
disc.

The guard is on any VM rather than only on the disc being scanned, because Open
closes the current image regardless of which file is being opened next, so
scanning an unrelated one does the same damage.

One warning, once, if anything asks for a scan while a VM is running. This went
unnoticed for a long time because it was completely silent, and it took a
throwaway build with a backtrace in Close to find.
2026-08-01 16:48:01 +02:00
J1coding 766dd7c45e vtlb: bound the page protection table indexes
A tester's Namco Museum 50th Anniversary crash on an M2 iPad turned out to be
memory corruption rather than anything to do with the software renderer he
thought he was hitting. The store that died was a softmem write through a page
table entry whose high word had gone from 1 to 2. ProtMode_Manual is 2, it is a
u32, and it lives at offset four of an eight byte record, so something had
written a protection mode over the top half of a vtlbdata.pmap pointer.

It comes from the fastmem branch of HandlePageFault. PSM resolves the whole
physical map rather than just main RAM, so a fault on VU memory arrives here
with an offset far past the end of m_PageProtectInfo, which is a fixed 8192
entries. Nothing checked that. The read alone is out of bounds, and when the
aliased value happens to equal ProtMode_Write the handler carries on into
mmap_ClearCpuBlock and writes. The branch twenty lines below has always had the
bound check; this one never did, and now does the same thing.

Nothing real is suppressed by it. Anything PSM resolves outside main RAM is ROM
or VU memory, neither of which is ever under EE write protection, so the right
answer for those faults is the else branch that was already there, handing them
to the backpatcher.

mmap_MarkCountedRamPage had the same unbounded index and a signed int on top of
it, which went negative and indexed backwards whenever the pointer landed below
Main. Clang has been warning about that conversion the whole time. Bounded the
same way as mmap_GetRamPageInfo, and the warning goes with it. No caller reaches
either case today since they all come through mmap_GetRamPageInfo first, so that
half is closing a trap rather than fixing live breakage.

A lot had to line up, which is why it lasted this long. 16K pages mean VU0
memory can never be folded into fastmem so it faults on every touch, the arena
base makes the aliased half read as exactly ProtMode_Write so the guard passes
because of what it is corrupting, and the game has to touch VU0 memory from the
EE and then reprogram the TLB to spread the poisoned entry around. It writes
once and stops, so it leaves nothing behind in the log.
2026-08-01 14:23:46 +02:00
J1coding 00ee8185b8 iOS: read JIT activity under the validation lock
Follow-up to the keepalive work in the hybrid JIT safety change. The lock it
adds closes the race between the idle canary and code memory being unmapped,
which is the important half, but the activity check sits outside that lock and
leaves a smaller gap behind.

WaitForJITValidation drains by taking the mutex and dropping it again, so it
only ever waits for a handler that has already acquired the lock. A handler that
passed the activity check but has not reached the acquire yet is invisible to
it. The boot path sets the VM active, cancels the timer and drains, all of which
that handler misses, and then it carries on into BeginCodeWrite and flips
protection across the whole arena while the EE thread is executing out of it.
That is the same shape as the Devil May Cry crash, and the comment above
ARMSX2JITWorkerBusy already describes the consequence as an instant instruction
abort.

Reading activity inside the lock leaves only two possible orderings and both are
fine. Either the handler gets there first and the drain waits for it to restore
the canary byte, or it gets there second, sees the VM is busy and returns
without touching anything.

The window is a few instructions against a twelve second timer, so nobody was
going to hit this on purpose, but it costs two lines to remove.
2026-07-31 16:06:07 +02:00
J1coding cd40a1f169 iOS: stop building against an M1 baseline
Crash report from an iPhone XS on 2.5.0: SIGILL less than a second after launch,
in ElfObject::GetCRC during the game list scan. The exception code carries the
offending instruction, 0xce000c40, and that decodes as eor3, an ARMv8.2 SHA3
instruction. An A12 has no SHA3.

The iOS BuildParameters has been handing clang -mcpu=apple-m1, under a comment
claiming iOS devices and Apple Silicon Macs share a minimum spec. They do not.
The oldest phone that can install at our deployment target is an iPhone XS, and
an M1 baseline lets clang fold the XOR chains in the hash and CRC loops into
eor3 and bcax.

It is not one unlucky function either. The shipped 2.5.0 binary has 36 eor3 and
16 bcax spread across twelve of them, including LoadBIOS, mVUcomputeProgramHash,
the memory card CRC and save paths, and the XXH3 hashing the texture cache leans
on. An A12 reaches one of those almost immediately whatever it does, so the app
has never really worked on that generation: iPhone XS, XS Max and XR, iPad Air 3,
iPad mini 5, iPad 8, and the A12X and A12Z iPad Pros.

iOS now builds with -mcpu=apple-a12, the oldest device we accept. macOS and
Catalyst keep the M1 baseline, which is correct for them. Picking the oldest
supported device rather than switching off the one offending instruction means a
future compiler that fancies some other post-A12 feature gets refused at compile
time instead of turning into another crash report.

Checked with otool either side of the change: the count of eor3, bcax, xar, rax1,
sha512, sm3 and sm4 in the binary goes from 52 to zero.

Worth knowing for next time, this flag lives in the iOS copy of BuildParameters,
not the one at the repo root. The iOS CMakeLists points CMAKE_MODULE_PATH at its
own cmake directory, so the root copy has no say in an iOS build at all.
2026-07-31 15:19:28 +02:00
J1coding 503c1728fc iOS: fix leaks and a threading hazard in the gamepad haptics
Read the whole of GamepadHaptics.mm after a run of bugs kept coming out of it.
Three things worth fixing turned up.

The controller rumble path was leaking four objects per rumble event: two haptic
event parameters, the event, and the pattern, all allocated and never released.
This file is manual reference counting, and the device path a few hundred lines
up gets it right, which is probably why nobody caught it. A game with a
controller connected comes through there on every change of value, so it was a
steady drip for as long as you played.

The bigger one is that s_gamepads was in use from two threads. The pump owns it
and closes pads on disconnect from the CPU thread, but the delayed rumble stop
was a dispatch_after onto the main queue that held an SDL_Gamepad pointer for
300ms and then used it, and the Joy-Con name check read the array from main as
well. Worse, the Test Rumble button in settings runs on the main thread and was
opening gamepads straight into the same array. Unplugging a controller mid
rumble, or pressing Test Rumble during a game, could land on freed memory.

The array is CPU thread only now. The SDL stop rides a per slot deadline the
pump already visits every frame, the Joy-Con verdict is worked out once when the
pad is opened and cached in an atomic, and Test Rumble hands its work to the
pump rather than doing it inline. With no VM running there is no pump to hand it
to and nothing to race, so it still goes straight through, with a fallback in
case a paused VM has stopped pumping.

Last, two fallbacks in the controller lookup were answering for slots that have
no controller of their own. One handed back the only connected pad for every
slot, the other handed back any pad with haptics. Between them a single Joy-Con
could make all four slots test positive and turn rumble off for everybody, and
player 2's rumble could come out in player 1's hands.
2026-07-31 15:19:28 +02:00
J1coding 1d0e6bea4e iOS: split the phone rumble across both motors
Tester on the last build said the phone rumble now sustains properly but plays
at one strength the whole time regardless of what the game asks for. Four things
were stacked up behind that.

The big one is that we took max() of the two motor values. The PS2 small motor
has no speed control at all, it is on or off, so it arrives here as a flat 1.0.
Taking the larger of the pair meant the moment a game touched the buzzer the
whole thing pinned to full and the heavy motor, the only one carrying any
variation, got thrown away.

The other three are in how the pattern was built. The live intensity parameter
multiplies the event's own intensity rather than replacing it, and we were
baking whatever the first rumble happened to be into the event, so that first
value became a ceiling for the rest of the burst. Sharpness was baked the same
way and then shifted again by its control, which is an offset rather than a
replacement, so it landed twice. And the sharpness curve had it backwards
against the hardware: the taptic engine puts out the most force around 0.73, and
we were sitting the binary buzzer right on top of that while the analog motor
played down at 80 Hz where you can barely feel it.

Each motor now gets its own looped channel, the heavy one low and dull, the
buzzer high and sharp, both built at full intensity so the live parameter has
room to work. Only intensity is sent at runtime now.

Test Rumble never reached any of this either. It only called the controller
path, which wants a real controller and quietly gives up without one, so on a
bare phone the button did nothing at all. It now steps the heavy motor up
through three levels and buzzes the small one, which is enough to check the
strength slider without loading a game.

Last thing, the tap fallback for hardware with no taptic engine was handed the
controller-clamped values and then divided by the full range, so it could never
get past 44 percent.
2026-07-31 15:19:28 +02:00
J1coding dcf56d79a9 iOS: give the phone's own rumble some range
Phone rumble came out at the same weak strength no matter what the game asked
for. Three separate things flattened it, stacked on top of each other.

The range was crushed at both ends. Everything is capped at 0x7000, which is
44 percent of full scale, and the Swift side then floored it at 0.3. The whole
chain came out as max(0.3, min(0.4375, motor / 255)), so motor bytes 1 to 76
all produced 0.300 and 112 to 255 all produced 0.4375. Of 256 possible values,
35 changed anything.

It was a tap rather than rumble. UIImpactFeedbackGenerator knocks once and
there is no way to sustain it or change it afterwards.

And a steady rumble only fired once, because the dedup gate skips a packed
value that has not changed. A game holding the motor for two seconds got one
blip.

There was already a continuous CoreHaptics implementation sitting in this file,
written and never called by anything: a looped continuous event with an advanced
player whose intensity is updated live. It was controller specific in two lines,
so it now creates a device engine instead and the phone gets sustained rumble
that tracks the motor. That deleted the dead path rather than adding a new one.

The phone reads the packed value unclamped, so it gets the whole range. The
0x7000 cap stays where it was tuned, on the controller motors. Multiplied by a
new Phone Rumble Strength slider under Virtual Pad, Feedback, at full by
default. Devices with no taptic engine keep the old tap, minus the 0.3 floor.

Reviving the dead code meant fixing what it had been getting away with while
nothing ran it. The engine comes from alloc/init now so the static owns it, the
player still comes from a factory method and needs the retain, and the dynamic
parameter array was leaking on every single intensity update. The stopped and
reset handlers hop to main before touching the player, since CoreHaptics calls
them back on its own queue and everything else here runs on main.

The zero has to reach the engine too. A looped player runs until told otherwise,
so wiring it up only where rumble starts would leave the phone buzzing after the
game stopped asking.
2026-07-31 00:18:48 +02:00
J1coding a255bbe9fd iOS: reconnect rumble to the core
Controller rumble and phone rumble have both been dead since the move to a
single shared core on the 8th of July, so 2.4.1, 2.5.0 and 2.5.1 all shipped
without either.

ARMSX2_iOSUpdatePadVibration is the only thing that ever writes the iOS rumble
queue, and nothing has called it since that move. It used to be hooked into
InputManager::SetPadVibrationIntensity as a patch on the iOS tree's own copy of
the core, and when we adopted the shared one the patch did not come along. One
dead producer starves all three consumers, which is why SDL rumble, the
CoreHaptics pulse and the phone's taptic fallback went silent together rather
than one at a time.

Android hit exactly this from exactly this migration and was fixed three days
later. That fix is still sitting in the same function saying so in its comment.
The iOS block goes right beside it so the two read as a pair.

Note InputManager.cpp had no TargetConditionals.h, so TARGET_OS_IPHONE was
undefined and the guard would have compiled the whole thing back out while the
build stayed green. The include is guarded the same way Host.cpp does it.

Three more things sat behind the dead call site:

The per frame pump skipped past the phone fallback before reaching it. It ran
the rumble step only after confirming a gamepad was in the slot, and the taptic
fallback exists for the case where there is no gamepad in the slot. Hoisted
above the check, which is what makes phone rumble work rather than just
controller rumble.

Emulation Only Mode set a flag that turned phone haptics off for the rest of the
session. It is only ever cleared on a branch that returning to a stripped VM does
not take. Dropped it: the same call also releases the cached generators, trigger
builds them again on demand, so the release was already self healing and the flag
was only blocking it.

The per slot pulse engine was an autoreleased object living in a static in a file
built without ARC, then messaged again a third of a second later from a delayed
stop. Retained now, and released at both places the slot is cleared. The @try
around it never helped, since messaging freed memory does not raise.

The three other unretained statics in that file have the same defect but their
assigning function has no callers, so nothing can reach them.
2026-07-31 00:18:48 +02:00
J1coding fb27fb0c18 iOS: get the skin search out from under the tab bar
The search field on the skins screen had ended up at the bottom of the
screen, tucked under the Games/BIOS/Settings pill where you cannot tap it.

Nothing about it was actually broken. The filtering has always worked and
the binding has always been live. What changed is iOS 26: an unqualified
searchable now puts the field at the bottom on iPhone, for thumb reach. We
do not use a TabView, we inject our own bar into that same strip with a
zIndex of a thousand, so the bar just paints over the field. The content
margin that keeps lists clear of the bar is no help either, since it insets
scroll content and a search field is navigation chrome.

Pinned to the navigation bar drawer, always shown rather than automatic.
Automatic hides it until you pull the list down, which is a poor trade on a
screen where someone has just said they could not find the search.

Two things came along with it. Matching moved to localizedStandard, so an
accent in a skin name no longer hides it from someone typing without one.
And there is an All / Installed / Ready filter, built on the installed set
and the iOS layout flag the rows already read for their subtitles.

The empty text now says which of the two emptied the list. Filtering to
Installed with nothing installed used to claim your search found nothing.

Checked on an iPhone 17 Pro simulator running 26.5, which is where the
bottom placement reproduces.
2026-07-30 21:31:17 +02:00
J1coding b55607c4bf VMManager: keep fastmem off once its reservation has failed
Fastmem wants a 4 GB virtual reservation and does not always get one on a
small device under LiveContainer. That case is handled: the area is marked
permanently unavailable and EnableFastmem is forced off. The INI still says
fastmem is on though, so every settings reload turns it back on, and the
disable was being re-applied by hand afterwards at each call site. Two of the
three had it. ApplyCoreSettings, which runs on every ELF change, did not.

You can see it happen in a DOA2 log from an iPhone SE 2. The EE dispatcher is
956 bytes when the game boots and 968 after the ELF lands, and the twelve
bytes between are the fastmem base load in _DynGen_EnterRecompiledCode, which
is the only runtime config dependent branch in that whole region. On a device
where the reservation succeeds it is 968 both times.

So the build ends up with CHECK_FASTMEM true and not one fastmem mapping:
codegen emits the fastmem paths, the backpatch handler has nothing to resolve
against, and the base register holds null. Whether that is what killed the
run in that log I cannot show, and the immediate consequence of the flip is
harmless because vtlb_ResetFastmem checks the sticky flag and returns. It is
still a state the emulator should never be in.

Moved the disable into LoadCoreSettings, which both reload paths go through,
and dropped the two hand-written copies. One place to get it right, and a
reload path added later cannot quietly miss it. The warning stays but fires
once now, since an ELF change would otherwise repeat it.
2026-07-30 21:31:17 +02:00
J1coding 16e9676825 iOS: offer fractional internal resolutions above 1x
Internal Resolution jumped straight from 1x to 2x to 3x, which is a bad fit
for a phone. 2x runs, 3x does not, and the setting that would have worked was
somewhere in between and not on the list.

The core has stored this as a float since forever. GetUpscaleMultiplier
returns one, the hardware renderer and the texture cache use it as one, and
nothing on our side rounds it. Both pickers already offered 0.25x, 0.5x and
0.75x, so the fractional path has been running in production all along, just
only ever below native. This adds quarter steps up to 3x, then 3.5x, and
leaves the higher integers alone.

Quarter steps because the 512x448 base times a quarter still lands on whole
pixels. Thirds would not.

The two pickers now read one list instead of keeping a copy each. They had
already drifted apart: the per game one stopped at 4x while the global one
went to 8x, for no reason anybody would be able to name. Since the new list
is a superset of both old ones, every value anyone already has saved is still
in there.
2026-07-30 18:26:29 +02:00
J1coding 2d940e6568 iOS: put ARMSX2 and the version back on the OSD
The version line has read "PCSX2 <rev>" since the frontend moved onto the
shared core. That commit swapped the fork's own ImGuiOverlays.cpp for
upstream's, and this went the same way the device stats line did a few
commits ago: the block has a branch for macOS and a branch for Android, iOS
was never given one, so it falls through to the generic case at the bottom.

Reads "ARMSX2 2.5.1 | Core: <rev>" now. The version is the same CMake
variable that sets the bundle version rather than a literal, so the overlay
and the About screen cannot drift apart. Worth doing that way round because
the two branches either side of this one do spell it out by hand, and the
Android one is already a release behind what its gradle file says.

The core rev stays on the line. It is what identifies a nightly in a
screenshot, and it is already what the startup log and the build id print.
2026-07-30 17:38:45 +02:00
J1coding 2905f1a180 GS: don't generate scanline code while the workers are running it
Scoping the write window to the pages being emitted fixed the crash, but it
left a smaller version of the same problem behind. Reservations are packed,
so the first page of a new routine also holds the tail of the last one, and
the rasterizer workers may well be executing that. Queue calls SetupDraw and
only then pushes to the workers, so the ones still busy with earlier draws
are running from exactly the pages we are about to make writable.

Page aligning every reservation would fix it and cost about eight times the
code footprint, which on a 64 MB reserve means a cache reset every few
thousand routines. Not worth it for what is really a scheduling problem.

SetupDraw takes allow_compile now. Queue probes with it off, and if anything
is missing it syncs the workers and then generates with nothing running.
Costs one sync per newly compiled selector, which is a few hundred times in
a session, and running out of code space falls into the same path because it
wants that sync before the reset anyway.

The single threaded rasterizer passes it on throughout. It draws on the
calling thread, so there is nobody to get out of the way of.
2026-07-30 17:38:45 +02:00
J1coding 4391670f5b GS: make the software JIT cache overflow path real
The software rasterizer has a recovery path for running out of code space:
SetupDraw returns false, the caller resets the cache and asks again. It has
never been able to run.

ReserveMemory only had a pxAssert, which is compiled out of a release build,
so it always handed back a pointer. GetDefaultFunction has no other way to
fail, so SetupDraw could not return false, so ResetCodeCache never ran. What
happens instead is that the bump pointer walks off the end of the reserve,
and since the software renderer sits last in the code arena, that is the end
of the arena.

It would not have worked if it had run, either. Clear emptied the codegen map
and rewound the pointer but left the active map holding pointers into memory
about to be handed out again, so the next lookup would have jumped into
whatever replaced it.

So: ReserveMemory reports full, Clear drops the active map along with the
codegen map, and a null is deliberately not cached on the way out. That last
one matters more than it looks. The active map is consulted before anything
else, so an entry cached during the failure would have survived the reset
that was supposed to fix it and gone on answering null for that selector for
the rest of the run.

Nothing here is iOS specific. It reads the same on every platform, we are
just the ones with a reason to have been looking.
2026-07-30 17:38:45 +02:00
J1coding 0b7323f016 iOS: bump app version to 2.5.1
Patch and build number only. Everything else derives from these two lines:
ARMSX2_VERSION feeds CFBundleShortVersionString and the compiled in
ARMSX2_VERSION_STR the startup log prints, ARMSX2_BUILD_NUMBER feeds
CFBundleVersion, and the places that show a version to the user read it
back off the bundle rather than carrying their own copy. Nothing else in
the tree spells the number out.

The RetroAchievements client token is deliberately not part of this. That
version comes from ra_ua_secret.h and is set on its own, so hardcore keeps
working without RA having to know a thing about this bump.
2026-07-30 17:38:45 +02:00
J1coding bd5a11ad05 GS: don't strip execute off every recompiler to write a scanline
Devil May Cry crashes a few tenths of a second after it swaps to the
software renderer for an FMV. It carries the SoftwareRendererFMV gamefix,
so the swap is automatic and every run reaches it. The report is a SIGBUS
with an instruction abort permission fault, the faulting thread is the CPU
thread, and its PC is 952 bytes into the EE dispatcher. At that instant the
GS thread is sitting inside mprotect.

The software rasterizer JITs a scanline routine per selector, and the
generator opened HostSys::BeginCodeWrite around the emit. On iOS in legacy
JIT mode, where the map_jit reservation failed and RW and RX are the same
address, that call is a single mprotect over the whole code arena. The
arena is 305 MB and holds the EE, the IOP, both VUs, the VIF unpackers and
the software renderer together, so the GS thread taking a write scope for
its own 8 KB took execute away from EE code the CPU thread was in the
middle of running.

Scoped the window to the pages actually being written. BeginCodeWriteRange
already exists for this and is what the EE and IOP recompilers and the
backpatcher moved to for the same reason. ReserveMemory only reads the bump
pointer, so taking the address before opening the scope is safe.

Legacy mode only. Under the dual mapping the generators write through the
RW alias and there is no toggle at all, and on macOS the toggle is per
thread, so neither could ever see this.
2026-07-30 17:38:45 +02:00
J1coding 08a4a452a2 iOS: drop the Widen theme in portrait toggle, which never did anything
The toggle is persisted, has a reset button, and no renderer anywhere reads the
value. Nothing in the drawing code looks at it in either direction, so flipping
it has never changed a pixel.

It also had a helper built around it, whose only job was to notice that this one
field had changed and skip the preview animation for it. With nothing reading the
field there is nothing to skip, so that goes too.

Removing a property from a Codable struct is safe in this direction: synthesised
decoding ignores keys it does not know, so settings saved by older builds still
load. Adding one back would not be, which is worth remembering if portrait
widening ever gets built for real.
2026-07-30 16:38:07 +02:00
J1coding 71192479fc iOS: grey out the dark gradient slider when it is being ignored
Two separate things pin the dark gradient to zero, and the slider only ever
greyed out for one of them.

The other is the eye button on a saved palette, which turns that palette's dark
effect off. It lives in a different section, has no label, and says nothing about
this slider, so the slider sat at full strength doing nothing with no way to work
out why. Nobody would connect the two.

Both conditions grey it out now, and each says which one is holding it. Same
shape the section already used, with a line of explanation added, since nothing
in this whole subtree ever explained a greyed out control before.
2026-07-30 16:38:07 +02:00
J1coding 93c012129a iOS: make Cancel in the colours editor mean cancel
Cancel is wrong in both directions at once. It throws away the swatch you just
saved and keeps the colour edits you just cancelled.

Saving a swatch mid session calls onSaveAppearance, which persists the entire
preferences struct rather than just the swatch, so every edit made before that
point is already committed. cancelChanges then restores the bindings in memory
and never writes them back, so those edits survive. Meanwhile the swatch list is
@AppStorage and writes through the moment the snapshot restores it, which deletes
the swatch that was explicitly saved.

So commit the restored state instead of leaving it in memory, and leave the
swatch list out of the restore.

Undo and redo still move the swatch list with everything else, which is what you
want from undo. Only Cancel treats a saved swatch as something you meant to keep.
2026-07-30 16:38:07 +02:00
J1coding 8b3f141639 iOS: keep a saved colour from turning black
Pick a saturated colour in the system picker, save it, come back, and the swatch
is black.

The picker hands back Display P3 on any recent device. getRed reports those in
extended sRGB, where anything outside the smaller gamut lands outside 0 to 1:
P3's pure red is 1.358, -0.074, -0.012. Scaled by 255 that is 346, -18 and -3,
and %02X writes those as three digits and as sixteen digit negatives. The reader
parses with UInt64(radix: 16) ?? 0, gives up on the resulting string, and falls
back to zero.

Convert to sRGB before reading the components. The clamp after it is what stops
the corruption on its own, but converting is what makes the saved swatch actually
match the colour that was picked rather than a truncated guess at it. getRed stays
as the fallback for when conversion fails.

Only affects colours saved from now on. Anything already stored as black was
already lost.
2026-07-30 16:38:07 +02:00
J1coding b1be120073 iOS: stop a Low Power Mode change crashing the video background
Toggling Low Power Mode with a video wallpaper on screen kills the app.

Foundation posts NSProcessInfoPowerStateDidChange from whatever queue it likes.
The handler is @objc on a UIView subclass, so it is main actor isolated, and this
target builds in Swift 6 language mode. That combination does not race, it traps:
the @objc thunk checks the executor and aborts before the body runs at all. The
other five observers in this file are UIKit lifecycle notifications and really do
arrive on main, which is why only this one goes bang.

So the @objc entry point becomes nonisolated and hops, and the body it used to be
stays main isolated. The seek completion handler a few lines above already does
exactly this, so there was a pattern in the file to follow.

The capture is weak on purpose. A strong one could leave the final release on that
Task's thread, and deinit here assumes it is on main, which would trade one trap
for another.

Registration stays selector based. Switching to the block form returns a token
that removeObserver(self) does not unregister, and teardown relies on that one
call clearing all six.

Needs a video wallpaper set, so it is not every install, but the automatic prompt
at 20% battery fires it without the user doing anything.
2026-07-30 16:38:07 +02:00
J1coding cdb46dffb3 iOS: stop the patch manager claiming Hardcore is blocking before it is
Flip Hardcore on and the Cheats and Patches screen immediately says cheats and
most patches are blocked. They are not. Hardcore only arms when a game boots, and
every gate in the core keys on it being active, so until then everything on that
screen carries on working under a banner saying it cannot.

hardcoreBlocksPnachContent was ORing the preference in with the active state, and
its own comment asserted the core refuses to apply the entries, which is what made
it look already settled. It now reports only what the core actually enforces.

The pending state gets its own line instead of borrowing the blocked one: switched
on, has not taken hold, still working until you boot a game. Somebody watching a
cheat keep working while that screen insisted it was suppressed is how the God of
War 2 report started.

Everything downstream follows the same predicate, so enabling and the preserve
already-enabled behaviour are now permissive while pending and unchanged once
Hardcore is really on.

Nothing here changes which entries apply. That is the core's business and it is
handled separately.
2026-07-29 23:58:11 +02:00
J1coding 2f3f28faae Patch: stop cheats filed as patches applying under Hardcore
A skip cutscene cheat for God of War 2 keeps working with RetroAchievements
Hardcore active. It is not a user file. It ships in our own patches.zip:

  gametitle=God of War 2 (SCUS-97481)
  [Widescreen 16:9]  gsaspectratio=16:9  patch=1,EE,00234A48,word,46000406
  [Skip Cutscenes]   author=Ezedequias   patch=1,EE,202D8194,byte,01

Hardcore only ever gated the cheats side. ReloadEnabledLists empties the enabled
cheats list, the on-disk walk skips the cheats folder, and the cheat enable call
sits behind EnableCheats. The patches list gets none of that: it is re-read
verbatim and applied unconditionally, not even behind EnablePatches. Anything
filed as a patch has always been exempt, and place=1 reapplies it every vsync.

Neither of the obvious rules can separate those two groups. They live in the same
pnach, so file location cannot, and the widescreen one writes EE memory too, so
"block memory writes" would take widescreen with it. What does separate them is
whether the group says what it is for. gsaspectratio or gsinterlacemode means
widescreen or no-interlacing, which we deliberately keep working under Hardcore.
A group that declares nothing and only writes memory is a cheat whatever its
label says.

Measured against the shipped database before settling on it: 1284 groups declare
presentation and stay, 478 write memory with nothing declared and now stop. The
ones that stop read like Car select, Auto-activate analogs and Throttle/brake on
right stick. On the reported game, Widescreen 16:9 stays and Skip Cutscenes goes.

The check runs at group selection rather than against the enable list, because an
unlabelled group never consults that list and would have sailed through a filter
applied there.

GameDB patches are left alone on purpose. They are a curated compatibility layer,
and dropping them under Hardcore would break games instead of stopping cheating.

Worth knowing what this does not cover: a pnach author can still launder a cheat
by pasting a gsaspectratio line into the group. That stops shipped and accidental
content, not somebody determined to cheat themselves.
2026-07-29 23:57:58 +02:00
J1coding 4b05eb8748 iOS: stop writing the GS thread's config from the CPU and UI threads
The OSD paths wrote both EmuConfig.GS and the global GSConfig. GSConfig belongs
to the GS thread, which reads those fields while it draws, and the writers run on
the CPU thread and, at scene connect, the UIKit one. They are bitfields packed
into a shared word, so each write is a read modify write racing whatever the GS
thread is doing to the neighbours.

The thirteen OsdShow flags did not need the second write at all. ImGuiOverlays
copies every one of them out of EmuConfig.GS into GSConfig on the GS thread each
frame, right before drawing, so dropping the poke changes nothing about when they
arrive. No push either, deliberately: adding one would put a queue drain behind
every tap of the preset picker for a value that is already being delivered.

The overlay position is not in that per frame copy, so it does have to be pushed.
Only from the two places that are provably on the CPU thread though: inside the
hop setPerformanceOverlayVisible already has, and at the one boot site that runs
after the GS is open. Not from ARMSX2SetIOSOsdFlags, which has no hop of its own
and is called from the UIKit thread at scene connect, where MTGS::RunOnGSThread
asserts and a second ring producer would corrupt the queue.

The two earlier boot sites needed nothing. GSopen assigns GSConfig wholesale from
EmuConfig, so a write before that point was being overwritten anyway.

Not fixed here, and worth its own look: the GPU timing switch in GSUpdateConfig
only fires on a rising edge of OsdShowGPU, and the per frame copy has always
levelled that field before the comparison runs, so it never fires. Removing these
writes does not change that.
2026-07-29 21:18:54 +02:00
J1coding bcdf555563 iOS: stop the cheats switch claiming cheats are on under Hardcore
Turn Hardcore Mode on and the Enable PNACH Cheats row keeps showing enabled.
Tapping it appears to do nothing. Cheats really are off at that point, so the
row is just wrong about it.

Hardcore clears EnableCheats in the running config only. The INI still says
true, and this row reads the INI, so it reports a setting that stopped being
in force the moment Hardcore came on. Turning it on is refused down in the
bridge without saying anything, which is why tapping it looks broken rather
than blocked.

So the row reports what is actually in force, greys out, and says which
setting is holding it. Same helper the Cheats and Patches manager already
uses, so the two screens cannot end up disagreeing about whether Hardcore is
blocking anything.

The lookup is cached rather than asked for on every redraw. It takes the
achievements lock, and this row sits in a Form that rebuilds whenever anything
else on the screen changes.

Nothing about which cheats apply changes here. The core already refuses them
three separate ways under Hardcore and the patch lists reload the moment it
toggles; only the switch was lying.
2026-07-29 20:34:34 +02:00
J1coding 29d8585594 iOS: stop the menu background showing over a game in light mode
Someone in light mode gets a white strip across the top of the screen while a
game is running, just above the OSD. Dark mode looks fine, which is the tell.

The strip is the top safe area. Portrait gameplay stays out of it on purpose so
the OSD clears the Dynamic Island, and it only ignores the bottom edge, so
something behind has to fill that band. Since 70443f8b66 that something is the
menu backdrop. The root used to be a switch and the grouped background lived
inside the menu case, so it simply was not there during a game; turning the
switch into a ZStack for the launch transition left it painting over everything.
systemGroupedBackground is near white in light mode and near black in dark, so
half the users saw nothing wrong.

So put it back to menu only, with the same condition as the background layer
directly under it so the two cannot drift. It fades rather than disappearing,
which keeps the launch transition looking the way it does now.

The game screens also paint their own black behind the whole layout instead of
trusting what is underneath. That band was only ever black because a boot
notification sets the root controller's colour, and anything that reaches
gameplay without firing one gets the white back. Cheap to not depend on.

The note in SceneDelegate about SwiftUI owning the menu background is accurate
again, so it stays as it is.
2026-07-29 20:18:58 +02:00
J1coding 88da3ebe39 iOS: give the NVM save date the same ownership as the toast
The toast lifetime fix left a second static in this file with the same shape.
s_lastNVMSaveDate takes an autoreleased NSDate straight from [NSDate date] and
the assignment does not retain it, so once the pool drains the static points at
freed memory.

Nothing is crashing on it today. Both reads happen in the same call as their
write, before the pool has drained, so the pointer is always fresh in practice.
It is one read from somewhere else away from being the same use-after-free as
the toast, which is not a distinction worth relying on when the file now has a
setter pattern to copy.

Also says out loud why the toast consume clears its static by hand instead of
calling the clear helper next to it. The static's reference is being handed to
the local, and the autorelease on the way out already pays for it, so routing it
through the helper would release twice. That line reads like an inconsistency
someone should tidy up, and tidying it crashes.
2026-07-29 20:18:58 +02:00
J1coding 400208b51e iOS: say when a game's own settings are driving a hack
The graphics screen edits global values, so that is what its rows show. A game
with a per-game override is running something else, and the row said nothing
about it. Better than saying the wrong thing, but it is the same silence the
rest of this work was about.

The rows now say so when the two disagree. Changing it is still the per-game
panel's job, and the sentence points there rather than trying to edit a
per-game value from a screen for globals.

Getting the answer meant reading the base layer and the game layer directly,
under one lock for the whole sweep, instead of nine calls to the Host getters.
Those take the same lock per call and it is not recursive, so once the lock is
held nothing in there may call one. Worth remembering if this loop grows: it
hangs the CPU thread rather than reporting something wrong.

For the same reason the game database lookups now happen before the lock is
taken instead of inside the loop. Nothing in that path reads settings today, so
it would have worked, but it was resting on that staying true.
2026-07-29 20:18:58 +02:00
J1coding 5efa3095fd iOS: let the sprite hacks be changed without dropping every other fix
The sprite and texture-offset rows were greyed out unless Manual Advanced Hacks
was on, and turning that on throws away every automatic fix the game had. So the
only way to argue with one hack was to give up all of them, which trades one
glitch for a different one and is why people end up convinced a setting is stuck.

Those rows are usable now. Changing one claims that hack, the database keeps
everything else it knows about the game, and only the setting that was argued
with changes hands. Skipdraw still needs the master toggle, since nothing claims
it.

When what the game is running is not what the row says, the row says why: 1x
ignoring it, the database choosing it, or the automatic fixes still being in
charge. A claimed row gets a way back to the database value, otherwise claiming
one is a door that only opens outward.

Resetting graphics defaults hands all of them back.

The footer used to say manual hacks were needed for the values below. That is no
longer true for the sprite and texture-offset ones, and it was the sentence
sending people to the master toggle in the first place, so it says what it costs
instead.

Nothing here has been driven on a device yet, only built.
2026-07-29 20:18:58 +02:00
J1coding f50a3c441e iOS: report which upscaling hacks the game actually got
The graphics screen has only ever been able to show the INI. Between that and
the renderer sit MaskUserHacks, MaskUpscalingHacks and the game database, any of
which can quietly land on a different answer, and none of which the UI can see.
That is most of why Align Sprite reads as broken: for a game the database has an
entry for, the row says off while the game runs it on, and no amount of tapping
the row changes that.

So snapshot the real values. EmuConfig belongs to the CPU thread and the masks
have only settled once an apply is finished, so the capture runs there, at the
end of the apply and again on a game change, since the database fixes do not
land until the serial is known. The UI reads the copy under a lock and gets a
notification when it moves.

Each entry carries the value, whether the player claimed that hack, and which of
the gates is holding it if the two disagree. Bools are read back with the bool
getter rather than the int one, because they are stored as true/false and asking
for an int just hands you the default and makes every row look overridden.

Claiming is a write only here, with the caller asking for the apply, so changing
a value and claiming it in the same tap coalesces into one apply instead of two.

Per-game overrides write the claim into the per-game file as well, otherwise
overriding one hack for one game still loses to the database for that game.
2026-07-29 20:18:58 +02:00
J1coding 0d5c6674ab VMManager: actually strip manual hacks before the ELF boots
ApplyGameFixes clears ManualUserHacks on the pre-ELF path and returns, with a
comment saying it is disabling the player's manual hardware fixes because they
might be problematic on the BIOS.

It is not disabling them. MaskUserHacks already ran back in LoadCoreSettings,
while the flag was still set, so it took its early return and left every hack
in place. Clearing the flag afterwards changes nothing that has already been
loaded, and the return skips the MaskUpscalingHacks call at the bottom too. The
hacks stay live on the BIOS screen and through the boot logo.

Mask both after clearing the flag, so the code does what it says.

Worth knowing this shifts behaviour for anyone who had manual hacks on and was
seeing them apply before the game started. That was the bug, not a feature, but
it will look like a change.
2026-07-29 20:18:58 +02:00
J1coding 915e914ba6 GS: let a hack the player picked outrank the game database
Right now changing one sprite hack is all or nothing. With manual hacks off,
MaskUserHacks wipes whatever the player set and the game database writes its own
value on top, so for the 227 games that carry alignSprite the toggle does nothing
in either direction while the settings screen still shows the player's value.
Turning manual hacks on to get around that throws away every other automatic fix
the game had, which tends to trade one glitch for a different one.

So let a single hack be claimed instead. GSOptions carries a bitmask of the ones
the player set on purpose. MaskUserHacks leaves those alone and clears the rest
as before, and applyGSHardwareFixes skips a claimed fix down the same path it
already uses for manual mode, so it still gets named in the warning and every
other database fix for the game is applied normally.

The mask sits outside the bitfield union deliberately. That packing is what
OptionsAreEqual compares wholesale, and this is not a hack value, it is who owns
one. It gets its own comparison so that claiming or releasing a hack counts as a
settings change even when no value moved with it.

MaskUpscalingHacks does not honour the mask. Below 2x the renderer skips these
regardless, so keeping one set there would only leave GSConfig and the settings
overlay claiming something that never runs.

The warning text now depends on which of the two cases fired, since blaming
manual mode when someone only claimed one hack would be wrong.

Nothing sets a bit yet, so behaviour is unchanged until a frontend does.
2026-07-29 20:18:58 +02:00
J1coding 3b38bd2605 GS: purge the texture cache when a geometry hack changes
Turning Align Sprite off and watching nothing happen is the report that keeps
coming back. The setting does reach the renderer now, but the pixels it already
moved are sitting in a cached target, and nothing invalidates them.

These hacks rewrite vertex positions or texture coordinates on the way into a
draw, so what they change ends up baked into whatever target that draw landed
on. Games that redraw a target every frame hide this, because the next frame
paints over it with the new setting. Games that build a background or a HUD
layer once and keep sampling it do not: that target keeps the shifted pixels
until something else happens to invalidate it, which can be a whole scene later.

From the player's side that reads as a setting that will not switch off, and
then as ghosting, which is exactly how it was reported.

Native scaling was already in the purge list for the same reason and its comment
claimed it was the only geometry hack that outlived the draw. It is not, it is
just the most obvious one, because it swaps the texture outright rather than
nudging what gets drawn into it. Align sprite, merge sprite, round sprite, half
pixel offset, wild arms, native palette draw, bilinear and the texture offsets
all have the same problem.

Costs a frame when you toggle one, same as every other entry in that list.
2026-07-29 20:18:58 +02:00
J1coding 7b89374919 iOS: stop blaming the network when the skin catalog is unreadable
The Skins browser showed "Can't reach the skin catalog. Check your connection"
for every failure, including the ones where the download worked perfectly. The
message was hardcoded in the view and shown whenever the list happened to be
empty, so the actual error never reached the screen.

That fired today: the community catalog picked up four missing commas, so it
downloads with a clean 200 and then fails to parse. Everyone who opened the
Skins tab was told to check their wifi over someone else's typo.

The three cases are now told apart. Unreachable still says to check the
connection. A non-200 reports the status code. A file that arrives but will not
parse says so, and says it needs fixing in the skins repo rather than on the
device, because there is nothing the user can do about it either way.

The view prints whatever the catalog reports instead of substituting its own
guess. A refresh that fails still leaves the previously loaded list on screen,
so a broken update does not empty the browser.

Reuses the SkinCatalogError enum, which had been sitting unused since it was
written.
2026-07-28 20:18:32 +02:00
J1coding dcf3a9a927 iOS: reimport a folder skin when the zip on disk is newer
The reporter who filed this blamed a filename check for their layout not
turning up. They were half right. There is one, it just isn't on the path they
were using: the ARMSX2 folder bootstrap skips a skin outright when an existing
descriptor's originalImportName matches the zip filename. Drop an updated
MySkin.zip into SKINS/ with a layout you have just added and nothing happens -
no re-extraction, no new art, and the summary calls it already present.

Keep the name as the lookup, but decide on the file. If the zip's modification
date is newer than the descriptor's updatedAt, reimport it through
importSkinArchive with replacingSkinID set, so it goes through the same
import-then-retire path a catalog reinstall does and keeps per-game picks and
layout assignments. Otherwise skip as before.

If the date can't be read at all, skip. That is the current behaviour and it
beats inventing a new way to fail. This settles by itself too - after a
reimport the descriptor is newer than the zip, so the next launch skips again.

importSkinArchive takes replacingSkinID now and forwards it to importSkin,
which already knew what to do with it. Every other caller gets the default.
2026-07-28 20:18:32 +02:00
J1coding cc7d43831b iOS: pick up a skin's layout even when the manifest doesn't name one
Some skins ship a layout json without listing it, and a manifest damaged past
repair leaves us with nothing to look up either. In both cases the file is
sitting right there next to the art.

So when nothing is named, scan the package for jsons that are not one of the
manifest names and actually decode as a layout snapshot. The export payload
needs five keys to decode at all, so a manifest can never pass for a layout by
accident - the name check is only there to keep them out of the loop. Capped
at eight candidates and a megabyte each.

Exactly one match gets applied. Two or more and none is, with a warning saying
so, because directory order is not stable and picking whichever came first
would give a different layout on different runs. None at all is the normal
case for most skins and says nothing.

The layout is resolved to a URL first now and decoded in one place at the end,
so the named path and the fallback path share the same preset creation and the
same "skipped invalid" wording. It still sits after the image count check: a
lone layout json with no art is not a skin and should still be refused.
2026-07-28 20:18:32 +02:00
J1coding a42d7c0d62 iOS: ask before replacing a skin that's already installed
Re-importing a skin you already have quietly produced a second copy called
"Something 2", which is almost never what an author updating their own work
wants. Now the picker notices and asks: Replace, Keep Both, or Cancel.

The match is on the resolved display name from the manifest, not on the
filename the zip arrived under. Two zips of the same skin with different
filenames are the normal case - the author renames the file and the name inside
stays put. Catalog installs are excluded, since replacing one by hand would
leave its catalogID pointing at nothing and the browser would stop showing it
as installed.

Replace goes through importSkin(replacingSkinID:), which imports first and only
then retires the old descriptor, carrying per-game picks and layout assignments
across. A failed import leaves the skin you already had exactly where it was.
Keep Both is just the old behaviour, and Cancel skips the archive and says so.

The dialog matches the delete one already in this file. The import parks on a
continuation while it is up, holding the staging directory, so every button
resumes it and so does the dismissal binding if the dialog goes away without
one - it resumes at most once either way.
2026-07-28 20:18:32 +02:00
J1coding 57739f4d9c iOS: read malformed skin manifests instead of ignoring them
manifest(in:) was a plain try? decode, so a manifest with a stray control
character in it came back nil and the layout it named was never looked up. No
error, no warning, the skin just imported without its layout. Now it tries the
strict decode first and, only if that fails, runs the same byte repair the
extractor does and decodes again. It reports back whether the repair was
needed, and the import result carries a warning saying so, so the author finds
out their file is broken rather than wondering why the layout vanished.

The same file gets parsed in two more places on the way in. hasV2Marker and
the v2 manifest decode both used a strict parse, which means a genuine advanced
package with one bad byte was misrouted to the legacy extractor and lost every
non-image asset. Both retry through the repair now.

VPadSkinLibraryStoreError had no LocalizedError conformance, so the picker put
"The operation couldn't be completed. (ARMSX2.VPadSkinLibraryStoreError error
1.)" in front of anyone whose zip held no usable images. Both cases read as
English now; the picker already appends its own longer hint after it.

Last, SkinInstaller was throwing the whole warning list away with a _ =, so
none of this would ever have shown up on a catalog install. It keeps them per
skin now and the browser row shows a yellow badge that opens the same alert
the error badge uses.
2026-07-28 20:18:32 +02:00
J1coding cc54608055 iOS: stop the skin extractor choking on a hand-edited manifest
The extractor builds its list of permitted .json entries by parsing
manifest.json and reading the layout name out of it. If that parse fails the
list stays at just manifest.json, so the layout file is filtered out and never
written to the staging directory. One raw tab byte inside a string is enough:
JSON forbids unescaped control characters, so NSJSONSerialization rejects the
whole file and the skin imports with no layout and nothing said about it.

So try the strict parse first, and only if that fails walk the bytes and
substitute a space for any raw control byte sitting inside a string, then parse
again. Substituting rather than deleting matters - "Onimusha<tab>Series" comes
back as "Onimusha Series" instead of "OnimushaSeries". Nothing below 0x20 can
be a UTF-8 continuation byte and an escaped tab on the wire is two bytes
neither of which is in range, so escapes and multi-byte names survive.

Also skip __MACOSX entries and ._ sidecars, which the newer package extractor
already does. Every file in a zip built on a Mac has a ._ sibling and zip order
interleaves them one for one, so they were burning half the 64-entry
extraction budget - a skin past roughly 31 images was quietly losing real art.
They never produced a warning because Foundation hides ._ files from
contentsOfDirectory, which is why nobody noticed.

Last, admit unnamed .json entries too, capped at 8 and 1 MB each. A manifest
that names no layout, or one too broken to read at all, still leaves the layout
sitting next to the art, and the Swift side can only look for it if it made it
out of the zip.
2026-07-28 20:18:32 +02:00
J1coding 9dc0cf5745 iOS: fix the indentation on the new restart transition line
The line that stashes the card transition before the restart alert was indented
with spaces inside a block the rest of the file indents with tabs, so it sat out
of line with the two statements either side of it.

Only that one line. The space indentation further down in the same function came
in with the earlier launch transition work and is left alone here.
2026-07-27 20:52:49 +02: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
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
J1coding 54191be600 iOS: fix Legacy-mode SIGBUS in the JIT keepalive canary
Main-thread boot crash on iOS 18 under LiveContainer (Legacy W^X mode):
EXC_BAD_ACCESS KERN_PROTECTION_FAILURE in ValidateJITAlive, a strb of the
0x42 canary into the arena base page. Under a dual-mapping g_code_rw_base
is the RW alias and the bare store is exactly the probe we want, but under
an identity mapping it is the live r-x code page -- the EE dispatcher sits
at arena offset 0 once the idle prewarm has run. The prewarm reordering
exposed it: before it, the boot gate ran with no arena allocated and the
canary was silently skipped.

Scope the canary per mode: Legacy flips just the first page RW and back
via mprotect, reporting a failed flip as alive=0 (grant died) instead of
faulting; the MAP_JIT toggle mode uses Begin/EndCodeWrite. Every caller
runs with the VM parked, so the brief execute-drop cannot race JIT
execution. Also covers the 12s idle keepalive timer, which would hit the
same fault after a prewarm.
2026-07-26 13:48:45 +02:00
J1coding dac184c894 iOS: fix Legacy-mode SIGBUS on cross-window JIT code patches
Boot crash on iOS 18 under LiveContainer (Legacy W^X mode): CPU thread,
EXC_BAD_ACCESS KERN_PROTECTION_FAILURE in Arm64BaseBlocks::New -- a str of
a branch encoding into an r-x page. In Legacy mode armStartBlock flips
only the current block's 1 MiB window to RW, but New()'s link-repoint
loop, Remove()'s entry stubs and the backedge patch write into EARLIER
blocks, whose windows are execute-protected by then. PatchWord trusted
its callers to hold a Begin/EndCodeWrite scope; no caller on those paths
did. The toggle modes (macOS, Simulator) masked it because the compile
thread's write-protect is off arena-wide during recRecompile.

Give the patch primitive its own scope instead: armPatchCodeWord stores
through the dual-map alias and, when the target page is outside the open
emit window, wraps the store in a page-granular Begin/EndCodeWriteRange.
The page check matters -- the bump allocator routinely puts the previous
block's link site on the same page as the current block's start, and
RX-flipping that page mid-emit would kill the compile in a new way.

The whole-arena Begin/EndCodeWrite scopes that only existed to cover
those patches are gone with it. They were their own crash: Legacy range
windows bypass the refcount, so recClear's paired EndCodeWrite RX-flipped
the open emit window whenever the stale-overlap walk cleared blocks
mid-compile, and recClearIOP paid a whole-arena mprotect pair per covered
IOP store. The fastmem backpatch and the cold-island patches route
through armPatchCodeWord for the same reason, and microVU's code-cache
scope narrows to its own buffer span so an MTVU close can no longer
RX-flip an EE emit window open on the other thread.
2026-07-26 13:48:45 +02:00
J1coding 80782e3781 iOS: fix two warnings that were real bugs
MTLCopyAllDevices is iOS 18 only and we deploy to 17, so on 17 it's a null
weak symbol and GSDeviceMTL::Create crashes on it before ever reaching the
MTLCreateSystemDefaultDevice fallback. Guard both call sites with @available;
below 18 there's one GPU and nothing to enumerate.

CocoaTools::GetBundlePath was defined twice on iOS -- once in MacOSStubs.cpp
returning nullopt, once in HostImpls.mm returning std::string where the header
says std::optional<std::string>. Return types aren't mangled, so both are the
same symbol, the linker picked one, and DynamicLibrary.cpp read an
uninitialised has_value byte off the stack. HostImpls.mm has the real
implementation, so drop the stub and give it the declared signature.

CreateMetalLayer and DestroyMetalLayer were duplicated the same way.
CreateMetalLayer also returned void* against a bool declaration, which reads
back the low byte of a pointer -- fine until a layer lands on a 256-byte
boundary. Nothing calls it on iOS today (Vulkan is off) but it's the only
definition left, so make it match.
2026-07-26 13:48:45 +02:00
J1coding 7823d20d05 iOS: close the gaps around per-game stick inversion
Follow-up to the per-game inversion fix. Three things the first pass left:

Reset All Overrides didn't mean it. The save path only knows about the
compatibility keys, so virtual-pad layout, skin and stick inversion all
survived the reset. Clear them alongside. The per-game master toggle also
never counted an inversion override as an override, so a game with nothing
but inverted camera read as having no per-game settings at all.

stickInversion(for:) opened and parsed the per-game INI on every stick
sample. Cache the overridden keys instead and rebuild only when the running
game changes or something writes an override. Globals stay uncached, so
changing one still takes effect without a rebuild.

The Stick Inversion pickers were gated on the pad layout identity, which
comes from a different store than the file the pickers write to. Gate them
on the game-settings identity, the same flag the rest of the panel uses,
so the controls can't appear for a game they'd silently write nowhere for.

Adds perGameIdentityKeyForCurrentGame/ForISO to the bridge for the cache
key and the gate.
2026-07-26 13:48:45 +02:00
J1coding 2ee9eca871 iOS: fix per-game stick inversion silently discarding writes
PadTab always used the ...ForCurrentGame per-game INI accessors. Those
resolve the game identity from the running VM and return early when there
isn't one, so opening Per-Game Settings from the library made all four
inversion pickers read "Use Global" no matter what was stored, and threw
every write away without a word.

Thread savesToRunningGame and the boot ISO into PadTab and branch on them,
the same way every other per-game control in the panel already does.

Also:
- Drop the redundant ContainsValue probe in stickInversion(for:) -- the
  getter already falls back to the default, and this runs per stick sample.
- Say in the Stick Inversion footer that the tab writes as you edit it.
  Nothing in PadTab is staged, so Save staying greyed is correct, but the
  panel never told anyone that.
2026-07-26 13:48:45 +02:00
J1coding c5a28d8377 iOS: fix tab background toggle snapping and cross-tab loading
The per-tab background toggle (backgroundEnabledInBIOS/Help/Settings)
was switching the TabView content between two different view trees
on toggle -- the view with a background vs SafeAreaProtectedMenuTabContent.
SwiftUI saw two different trees and destroyed/recreated the
NavigationStack, snapping back to root and breaking adjacent tabs.

Always render each tab's view directly. Each view already manages
its own background ZStack internally, and NavigationStack handles
landscape safe area correctly on iOS 17+. No if/else branch switch
means the view tree is stable across toggles.
2026-07-26 13:48:45 +02:00
J1coding 8a9c1aaef7 iOS: fix RA toast crash on NSDictionary bridge
consumePendingRetroAchievementsToast used Dictionary(uniqueKeysWithValues:)
to bridge the pending notification NSDictionary to Swift, which crashes
with -[NSConcreteAttributedString count] when the dictionary contains
an attributed string (upstream dynamic UI changes can inject one).

Extract the known fields (title, message, badgePath, duration) by key
directly from the NSDictionary instead, building a clean Swift
dictionary with only the expected types.
2026-07-25 00:31:36 +02:00
J1coding fd92b8eaea iOS: fix SIGTRAP race in JIT alloc when universal TXM times out
The detached Universal TXM worker thread can still be stuck in
brk #0xf00d when the main thread falls back to legacy brk #0x69.
Previously the old SIGTRAP handler was restored immediately after
the legacy path, so a late trap from the worker hit the default
handler and killed the process.

Move the sigaction restore to AFTER vm_remap + mprotect complete,
and add it to every error-return path. This keeps our handler
installed during the entire allocation so late worker traps are
caught safely.
2026-07-25 00:31:36 +02:00
J1coding 90a9d14002 iOS: extract skin zip before importSkin
importSkin expects a directory of loose files, not a raw zip.
The previous code passed the downloaded zip URL directly, so
skinImportFiles returned [zipURL] and isSupportedImageFile
rejected .zip → noUsableSkinImages (error 1).

Now downloads the zip, renames it to .zip, extracts it via
ARMSX2Bridge.extractControllerSkinArchive to a temp directory,
then passes the directory to importSkin — matching the existing
manual import flow in VirtualPadSettingsView.
2026-07-25 00:31:36 +02:00
J1coding df4031016f iOS: fix skin browser — tappable error alert, full-screen preview, larger thumbnails
The orange warning triangle used .help(error) which is macOS-only
and shows nothing on iOS. Replaced with a tappable button that
opens an alert with the actual error message.

Preview thumbnails were 64x40 and not interactive. Now 80x50 and
tappable — opens a full-screen sheet with the skin preview.

The error alert will also reveal why a specific skin fails to
install (URL encoding, unsupported images, corrupt zip, etc.) so
the cause is visible instead of hidden.
2026-07-25 00:31:36 +02:00
J1coding effc6abc56 iOS: browse and install community controller skins in-app
Adds an in-app skin browser that fetches the community catalog
(bagasromadon/ARMSX2-CustomControllerSkins) and installs skins
through the existing VPadSkinLibraryStore.importSkin path.

SkinCatalog fetches manifest.json, filters to iOS-ready skins
(those with an iosLayout field). SkinInstaller downloads the
zip and calls importSkin(from:) which handles extraction, button
images, and the linked layout preset in one call.

SkinBrowserView shows a searchable list with preview thumbnails,
Get/Installed state, swipe-to-remove, and inline error display.
Reachable from Virtual Pad settings under Browse Skins.
2026-07-25 00:31:36 +02:00
J1coding bbdfe15aaf ci(nightly): run iOS build on macos-26 with Xcode selected
The nightly iOS job ran on macos-15 without selecting full Xcode, so
build-ios-ipa.sh bailed at its xcrun --sdk iphoneos --find metal check
(Command Line Tools have no iOS SDK). build-all's iOS job builds on
macos-26 after sudo xcode-select -switch and is green, so match that:
same runner + select /Applications/Xcode.app before the build.
2026-07-24 13:48:25 +02:00
J1coding ac786b03bd iOS: fix GetOSVersionString build break in HTTP user-agent
GetIOSVersionForUserAgent() fell back to Host::GetOSVersionString(), but
GetOSVersionString() is a free function (common/HostSys.h, DarwinMisc),
not a Host member. The other user-agent call sites already call it
unqualified; this one broke the iOS device build.
2026-07-24 13:09:17 +02:00
J1coding 2710346dad iOS: bump app version to 2.5.0 2026-07-24 12:15:28 +02:00
J1coding 3704ec939d iOS: re-measure overlay containers on rotation to fix the pause-menu squish
After a landscape-to-portrait rotation the in-game pause menu and the
per-game settings panel were laid out into a stale landscape size and
looked squished. The body GeometryReader is the only surface SwiftUI
re-measures on rotation (ARMSX2HostingController's viewWillTransition
nudge covers it); the conditionally-presented .overlay subtrees that host
those panels are not re-measured by that nudge.

Lift the orientation off the body GeometryReader via the existing
GameScreenSizePreferenceKey preference, store it in screenIsLandscape, and
key the per-game-settings and pause-menu overlay containers on it with
.id(screenIsLandscape). On a flip the id change rebuilds GameOverlayContainer
so its nested GeometryReader measures the now-portrait proposed size. The
rebuild is an instant swap with no presentation animation, masked by the
device rotation.
2026-07-24 12:15:28 +02:00
J1coding 38f4358b0c iOS: expose GS Back Thread mode in Graphics settings
Surface the GS Back Thread (GSBackThreadMode) setting in the Graphics
panel under a Performance section, with a picker for Disabled (default),
Inline Records and Lockstep debug modes, and Pipelined, which splits GS
emulation across a second thread on multi-core devices. The two debug
modes are flagged as much slower and not for play.

The setting is persisted and applied through the same Setting<Int> path
as the other EmuCore/GS options, using the guarded graphics-apply helper
so it cannot fire while SettingsStore is loading.
2026-07-24 12:15:28 +02:00
J1coding 622887f093 iOS: Frame Pacing presets, ProMotion unlock, and per-game overrides
Consolidated Frame Pacing surface over the EmuCore/GS, SPU2/Output, and
Framerate INI keys.

Presets and migration
  FramePacingPreset (Optimal / Smooth / Low Latency / Battery Saver / Custom)
  drives the individual keys through applyFramePacingPreset, which writes them
  via the existing clamped Setting setters. A one-shot migration moves a fresh
  install (or a user still on the PCSX2 v1.0 defaults) onto Optimal; anything
  customized is preserved as Custom.

ProMotion
  Unlock 120 Hz panels with CADisableMinimumFrameDurationOnPhone in Info.plist.
  SceneDelegate no longer tries to pin preferredFrameRateRange via
  -[UIUpdateLink initWithWindowScene:], a selector that does not exist and
  crashed every cold launch.

Per-game
  The per-game Frame Pacing tab mirrors the main-menu panel (preset + frame
  limiter + FPS target + vsync queue + sync-to-host + audio buffer/latency),
  each with a "Use Global" option. The FPS target maps to Framerate/NominalScalar,
  so the per-game bridge gains Float getters/setters and a shared encoder; a
  named preset applies its full profile per-game, including the FPS target.

Init safety
  Starting the adaptive-resolution controller reads SettingsStore.shared, which
  re-enters the swift_once token during SettingsStore.init and deadlocks
  dispatch_once, so that bootstrap is deferred to the next run-loop tick. The
  graphics Setting onSet closures route through requestGraphicsApplyGuarded,
  which no-ops while the INI is loading.

The Adaptive Resolution toggle is hidden from the UI (the controller stays
dormant). The pause-menu Frame Pacing panel is removed; Frame Pacing lives in
the main Settings and the per-game settings only.
2026-07-24 12:15:28 +02:00
J1coding 29190ff8eb iOS: custom background support for BIOS, Help, and Settings tabs
The Games library already rendered a user-chosen background behind its
list; the other menu tabs did not, and the background helpers that drew it
lived privately inside GameListView. This promotes those helpers to shared
code and lets the remaining menu tabs opt in.

  - New MenuBackgroundSupport.swift holds MenuBackgroundLayer (the edge-to-
    edge wallpaper view) and the menuBackgroundListRow modifier (the rounded
    material row backing used so list rows stay readable over a wallpaper).
    GameListView now uses these instead of its private copies.
  - BIOSListView, HelpView, and SettingsRootView draw the background inside
    their own NavigationStack when their per-tab toggle is on, matching how
    GameListView already worked. RootView (MenuTabView) no longer wraps those
    tabs in a background ZStack; a tab that owns its wallpaper must not also
    be wrapped in SafeAreaProtectedMenuTabContent, or the safe-area padding
    would clip the wallpaper.
  - Three per-tab toggles (backgroundEnabledInBIOS/Help/Settings, persisted
    in UserDefaults) are surfaced in Appearance settings under "Show
    Background In". Games keeps the background unconditionally; the others
    default off so existing users see no change until they opt in.

SafeAreaProtectedMenuTabContent is also tightened for iOS 26+: SwiftUI now
reports the correct landscape safe-area inset for a TabView page, so the
manual notch-clearing pad is only applied when SwiftUI itself reports no
horizontal inset. Applying it unconditionally doubled the inset on iOS 26+
and made the menu tabs feel cramped in landscape.
2026-07-24 12:15:28 +02:00
J1coding 80feae5f31 iOS: route RetroAchievements through native UI instead of ImGui FullscreenUI
The iOS app renders its own SwiftUI UI, so the shared core's ImGui
FullscreenUI overlay never appears on screen. Before this change every
RetroAchievements event still initialized FullscreenUI and posted the
notification through it, adding per-frame render work for an overlay that
is invisible on iOS, and the native toast layer never saw the events at all.

Add two Host callbacks so a platform can take over notification rendering:

  bool Host::HasNativeAchievementNotifications()
  void Host::OnAchievementNotification(key, duration, title, message, badge_path)

When HasNativeAchievementNotifications() is true the shared core hands each
RA event (unlocks, mastery, leaderboard start/submit/scoreboard,
login, connect/disconnect, summary) to OnAchievementNotification and skips
ImGuiManager::InitializeFullscreenUI() entirely — in BeginLoadingScreen,
ClientLoadGameCallback, DisplayHardcoreDeferredMessage, and
SetHardcoreMode — so the invisible overlay and its render loop stay down.
The existing ImGui path is unchanged for desktop/Android, which return
false from the new callback. Every frontend (eerunner, gsrunner, libretro,
sdl, qt, android, test stub, macOS stubs) gets a no-op implementation; iOS
provides the real one, posting the notification to its SwiftUI toast layer
through ARMSX2_PostRetroAchievementsNotification. The notification now also
carries the configured display duration.

Also flesh out Achievements::GetCurrentUserStats / GetCurrentGameStats /
GetCurrentAchievementList, which were previously unimplemented stubs
returning false. The iOS bridge already wired these up to the
RetroAchievements panel; they now return the logged-in user's score, the
active game's unlock progress, and a bucket-ordered achievement list so the
native panel has real data instead of an empty state.

Stray RetroAchievements debug fprintf spam in the iOS bridge and overlay
defaults is dropped.
2026-07-24 12:15:28 +02:00
J1coding 2e50c8bfbc iOS: gate MetalFX upscaler out of simulator builds
MetalFX.framework is not part of the iphonesimulator SDK, so building the
iOS app for the simulator failed at compile time on every MetalFX reference
(`<MetalFX/MetalFX.h>`, `MTLFXSpatialScalerDescriptor`, the cached scaler
members) even though the upscaler is correctly runtime-gated on real
hardware.

Introduce a compile-time switch that follows the SDK target:

- GSDeviceMTL.h defines PCSX2_HAS_METALFX (1 on device, 0 on sim) and
  wraps the MetalFX include, the m_mfx_spatial cache members, and the
  scaler function declarations behind it. The sim build gets a stub
  EnsureMetalFXSpatial that returns false, and DoMetalFXSpatial short-
  circuits the same way; m_features.metalfx_spatial keeps its default
  false so the UI reports the upscaler as unavailable.
- ARMSX2Bridge.mm gates its MetalFX import the same way (ARMSX2_HAS_METALFX)
  and isMetalFXSupported returns NO on sim without touching the descriptor.
- pcsx2/CMakeLists.txt only emits -weak_framework MetalFX for device or
  non-iOS builds; on sim there is nothing to link against.

Adds generate-ios-sim-xcode.sh, a simulator counterpart to the existing
generate-ios-xcode.sh, so the CMake-generated Xcode project can target
iphonesimulator (ARMSX2_REAL_DEVICE=OFF) for simulator debug and test
workflows. Device IPA builds keep using the existing scripts unchanged.
2026-07-24 12:15:28 +02:00
J1coding 6e5569a794 ci(nightly): add iOS IPA 2026-07-24 07:46:56 +02:00
J1coding 1cf72321ed iOS: Ignore unrelated UIKit text fields in SDL text input
SDL_uikitviewcontroller registers its UITextFieldTextDidChangeNotification
observer with object:nil, so textFieldTextDidChange: fires for every
UITextField in the app, not just SDL's own hidden field. The handler
unconditionally calls SDL_StartTextInput(window) (and the marked-text
path later calls SDL_StopTextInput), so any app-level text input --
notably SwiftUI-hosted login forms -- was stealing first responder on
every keystroke: the user typed one character, the keyboard was
dismissed and re-presented, and they had to tap the field again before
the next character.

Filter the notification at the top of the handler: if notification.object
is not this controller's textField, return early. SDL's own field still
goes through the existing password-manager workaround unchanged.

The SDL3 sources in this repo are vendored, not a submodule, so this
patch lives in tree until the next SDL sync.

Verified by typing into the RetroAchievements login sheet: each
keystroke now keeps first responder and the keyboard stays up for the
whole credential entry.
2026-07-18 17:46:15 +02:00
J1coding 6621011409 iOS: Restore fullscreen SDL window frame at landscape launch
At cold launch the app's root surface rendered as a left-aligned square
inside a landscape screen. The window was sized for portrait because
SDL 3.5.0's UIKit_CreateWindow creates the UIWindow via
[[UIWindow alloc] initWithWindowScene:scene], which inherits the
scene's bounds at a moment when iOS has not yet autorotated out of the
first plist orientation (portrait). SDL 3.3.0 used
initWithFrame:screen.bounds, which filled the screen regardless of the
scene's initial orientation. SDL_SetWindowSize is a no-op for this
path on iOS (only visionOS implements UIKit_SetWindowSize).

The SDL3 sources in this repo are vendored, not a submodule, so patch
UIKit_CreateWindow's scene branch with an #else companion to the
existing visionOS frame-setting block that assigns
uiwindow.frame = data.uiscreen.bounds. This restores the fullscreen
condition that UIKit_ComputeViewFrame's orientation-swap gate checks.

Under host containers that resolve orientation after the
rootViewController is created in willConnectTo:, rootVC.view can stay
pinned to portrait bounds. Add a one-shot re-sync in
sceneDidBecomeActive: that snaps rootVC.view to the window's bounds
when their orientations disagree, then lets Auto Layout propagate to
the SwiftUI child. It is a no-op on the normal launch path where
orientations already agree.

Verified by launching in held-landscape orientation on native sideload
and host-container hosts: the menu fills the screen instead of
rendering in a portrait-width square.
2026-07-18 17:46:04 +02:00
J1coding 818a513e95 iOS: Skip RetroAchievements re-initialization when already active
VMManager::Internal::CPUThreadInitialize() can run more than once in
a process lifetime on mobile hosts that tear down and rebuild the CPU
thread across game launches. Achievements::Initialize() asserts
`!s_client && !s_http_downloader` at the top, so a second call with
RetroAchievements enabled fired pxFailRel("No client and downloader")
and aborted the process. The crash only manifested on hosts that
re-enter CPUThreadInitialize without an intervening Achievements
shutdown, which is why desktop builds never hit it.

Gate the call with !Achievements::IsActive(), which checks the same
s_client pointer the assertion reasons about. The normal cold-start
path (s_client == nullptr) still calls Initialize(); a second entry
with the client already constructed is now a no-op, matching the
expectation the rest of the file already has.

Verified by repeated launch/exit/relaunch cycles on iOS with
RetroAchievements enabled: no abort, and the client initialises
exactly once per process.
2026-07-18 17:45:52 +02:00