From 2ee9eca87181774bb9ffc40413a3efd359d6dbe9 Mon Sep 17 00:00:00 2001 From: J1coding Date: Sat, 25 Jul 2026 12:51:27 +0200 Subject: [PATCH] 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. --- .../src/main/swift/Models/SettingsStore.swift | 8 ++-- .../swift/Views/PerGameSettingsPanel.swift | 6 ++- .../swift/Views/Settings/PerGame/PadTab.swift | 46 +++++++++++++++++-- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/platforms/ios/app/src/main/swift/Models/SettingsStore.swift b/platforms/ios/app/src/main/swift/Models/SettingsStore.swift index 1ed25aebc8..ed36098058 100644 --- a/platforms/ios/app/src/main/swift/Models/SettingsStore.swift +++ b/platforms/ios/app/src/main/swift/Models/SettingsStore.swift @@ -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)) diff --git a/platforms/ios/app/src/main/swift/Views/PerGameSettingsPanel.swift b/platforms/ios/app/src/main/swift/Views/PerGameSettingsPanel.swift index 0ec8e57507..9552e1bdf0 100644 --- a/platforms/ios/app/src/main/swift/Views/PerGameSettingsPanel.swift +++ b/platforms/ios/app/src/main/swift/Views/PerGameSettingsPanel.swift @@ -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 ) } diff --git a/platforms/ios/app/src/main/swift/Views/Settings/PerGame/PadTab.swift b/platforms/ios/app/src/main/swift/Views/Settings/PerGame/PadTab.swift index b04d2e9678..2a4a7de4df 100644 --- a/platforms/ios/app/src/main/swift/Views/Settings/PerGame/PadTab.swift +++ b/platforms/ios/app/src/main/swift/Views/Settings/PerGame/PadTab.swift @@ -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) } }