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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
"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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
A skip cutscene cheat for God of War 2 keeps working with RetroAchievements
Hardcore active. It is not a user file. It ships in our own patches.zip:
gametitle=God of War 2 (SCUS-97481)
[Widescreen 16:9] gsaspectratio=16:9 patch=1,EE,00234A48,word,46000406
[Skip Cutscenes] author=Ezedequias patch=1,EE,202D8194,byte,01
Hardcore only ever gated the cheats side. ReloadEnabledLists empties the enabled
cheats list, the on-disk walk skips the cheats folder, and the cheat enable call
sits behind EnableCheats. The patches list gets none of that: it is re-read
verbatim and applied unconditionally, not even behind EnablePatches. Anything
filed as a patch has always been exempt, and place=1 reapplies it every vsync.
Neither of the obvious rules can separate those two groups. They live in the same
pnach, so file location cannot, and the widescreen one writes EE memory too, so
"block memory writes" would take widescreen with it. What does separate them is
whether the group says what it is for. gsaspectratio or gsinterlacemode means
widescreen or no-interlacing, which we deliberately keep working under Hardcore.
A group that declares nothing and only writes memory is a cheat whatever its
label says.
Measured against the shipped database before settling on it: 1284 groups declare
presentation and stay, 478 write memory with nothing declared and now stop. The
ones that stop read like Car select, Auto-activate analogs and Throttle/brake on
right stick. On the reported game, Widescreen 16:9 stays and Skip Cutscenes goes.
The check runs at group selection rather than against the enable list, because an
unlabelled group never consults that list and would have sailed through a filter
applied there.
GameDB patches are left alone on purpose. They are a curated compatibility layer,
and dropping them under Hardcore would break games instead of stopping cheating.
Worth knowing what this does not cover: a pnach author can still launder a cheat
by pasting a gsaspectratio line into the group. That stops shipped and accidental
content, not somebody determined to cheat themselves.
The OSD paths wrote both EmuConfig.GS and the global GSConfig. GSConfig belongs
to the GS thread, which reads those fields while it draws, and the writers run on
the CPU thread and, at scene connect, the UIKit one. They are bitfields packed
into a shared word, so each write is a read modify write racing whatever the GS
thread is doing to the neighbours.
The thirteen OsdShow flags did not need the second write at all. ImGuiOverlays
copies every one of them out of EmuConfig.GS into GSConfig on the GS thread each
frame, right before drawing, so dropping the poke changes nothing about when they
arrive. No push either, deliberately: adding one would put a queue drain behind
every tap of the preset picker for a value that is already being delivered.
The overlay position is not in that per frame copy, so it does have to be pushed.
Only from the two places that are provably on the CPU thread though: inside the
hop setPerformanceOverlayVisible already has, and at the one boot site that runs
after the GS is open. Not from ARMSX2SetIOSOsdFlags, which has no hop of its own
and is called from the UIKit thread at scene connect, where MTGS::RunOnGSThread
asserts and a second ring producer would corrupt the queue.
The two earlier boot sites needed nothing. GSopen assigns GSConfig wholesale from
EmuConfig, so a write before that point was being overwritten anyway.
Not fixed here, and worth its own look: the GPU timing switch in GSUpdateConfig
only fires on a rising edge of OsdShowGPU, and the per frame copy has always
levelled that field before the comparison runs, so it never fires. Removing these
writes does not change that.
Turn Hardcore Mode on and the Enable PNACH Cheats row keeps showing enabled.
Tapping it appears to do nothing. Cheats really are off at that point, so the
row is just wrong about it.
Hardcore clears EnableCheats in the running config only. The INI still says
true, and this row reads the INI, so it reports a setting that stopped being
in force the moment Hardcore came on. Turning it on is refused down in the
bridge without saying anything, which is why tapping it looks broken rather
than blocked.
So the row reports what is actually in force, greys out, and says which
setting is holding it. Same helper the Cheats and Patches manager already
uses, so the two screens cannot end up disagreeing about whether Hardcore is
blocking anything.
The lookup is cached rather than asked for on every redraw. It takes the
achievements lock, and this row sits in a Form that rebuilds whenever anything
else on the screen changes.
Nothing about which cheats apply changes here. The core already refuses them
three separate ways under Hardcore and the patch lists reload the moment it
toggles; only the switch was lying.
Someone in light mode gets a white strip across the top of the screen while a
game is running, just above the OSD. Dark mode looks fine, which is the tell.
The strip is the top safe area. Portrait gameplay stays out of it on purpose so
the OSD clears the Dynamic Island, and it only ignores the bottom edge, so
something behind has to fill that band. Since 70443f8b66 that something is the
menu backdrop. The root used to be a switch and the grouped background lived
inside the menu case, so it simply was not there during a game; turning the
switch into a ZStack for the launch transition left it painting over everything.
systemGroupedBackground is near white in light mode and near black in dark, so
half the users saw nothing wrong.
So put it back to menu only, with the same condition as the background layer
directly under it so the two cannot drift. It fades rather than disappearing,
which keeps the launch transition looking the way it does now.
The game screens also paint their own black behind the whole layout instead of
trusting what is underneath. That band was only ever black because a boot
notification sets the root controller's colour, and anything that reaches
gameplay without firing one gets the white back. Cheap to not depend on.
The note in SceneDelegate about SwiftUI owning the menu background is accurate
again, so it stays as it is.
The toast lifetime fix left a second static in this file with the same shape.
s_lastNVMSaveDate takes an autoreleased NSDate straight from [NSDate date] and
the assignment does not retain it, so once the pool drains the static points at
freed memory.
Nothing is crashing on it today. Both reads happen in the same call as their
write, before the pool has drained, so the pointer is always fresh in practice.
It is one read from somewhere else away from being the same use-after-free as
the toast, which is not a distinction worth relying on when the file now has a
setter pattern to copy.
Also says out loud why the toast consume clears its static by hand instead of
calling the clear helper next to it. The static's reference is being handed to
the local, and the autorelease on the way out already pays for it, so routing it
through the helper would release twice. That line reads like an inconsistency
someone should tidy up, and tidying it crashes.
The graphics screen edits global values, so that is what its rows show. A game
with a per-game override is running something else, and the row said nothing
about it. Better than saying the wrong thing, but it is the same silence the
rest of this work was about.
The rows now say so when the two disagree. Changing it is still the per-game
panel's job, and the sentence points there rather than trying to edit a
per-game value from a screen for globals.
Getting the answer meant reading the base layer and the game layer directly,
under one lock for the whole sweep, instead of nine calls to the Host getters.
Those take the same lock per call and it is not recursive, so once the lock is
held nothing in there may call one. Worth remembering if this loop grows: it
hangs the CPU thread rather than reporting something wrong.
For the same reason the game database lookups now happen before the lock is
taken instead of inside the loop. Nothing in that path reads settings today, so
it would have worked, but it was resting on that staying true.
The sprite and texture-offset rows were greyed out unless Manual Advanced Hacks
was on, and turning that on throws away every automatic fix the game had. So the
only way to argue with one hack was to give up all of them, which trades one
glitch for a different one and is why people end up convinced a setting is stuck.
Those rows are usable now. Changing one claims that hack, the database keeps
everything else it knows about the game, and only the setting that was argued
with changes hands. Skipdraw still needs the master toggle, since nothing claims
it.
When what the game is running is not what the row says, the row says why: 1x
ignoring it, the database choosing it, or the automatic fixes still being in
charge. A claimed row gets a way back to the database value, otherwise claiming
one is a door that only opens outward.
Resetting graphics defaults hands all of them back.
The footer used to say manual hacks were needed for the values below. That is no
longer true for the sprite and texture-offset ones, and it was the sentence
sending people to the master toggle in the first place, so it says what it costs
instead.
Nothing here has been driven on a device yet, only built.
The graphics screen has only ever been able to show the INI. Between that and
the renderer sit MaskUserHacks, MaskUpscalingHacks and the game database, any of
which can quietly land on a different answer, and none of which the UI can see.
That is most of why Align Sprite reads as broken: for a game the database has an
entry for, the row says off while the game runs it on, and no amount of tapping
the row changes that.
So snapshot the real values. EmuConfig belongs to the CPU thread and the masks
have only settled once an apply is finished, so the capture runs there, at the
end of the apply and again on a game change, since the database fixes do not
land until the serial is known. The UI reads the copy under a lock and gets a
notification when it moves.
Each entry carries the value, whether the player claimed that hack, and which of
the gates is holding it if the two disagree. Bools are read back with the bool
getter rather than the int one, because they are stored as true/false and asking
for an int just hands you the default and makes every row look overridden.
Claiming is a write only here, with the caller asking for the apply, so changing
a value and claiming it in the same tap coalesces into one apply instead of two.
Per-game overrides write the claim into the per-game file as well, otherwise
overriding one hack for one game still loses to the database for that game.
ApplyGameFixes clears ManualUserHacks on the pre-ELF path and returns, with a
comment saying it is disabling the player's manual hardware fixes because they
might be problematic on the BIOS.
It is not disabling them. MaskUserHacks already ran back in LoadCoreSettings,
while the flag was still set, so it took its early return and left every hack
in place. Clearing the flag afterwards changes nothing that has already been
loaded, and the return skips the MaskUpscalingHacks call at the bottom too. The
hacks stay live on the BIOS screen and through the boot logo.
Mask both after clearing the flag, so the code does what it says.
Worth knowing this shifts behaviour for anyone who had manual hacks on and was
seeing them apply before the game started. That was the bug, not a feature, but
it will look like a change.
Right now changing one sprite hack is all or nothing. With manual hacks off,
MaskUserHacks wipes whatever the player set and the game database writes its own
value on top, so for the 227 games that carry alignSprite the toggle does nothing
in either direction while the settings screen still shows the player's value.
Turning manual hacks on to get around that throws away every other automatic fix
the game had, which tends to trade one glitch for a different one.
So let a single hack be claimed instead. GSOptions carries a bitmask of the ones
the player set on purpose. MaskUserHacks leaves those alone and clears the rest
as before, and applyGSHardwareFixes skips a claimed fix down the same path it
already uses for manual mode, so it still gets named in the warning and every
other database fix for the game is applied normally.
The mask sits outside the bitfield union deliberately. That packing is what
OptionsAreEqual compares wholesale, and this is not a hack value, it is who owns
one. It gets its own comparison so that claiming or releasing a hack counts as a
settings change even when no value moved with it.
MaskUpscalingHacks does not honour the mask. Below 2x the renderer skips these
regardless, so keeping one set there would only leave GSConfig and the settings
overlay claiming something that never runs.
The warning text now depends on which of the two cases fired, since blaming
manual mode when someone only claimed one hack would be wrong.
Nothing sets a bit yet, so behaviour is unchanged until a frontend does.
Turning Align Sprite off and watching nothing happen is the report that keeps
coming back. The setting does reach the renderer now, but the pixels it already
moved are sitting in a cached target, and nothing invalidates them.
These hacks rewrite vertex positions or texture coordinates on the way into a
draw, so what they change ends up baked into whatever target that draw landed
on. Games that redraw a target every frame hide this, because the next frame
paints over it with the new setting. Games that build a background or a HUD
layer once and keep sampling it do not: that target keeps the shifted pixels
until something else happens to invalidate it, which can be a whole scene later.
From the player's side that reads as a setting that will not switch off, and
then as ghosting, which is exactly how it was reported.
Native scaling was already in the purge list for the same reason and its comment
claimed it was the only geometry hack that outlived the draw. It is not, it is
just the most obvious one, because it swaps the texture outright rather than
nudging what gets drawn into it. Align sprite, merge sprite, round sprite, half
pixel offset, wild arms, native palette draw, bilinear and the texture offsets
all have the same problem.
Costs a frame when you toggle one, same as every other entry in that list.
The Skins browser showed "Can't reach the skin catalog. Check your connection"
for every failure, including the ones where the download worked perfectly. The
message was hardcoded in the view and shown whenever the list happened to be
empty, so the actual error never reached the screen.
That fired today: the community catalog picked up four missing commas, so it
downloads with a clean 200 and then fails to parse. Everyone who opened the
Skins tab was told to check their wifi over someone else's typo.
The three cases are now told apart. Unreachable still says to check the
connection. A non-200 reports the status code. A file that arrives but will not
parse says so, and says it needs fixing in the skins repo rather than on the
device, because there is nothing the user can do about it either way.
The view prints whatever the catalog reports instead of substituting its own
guess. A refresh that fails still leaves the previously loaded list on screen,
so a broken update does not empty the browser.
Reuses the SkinCatalogError enum, which had been sitting unused since it was
written.