iOS: fix per-game stick inversion silently discarding writes

PadTab always used the ...ForCurrentGame per-game INI accessors. Those
resolve the game identity from the running VM and return early when there
isn't one, so opening Per-Game Settings from the library made all four
inversion pickers read "Use Global" no matter what was stored, and threw
every write away without a word.

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

Also:
- Drop the redundant ContainsValue probe in stickInversion(for:) -- the
  getter already falls back to the default, and this runs per stick sample.
- Say in the Stick Inversion footer that the tab writes as you edit it.
  Nothing in PadTab is staged, so Save staying greyed is correct, but the
  panel never told anyone that.
This commit is contained in:
J1coding
2026-07-26 13:48:45 +02:00
committed by Jeen
parent c5a28d8377
commit 2ee9eca871
3 changed files with 50 additions and 10 deletions
@@ -1572,11 +1572,11 @@ final class SettingsStore {
/// Effective axis inversion for a stick, resolving a per-game override (current game INI)
/// before the global default. Read live at the stick input choke point.
func stickInversion(for side: StickSide) -> (x: Bool, y: Bool) {
// Passing the global as the default covers "no override", "no per-game file"
// and "no running game" in one read. A separate ContainsValue probe would
// parse the INI twice on every stick sample.
func resolve(_ key: String, global: Bool) -> Bool {
if ARMSX2Bridge.hasPerGameINIValueForCurrentGame(Self.stickInversionSection, key: key) {
return ARMSX2Bridge.getPerGameINIBoolForCurrentGame(Self.stickInversionSection, key: key, defaultValue: global)
}
return global
ARMSX2Bridge.getPerGameINIBoolForCurrentGame(Self.stickInversionSection, key: key, defaultValue: global)
}
switch side {
case .left: return (resolve("InvertLeftStickX", global: invertLeftStickX), resolve("InvertLeftStickY", global: invertLeftStickY))
@@ -334,6 +334,8 @@ struct PerGameSettingsPanel: View {
}
/// Encodes the current editable per-game state so Save can be gated on real changes.
/// Virtual Pad values are left out on purpose: that tab writes as you edit it, so
/// there is never anything of its own left for Save to commit.
private func perGameFingerprint() -> String {
let fixes = SettingsStore.gameFixOptions.map { "\($0.key):\(perGameFixes[$0.key] ?? -1)" }.joined(separator: ",")
return "\(enabled)|\(upscaleMultiplier)|\(aspectRatio)|\(textureFiltering)|\(hardwareMipmapping)|\(blendingAccuracy)|\(interlaceMode)|\(trilinearFiltering)|\(halfPixelOffset)|\(roundSprite)|\(alignSpriteOverride)|\(alignSprite)|\(mergeSpriteOverride)|\(mergeSprite)|\(wildArmsOffsetOverride)|\(wildArmsOffset)|\(textureOffsetXOverride)|\(textureOffsetX)|\(textureOffsetYOverride)|\(textureOffsetY)|\(skipDrawStartOverride)|\(skipDrawStart)|\(skipDrawEndOverride)|\(skipDrawEnd)|\(volumeOverride)|\(volumePercent)|\(eeCoreType)|\(mtvu)|\(eeCycleRate)|\(eeCycleSkip)|\(fastBoot)|\(enableCheats)|\(enablePatches)|\(enableGameFixes)|\(enableGameDBHardwareFixes)|\(perGameAAT)|\(perGameTextureInsideRt)|\(perGameRenderer)|\(perGameFXAA)|\(perGameUpscaler)|\(perGameShadeBoost)|\(perGameTVShader)|\(perGameCASMode)|\(perGameMaxAnisotropy)|\(perGameCASSharpness)|\(perGamePCRTCOffsets)|\(perGameIntegerScaling)|\(perGameSkipDupFrames)|\(perGamePCRTCOverscan)|\(perGamePCRTCAntiBlur)|\(perGameDisableInterlaceOffset)|\(perGameWidescreen)|\(perGameNoInterlace)|\(perGameShadeBoostBrightness)|\(perGameShadeBoostContrast)|\(perGameShadeBoostSaturation)|\(perGameShadeBoostGamma)|\(perGameDithering)|\(perGameFastForwardVolume)|\(perGameIOP)|\(perGameVU0)|\(perGameVU1)|\(perGameHWDownloadMode)|\(perGameCPUCLUT)|\(perGameGPUTargetCLUT)|\(perGameVsyncQueue)|\(perGameLoadTextureReplacements)|\(perGameLoadTextureReplacementsAsync)|\(perGamePrecacheTextureReplacements)|\(perGameSyncToHostRefresh)|\(perGameBufferMS)|\(perGameOutputLatencyMS)|\(perGameEEFpuRound)|\(perGameVU0Round)|\(perGameVU1Round)|\(perGameEEClamp)|\(perGameVUClamp)|\(raEnabledOverride)|\(raHardcoreOverride)|\(perGameFramePacingPreset)|\(perGameFrameLimiter)|\(perGameTargetFPS)|\(fixes)"
@@ -703,7 +705,9 @@ struct PerGameSettingsPanel: View {
padLayoutIdentity: $padLayoutIdentity,
showPadLayoutEditor: $showPadLayoutEditor,
layoutPresets: layoutPresets,
skinLibrary: skinLibrary
skinLibrary: skinLibrary,
savesToRunningGame: savesToRunningGame,
iso: game.bootName
)
}
@@ -9,6 +9,8 @@ struct PadTab: View {
let layoutPresets: PadLayoutPresetStore
let skinLibrary: VPadSkinLibraryStore
let savesToRunningGame: Bool
let iso: String
@State private var inversionDrafts: [String: Int] = [:]
private let inversionKeys = ["InvertLeftStickX", "InvertLeftStickY", "InvertRightStickX", "InvertRightStickY"]
@@ -74,7 +76,7 @@ struct PadTab: View {
layoutPresets.clearVPadOverrides(for: padLayoutIdentity)
inversionDrafts = [:]
for key in inversionKeys {
ARMSX2Bridge.deletePerGameINIValueForCurrentGame(inversionSection, key: key)
clearInversionOverride(key)
}
} label: {
Label("Reset All VPad Overrides", systemImage: "arrow.counterclockwise")
@@ -101,7 +103,7 @@ struct PadTab: View {
} header: {
Text("Stick Inversion")
} footer: {
Text("Overrides the global stick inversion for this game only.")
Text("Overrides the global stick inversion for this game only. Everything on this page saves as you change it, so Save and Cancel don't apply here.")
}
}
}
@@ -121,21 +123,55 @@ struct PadTab: View {
private func loadInversionDrafts() {
var drafts: [String: Int] = [:]
for key in inversionKeys {
if ARMSX2Bridge.hasPerGameINIValueForCurrentGame(inversionSection, key: key) {
drafts[key] = ARMSX2Bridge.getPerGameINIBoolForCurrentGame(inversionSection, key: key, defaultValue: false) ? 1 : 0
if hasInversionOverride(key) {
drafts[key] = inversionOverride(key) ? 1 : 0
}
}
inversionDrafts = drafts
}
// Commits on pick, like the layout and skin pickers. Nothing in this tab is
// staged, so none of it feeds the panel's Save fingerprint.
private func applyInversion(_ key: String, value: Int) {
var drafts = inversionDrafts
drafts[key] = value
inversionDrafts = drafts
if value == -1 {
clearInversionOverride(key)
} else {
setInversionOverride(key, value == 1)
}
}
// The panel opens both in-game and from the library. The "current game" bridge
// variants resolve their identity from the running VM and silently no-op without
// one, so the library path has to address the per-game INI by ISO instead.
private func hasInversionOverride(_ key: String) -> Bool {
savesToRunningGame
? ARMSX2Bridge.hasPerGameINIValueForCurrentGame(inversionSection, key: key)
: ARMSX2Bridge.hasPerGameINIValue(inversionSection, key: key, forISO: iso)
}
private func inversionOverride(_ key: String) -> Bool {
savesToRunningGame
? ARMSX2Bridge.getPerGameINIBoolForCurrentGame(inversionSection, key: key, defaultValue: false)
: ARMSX2Bridge.getPerGameINIBool(inversionSection, key: key, defaultValue: false, forISO: iso)
}
private func setInversionOverride(_ key: String, _ value: Bool) {
if savesToRunningGame {
ARMSX2Bridge.setPerGameINIBoolForCurrentGame(inversionSection, key: key, value: value)
} else {
ARMSX2Bridge.setPerGameINIBool(inversionSection, key: key, value: value, forISO: iso)
}
}
private func clearInversionOverride(_ key: String) {
if savesToRunningGame {
ARMSX2Bridge.deletePerGameINIValueForCurrentGame(inversionSection, key: key)
} else {
ARMSX2Bridge.setPerGameINIBoolForCurrentGame(inversionSection, key: key, value: value == 1)
ARMSX2Bridge.deletePerGameINIValue(inversionSection, key: key, forISO: iso)
}
}