iOS: keep the next setting from drifting the way the last ones did

Regex over the source, same shape as the other two tests in here. No
build impact, nothing to wire up. It is the only thing in this branch
that constrains the next setting anyone adds.

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

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

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

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

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

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

The two dozen widest setter lines wrap now. commit(_xConfig, x) names
the setting three times and didSet gives you no newValue to shorten
it with, so the longest ran to 177 characters.
This commit is contained in:
J1coding
2026-08-04 18:03:54 +02:00
committed by Jeen
parent 0da4a18a65
commit 72f95b7534
4 changed files with 313 additions and 78 deletions
@@ -1,22 +1,17 @@
// Setting.swift where one INI-backed setting lives and how it travels
// Setting.swift the section, key and default behind one INI-backed setting
// SPDX-License-Identifier: GPL-3.0+
import Foundation
/// Section, key, default and codec for one setting. The @Observable macro owns
/// the stored property; `didSet` consults this config.
/// One INI-backed setting. The @Observable macro owns the stored property;
/// `didSet` hands the value to `commit`.
///
/// `suppressible` does much less than it looks like. Swift skips property
/// observers inside a class's own initializer, so none of these didSets run
/// while `init()` loads the INI, and that window is the only time
/// `suppressINIWrites` is ever true. Measured on a launch: zero commits. What
/// the flag still catches is assignments made by helpers `init()` calls, which
/// are ordinary method calls and do fire their observers.
///
/// So the 88 settings marked `suppressible: false` do not write our defaults
/// into the INI at startup, whatever the folklore says. They write nothing at
/// startup. Left alone here because deciding what each one ought to be is its
/// own job, not part of moving the write path.
/// `suppressible` catches nothing today, and is still worth keeping. init()'s
/// own assignments do not run their observers, so a launch reaches `commit`
/// zero times either way. That is measured, with a control in the same run,
/// not read off the language reference. Leave the flag alone rather than tidy
/// it away: it is what would catch a setting assigned from a helper `init()`
/// calls, where the observers do fire, and it costs one `&&`.
///
/// Every `EmuCore/GS` setting nudges the running VM after it is written. That
/// used to be an opt-in closure, which is how the sprite hacks, the user hacks
@@ -45,12 +40,14 @@ struct Setting<Value> {
self.codec = codec
}
/// What the INI currently holds, or our default if it holds nothing.
@MainActor func load() -> Value { codec.read(section, key, defaultValue) }
/// Same, for the odd setting whose fresh-install value depends on something
/// only `init()` knows. Spelled out so the disagreement is greppable.
@MainActor func load(default override: Value) -> Value {
codec.read(section, key, override)
/// What the INI currently holds, or our default if it holds nothing. The
/// overload is for the odd setting whose fresh-install value depends on
/// something only `init()` knows; spelled out so it stays greppable.
@MainActor func load() -> Value { load(default: defaultValue) }
@MainActor func load(default fallback: Value) -> Value {
codec.read(section, key, fallback)
}
/// For where `init()` has to correct the INI rather than read it.
@MainActor func write(_ value: Value) { codec.write(section, key, value) }
}
@@ -3,18 +3,12 @@
import Foundation
/// Reading and writing a setting, kept together in one value.
///
/// These used to be two hand-written halves: a `writer` closure on the
/// descriptor, and a matching `getINI` call a thousand lines away in `init()`.
/// Nothing made the two agree. Pairing them is what stops the next one drifting.
/// The INI read and write for one setting, so the two cannot drift apart.
struct SettingCodec<Value> {
let read: @MainActor (_ section: String, _ key: String, _ fallback: Value) -> Value
let write: @MainActor (_ section: String, _ key: String, _ value: Value) -> Void
}
// MARK: - The straightforward ones
@MainActor
extension SettingCodec where Value == Bool {
static let bool = Self(read: ARMSX2Bridge.getINIBool, write: ARMSX2Bridge.setINIBool)
@@ -37,7 +31,7 @@ extension SettingCodec where Value == Int {
read: { s, k, d in Int(ARMSX2Bridge.getINIInt(s, key: k, defaultValue: Int32(d))) },
write: { s, k, v in ARMSX2Bridge.setINIInt(s, key: k, value: Int32(v)) })
/// Same, but a value from outside the range is pulled back in rather than trusted.
/// Clamps on the way in and out.
static func int(in range: ClosedRange<Int>) -> Self {
Self(clampedBy: { SettingsStore.clamped($0, to: range) })
}
@@ -53,11 +47,9 @@ extension SettingCodec where Value == Int {
}
}
// MARK: - Enums
@MainActor
extension SettingCodec where Value: RawRepresentable, Value.RawValue == Int {
/// Stored as its raw number. An unknown number falls back to the default.
// var, not let: the type is still generic here, so it gets no static storage.
static var rawInt: Self {
Self(read: { s, k, d in
Value(rawValue: Int(ARMSX2Bridge.getINIInt(s, key: k, defaultValue: Int32(d.rawValue)))) ?? d },
@@ -67,14 +59,13 @@ extension SettingCodec where Value: RawRepresentable, Value.RawValue == Int {
@MainActor
extension SettingCodec where Value: RawRepresentable, Value.RawValue == String {
/// Stored as its raw name. An unknown name falls back to the default.
static var rawString: Self {
Self(read: { s, k, d in Value(rawValue: ARMSX2Bridge.getINIString(s, key: k, defaultValue: d.rawValue)) ?? d },
write: { s, k, v in ARMSX2Bridge.setINIString(s, key: k, value: v.rawValue) })
}
}
// MARK: - The awkward handful
// MARK: - The ones that need their own spelling
@MainActor
extension SettingCodec where Value == Bool {
@@ -209,8 +209,9 @@ final class SettingsStore {
static let defaultEmulatorVolumePercent = 100
static let textureOffsetRange = -4096...4096
static let skipDrawRange = 0...5000
// Named so the loader, the writer's clamp and the descriptor above all agree about the bounds.
// The numbers were already spelled out in each place, which is how they drift.
// Named so the stepper and the per-game panel agree about the bounds. Neither
// this one nor casSharpnessRange is on its global descriptor's codec, so a
// hand-edited INI still gets through. A gap rather than a decision.
static let vsyncQueueRange = 2...16
static let audioBufferMsRange = 10...200
static let audioOutputLatencyMsRange = 5...200
@@ -254,19 +255,14 @@ final class SettingsStore {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: workItem)
}
/// The whole write path for one setting: hold off while the INI is loading,
/// persist, then nudge the running VM if it is a graphics key.
private func commit<T>(_ setting: Setting<T>, _ value: T) {
guard !(setting.suppressible && suppressINIWrites) else { return }
setting.codec.write(setting.section, setting.key, value)
if setting.appliesGraphics { requestGraphicsApplyGuarded() }
}
/// Used by every graphics writer: `commit`, the few keys written from a plain
/// `didSet`, and `setGSBoolHack`. Swift skips property
/// observers during init, so they don't fire while loading from the INI;
/// this no-ops while `suppressINIWrites` is true as a guard against that
/// ever changing.
/// Nothing should reach this while the INI is loading, but it checks anyway:
/// a reload there would throw away the values init has just read.
func requestGraphicsApplyGuarded() {
guard !suppressINIWrites else { return }
requestGraphicsApply()
@@ -352,41 +348,59 @@ final class SettingsStore {
let _emulationOnlyDisablePatchesConfig = Setting<Bool>(
section: "ARMSX2iOS/UI", key: "EmulationOnlyDisablePatches", default: true,
codec: .bool)
var emulationOnlyDisablePatches: Bool = true { didSet { commit(_emulationOnlyDisablePatchesConfig, emulationOnlyDisablePatches) } }
var emulationOnlyDisablePatches: Bool = true {
didSet { commit(_emulationOnlyDisablePatchesConfig, emulationOnlyDisablePatches) }
}
// Discord presence is always released by Emulation-Only Mode.
let emulationOnlyDisableDiscordPresence = true
let _emulationOnlyDisablePINEConfig = Setting<Bool>(
section: "ARMSX2iOS/UI", key: "EmulationOnlyDisablePINE", default: true,
codec: .bool)
var emulationOnlyDisablePINE: Bool = true { didSet { commit(_emulationOnlyDisablePINEConfig, emulationOnlyDisablePINE) } }
var emulationOnlyDisablePINE: Bool = true {
didSet { commit(_emulationOnlyDisablePINEConfig, emulationOnlyDisablePINE) }
}
let _emulationOnlyDisableRetroAchievementsConfig = Setting<Bool>(
section: "ARMSX2iOS/UI", key: "EmulationOnlyDisableRetroAchievements", default: true,
codec: .bool)
var emulationOnlyDisableRetroAchievements: Bool = true { didSet { commit(_emulationOnlyDisableRetroAchievementsConfig, emulationOnlyDisableRetroAchievements) } }
var emulationOnlyDisableRetroAchievements: Bool = true {
didSet { commit(_emulationOnlyDisableRetroAchievementsConfig, emulationOnlyDisableRetroAchievements) }
}
let _emulationOnlyDisableInputRecordingConfig = Setting<Bool>(
section: "ARMSX2iOS/UI", key: "EmulationOnlyDisableInputRecording", default: true,
codec: .bool)
var emulationOnlyDisableInputRecording: Bool = true { didSet { commit(_emulationOnlyDisableInputRecordingConfig, emulationOnlyDisableInputRecording) } }
var emulationOnlyDisableInputRecording: Bool = true {
didSet { commit(_emulationOnlyDisableInputRecordingConfig, emulationOnlyDisableInputRecording) }
}
let _emulationOnlyDisableOSDConfig = Setting<Bool>(
section: "ARMSX2iOS/UI", key: "EmulationOnlyDisableOSD", default: true,
codec: .bool)
var emulationOnlyDisableOSD: Bool = true { didSet { commit(_emulationOnlyDisableOSDConfig, emulationOnlyDisableOSD) } }
var emulationOnlyDisableOSD: Bool = true {
didSet { commit(_emulationOnlyDisableOSDConfig, emulationOnlyDisableOSD) }
}
let _emulationOnlyDisableFramePacingConfig = Setting<Bool>(
section: "ARMSX2iOS/UI", key: "EmulationOnlyDisableFramePacing", default: true,
codec: .bool)
var emulationOnlyDisableFramePacing: Bool = true { didSet { commit(_emulationOnlyDisableFramePacingConfig, emulationOnlyDisableFramePacing) } }
var emulationOnlyDisableFramePacing: Bool = true {
didSet { commit(_emulationOnlyDisableFramePacingConfig, emulationOnlyDisableFramePacing) }
}
let _emulationOnlyDisableVirtualControlsConfig = Setting<Bool>(
section: "ARMSX2iOS/UI", key: "EmulationOnlyDisableVirtualControls", default: true,
codec: .bool)
var emulationOnlyDisableVirtualControls: Bool = true { didSet { commit(_emulationOnlyDisableVirtualControlsConfig, emulationOnlyDisableVirtualControls) } }
var emulationOnlyDisableVirtualControls: Bool = true {
didSet { commit(_emulationOnlyDisableVirtualControlsConfig, emulationOnlyDisableVirtualControls) }
}
let _emulationOnlyDisableQuickMenuConfig = Setting<Bool>(
section: "ARMSX2iOS/UI", key: "EmulationOnlyDisableQuickMenu", default: true,
codec: .bool)
var emulationOnlyDisableQuickMenu: Bool = true { didSet { commit(_emulationOnlyDisableQuickMenuConfig, emulationOnlyDisableQuickMenu) } }
var emulationOnlyDisableQuickMenu: Bool = true {
didSet { commit(_emulationOnlyDisableQuickMenuConfig, emulationOnlyDisableQuickMenu) }
}
let _emulationOnlyClearNetworkCacheConfig = Setting<Bool>(
section: "ARMSX2iOS/UI", key: "EmulationOnlyClearNetworkCache", default: true,
codec: .bool)
var emulationOnlyClearNetworkCache: Bool = true { didSet { commit(_emulationOnlyClearNetworkCacheConfig, emulationOnlyClearNetworkCache) } }
var emulationOnlyClearNetworkCache: Bool = true {
didSet { commit(_emulationOnlyClearNetworkCacheConfig, emulationOnlyClearNetworkCache) }
}
let _emulationOnlyModeDelayConfig = Setting<Int>(
section: "ARMSX2iOS/UI", key: "EmulationOnlyModeDelaySeconds",
default: SettingsStore.defaultEmulationOnlyModeDelaySeconds,
@@ -574,17 +588,23 @@ final class SettingsStore {
section: "EmuCore/GS", key: "UserHacks", default: true,
suppressible: false,
codec: .inverted)
var enableGameDBHardwareFixes: Bool = true { didSet { commit(_enableGameDBHardwareFixesConfig, enableGameDBHardwareFixes) } }
var enableGameDBHardwareFixes: Bool = true {
didSet { commit(_enableGameDBHardwareFixesConfig, enableGameDBHardwareFixes) }
}
let _enableWidescreenPatchesConfig = Setting<Bool>(
section: "EmuCore", key: "EnableWideScreenPatches", default: false,
suppressible: false,
codec: .bool)
var enableWidescreenPatches: Bool = false { didSet { commit(_enableWidescreenPatchesConfig, enableWidescreenPatches) } }
var enableWidescreenPatches: Bool = false {
didSet { commit(_enableWidescreenPatchesConfig, enableWidescreenPatches) }
}
let _enableNoInterlacingPatchesConfig = Setting<Bool>(
section: "EmuCore", key: "EnableNoInterlacingPatches", default: false,
suppressible: false,
codec: .bool)
var enableNoInterlacingPatches: Bool = false { didSet { commit(_enableNoInterlacingPatchesConfig, enableNoInterlacingPatches) } }
var enableNoInterlacingPatches: Bool = false {
didSet { commit(_enableNoInterlacingPatchesConfig, enableNoInterlacingPatches) }
}
let _hostFilesystemConfig = Setting<Bool>(
section: "EmuCore", key: "HostFs", default: false,
suppressible: false,
@@ -777,17 +797,23 @@ final class SettingsStore {
section: "EmuCore/GS", key: "LoadTextureReplacements", default: false,
suppressible: false,
codec: .bool)
var loadTextureReplacements: Bool = false { didSet { commit(_loadTextureReplacementsConfig, loadTextureReplacements) } }
var loadTextureReplacements: Bool = false {
didSet { commit(_loadTextureReplacementsConfig, loadTextureReplacements) }
}
let _loadTextureReplacementsAsyncConfig = Setting<Bool>(
section: "EmuCore/GS", key: "LoadTextureReplacementsAsync", default: true,
suppressible: false,
codec: .bool)
var loadTextureReplacementsAsync: Bool = true { didSet { commit(_loadTextureReplacementsAsyncConfig, loadTextureReplacementsAsync) } }
var loadTextureReplacementsAsync: Bool = true {
didSet { commit(_loadTextureReplacementsAsyncConfig, loadTextureReplacementsAsync) }
}
let _precacheTextureReplacementsConfig = Setting<Bool>(
section: "EmuCore/GS", key: "PrecacheTextureReplacements", default: false,
suppressible: false,
codec: .bool)
var precacheTextureReplacements: Bool = false { didSet { commit(_precacheTextureReplacementsConfig, precacheTextureReplacements) } }
var precacheTextureReplacements: Bool = false {
didSet { commit(_precacheTextureReplacementsConfig, precacheTextureReplacements) }
}
let _texturePreloadingConfig = Setting<Int>(
section: "EmuCore/GS", key: "texture_preloading", default: 2,
suppressible: false,
@@ -797,17 +823,23 @@ final class SettingsStore {
section: "EmuCore/GS", key: "DumpReplaceableTextures", default: false,
suppressible: false,
codec: .bool)
var dumpReplaceableTextures: Bool = false { didSet { commit(_dumpReplaceableTexturesConfig, dumpReplaceableTextures) } }
var dumpReplaceableTextures: Bool = false {
didSet { commit(_dumpReplaceableTexturesConfig, dumpReplaceableTextures) }
}
let _dumpReplaceableMipmapsConfig = Setting<Bool>(
section: "EmuCore/GS", key: "DumpReplaceableMipmaps", default: false,
suppressible: false,
codec: .bool)
var dumpReplaceableMipmaps: Bool = false { didSet { commit(_dumpReplaceableMipmapsConfig, dumpReplaceableMipmaps) } }
var dumpReplaceableMipmaps: Bool = false {
didSet { commit(_dumpReplaceableMipmapsConfig, dumpReplaceableMipmaps) }
}
let _dumpTexturesWithFMVActiveConfig = Setting<Bool>(
section: "EmuCore/GS", key: "DumpTexturesWithFMVActive", default: false,
suppressible: false,
codec: .bool)
var dumpTexturesWithFMVActive: Bool = false { didSet { commit(_dumpTexturesWithFMVActiveConfig, dumpTexturesWithFMVActive) } }
var dumpTexturesWithFMVActive: Bool = false {
didSet { commit(_dumpTexturesWithFMVActiveConfig, dumpTexturesWithFMVActive) }
}
let _dumpDirectTexturesConfig = Setting<Bool>(
section: "EmuCore/GS", key: "DumpDirectTextures", default: true,
suppressible: false,
@@ -851,7 +883,7 @@ final class SettingsStore {
let _cpuSpriteRenderBwConfig = Setting<Int>(
section: "EmuCore/GS", key: "UserHacks_CPUSpriteRenderBW", default: 0,
suppressible: false,
codec: .int(in: 0...10))
codec: .int(in: SettingsStore.cpuSpriteRenderBwRange))
var cpuSpriteRenderBw: Int = 0 { didSet { commit(_cpuSpriteRenderBwConfig, cpuSpriteRenderBw) } }
let _cpuSpriteRenderLevelConfig = Setting<Int>(
section: "EmuCore/GS", key: "UserHacks_CPUSpriteRenderLevel", default: 0,
@@ -983,7 +1015,9 @@ final class SettingsStore {
section: "EmuCore/GS", key: "disable_interlace_offset", default: false,
suppressible: false,
codec: .bool)
var disableInterlaceOffset: Bool = false { didSet { commit(_disableInterlaceOffsetConfig, disableInterlaceOffset) } }
var disableInterlaceOffset: Bool = false {
didSet { commit(_disableInterlaceOffsetConfig, disableInterlaceOffset) }
}
let _skipDuplicateFramesConfig = Setting<Bool>(
section: "EmuCore/GS", key: "SkipDuplicateFrames", default: true,
suppressible: false,
@@ -1091,10 +1125,13 @@ final class SettingsStore {
codec: .rawInt)
var lastActiveOsdPreset: OsdPreset = .simple { didSet { commit(_lastActiveOsdPresetConfig, lastActiveOsdPreset) } }
let _osdPerformancePositionConfig = Setting<Int>(
section: "EmuCore/GS", key: "OsdPerformancePos", default: 3,
section: "EmuCore/GS", key: "OsdPerformancePos",
default: SettingsStore.defaultOsdPerformancePosition,
suppressible: false,
codec: .int)
var osdPerformancePosition: Int = 3 { didSet { commit(_osdPerformancePositionConfig, osdPerformancePosition) } }
var osdPerformancePosition = SettingsStore.defaultOsdPerformancePosition {
didSet { commit(_osdPerformancePositionConfig, osdPerformancePosition) }
}
/// Suppresses transient on-screen messages (shader compilation, save state,
/// settings-applied). Critical SwiftUI alerts are unaffected. Backed by the
/// core's OsdMessagesPos (1 = TopLeft default, 0 = None).
@@ -1211,7 +1248,9 @@ final class SettingsStore {
section: "EmuCore/GS", key: "OsdShowTextureReplacements", default: false,
suppressible: false,
codec: .bool)
var osdShowTextureReplacements: Bool = false { didSet { commit(_osdShowTextureReplacementsConfig, osdShowTextureReplacements) } }
var osdShowTextureReplacements: Bool = false {
didSet { commit(_osdShowTextureReplacementsConfig, osdShowTextureReplacements) }
}
let _osdShowDeviceStatsConfig = Setting<Bool>(
section: "ARMSX2iOS/UI", key: "OsdShowDeviceStats", default: false,
suppressible: false,
@@ -1236,7 +1275,9 @@ final class SettingsStore {
section: "ARMSX2iOS/UI", key: "IncreaseRumbleDurationAndInterpolation", default: true,
suppressible: false,
codec: .bool)
var increaseRumbleDurationAndInterpolation: Bool = true { didSet { commit(_increaseRumbleDurationAndInterpolationConfig, increaseRumbleDurationAndInterpolation) } }
var increaseRumbleDurationAndInterpolation: Bool = true {
didSet { commit(_increaseRumbleDurationAndInterpolationConfig, increaseRumbleDurationAndInterpolation) }
}
let _hapticFeedbackConfig = Setting<Bool>(
section: "ARMSX2iOS/UI", key: "HapticFeedback", default: true,
suppressible: false,
@@ -1260,7 +1301,9 @@ final class SettingsStore {
let _autoHideVirtualPadWhenControllerConnectedConfig = Setting<Bool>(
section: "ARMSX2iOS/UI", key: "AutoHideVirtualPadWhenControllerConnected", default: true,
codec: .bool)
var autoHideVirtualPadWhenControllerConnected: Bool = true { didSet { commit(_autoHideVirtualPadWhenControllerConnectedConfig, autoHideVirtualPadWhenControllerConnected) } }
var autoHideVirtualPadWhenControllerConnected: Bool = true {
didSet { commit(_autoHideVirtualPadWhenControllerConnectedConfig, autoHideVirtualPadWhenControllerConnected) }
}
let _autoFullscreenConfig = Setting<Bool>(
section: "ARMSX2iOS/UI", key: "AutoFullscreen", default: true,
codec: .bool)
@@ -1352,10 +1395,13 @@ final class SettingsStore {
codec: .bool)
var autoOpenStikDebug: Bool = false { didSet { commit(_autoOpenStikDebugConfig, autoOpenStikDebug) } }
let _jitScriptProtocolConfig = Setting<JITScriptProtocol>(
// Which protocol is right depends on the iOS version, so ask rather than assume.
section: "ARMSX2iOS/JIT", key: "ScriptProtocol", default: JITScriptProtocol.defaultValue,
section: "ARMSX2iOS/JIT", key: "ScriptProtocol",
// Which one is right depends on the iOS version, so ask rather than assume.
default: JITScriptProtocol.defaultValue,
codec: .rawString)
var jitScriptProtocol: JITScriptProtocol = .legacy { didSet { commit(_jitScriptProtocolConfig, jitScriptProtocol) } }
var jitScriptProtocol = JITScriptProtocol.defaultValue {
didSet { commit(_jitScriptProtocolConfig, jitScriptProtocol) }
}
// DEV9 / Network
// writes HddEnable + HddFile (+ excludes HDD image from backup on enable)
@@ -1517,9 +1563,11 @@ final class SettingsStore {
// Init from INI
private init() {
// The wrapped properties now have inline default values, so assignments
// here fire their didSet. Suppress INI writes during initialization so
// reading from INI does not also write back. Matches reload()'s pattern.
// Assignments here do not fire their didSet. Swift skips property observers
// inside a class's own init and @Observable does not change that, so nothing
// below writes back while the INI loads. Worth knowing before you split this
// up: in a helper they are ordinary assignments, the observers fire, and
// every non-suppressible setting writes itself to disk on each launch.
suppressINIWrites = true
defer { suppressINIWrites = false }
@@ -1596,11 +1644,11 @@ final class SettingsStore {
// Not load(): the INI can name a desktop renderer, so we correct it on disk too.
#if targetEnvironment(macCatalyst)
renderer = 17
ARMSX2Bridge.setINIInt("EmuCore/GS", key: "Renderer", value: Int32(17))
_rendererConfig.write(17)
#else
let initialRenderer = Self.supportedIOSRenderer(Int(ARMSX2Bridge.getINIInt("EmuCore/GS", key: "Renderer", defaultValue: 17)))
renderer = initialRenderer
ARMSX2Bridge.setINIInt("EmuCore/GS", key: "Renderer", value: Int32(initialRenderer))
_rendererConfig.write(initialRenderer)
#endif
upscaleMultiplier = _upscaleMultiplierConfig.load()
vsyncQueueSize = _vsyncQueueSizeConfig.load()
@@ -1758,7 +1806,7 @@ final class SettingsStore {
backgroundEnabledInSettings = UserDefaults.standard.object(forKey: "ARMSX2iOSBackgroundEnabledInSettings") as? Bool ?? true
normalizeDEV9Settings()
VPadSkinLibraryStore.shared.adoptLegacySelection(virtualPadSkin)
ARMSX2Bridge.setINIString("EmuCore/GS", key: "AspectRatio", value: Self.aspectRatioName(for: aspectRatio))
_aspectRatioConfig.write(aspectRatio)
// Do NOT re-apply the OSD preset here. The saved per-item OSD flags are the
// source of truth and are pushed into the live GSConfig natively by
// ARMSX2ApplyIOSOsdPresetFromConfig() at scene startup. Calling
@@ -2239,7 +2287,7 @@ final class SettingsStore {
appLanguage = .system
controllerMultitapMode = 0
autoOpenStikDebug = false
jitScriptProtocol = .defaultValue
jitScriptProtocol = JITScriptProtocol.defaultValue
dev9HddEnabled = false
dev9HddFile = "DEV9hdd.raw"
@@ -0,0 +1,199 @@
import re
import unittest
from pathlib import Path
# Repo root, same as the other tests here.
ROOT = Path(__file__).resolve().parents[4]
MODELS = ROOT / "platforms/ios/app/src/main/swift/Models"
STORE = MODELS / "SettingsStore.swift"
DESCRIPTOR = re.compile(r"let _(\w+)Config = Setting<([^>]+)>\(")
COMMIT = re.compile(r"commit\(_(\w+)Config, (\w+)\)")
RESET_FUNCS = ("resetEmulatorDefaults", "resetGraphicsDefaults", "resetAllDefaults")
# init() loads these by hand, and each says why on the line above it. Their
# codecs deliberately do less than the hand-load, so pointing one at load()
# would drop a clamp or a sentinel without breaking the build.
HAND_LOADED = {
"renderer": "supportedIOSRenderer keeps a desktop renderer out of the Metal path",
"lastActiveOsdPreset": "-1 means never set, and then the preset decides",
"osdPerformancePosition": "old INIs hold positions this build no longer offers",
"jitScriptProtocol": "JITScriptProtocol.normalized maps names older builds wrote",
}
# Migrations run from init() before any property is loaded, and want the value as
# it is on disk right now, sentinel defaults and all. They have to read by hand.
MIGRATION_READS = {
("EmuCore/GS", "VsyncQueueSize"),
("EmuCore/GS", "SyncToHostRefreshRate"),
("SPU2/Output", "OutputLatencyMS"),
("SPU2/Output", "BufferMS"),
("ARMSX2iOS/UI", "PhoneRumbleStrength"),
}
def normalised(value):
"""`Self.` inside the type and `SettingsStore.` outside it are the same thing."""
return value.strip().replace("SettingsStore.", "Self.")
def block_at(lines, first):
"""The brace-balanced block starting at line index `first`."""
depth = 0
for i in range(first, len(lines)):
depth += lines[i].count("{") - lines[i].count("}")
if depth == 0 and i > first:
return lines[first:i + 1]
return lines[first:]
def call_at(lines, first):
"""The paren-balanced `Setting<T>(...)` literal starting at line index `first`."""
depth = 0
for i in range(first, len(lines)):
depth += lines[i].count("(") - lines[i].count(")")
if depth == 0 and i >= first:
return lines[first:i + 1]
return lines[first:]
def block_named(lines, opener):
for i, line in enumerate(lines):
if line.strip().startswith(opener):
return block_at(lines, i)
raise AssertionError(f"{opener!r} is gone from SettingsStore.swift; update this test")
class SettingsDescriptorTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.source = STORE.read_text(encoding="utf-8")
cls.lines = cls.source.split("\n")
# Every Setting in the store: name -> (section, key, default).
cls.descriptors = {}
for i, line in enumerate(cls.lines):
match = DESCRIPTOR.search(line)
if not match:
continue
blob = " ".join(call_at(cls.lines, i))
cls.descriptors[match.group(1)] = (
re.search(r'section: "([^"]*)"', blob).group(1),
re.search(r'key: "([^"]*)"', blob).group(1),
re.search(r"default: (.*?),", blob).group(1).strip(),
)
# The property each descriptor belongs to, read off its commit call.
owner = {m.group(1): m.group(2) for m in COMMIT.finditer(cls.source)}
cls.property_of = {name: owner.get(name, name) for name in cls.descriptors}
cls.init_body = "\n".join(block_named(cls.lines, "private init()"))
# The whole store, extensions included, so a read that moved into a
# helper file cannot slip past the checks below.
cls.store_source = "\n".join(
path.read_text(encoding="utf-8") for path in sorted(MODELS.glob("SettingsStore*.swift")))
def test_the_descriptors_were_actually_found(self):
"""Rename the convention and every check below passes on an empty set."""
self.assertGreater(len(self.descriptors), 100)
def test_the_exemption_lists_have_not_gone_stale(self):
for prop in HAND_LOADED:
with self.subTest(setting=prop):
self.assertIn(prop, self.property_of.values(), f"{prop} is no longer a setting")
assigned = re.findall(rf"^\s*{re.escape(prop)} = (.+)$", self.init_body, re.M)
self.assertFalse(any(".load(" in rhs for rhs in assigned),
f"{prop} loads through its descriptor now; drop the exemption")
by_hand = set(re.findall(r'getINI\w+\(\s*"([^"]+)",\s*key: "([^"]+)"', self.store_source))
self.assertEqual(sorted(MIGRATION_READS - by_hand), [],
"these are exempted but nothing reads them by hand any more")
def test_every_descriptor_is_loaded_in_init(self):
"""A new setting that nobody loads is the whole bug class this guards."""
for name, prop in self.property_of.items():
with self.subTest(setting=prop):
assigned = re.findall(rf"^\s*{re.escape(prop)} = (.+)$", self.init_body, re.M)
self.assertTrue(assigned, f"{prop} is never assigned in init()")
if prop in HAND_LOADED:
continue
for rhs in assigned: # both sides of an #if have to load
self.assertIn(f"_{name}Config.load(", rhs,
f"{prop} is assigned without loading through _{name}Config")
def test_nothing_reads_a_key_a_descriptor_already_owns(self):
"""Reading the INI by hand for an owned key is how the two halves drift."""
allowed = MIGRATION_READS | {self.descriptors[n][:2] for n in self.descriptors
if self.property_of[n] in HAND_LOADED}
owned = {(section, key) for section, key, _ in self.descriptors.values()}
by_hand = set(re.findall(r'getINI\w+\(\s*"([^"]+)",\s*key: "([^"]+)"', self.store_source))
self.assertEqual(sorted((by_hand & owned) - allowed), [],
"a descriptor owns these keys, but the store still reads them by hand")
def test_every_descriptor_picks_a_named_codec(self):
"""An inline read/write pair is free to disagree, which is the thing this all exists to stop."""
for i, line in enumerate(self.lines):
match = DESCRIPTOR.search(line)
if not match:
continue
literal = " ".join(call_at(self.lines, i))
with self.subTest(setting=match.group(1)):
self.assertRegex(literal, r"codec: \.\w+",
f"_{match.group(1)}Config does not name one of SettingCodec's presets")
def test_nothing_on_the_init_path_touches_the_shared_store(self):
"""Reaching SettingsStore.shared from a file init() runs re-enters its own swift_once."""
for name in ("SettingCodec.swift", "Setting.swift"):
with self.subTest(file=name):
self.assertNotIn("SettingsStore.shared", (MODELS / name).read_text(encoding="utf-8"))
def test_no_two_descriptors_claim_the_same_key(self):
seen = {}
for name, (section, key, _) in self.descriptors.items():
self.assertNotIn((section, key), seen,
f"{name} and {seen.get((section, key))} both claim {section}/{key}")
seen[(section, key)] = name
def test_every_descriptor_backed_setter_goes_through_commit(self):
"""Anything writing the INI its own way skips suppression and the graphics apply."""
for name, prop in self.property_of.items():
with self.subTest(setting=prop):
declared = next((i for i, l in enumerate(self.lines)
if re.match(rf"^\s*var {re.escape(prop)}\b", l)), None)
self.assertIsNotNone(declared, f"cannot find the declaration of {prop}")
observer = "\n".join(block_at(self.lines, declared))
self.assertIn(f"commit(_{name}Config, {prop})", observer,
f"{prop}'s didSet does not commit itself through _{name}Config")
def test_the_property_starts_on_its_descriptor_default(self):
"""Swift will not let the initializer say _xConfig.defaultValue, so check it here."""
for name, (_, _, default) in self.descriptors.items():
prop = self.property_of[name]
# The declaration wraps onto a second line when the name is long.
declaration = re.search(rf"^\s*var {re.escape(prop)}(?::[^=]+)? = (.+?) \{{\s*didSet",
self.source, re.M)
with self.subTest(setting=prop):
self.assertIsNotNone(declaration, f"cannot find the declaration of {prop}")
# A leading-dot default is the same enum case spelled shorter.
started, declared = normalised(declaration.group(1)), normalised(default)
if started.startswith(".") or declared.startswith("."):
started, declared = started.split(".")[-1], declared.split(".")[-1]
self.assertEqual(started, declared,
f"{prop} starts at {declaration.group(1)} "
f"but _{name}Config defaults to {default}")
def test_resets_agree_with_the_descriptor_defaults(self):
"""Reset keeps readable literals; this is what stops them drifting."""
by_property = {self.property_of[n]: (n, d) for n, (_, _, d) in self.descriptors.items()}
for func in RESET_FUNCS:
for line in block_named(self.lines, f"func {func}"):
match = re.match(r"^\s*(\w+) = (.+?)(?:\s*//.*)?$", line)
if not match or match.group(1) not in by_property:
continue
name, default = by_property[match.group(1)]
with self.subTest(reset=func, setting=match.group(1)):
self.assertEqual(
normalised(match.group(2)), normalised(default),
f"{func} resets {match.group(1)} to {match.group(2)}, "
f"but _{name}Config defaults to {default}")
if __name__ == "__main__":
unittest.main()