Commit Graph
100 Commits
Author SHA1 Message Date
J1coding ce77e85dd6 Android: fix the miscased GL include that breaks every APK build
Every Android build has failed since the screensavers landed in 81d0a3e510.
The workflow still reported success, because the Android job is
continue-on-error, so nothing said so and no APK has been uploaded since.

shockwave.cpp asked for <gl/gl.h>. The vendored header is compat/GL/gl.h,
with an uppercase directory. macOS resolves that spelling and the Linux
runner does not, so the build dies on "fatal error: 'gl/gl.h' file not
found".

It is the only miscased include in the target. All 742 includes under
savers/ were checked against real directory entries rather than by testing
whether files exist, since a case-insensitive filesystem answers yes to
both spellings and would have hidden exactly this.

The line is also redundant. shockwave.cpp is never compiled on its own:
unit_shockwave.cpp wraps it in a namespace and already includes <GL/gl.h>
above it, so the corrected line hits the header guard and expands to
nothing. Its five sibling files carry no GL include at all and rely on the
wrapper, so deleting it would match them. These savers are vendored GPL
sources kept close to upstream, so this changes one character instead.

What this does not establish is whether the build then finishes. The error
has masked everything after it for days, so a second one may be waiting.
2026-08-23 12:03:28 +02:00
J1coding 1b737e25f0 iOS: give CI builds the RetroAchievements client identity
Nightly and MoonStore builds identify themselves to RetroAchievements as
stock PCSX2, so the server allows softcore only. Hardcore has worked on
locally built IPAs and nowhere else.

Nothing was broken. pcsx2/Host.cpp reads the client version from
ra_ua_secret.h behind __has_include and falls back to a stock agent when
the macro is absent. That header is gitignored, so it has never existed on
a runner.

CI now writes it from the IOS_RA_UA_VERSION repository secret, the way the
Android job already writes its keystores from NIGHTLY_RELEASE_KS_B64. A
missing secret warns and continues, because fork pull requests never
receive secrets and a nightly that fails to publish is worse than one
without hardcore. A malformed version fails the build instead:
RetroAchievements refuses a version it cannot order, and a refused agent is
indistinguishable from an unknown one on the client.

Only the nightly and pushes to master embed it. Pull request artifacts keep
the stock agent, so a test build is not one more public copy of an identity
that needs a release to revoke.

A second step reads the finished binary and fails unless the exact version
is in it, trailing space included, since a stale 1.2.3 is a prefix of a
current 1.2.345. Both iOS jobs are continue-on-error, so that failure does
not stop the workflow, but it does skip the upload that follows it, and the
publish step treats a missing IPA as absent rather than fatal. A softcore
build is not shipped.

None of this makes the version private. The compiler bakes the finished
agent into the binary as a plain literal, so anyone holding a build can
read it out. What the gitignored header prevents is a fork inheriting a
live identity straight from source.
2026-08-22 16:26:22 +02:00
J1coding be72a8e1eb iOS: put Download Shaders under Preset, where it can be reached
It shipped as its own Section on the settings page, which put it below the
parameter list. With crt-aperture selected that is twenty-two sliders and
about thirty-nine swipes, on the one control a tester had asked for by name.
Found by walking the screen in the simulator rather than by reading it,
which is the only way this kind of thing turns up.

It moves into ShaderChainSection, directly under Preset and above Install
Shader Pack, so the three ways to get a preset sit together in the order you
would try them: pick one you have, download one, install one from a file.

That also puts it in the in-game pause panel, which is a gain rather than a
side effect, and it works only because GameScreenView wraps the shared
section in its own NavigationStack. Without that every NavigationLink in the
section is dead on tap, Preset included, so the fence now checks for the
stack in the text immediately around the mount.

Immediately around, and not anywhere earlier in the file, because the first
version of that check searched backwards from the mount through the whole of
GameScreenView and any one of its several other NavigationStacks satisfied
it. Deleting the one that matters left the suite green. That is the third
time in this branch an assertion has been satisfied by a neighbour, and the
only reason any of the three were caught is that each new check was run
against a deliberately broken source before being trusted.

The row sits outside the enabled gate, which the first attempt at this move
got wrong. Every other row in that section is behind `if enabled`, and
putting the download row there too hid it whenever the chain was off -- so a
first run had nothing to select, no way to fetch anything, and no hint that
the toggle came first. Caught by relaunching with the chain off and looking,
one screenshot after the change built.
2026-08-18 23:41:53 +02:00
J1coding 4fa820435d iOS: decode a launch link's filename once, not twice
queryValue returns its answer already percent-decoded on both of its paths:
URLComponents decodes for the ordinary case, and the raw-query fallback
decodes by hand for callbacks that arrive unencoded. launchGame then decoded
it again.

A second pass reads a literal percent in the value as the start of a new
escape. 100%.iso percent-encodes to 100%25.iso, the first decode gives back
100%.iso, and the second sees % followed by .i, which is not hex, so
removingPercentEncoding returns nil. The guard falls through and tells the
player the link is missing a game filename, which is the one thing it
plainly carries. Only the launch route did this; exportLibrary reads the
same helper and does not.

The contract now sits on queryValue rather than being something each caller
has to know, since knowing it is what went wrong.

The fence pins more than the fix, because armsx2://launch?game= is not an
internal detail. libraryPayload hands that string to other frontends, which
store it and replay it much later, so the verb, the parameter name and the
encoding are a contract with software this repository does not control. It
also compares the schemes the handler accepts against the ones Info.plist
registers, in both directions: a scheme in code but not in the plist fails
silently, because iOS never routes the URL and the handler that would have
accepted it is never reached.

Six mutations run against the real source, all six caught, each restored
byte for byte. The scheme check needed the second direction to catch the
sixth; the first version iterated the known list and could not see an
addition.
2026-08-18 23:41:53 +02:00
J1coding 05d94ed82d iOS: download the RetroArch shader collection from inside the app
867 presets over 27 categories, one manifest and one zip each, and no third
request anywhere. A tester asked for what Manic EMU has: a button that
fetches the collection instead of making people find a zip and side-load it.
The closure resolution and the licence sign-off landed first; this is the
phone half.

Order is the whole safety argument, because a remote manifest is attacker-
controlled if the host is. The stated size is refused before the transfer
rather than after -- the manifest carries it, so the refusal costs nothing.
The received byte count and the SHA-256 are both compared before the
importer is called, and the hash is streamed rather than read whole. The
relative path is validated before it becomes a URL, because .. and / both
survive percent-encoding. Then the fenced extractor does the writing,
unchanged, so there is no second containment guard to get wrong.

The manifest is 8 MB raw and 312 KB gzipped, and 96% of those bytes are the
per-file array. The entry type does not declare that key, so it is skipped:
the zip carries its own hash and that covers every file inside it. What
lands in the cache is this build's own projection rather than the served
bytes, which is also what makes browsing work with no network -- a failed
refresh ages the list instead of emptying it.

Two things the import path never had. Staging files are swept at launch,
because defer does not run when iOS kills a backgrounded app mid-download,
which is the ordinary outcome and not an edge case. And cancelling removes
the pack if the extract already began, which is not the same as stopping it;
the comment says so rather than implying otherwise.

The importer returns the name it installed instead of only publishing it.
One property on a shared object is fine for one caller and wrong for a
screen with 867 rows and no reason to install them one at a time: two
installs overwrite each other's answer, which would write one entry's marker
into the other's folder and make cancelling one delete the other.

Three fixes in the code around it, from the same review. A loaded chain
owned a render target and a pipeline per pass and nothing freed any of it
when the player turned shaders off, because DestroyShaderChain had exactly
two callers, a preset change and device teardown. The Metal frame path
flushed on success and returned on failure, though a chain that failed
partway has already encoded passes into the same command buffer and needs
the submit for the same reason the success path does. And the pack extractor
held every file's bytes resident to the 32 MB cap, because the autoreleased
data was never drained inside the loop.

The catalogue is not published yet. The base URL is one constant, and an INI
key no UI writes can repoint it, accepting only https and file -- which is
how a simulator reads a local emit, since ATS refuses plain HTTP and there
is no reason to weaken it for a test.

Eleven checks in the new fence, six mutations run against the real source
and all six caught, each restored byte for byte. Still open and written down
rather than left to be rediscovered: the extractor's per-entry decompressed
cap is applied after the entry is fully inflated, so a crafted zip can spend
up to that cap before the refusal. Bounding it earlier needs a streaming
inflate.

The extractor's own fence gains an ordering claim. It asserted that a
canonical resolve appears somewhere in the method, which passes for a
resolve whose answer is discarded; deleting the entire containment refusal
left it green. It anchors on resolvedParent now, because the body carries
several refusal sites naming the same constants and anything looser is
satisfied by a neighbouring refusal that has nothing to do with containment
-- which is the same trap the first attempt at this fix fell into.
2026-08-18 23:41:53 +02:00
J1coding af30d18304 iOS: three defects the branch review found, two of them silent
Seven lenses over the branch, each finding then handed to a skeptic told to
refute it rather than confirm it. Thirty-one were raised. These three
survived and matter, and two of them fail without saying anything, which is
why none of them turned up in a device pass over a green suite.

Per-game write() set the enabled key straight from the picker, before the
guard that needs the preset to resolve. Choose On, delete the pack the
preset came from, then save any unrelated row on that game: the file keeps
the chain enabled and loses both preset keys, and an absent key in the game
layer falls through to the base layer. That game then renders the GLOBAL
preset. The type's own first comment says this never happens and boot-time
repair has always got it right; write did not. It is the worst kind of wrong
because it is invisible -- one CRT shader looks like another, so the player
sees a filter and assumes it is theirs.

A per-game preset never received its saved parameter values at all.
SettingsStore pushes the global tier's overrides at launch and on every
change, but a per-game preset is chosen in a file SettingsStore never reads,
so the game rendered the shader author's defaults and every value saved
against that preset was ignored. The boot repair already resolves that token
before bootISO, which is the one place that knows both the token and the
timing; pushStored is nonisolated now so it can be called from there without
hopping actors and losing the ordering.

Save as New Preset could destroy the preset it was saving from. The
reference it writes is relative to My Presets and the sheet pre-fills the
base's own name, so selecting a saved preset, nudging a value and accepting
the default replaced that file with one whose only reference is its own
filename. Nothing resolves that, and the values it held are gone. It refuses
now, in the write path, which is the only place that can see both the target
and the base.
2026-08-18 23:41:53 +02:00
J1coding 30d9816eda iOS: resolve the shader catalogue off-device, and sign what it may ship
The half of the downloader that cannot run on a phone: a generator that
turns any preset in a pinned slang-shaders tree into a complete, path-safe,
licence-classified file closure, and refuses to emit anything until a person
has signed the rules it would be built from.

Resolving a closure means walking includes and references across a
5,000-file tree. Over the GitHub API that costs two to five requests per
preset against a 60-per-hour limit keyed to the originating IP rather than
to the app, so every user behind one carrier NAT shares one budget. On a
local clone it costs fifteen seconds of CPU and no network at all. That
asymmetry is the whole design.

emit refuses without a signed rules file recording the pin, so the catalogue
cannot physically exist before the nine class questions were answered. Six
were confirmations of rules the bundled sixteen already ran under. Three had
never been decided and were worth 577 presets between them, and the one that
mattered was whether a LICENSE file governs the directory it sits in --
worth 552 on its own, and exactly the inference the standing rule exists to
refuse. Admitted, with the reasoning in the signed document rather than
here.

Of 2,553 presets in the tree, 867 are offered: 13 dropped on upstream
defects, 8 on an extension the extractor will not write, and 1,665 excluded
by class. Fourteen presets the earlier hand audit had measured agree row for
row on file count and on upstream bytes, which is the free correctness check
on all of it.

The whole-tree run found the divide-by-zero prescale in ten more files than
the two bundled ones, refusing 98 presets. Twelve sites and not ten, because
the scanner reports one per file and clamping the first in crt-potato and
ultra_potato made a second visible in each; the scan was re-run until it
came back empty.

All twelve now carry a notice in the file itself saying it changed and when.
ATTRIBUTION.md covers the bundle and covers nothing once the same file
travels in a zip on its own, which is where GPL section 2(a) asks for the
notice anyway. The first wording of that notice said the change was "one
max() and nothing else", and the guard test looked for max() anywhere in the
file -- so the comment describing the fix satisfied the test that checks the
fix exists. Both were changed: the notice says clamp, and the test now
requires the guard on a line that actually matches the prescale pattern.
2026-08-18 23:41:53 +02:00
J1coding e2b1bcaea2 iOS: let one game keep its own shader preset
A preset was a single global value, so picking crt-geom for a 2D fighter
also applied it to the next 3D game booted. The per-game subsystem is the
right home; the blocker was that its bridge exposed Int, Bool and Float and
no String, while a preset is a string token.

Four String accessors added, in the forISO and the current-game shapes the
twenty existing per-game settings already use. A Shaders section on the per-
game Graphics tab, where the global Shader Chain section sits, on the same
tri-state sentinel every other control there uses: use global, off, on.

Scope is preset only, decided with both arms in front of the developer.
Parameter values stay global and stay keyed by preset token, and the panel
says so on screen rather than leaving it to be discovered. A preset exposes
up to twenty-two values and per-game copies of those would multiply the
storage and the UI.

The identity is the same root token the global tier uses, re-rooted at boot
before bootISO reads the file, so a per-game choice survives a reinstall for
the same reason a global one does. ShaderPresetLibrary.resolve stays the
only token-to-path resolver; nothing here reimplements containment.

The rule that matters, and the one the fence exists for: a token that no
longer names a file turns the chain off for that game rather than falling
through to the global preset. A different CRT shader looks like a CRT
shader, so a substitution is invisible -- the player sees a filter, assumes
it is theirs, and never learns their choice is gone.

Six source checks over the six files the selection lives in, and four
mutations run against the real source with every restore byte identical.

Also here, because it landed in the same wave: the prescale fence widens to
.inc and .h. A .slangp names its stages, but a stage includes whatever it
likes, so that bug can sit in a header and never appear in a .slang -- which
is exactly where the whole-tree catalogue run found it.
2026-08-18 23:41:53 +02:00
J1coding 7e8f7f1955 iOS: give the shader controls their own page and a pause-menu route
Four things a tester asked for after playing the first build, and the two
defects found while building them.

Shaders are their own settings page rather than a section inside Graphics.
The section already took its persistence from the caller, so this is a move
and a root row.

The same controls reach the in-game Quick Menu, under Game Tools rather than
Quick Actions. Not a drop-in: the settings section is Section-shaped and
embeds a push, while the Quick Menu is card-shaped with no navigation stack,
so it routes out to a sheet the way the speed panel does. A test holds it to
that shape, because the shape is the thing that works rather than an
implementation detail.

Parameter rows use NumberRow, the control eleven other settings files
already use, so a value can be typed instead of only dragged. Detents, units
and the reset affordance come with it.

Every label is translated into the nine languages beside English.

Then the two defects. A preset's saved values reached the core only when a
shader screen was open, so a cold launch rendered the author's defaults
until the player visited the page -- proven by measuring frame luminance
across a launch rather than by reading the code. And the guard that keeps
SettingsStore.init off SettingsStore.shared was recovered from an orphaned
commit and turned out to be broken: a plus-or-minus 400 character window let
an allowlist entry cover its neighbour, so the test would not have caught
the crash it was written for. It requires the match to span the access now.
2026-08-18 23:41:53 +02:00
J1coding d24e9d76ed iOS: clamp a shader's prescale so upscaling cannot blacken the frame
crt-aperture and sharp-bilinear each derive a whole-number prescale from
output height over source height and then divide by it. RetroArch only ever
feeds them a small console framebuffer being scaled up, so that ratio never
falls below one. PCSX2 renders internally at up to 8x: past roughly 1.5x on
a phone the source is taller than the screen, the ratio drops under one,
floor() returns zero and the divide yields NaN. The whole frame goes black.

Reported on an iPhone SE 2 with a 1334x750 window, where 1.5x rendered and
2x did not. Two hypotheses were wrong first -- push-constant placement, then
parameter placement -- and both were refuted by tester data before the
reporter supplied the actual trigger, which was the internal resolution and
not the preset. Reproduced in the simulator at 3x and fixed there.

The clamp is what the sibling sharp-bilinear-simple already carries as
max(floor(...), vec2(1.0)) and what crt-geom carries as clamp(floor(...),
1.0, 2.0). Nine of the eleven bundled presets never divide by a derived
scale and were unaffected.

These files are otherwise byte-verbatim copies of a pinned upstream commit,
so the divergence is a reversible patch beside the librashader one and a
note in ATTRIBUTION.md. The test fails if either guard is dropped, which is
what a re-sync from upstream would otherwise do silently.
2026-08-18 23:41:53 +02:00
J1coding 16b571cf72 iOS: put the shader chain and its parameters in settings
A section in Graphics after Shade Boost, matching pipeline order, a folder-
at-a-time preset browser, and every parameter a preset declares on screen.

The section is absent rather than disabled in a build without librashader,
gated on a bridge capability, so a cargo-less build does not advertise a
feature it cannot run.

Every number in a preset's parameter block is the shader author's, so every
number is treated as hostile. Absent, non-finite, inverted ranges and a zero
step all occur in the published collection. A parameter whose range cannot
be made sense of is dropped rather than rendered as a control that does
nothing.

Pushing a value sends the effective value of every parameter, not only the
changed one. librashader has no unset call, so a name dropped from the
override map would leave the chain on whatever was pushed last and a reset
would never take.

A tweaked preset can be saved as its own file: a #reference to the base plus
the changed values, written into My Presets inside the scanned root so it
becomes selectable with no extra plumbing. The reference is relative while
the base is in Documents, so the pair survives the container moving; a
bundled base gets a path instead, which a reinstall breaks, and the sheet
says so.

The naming sheet is .sheet(item:) rather than .sheet(isPresented:), because
the parent's body invalidating tears the content down and takes keyboard
focus with it, which is the failure this codebase has a rule about.

Also here: the once-cached name lists are owned rather than read after free,
and librashader builds for the simulator as well as the device, which is
what makes any of this testable without hardware.
2026-08-18 23:41:53 +02:00
J1coding f23ddabf1e iOS: run RetroArch shader chains on the Metal renderer
librashader built for arm64 and pinned, wired into GSDeviceMTL, and a preset
library behind it that can name a file the same way twice across a
reinstall.

The chain runs from DoApplyShaderChain, after ShadeBoost and before present,
on the same ping-pong the FXAA path uses. Two things about it are load-
bearing rather than incidental. EndRenderPass comes first, because the chain
opens its own passes and Metal aborts if ours is still encoding. And
FlushEncoders comes last, because librashader recycles its per-frame objects
over a ring shallower than our deferred-submit window, so a chain frame has
to end the batch. A failure latches on the preset that caused it, or a
preset that will not compile recompiles every frame forever.

Static archive rather than dylib, decided by building both against a working
tracer and measuring, and the loser was deleted rather than left as an option.

The library underneath is where the reinstall problem lives. Both preset
roots sit under a container UUID that changes on every sideload, so a
selection stored as an absolute path is stale within days. A preset is
stored as a marker plus a root-relative path -- bundle: or data: -- and re-
rooted at launch. The separator is a colon because Files refuses one in a
name and it is not a path separator, so the relative half never needs
escaping.

Packs come in as a picked zip or folder through an extractor that keeps the
directory tree, because a .slangp names its stages by relative path and the
tree is part of the pack rather than an arrangement of it. That is the
opposite of the skin extractor's flattening policy, so a test fences the two
apart. Sixteen presets ship in the app, each cleared against its own header
rather than a blanket grant.

librashader's own cache goes to Library/Caches through XDG_CACHE_HOME, set
before anything loads it. Latent today because the Metal runtime never
reaches that cache, but a pin bump that adds caching would otherwise put a
disposable file somewhere iOS can neither purge nor keep out of a backup.
2026-08-18 23:41:53 +02:00
J1coding 788a59d641 iOS: bump to 2.5.3 2026-08-07 11:15:34 +02:00
J1coding 33978664d5 iOS: add disable depth emulation to per game settings
A Use Global, Off, On row in the per game Graphics tab, under Hardware
Fixes and Display beside Hardware Download Mode. The generic write
helpers already keep the claim mask in step for it, so the setting
survives the mask and the game database without manual hacks mode.

Asked for by a user who noticed the global screen had it and the per
game panel did not.
2026-08-07 11:15:34 +02:00
J1coding 5be719f8ac iOS: pin the hardware fixes rows
The eleven newly claimable hacks join the pinned key table and the hack
state table, the five picker and number rows go through the claiming
binding, and the bool rows claim from their shared write funnel.
Unpinning any of them puts the default back, and resetting all settings
drops the claims along with the values.

Every one of these rows wrote its value and then watched the mask throw
it away on the next settings load. The hack state table entries are
what give them the effective value note and the way back to the
database value.
2026-08-07 11:15:34 +02:00
J1coding fc4d1eb44d GS: let players claim the remaining hardware fixes
Eleven more hacks get a claim bit: palette conversion, depth support,
framebuffer conversion, read targets on close, the 24 bit depth limit,
texture region estimation, draw buffering, both CPU sprite render
values, CPU CLUT render and GPU target CLUT. They move behind the same
keep guard the sprite hacks use, and the ten with a database fix id map
across to it.

Every one of them was cleared on each settings load whenever manual
hacks were off, which is the default, so a frontend row for any of them
did nothing at all unless the player also turned the database fixes off
for that game. Disable safe features and disable render fixes stay
unconditional, since no frontend exposes them.
2026-08-07 11:15:34 +02:00
J1coding 06bb419db9 iOS: shorten the longest comment blocks in the hack code
Tightens the new comments this branch added and the longest blocks it
sits next to, so every file it touches lands at or under the comment
density it started with.

Two of the trimmed blocks described what the code no longer does, which
is the kind of comment that goes stale without anyone noticing.
2026-08-06 17:10:11 +02:00
J1coding f7ca80075f iOS: assert the hack claim plumbing in tests
Three source assertions in the style of the descriptor tests: every
hack the bridge reports pins from its global row, every reported hack
is in the per game claim derivation table, and the writer never grows
a global claim merge again.
2026-08-06 17:10:11 +02:00
J1coding 7e6c5dd81e iOS: pin the remaining hacks the database can override
Texture Inside RT, Native Scaling and Bilinear Upscale go through the
claiming binding, the three bool hacks with pin bits claim on write,
and the bridge's hack table grows their entries so the notes and the
unpin button work for them too.

These had pin support in the core since the claim mask landed, but no
frontend ever set their bits, so the database kept overwriting them
the same way it overwrote align sprite.
2026-08-06 17:10:11 +02:00
J1coding 9fd49d3356 iOS: reset a hack's value when unpinning it
Use the game database value now writes the hack's default back through
the store property before dropping the pin, so the row keeps showing
what the INI actually asks for.

Unpinning alone left the old value in the INI while the core masked it
out and the database re decided, which read as a toggle that says on
and does nothing.
2026-08-06 17:10:11 +02:00
J1coding d30c4d61d4 iOS: let pinned hack rows work without manual mode
Half pixel offset, round sprite, the three sprite pickers and both
texture offsets now only need the per game master toggle, since a per
game value claims its own fix and the database steps aside for it.
Skipdraw keeps the manual hacks gate, and the captions now say only
skipdraw needs it.

The old gate is the closed loop from the report: rows went grey while
still showing on, the save kept writing them, and there was no way to
turn the hack off without flipping the whole panel off.
2026-08-06 17:10:11 +02:00
J1coding a7f6ca2395 iOS: make the per game sprite hacks tri state pickers
Align Sprite, Merge Sprite and Wild Arms Offset drop their override
plus value toggle pairs for the same Use Global, Off, On picker every
neighbouring row uses. The bridge takes one int with the use global
sentinel per hack, and the generic per game helpers keep the claim
mask in step whenever they touch a pinned hack key, which also covers
Texture Inside RT from the compatibility tab.

Two toggles per hack left an on state visible and stuck whenever the
rows were disabled, which is the shape of the report that keeps coming
back. One picker always names its state, and Use Global is always one
tap away.
2026-08-06 17:10:11 +02:00
J1coding 81a9e59b87 iOS: refresh the hack note after a per game save
Both per game reload paths now recapture the effective hack state after
the settings apply, the same call the global apply already makes.

Without it the graphics screen's hack notes kept describing the world
before the save, so even a change that worked read as stuck.
2026-08-06 17:10:11 +02:00
J1coding 54fe4ad3fe iOS: write only the game's own hack claims to its file
The per game writer derives the claim mask from the hack keys actually
present in the file instead of enumerating them, and stops folding the
global mask in, since the core now carries that across the layers. A
file whose stored mask disagrees with its keys is repaired when the
panel reads it, the same way the stale MTVU key already is.

Freezing the global claims into every game file meant a later global
unpin never reached those games, so the database fixes they should have
gone back to stayed suppressed for good.
2026-08-06 17:10:11 +02:00
J1coding 79a12a4194 GS: fold base hack claims back in under a game file
LoadCoreSettings ORs the base layer's UserHackOverrides into the mask it
just loaded, before the masks run.

The game settings layer replaces that key rather than merging into it,
so a game with its own file silently dropped every claim the player had
made globally, and the database took those hacks straight back. The
frontend used to compensate by freezing the global mask into each game
file at save time, which is how stale claims ended up pinned there
forever.
2026-08-06 17:10:11 +02:00
J1coding d0761f7285 iOS: give the menu reveal tap a surface that works on iOS 27
The reveal tap moves onto a clear SwiftUI shape overlaid on the game
view, and the dynamic input zones report their classified taps through
a notification the game screen listens for.

The old gesture sat on the Metal render view, which iOS 27 makes non
interactive so the SwiftUI overlays own touch. On that OS the tap
could never fire, and once the controller poll was gone there was no
path left to bring a hidden menu button back. The zones matter for the
same reason: with dynamic thumbsticks or swipe camera on they tile the
whole landscape screen and eat every tap before it reaches anything
below.
2026-08-06 12:43:48 +02:00
J1coding 2697dfb97d iOS: move the per game renderer picker to the graphics tab
The picker sits at the top of the Graphics section now, above Internal
Resolution, where the global screen keeps its own renderer section.
Fixes and Compatibility starts at Accurate Alpha Test, and its footer
drops the clause about the renderer needing a reset.

Testers kept looking for it under Graphics, which is where every other
renderer adjacent setting already lives.
2026-08-06 11:52:40 +02:00
J1coding 77c91fb8c5 iOS: save the per game renderer through the shared write path
The renderer write goes through the same useCurrent helpers as every
other per game key, and CheckForConfigChanges keeps a running game on
the renderer it booted with, iOS only.

Mid game the old forISO write resolved identity through a cache only
lookup that can silently miss, and even a hit can land the key in a
different file than the panel reads back and the boot path loads, so
the selection quietly vanished. The file only bypass never dodged the
live apply either: sibling writes in the same save trigger the same
debounced reload, and a renderer change in that reload would reopen GS
and tear the Metal device down under the running game.
2026-08-06 11:52:40 +02:00
J1coding 2559745f9c iOS: keep the hidden menu button hidden until a tap asks for it
Hide Menu Button is a real preference now. A tap on the game view shows
the button for four seconds and it fades back out, the controller input
poll is gone, and nothing writes the setting off behind your back. The
quick menu hint and the settings screen caption describe the new
behaviour, and the orphaned status message leaves the translation
tables.

Any controller input restored the button through a tenth of a second
poll, and any stray touch outside the pad brought it back too, each
time flipping the persisted setting off with it. The poll also never
bought a controller player anything: restoring the button still took a
screen tap to actually open the menu.
2026-08-06 11:43:03 +02:00
J1coding 1d5b367ecb iOS: match the root view to the window before the first frame
Move the orientation snap into syncRootViewToWindow and call it before
makeKeyAndVisible, after the SwiftUI menu is attached, and on
sceneWillEnterForeground as well as sceneDidBecomeActive. Compare the
two sizes rather than their orientation class.

A host container connects the scene while the app is still backgrounded,
where UIKit has no interface orientation to resolve, so the window takes
the real landscape bounds while its root view controller starts portrait.
Correcting that only once the scene was active left the menu squeezed
into a portrait strip for everything drawn before it.
2026-08-05 23:41:33 +02:00
J1coding 4eaf4e2dd5 iOS: load each icon preview once and simplify the picker
Cache the preview images instead of reopening every PNG on each pass of
the Form. Size and clip the thumbnail in one place so a non-square
preview cannot spill over the row. Drop AppIconManager for the async
setAlternateIconName the SDK already exposes, fold the failure alert
onto the option it belongs to, drop a section header that repeated the
navigation title, and add the inline title the other settings screens
use.
2026-08-05 22:31:16 +02:00
J1coding 74992820b5 iOS: give the app icon picker its own settings pane
Add an App Icon row to the Interface section, below Appearance, and a
matching SettingsPane case wired to AppIconSettingsView.

The picker used to open from a link at the top of Appearance. Rewriting
that screen dropped the link, and nothing else pointed at the view, so
it has been unreachable since. A pane cannot go the same way: the
detail switch is exhaustive, so leaving one out fails the build.
2026-08-05 22:31:16 +02:00
J1coding 1169f4f91a iOS: pin the interface to dark instead of following the system
Sets UIUserInterfaceStyle to Dark in the Info.plist template, so the app comes
up dark whatever the device is set to. One key covers the launch screen, the
window SDL creates, every UIKit alert and picker, and the SwiftUI tree, so no
view has to opt in.

The library, the BIOS list and every settings screen followed the system, while
the pause menu, per game settings and the gameplay surface were already dark
from their own palette. On a light device the two halves did not match, and the
save states and speed sheets came up white over a running game.
2026-08-05 18:44:49 +02:00
J1coding 95e13aa60a iOS: shorten the longest comment blocks in the settings code
Cuts the fifteen line block on Setting, the eight line one on NumberSetting and
the seven line one in NumberRow's accessory column down to the two or three
lines each of them needed. No code changes.
2026-08-05 17:10:21 +02:00
J1coding 324056d2c1 iOS: stop the keyboard and rotation breaking the per-game panel
The overlay container publishes how much of the card the keyboard covers
instead of insetting the hosted content itself, and the per-game panel
applies that inset inside its own reader, below the point where it picks one
column or two. The container's two card arms become one. The per-game overlay
no longer carries an id on orientation. The panel's rail and its form share a
single property for which section is open, with the form driving it through a
navigation path. The game screen and emulation only mode take orientation and
the portrait viewport split from the window rather than the safe region, and
the fullscreen sync that rode along on the size preference gets its own
trigger.

An inset applied from outside shrank the box the panel measured itself in, and
the panel picks its layout from that box, so a keyboard flipped it to the
landscape rail: wrong layout, navigation back at General, content clipped, and
a gap where its background stopped short of the card. The id and the two arms
each rebuilt the panel on a flip and took any unsaved edits with them.
2026-08-05 17:10:21 +02:00
J1coding f4e6452141 iOS: enforce the two ranges that were only named
vsyncQueueRange and casSharpnessRange are used by the stepper and by
the per-game panel, but neither was on its global descriptor's codec,
so the global path was the odd one out. The core does not clamp
either: VsyncQueueSize is read with a plain SettingsWrapEntry and used
raw in MTGS.

Only reachable by hand editing the INI, so this is insurance rather
than a fix. Checked it anyway: wrote VsyncQueueSize = 99 and
CASSharpness = 500 into the file, launched, and the rows read 16 and
100%. The file still says 99 and 500 afterwards, because nothing
rewrites it on launch, so someone's INI is only corrected once they
change something themselves.

Both ini gates stay empty, since 8 and 50 are already inside their
ranges and every frame pacing preset writes 2, 4 or 8.

The comment calling this a gap goes with it.
2026-08-04 18:33:28 +02:00
J1coding 0bdacf3bdb iOS: tell people GS Back Thread needs a restart
The core counts GSBackThreadMode in RestartOptionsAreEqual, so a
change while a VM is live goes down GSreopen and tears the Metal
device down under the running game. That is the same reason the
renderer is boot only, and the renderer says so in its picker. This
one said nothing, so you change it, nothing happens, and there is
nothing on screen telling you why.

Nobody can reach the teardown today, because the graphics apply is a
no op without a live VM and the only way out of a game is Stop
Emulation. bootOnly closes it anyway and costs a line.

The setting still writes to the INI exactly as before. bootOnly only
takes away the nudge to the running VM. Checked on the simulator:
picked Pipelined, the key landed, quit, relaunched, and the picker
came back on Pipelined.

No translations. The whole section falls back to English already,
picker label and all four options included, and translating one
footer while the label above it stays English reads worse.
2026-08-04 18:33:28 +02:00
J1coding 72f95b7534 iOS: keep the next setting from drifting the way the last ones did
Regex over the source, same shape as the other two tests in here. No
build impact, nothing to wire up. It is the only thing in this branch
that constrains the next setting anyone adds.

Twelve checks, and they earned their keep straight away. Two
descriptors disagreed with the value their own property starts at:
the OSD position declared a named constant then started at a bare 3,
and the JIT protocol declared the by-version default then started at
.legacy regardless. Swift will not let the initializer say
_xConfig.defaultValue, so the duplication has to stay and the test is
what keeps it honest.

It also found that a descriptor could inline its own read and write
pair straight into SettingCodec, which is the exact asymmetry the
type exists to prevent, and that four exemptions could rot without
anyone noticing.

Four settings load by hand on purpose and are listed as such, so
adding to that list is a decision rather than something a new setting
inherits by sitting next to one. Five migration reads are listed the
same way: a migration wants the value as it is on disk before
anything loads, sentinel and all.

I broke the tree twelve ways to watch each check fail, including
renaming the _xConfig convention, which used to make the whole suite
pass on an empty set in three milliseconds.

The reset functions still read fxaa = false rather than spelling the
descriptor out. The literal is easier to read and the test is what
stops it drifting.

Also wrote down what init() actually does, because the comment above
it said the opposite. Assignments there do not fire their didSet, so
nothing writes back while the INI loads. That matters most if you are
about to tidy init() into per section helpers, where they would fire,
and every non suppressible setting would start writing itself to disk
on every launch. Measured with a probe inside commit rather than read
off the language reference: zero calls across a launch, one call from
one toggle in the same run with the same probe.

The two dozen widest setter lines wrap now. commit(_xConfig, x) names
the setting three times and didSet gives you no newValue to shorten
it with, so the longest ran to 177 characters.
2026-08-04 18:03:54 +02:00
J1coding 0da4a18a65 iOS: let the JIT protocol descriptor pick by iOS version like everything else
Both reset paths use JITScriptProtocol.defaultValue, which answers
universal on iOS 26 and up and legacy below it. The descriptor said
plain .legacy.

Nothing reads it today, because init() maps the older spellings by
hand instead of loading. It would have started mattering the moment
anyone pointed that line at load(), and a fresh install on iOS 26
would have quietly come up legacy.
2026-08-04 18:03:54 +02:00
J1coding 62450cc55a iOS: describe each setting once instead of five times
A setting's section, key and default were written out up to five
times: the descriptor, the read in init(), the read in reload(), a
bare literal in the reset functions, and for two dozen of them the UI
descriptor as well. Nothing made them agree. They agreed by
discipline, and that is exactly how a setting quietly stops
persisting.

SettingCodec holds the read and the write together, so the pair
cannot drift apart the way two hand written halves do. A descriptor
names the one it wants: .bool, .int, .int(in:), .rawInt, and a small
number that spell their values out because the core does. Aspect
ratio is a menu index here and a name in the file. Audio time stretch
is a switch here and the word TimeStretch there.

commit() says the three lines every setter was repeating. 115
settings are one line now. The 20 that really do something extra keep
it on the line below where you can see it.

init() loads through the descriptor that saves it, so there is one
place left to get a key wrong. Five stay hand written because they
are genuinely not one key to one property, and each says why: the
renderer has to correct a desktop value on disk, LastActiveOsdPreset
uses -1 to mean never set, the OSD position drops values this build
no longer offers, and the JIT protocol maps names older builds wrote.
osdShowDeviceStats disagrees with its descriptor on purpose, so it
says load(default:) rather than looking like every other line and
behaving differently.

onSet was only ever asking "is this a graphics key", so it is a plain
Bool now and the store calls its own method instead of reaching
through SettingsStore.shared from inside a closure.

Checked by diffing PCSX2-iOS.ini before and after on the same
container, once with settings already in it and once with none at
all. Both empty.
2026-08-04 18:03:54 +02:00
J1coding 421ca0ac73 iOS: clear out the settings code nothing calls
reload() was 218 lines with no callers anywhere in the history, and it
had already drifted: two settings were missing from it, so wiring it
up would have quietly reverted the frame pacing preset and adaptive
resolution on every VM start. frameLimiterDisabledForFastForward went
with it, set false twice and never read.

The adaptive resolution setter no longer reaches back into init to
start its controller. Nothing hangs today, because upscaleMultiplier
happens to be loaded further up, but that is luck rather than design
and the comment claimed a guard that was not there.

applyFramePacingPreset was restating the same 24 numbers that
SettingsStore+FramePacing already holds and PerGameSettingsPanel
already trusts. It reads them from the table now. Still six explicit
assignments rather than a loop, because the order is the point.
2026-08-04 18:03:54 +02:00
J1coding fd4fcfac9d iOS: translate the new setting names
CAS Sharpness, the four Shade Boost components and the three CPU sprite render
level options were falling back to English everywhere.
2026-08-04 00:28:52 +02:00
J1coding 22192ddcd2 iOS: give the background art rows the step they print
These stay continuous on purpose. They are live preview controls where every
value in the range is a legitimate one, so stops would only get in the way.

What they were missing is a step. A row could read 50% while quietly storing
0.4973: the readout rounded, the stored value did not, and the reset arrow was
comparing against the hidden one. They snap to the precision they print now.
2026-08-04 00:28:52 +02:00
J1coding 0b0d3f4441 iOS: put every number setting on one row
Every slider, stepper and typed number is the same row now, and each setting is
described once instead of once per screen. The FPS target was drawn on three
screens and each spelled out its own bounds, stops, default and title, so
Emulator ran 15 to 120 with no stops while Frame Pacing ran 30 to 120 with them.
Same setting, two controls. A screen names the setting and hands over the
binding; there is nothing left for it to spell differently.

Sliders stop on the values people actually pick rather than spreading evenly
over the range, so 60 sits near the middle of the FPS track instead of at forty
three percent, and the ticks stay few enough to mean something. Every stop list
holds the default so reset lands on one, and typing still reaches anything in
between, which is why stops never cost you a value.

The number is also the last thing in every row now. The reset button used to sit
after it, reserved whether or not it was showing, so a setting with a default
pushed its number thirty six points inboard while one without sat flush, and
inside a single row the value and the slider's own upper bound were thirty six
points apart. The button moves inboard instead. Grey reads, tinted types, tinted
in a box is a row where the number is the whole control, and the tap target
reaches a finger by spending the gap under the header rather than making every
row taller.

Fixed on the way past:

Queue Size had a default you could never get back to, because the stepper built
its own row and never rendered the reset button. On the per game tab that was
worse: once you overrode it, nothing short of the reset that wipes all six
pacing values would put it back to Use Global.

Skipdraw and the texture offsets rendered tinted and boxed while completely
inert, since a disabled Text does not dim on its own.

The reset arrow compared against a millionth of the range, so a row could print
50% and still offer to put it back to 50%.

CAS sharpness and the emulation only timer had no default at all. CPU sprite
render level was a typed field over three values sitting beside pickers over
similar lists. The emulator screen did not hold the hardcore speed floor that
frame pacing did, which matters now the two rows look identical. IntSliderRow is
gone before something picks it up again.
2026-08-04 00:28:52 +02:00
J1coding 1e0a91c8b7 iOS: fix what the audit found in the number row
Six things, found by reviewing the change rather than by using it.

Opening the keyboard and closing it again used to rewrite the setting.
The field starts as the rounded readout, so committing it back dropped
whatever precision the stored value had beyond the decimals on show. A
background slider holding 1.234 became 1.23 just for being looked at. It
only writes now if you actually changed the text.

A typed value was lost if you tapped Save on the per game panel with the
keyboard still up, because the panel reads its staged value straight away
and the draft had not been committed yet. Worse, if the typed value was
your only change, Save was greyed out and did nothing. Digits now reach
the value as you type, once what you have typed is inside the range, so
half typed numbers still do not snap to a bound.

German was a ten times error. It groups on a full stop and points on a
comma, and the grouping separator was stripped first, so a typed 0.5
became 05.

Arabic could not be typed into at all. Its digits are Arabic Indic and
would not parse back, so nothing committed. The field always edits in
Latin digits now; the readout stays in the chosen language.

The swipe sensitivity control printed its name twice, once on the toggle
and again on the row below it.

Also dropped a degrees format nothing used, and cut the header comment
down. It was listing the types this replaced, which is a changelog entry
rather than something a future reader can look up.
2026-08-03 09:33:29 +02:00
J1coding 16da198d78 iOS: put the in game speed control on the number row
The last raw slider. Fast Forward Speed baked its value into a readout
row of its own and had no bounds; it is the same control as everything
else now, with the quick buttons still underneath.
2026-08-03 09:33:29 +02:00
J1coding aa7afae16f iOS: put the dynamic background sliders on the number row
All 199 of them, in two pieces.

The 105 that hand the row a string they formatted themselves go through
an adapter, so their call sites are untouched and their readouts are
byte identical. The 94 that went through controlSlider now name a shared
format instead of passing a formatting closure, which is what lets them
show their bounds for the first time.

Two readouts genuinely move. The percent helper truncated where the
shared format rounds, so a value like 0.675 reads 68 rather than 67, and
the signed one printed a plus in front of zero.

Glow blur keeps one decimal place. The points format is whole numbers,
and letting it round would have turned 12.5 into 13 on a row where half
points are the point.

The preview overlay's live readout moves to the number row's own
reporting channel, which is the same three arguments, so the HUD is
unchanged.

Also stopped an opaque readout from offering to be typed into. The string
is one somebody else built, so the keyboard would have opened on digits
that were not the ones on screen and written them back on dismissal. That
was a hole in the row rather than in these screens, so it is fixed there.
2026-08-03 09:33:29 +02:00
J1coding 440f7c94b7 iOS: put the virtual pad sliders on the number row
Thirty five rows across the thumbstick, gyro, crosshair and tap timing
sections. DynamicControlSlider becomes an adapter over the shared row, so
its call sites did not have to move.

Pad Opacity, Analog Stick Size and Phone Rumble Strength were three hand
rolled sliders that baked the value into the title, which is a different
rhythm from every other row in the app. Two of them also truncated the
percentage, so a slider sitting on 70 reported 69.

The accessibilityElement combine on the old slider is gone. It flattened
the row into one element and took the adjustable trait with it, so
VoiceOver read about thirty controls as static text rather than as
sliders you can change.

These sliders now bracket their drags like the graphics ones do, so they
no longer let a graphics reload fire mid drag, and every readout can be
typed into.

Two rows keep their own formatting. Negative deadzone reads as a phrase
with a translated noun rather than a number in a unit, and swipe
sensitivity is in degrees per point, which has no shared format.
2026-08-03 09:33:29 +02:00
J1coding d557b139cc iOS: put the graphics and per game numbers on the number row
Shade Boost, CAS Sharpness, the six typed fields for texture offset,
skipdraw and CPU sprite render, and the per game rows that had their own
three state control.

CAS Sharpness was the odd one. The value is stored as a percentage, but
the slider ran 0 to 1 and converted back with Int(v * 100) on every tick,
which truncates, so dragging could land you one below what you aimed at.
It drives the percentage directly now.

ClampedIntField goes. The number row does typing for every setting, so
the advanced hack fields are the same control as everything else and pick
up bounds, a proper VoiceOver label and a keyboard Done button on the way
past. One small regression: that field dimmed itself and left its label
at full opacity, and .disabled on a row dims the whole thing.

NumberOverrideRow keeps its inherit row, which is the part that is
genuinely its own, and hands the overridden state to the shared control.
Its suffix argument becomes a format, so the unit is translatable rather
than glued on at the call site. The Global button becomes a glyph in the
process, since the row has one accessory slot and bounds now own the
line underneath.

Per game Emulator Volume also stops being a third hand built copy of the
volume row, and gains the top half of its range: it was capped at 100
while the clamp behind it always allowed 150.
2026-08-03 09:33:29 +02:00
J1coding b2bb95c709 iOS: put the appearance and emulator numbers on the number row
Background Dim, the Emulation Only Mode timer and the emulator FPS
target.

Background Dim was reading low. It converted for display with
Int(dim * 100), which truncates, and 0.7 as a double is a hair under
0.7, so a slider sat at 70% reported 69%. The stored value never
changed and neither does it now; the number on screen is just correct.
Pad opacity and rumble strength have the same bug and are fixed when
those screens convert.

The timer's bounds were the strings "0s" and "15s" written underneath a
range taken from a constant. They agree today, which is the only reason
nobody noticed. They come from the range now.

Its Double adapter binding goes too, since the row does that conversion
itself for every caller.
2026-08-03 09:33:29 +02:00
J1coding 0f437e758b iOS: put the audio and pacing numbers on the number row
First screens onto the shared control. Emulator Volume, Buffer Size,
Output Latency, Fast Forward Volume, Queue Size and FPS Target all become
the same row, and IntSliderRow goes since the new one covers what it did.

Three things these rows gain. Bounds now come from the range constant
rather than being written out again as text, so a Text("150%") can no
longer disagree with the slider it sits under. Every row can be typed
into. And they all carry a VoiceOver label and value, where only the
volume slider did.

Queue Size keeps its stepper. Fifteen values and you usually want a
specific one.

Emulator volume gets a named range like the other numeric settings, and
the clamp helper now reads from it rather than repeating 0 and 150.
2026-08-03 09:33:29 +02:00
J1coding e71fc0789f iOS: make the visual slider bracket release itself
Two things had to change before every slider in the app starts using this
rather than the two that do today.

The count only ever came back down in the editing ended handler, so a
drag interrupted by a sheet closing or a tab switch left it raised and
live apply stayed off for the rest of the session. There is a thirty
second watchdog now, and it logs when it fires, since anything reaching
it is a bug worth seeing.

And the release fired a full graphics reload whether or not anything
graphics related had moved. That was fine when the only two callers were
on the graphics screen. It is not fine once an audio or virtual pad drag
raises the same count, so the reload now only happens if a graphics
writer actually asked for one while the bracket was up.
2026-08-03 09:33:29 +02:00
J1coding 328bc24b03 iOS: translate the number row strings
Five new keys across the nine translated languages: the reset and use
global button labels that VoiceOver reads, the hint telling you the value
can be typed into, the tap count unit, and seconds where the language
uses a word for it rather than s.

Only strings that actually differ from English are here. The unit
templates that are the same everywhere, ms and FPS and pt and rad/s and
the degree and percent signs, are left out on purpose: localized falls
back to the key, so an entry mapping "%@ ms" to "%@ ms" nine times is
noise that still has to be maintained.

Done was already translated, so it is reused rather than added again. A
duplicate key in one of these dictionary literals is a crash on launch,
not a warning.
2026-08-03 09:33:29 +02:00
J1coding 5ecec1ed64 iOS: add the number row
Every numeric setting in the app is about to go through one control:
label and value on the first line, the track between its bounds on the
second, and the value is tappable to type an exact number.

The shape is the two rows that already worked best, the background dim
slider and the emulation only mode timer, plus the bounds and reset that
the audio rows had. Bounds are digits with no unit, since the unit is in
the readout directly above and repeating it costs about a third of the
track on a narrow row.

NumberFormat is a value rather than a closure so the unit is one
translatable template instead of " ms" spelled at every call site, and so
rounding happens in one place. Right now the app both truncates and
rounds the same percentage conversion in different files.

Double core with Int and Float initialisers rather than a generic. Slider
only speaks floating point, so a generic funnels here anyway and costs
type check depth at every call site.

Nothing uses it yet.
2026-08-03 09:33:29 +02:00
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