Commit Graph
66 Commits
Author SHA1 Message Date
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
J1coding 9b569a6fc5 iOS: Keep keyboard focus while typing RetroAchievements credentials
Each keystroke in the RetroAchievements login sheet dismissed the
keyboard and forced the user to tap the field again before the next
character, which made password entry effectively unusable.

Two SwiftUI mistakes caused it. The sheet's username and password
fields were @Bindings back to @State in the presenting parent, and
the parent's body is what builds the .sheet content closure. Every
keystroke mutated the parent's @State, the parent body re-evaluated,
the sheet content closure re-ran, and SwiftUI rebuilt the sheet.
.presentationDetents([.medium, .large]) re-snapped to .medium on
each rebuild, dismissing and re-presenting the keyboard.

Separately, the parent's .onReceive handler for
ARMSX2RetroAchievementsStateChanged called refresh(), which mutates
more @State on a background cadence. With the parent already
re-evaluating on each keystroke, those notifications forced the same
sheet rebuild from a second source.

Make username and password sheet-local @State, initialised from the
parent's value at presentation so "Log In Again" pre-fill still
works, and pass credentials back through an onLogin(NSString, String)
closure. The parent's body is no longer invalidated by typing, so
the sheet is never rebuilt mid-input. Also guard refresh() with
`guard !showingLogin else { return }` so background notifications
are ignored while the login sheet is up. Switch to a single .medium
detent so the initial keyboard-appearance cannot trigger a one-time
re-snap either.

Verified by entering a multi-character password in the login sheet
without losing keyboard focus between keystrokes.
2026-07-18 17:45:52 +02:00
J1coding 445d7aee1b iOS: Use sandbox-safe shared memory for GS
GSAllocateWrappedMemory's POSIX branch called shm_open("/GS.mem",
O_RDWR | O_CREAT | O_EXCL, 0600) directly. The iOS application
sandbox rejects named POSIX shared memory in the system-wide
namespace, so shm_open returned -1, the function returned nullptr,
and GSLocalMemory::GSLocalMemory() aborted via
pxFailRel("Failed to allocate GS memory storage."). On hosts that
re-enter the CPU thread across launches, this surfaced as a game-
launch abort on the GS thread.

The rest of the codebase already routes shared-memory creation
through HostSys::CreateSharedMemory (pcsx2/Memory.cpp uses it for
EE/IOP RAM), whose Linux/Apple branch selects memfd_create on
Android, shm_open on desktop POSIX, and a file-backed TMPDIR
fallback on iOS so the same call works under the sandbox.
GSAllocateWrappedMemory was the only production caller bypassing
the helper.

Delegate fd creation to HostSys::CreateSharedMemory, drop the
Android-only memfd_create special-case and the redundant ftruncate
(both are handled inside the helper), and use
HostSys::GetFileMappingName so the name is PID-qualified instead of
the fixed "/GS.mem". The MAP_SHARED repeat-mirroring mmap loop is
preserved unchanged so the 4 MB GS VRAM still appears `repeat` times
at contiguous virtual addresses for the PS2 GS address-wrap
behaviour. GSFreeWrappedMemory is updated symmetrically to call
HostSys::DestroySharedMemory.

The Windows branch is untouched, and the caller contract (return
nullptr on failure; the caller's pxFailRel handles the abort) is
preserved.

Verified with the iOS build and repeated game launch/exit/relaunch
cycles: GSLocalMemory construction no longer aborts and the wrapped
memory layout is unchanged.
2026-07-18 17:45:36 +02:00
J1coding 7675402127 iOS: fix post-merge build (AppKit/IOKit/Darwin CDVD gating + MetalFX link)
Upstream's if(APPLE) blocks assumed macOS and broke the iOS build:
- AppKit/IOKit find_library + Darwin CDVD sources use macOS-only frameworks
  absent from the iOS SDK; gate them off iOS (iOS links its frameworks at
  the app target)
- Keep -weak_framework MetalFX for both platforms (iOS uses MetalFX)
- Stub the Achievements::GetCurrent* queries declared upstream but not yet
  implemented, so the iOS bridge links; return false (no data)
2026-07-16 23:39:54 +02:00
Jeen c9072d4f86 CI: remove push trigger from Nightly workflow
Nightly should only run on its daily schedule (08:00 UTC) and manual
dispatch, not on every push to master. The push trigger was added in
e2889838ab under the assumption nightly should run per-push; that
duplicates build-all.yml (which already runs on every push) and wastes
~2h of CI plus an unwanted nightly release on every commit.

Surgical edit only -- e2889838ab also added the changelog generator,
dual-core Android build, and PGO profile, which are correct and stay.
build-all.yml is unchanged (still fires on push to master + PR +
workflow_dispatch).
2026-07-13 18:14:10 +02:00
Jeen 8eada3b11a iOS: add opt-in dSYM CMake option
Add ARMSX2_IOS_DSYM (default OFF) to inject -g into Release builds so a
.dSYM can be extracted for crash symbolication. Off by default to avoid
the link-time and artifact-size overhead on every build; enable with
-DARMSX2_IOS_DSYM=ON when producing tester or debug builds.

The flag must go through add_compile_options (CMake's own flag model) so
the Xcode generator serializes it into each target's compile command.
Four alternatives were tried and all silently failed due to CMake bug
#15224 (the Xcode generator writes per-target OTHER_CFLAGS without
$(inherited), shadowing project/command-line values):
  - XCODE_ATTRIBUTE_DEBUG_INFORMATION_FORMAT on the target
  - CMAKE_XCODE_ATTRIBUTE_DEBUG_INFORMATION_FORMAT project-wide
  - CMAKE_C_FLAGS_RELEASE / CMAKE_CXX_FLAGS_RELEASE
  - OTHER_CFLAGS='$(inherited) -g' on the xcodebuild passthrough

Mirrors the existing if(LINUX) -g1 path at BuildParameters.cmake:225.
No CI change -- the option is purely for manual opt-in.
2026-07-13 17:22:24 +02:00
Jeen 8315f97a50 vtlb: guard fastmem mapping against missing reservation
On low-memory iOS devices (iPhone SE 2, 4 GB RAM, under LiveContainer)
the 4 GB fastmem virtual-address reservation fails at boot. The iOS
boot path correctly disables fastmem and leaves s_fastmem_virtual_mapping
empty. But mid-game, a configuration change (CheckForCPUConfigChanges ->
vtlb_ResetFastmem) can flip EnableFastmem back on, and
vtlb_CreateFastmemMapping then indexes the empty vector -- a NULL deref
that crashes the CPU thread at 0x200 (page 128 * 4 bytes). This was the
long-standing 'NULL+0x200' iPhone SE 2 crash, confirmed by symbolication
against the matching dSYM.

Add two defence-in-depth guards, both mirroring the existing empty-vector
guard in vtlb_RemoveFastmemMappings (commit eb18e0f188):

  1. vtlb_CreateFastmemMapping: early-return when
     s_fastmem_virtual_mapping is empty.
  2. vtlb_ResetFastmem: early-return when s_fastmem_area_unavailable,
     so a config change cannot repopulate mappings into a null area and
     the pointless VTLB_VMAP_ITEMS scan is avoided.

Cross-platform safety: both guards are dead code on non-iOS.
s_fastmem_area_unavailable is only ever set true inside
`#if TARGET_OS_IPHONE && !TARGET_OS_SIMULATOR` at vtlb.cpp:1379; every
other platform takes the #else branch that aborts boot on reservation
failure (Host::ReportErrorAsync + return false), leaving the flag false.
On platforms where the reservation succeeds, the vector is non-empty so
the .empty() guard never triggers. Verified safe on macOS, Linux,
Windows, Android.
2026-07-13 17:20:57 +02:00
Jeen 4bf3be1f69 GS: write JIT code through RW alias on iOS dual-map
The GS software renderer's ARM64 code generators (DrawScanline and
SetupPrim) handed the raw RX pointer to their vixl MacroAssembler. On
iOS 26+ devices using the LuckTXM JIT mode, executable memory is
dual-mapped: a read-execute alias for the CPU and a separate read-write
alias for the emitter, offset by g_code_rw_offset. Writing to the RX
page faults instantly (KERN_PROTECTION_FAILURE) -- the GS-thread SIGBUS
crash seen on iPhone 16 / iOS 27 beta.

The EE and VU recompilers already handle this via armGetWritableCodePtr
(pcsx2/arm64/AsmHelpers.cpp). Mirror that pattern in the GS path: a
file-local gsGetWritableCodePtr() helper adds the offset on real iOS
devices and is an identity no-op everywhere else (macOS, iOS Simulator,
Android, Legacy-iOS). The original RX pointer is kept as m_code_rx so
GetCode() still returns the executable entry point the rasterizer calls
into -- vixl's GetStartAddress echoes the constructor argument, so
without this override GetCode() would return the non-executable RW
pointer.

Cross-platform safety: the guard (#if __APPLE__ && TARGET_OS_IPHONE &&
!TARGET_OS_SIMULATOR) is byte-identical to the already-shipped EE/VU
helper. On every non-iOS path the helper returns the pointer unchanged,
and g_code_rw_offset is 0, so behaviour is identical to before. The x86
GS path (.all.cpp, selected by CMakeLists.txt:480-490 under ARCH_X86)
shares no code with these arm64 files and is not compiled on any arm64
target.
2026-07-13 17:19:50 +02:00
Jeen 04ea993e99 Merge remote-tracking branch 'armsx2/master' into ios/pr-ready 2026-07-13 00:41:14 +02:00
Jeen 805c161aaf iOS: merge ARMSX2/master into ios/pr-ready 2026-07-13 00:27:07 +02:00
Jeen ecd198419b iOS: fix Games navigation title truncating to Ga...
The inline navigation title 'Games' was being squeezed between the leading
'BIOS Only' button and four trailing toolbar items (import, layout menu,
covers menu, refresh), causing it to truncate to 'Ga...' on the nav bar.

Fix: remove the .navigationBarTitleDisplayMode(.inline) modifier so SwiftUI
uses the default large title mode, which renders on its own row and is not
affected by toolbar item crowding.
2026-07-12 19:14:13 +02:00
Jeen c6b7049664 iOS: disable EE block chaining to fix recompiler crashes
The direct-B block chaining system (s_eeBlockLinkEnabled) is a newer
recompiler optimization that patches direct branch instructions between
JIT-compiled blocks. The proven fork does not use this system and never
crashes, while the monorepo with block chaining enabled crashes on three
different devices (iPhone SE 2, iPhone 17, iPad Pro M1) with signatures
pointing to corrupt JIT-generated code:

  iPhone SE 2: CPU thread data-read translation fault at 0x200 (NULL+0x200)
  iPhone 17: CPU thread instruction-fetch translation fault at 0x100000000
  iPad Pro M1: GS thread write permission fault inside JIT RX code region

All three had JIT-generated recompiled code on the faulting thread's call
stack, one level above the crash site. The common factor is the recompiler
producing or consuming a bad pointer value through the new block-chaining
code path.

Fix: gate s_eeBlockLinkEnabled to false on iOS, falling back to the proven
LUT-indirect dispatch path (adrp+add+ldr+br). This is the same path the
fork uses and has been stable across thousands of gameplay hours. The
performance impact is minimal (one LUT lookup per block exit instead of
a direct branch). Non-Apple platforms keep block chaining enabled.

Root cause traced via systematic debugging:
  Phase 1: 3 crash logs analyzed across 3 devices/iOS versions
  Phase 2: full fork-vs-monorepo diff of aR5900.cpp/AsmHelpers.cpp
  Phase 3: block chaining identified as the only new JIT codegen path
2026-07-12 18:51:33 +02:00
Jeen 922772faf8 Revert "iOS: fix JIT keepalive timer running during gameplay"
This reverts commit b8e94ea84f.
2026-07-12 18:30:51 +02:00
Jeen b8e94ea84f iOS: fix JIT keepalive timer running during gameplay
The keepalive timer was stopped before VMManager::Initialize and skipped
validation while s_vmThreadActive was true. This was wrong: iOS can revoke
CS_DEBUGGED at any time, including mid-frame during active gameplay. When
revocation happened during gameplay, the protection on code and data pages
was silently flipped, crashing the CPU thread (instruction abort), GS thread
(data abort write fault on shared memory), and MTVU thread (translation
fault at null) simultaneously.

Fix: keep the timer running continuously at its 12-second interval during
all app states, including active gameplay. The validation cost is trivial
(one csops syscall plus one byte canary write). When revocation is detected
during gameplay, the timer posts a JITExpired notification and stops — the
next boot attempt will fall back to interpreter mode.

Root cause traced from crash log analysis on iPad Pro M1 (iPad13,8, iOS
26.5): God of War II crashed at frame 71, ~12 seconds after JIT acquisition,
with SIGBUS KERN_PROTECTION_FAILURE on shared memory writes from the GS
thread. Three threads faulted simultaneously, confirming a grant revocation
rather than a single-thread bug.
2026-07-12 18:06:15 +02:00
Jeen d57633224d iOS: merge upstream/master into ios/pr-ready
Resolve two conflicts:
  Threading.h: keep upstream's doc comment for SetNicePriority
  VMManager.cpp: keep our iOS-aware guard that suppresses the controller
  warning on both Android and iOS, replacing upstream's Android-only
  comment-out hack
2026-07-12 17:17:16 +02:00
Jeen ecf0e5c44a iOS: remove fork-only docs and merge device IPA build into build-all.yml
Remove the design specs, troubleshooting guide, changelog, and bringup brief that were fork-local planning artifacts not intended for the upstream monorepo. Merge the confirmed-working real-device IPA build from the fork-local ios_build.yml into the existing build-all.yml iOS job, replacing the simulator-only .app build with the iphoneos SDK device build that produces an unsigned .ipa named with the commit SHA. Remove the now-redundant ios_build.yml.
2026-07-12 17:06:54 +02:00
Jeen fb2c07eb6e iOS: add JIT resilience layer with keepalive, interpreter fallback, and boot watchdog
Build a comprehensive JIT resilience layer that prevents silent black screens when iOS revokes the CS_DEBUGGED grant after approximately 30 to 60 seconds of app inactivity. Add a ValidateJITAlive helper to DarwinMisc that re-probes CS_DEBUGGED via csops and writes a canary byte to the JIT RW alias to detect whether the mapping is still writable, covering the case where the flag lingers but the underlying grant is already dead. Add a 12-second dispatch timer in SceneDelegate that calls ValidateJITAlive while the VM is idle, skipping during active gameplay since the recompiler keeps JIT in constant use, and posts a JITExpired notification on detection. Add interpreter fallback to the boot gate so that when JIT is dead the app falls back to the pure EE, IOP, VU0, and VU1 interpreter instead of blocking boot, wiring up the previously dormant iPSX2_FORCE_EE_INTERP flag and fixing applyFullInterpreterPreset to actually write EnableEE equals false since CoreType was an iOS-UI-only concept the C++ core ignored. Skip executable code-memory allocation in Memory.cpp when in interpreter mode since the interpreter does not generate native code, and null-guard SetJitRange to prevent recording bogus JIT ranges. Add a 15-second VM init watchdog that catches TXM prepare hangs and shows an error dialog instead of leaving a permanent black screen. Add an 8-second timeout to the Universal TXM prepare path by running the brk number 0xf00d cycle on a detached worker thread with thread_local sigjmp_buf, falling back to the Legacy brk number 0x69 protocol on timeout. Add re-boot JIT revalidation so the persistent VM thread validates JIT before signaling, resetting to interpreter mode with s_vmThreadShouldExit to cleanly tear down and recreate the thread with paired CPUThreadShutdown. Restore recompiler settings and fastmem when JIT returns on next app launch. Add the design spec and a user-facing JIT troubleshooting guide documenting black screen causes, interpreter fallback expectations, diagnostic log markers, and workarounds.
2026-07-12 16:41:17 +02:00
Jeen eb18e0f188 iOS: fix fastmem NULL deref crash on low-memory devices and rotation layout break
Fix a NULL dereference crash in vtlb_RemoveFastmemMappings that occurred when the 4 GB fastmem virtual-address reservation failed on low-memory iOS devices such as the iPhone SE 2 under LiveContainer. The s_fastmem_virtual_mapping vector was never resized but was still indexed unconditionally by vtlb_Init through vtlb_VMapUnmap on every boot, dereferencing NULL. Add the same empty-vector early-return guard that the zero-arg overload already has, protecting both the boot path and the COP0 TLB-write runtime path. Fix the portrait and landscape rotation layout break by overriding viewWillTransition in ARMSX2HostingController to re-assign rootView inside the transition coordinator animation block, forcing UIHostingController to invalidate its internal sizing cache and re-measure for the new container size. Without this nudge, SwiftUI kept stale geometry after rotation, producing black bars, cropped viewports, and misplaced touch controls because the GeometryReader never updated and the Metal drawableSize stayed at the pre-rotation value. Revert the two-column pause menu layout in portrait for iPhones since the screen is too narrow even on Plus and Max devices, restoring single-column scroll while keeping two-column in landscape and on iPad.
2026-07-12 16:40:51 +02:00
Jeen 351479e7af iOS: add MetalFX spatial upscaler setting to global and per-game graphics settings
Wire up the MetalFX spatial upscaler as a user-facing setting across the iOS frontend. Add an isMetalFXSupported probe to the ObjC++ bridge that queries MTLCreateSystemDefaultDevice and MTLFXSpatialScalerDescriptor supportsDevice at runtime, using MRCOwned to avoid leaking the device handle under the codebase's manual reference counting. Add isMetalFXAvailable as a computed property and the upscaler Int property with an EmuCore/GS Upscaler INI config to SettingsStore, loaded in init and reload and reset in resetGraphicsDefaults, following the exact tvShader pattern. Add a new Upscaler section to the global Graphics settings view between Upscaling and Filtering, gated on isMetalFXAvailable so the entire section is hidden on unsupported devices. Add per-game Upscaler override to the per-game Graphics tab with the standard Use Global, Off, and MetalFX Spatial options using the minus-one sentinel convention, threaded through PerGameSettingsPanel state, fingerprint, save, and the GraphicsTab binding. Add the hasPerGameUpscaler and perGameUpscaler keys to the bridge dictionary builder so the preload path works without extra round trips. Add the design spec to docs/superpowers/specs.
2026-07-12 16:40:30 +02:00
Jeen 3c3915a70c iOS: enable MetalFX spatial upscaling on iOS 16+ and document in changelog
MetalFX Spatial (MTLFXSpatialScaler) has always been available on iOS 16+ at the Apple API level, but five compile and build gates were stripping the feature entirely on TARGET_OS_IPHONE, leaving iOS users with only plain bilinear stretching. Remove all five gates: hoist the MetalFX include out of the macOS-only branch in GSDeviceMTL.h so iOS gets it too, drop the PCSX2_MTL_USES_UIVIEW guard on the m_mfx_spatial member and widen API_AVAILABLE to macos 13.0 and ios 16.0, remove the TARGET_OS_IPHONE stubs for EnsureMetalFXSpatial and DoMetalFXSpatial so the real implementations compile on iOS and widen the availability check, unguard the supportsDevice feature probe, and add weak_framework MetalFX to the iOS CMake link branch mirroring the macOS pattern. The runtime supportsDevice probe is retained, so MetalFX cleanly no-ops on the iOS Simulator, pre-iOS-16 devices, and any GPU lacking the hardware, falling back to bilinear with a one-shot OSD notice. Update the OSD unsupported message and doc comments to mention iOS 16.
2026-07-12 16:40:01 +02:00
Jeen 3467e72dba iOS: bump version to 2.4.1, activate NEON SPU2 mixing, and port EE recompiler zero-register folds
Bump the iOS app version to 2.4.1 with build number 241 across CMakeLists.txt, Info.plist, and the SwiftUI about screen. Activate the dormant NEON SPU2 voice mixing and reverb backend by wiring up RegisterNEONBackend in InternalReset, which vectorizes volume application, voice accumulation, and clamping using int32x4_t NEON intrinsics with scalar fallbacks preserved. Port five zero-register fold fast paths in the ARM64 EE recompiler shift handlers (SLL, SRL, SRA, their variable-shift variants, and the 64-bit DSLLV, DSRLV, DSRAV) that emit a Mov to zero or a plain register move when the source operand is the zero register, avoiding unnecessary shift instructions.
2026-07-12 16:39:42 +02:00
Jeen 431ca0c063 iOS: port JIT and W^X foundation, refresh SwiftUI frontend, fix critical boot and display bugs
Port the complete JIT and write-xor-execute infrastructure to DarwinMisc with four JitModes (Simulator, Legacy, LuckTXM, LuckNoTXM), dual-mapping via vm_remap for writable code aliases, the csops CS_DEBUGGED probe for JIT availability detection, brk assembly helpers for the TXM protocol, and the W^X toggle functions. Connect the JIT foundation to the code emitters through AsmHelpers dual-map bridge, Memory.cpp MmapCodeDualMap allocation, and the aR5900 LegacyEnsureExecutable path. Refresh the iOS SwiftUI frontend from the iOS-refresh branch, bringing in 11 missing and 20 drifted Swift files plus ios_main.mm integration. Switch the CI to a real device build using the iphoneos SDK. Fix the Achievements crash by gracefully degrading when no HTTPDownloader is available (no CURL on iOS). Fix the Metal surface to reuse the UIView's existing CAMetalLayer instead of an orphaned allocation that caused half-screen crops. Fix GS memory allocation by using mmap and vm_remap instead of shm_open which is blocked by the iOS sandbox. Suppress the false positive Graphics not Automatic OSD warning. Merge upstream master and resolve all resulting compile errors.
2026-07-12 16:39:22 +02:00
Jeen 14fc29c0cd iOS: gate macOS-only APIs behind TARGET_OS_IPHONE and add frontend link stubs
Gate all macOS-only system APIs behind TARGET_OS_IPHONE checks across the shared core: ApplicationServices, IOKit, mouse APIs, AppKit, MetalFX, CDVD Darwin sources, USB, discord-rpc, cubeb CoreAudio HAL, and BSD networking headers in DEV9. Port TARGET_OS_IPHONE guards for DEV9 AdapterUtils with iOS fallbacks for sockaddr_dl, rt_msghdr, and sysctl. Add ARMSX2_ROOT to the include path for the frontend's common include style. Drop the global _M_ARM64 define that triggered fast_float MSVC intrin.h inclusion since __aarch64__ covers the core paths. Gate MTLFeatureSet_macOS_GPUFamily1_v1 behind !TARGET_OS_IPHONE. Fix missing unistd.h and gate pthread_jit_write_protect_np which is unavailable on iOS. Add stubs for DarwinMisc JIT diagnostics, Achievements stats and info APIs, Discord_Register, Host capture callbacks, and the host hotkeys map so the frontend links cleanly. Merge upstream master.
2026-07-12 16:38:59 +02:00
Jeen b5e581b1f3 iOS: remove Android and React Native source, wire CMake build system, and add self-contained build workflow
Strip the legacy Android, React Native, Java, Gradle, and res/ directories from platforms/ios that were inherited from the original port. Configure the CMake build for the iOS target with local module discovery, PCAP and CURL guards, rapidyaml source path fixes, Vulkan disabled, lz4 build flags scoped, libjpeg-turbo skipped on iOS, and the Qt UI and test runners turned off. Set CMAKE_SYSTEM_PROCESSOR to arm64 for cross-compilation. Add a self-contained GitHub Actions workflow that builds an unsigned IPA for real devices using the iphoneos SDK, named with the commit SHA. Merge upstream master to stay current.
2026-07-12 16:38:18 +02:00