diff --git a/platforms/ios/app/src/main/swift/Models/PerGameShaderSelection.swift b/platforms/ios/app/src/main/swift/Models/PerGameShaderSelection.swift index b78a0f7181..3931c73034 100644 --- a/platforms/ios/app/src/main/swift/Models/PerGameShaderSelection.swift +++ b/platforms/ios/app/src/main/swift/Models/PerGameShaderSelection.swift @@ -8,15 +8,13 @@ import Foundation enum PerGameShaderSelection { static let section = "EmuCore/GS" - /// Written together and cleared together. The token is the durable identity and the absolute - /// is a cache of it, because both preset roots sit under a container UUID that moves. + /// Written and cleared together: the token is the identity, the absolute is its cache. static let keys = ( enabled: "ShaderChainEnabled", presetRef: "ShaderChainPresetRef", presetPath: "ShaderChainPreset") - /// Re-roots a game's token against the container this launch got. Writes nothing for a game - /// that chose no shader, and nothing again when the absolute already agrees. + /// Re-roots a game's token against this launch's container, and pushes what it resolves to. static func repair(forISO isoName: String) { let token = string(keys.presetRef, useCurrent: false, iso: isoName) guard !token.isEmpty else { return } @@ -26,11 +24,13 @@ enum PerGameShaderSelection { setBool(keys.enabled, false, useCurrent: false, iso: isoName) return } + // Nothing else pushes this one: the settings store never reads the per-game file. + ShaderParams.pushStored(token: token) guard string(keys.presetPath, useCurrent: false, iso: isoName) != url.path else { return } setString(keys.presetPath, url.path, useCurrent: false, iso: isoName) } - /// -1 use global, 0 off, 1 on, on the sentinel every other per-game control already uses. + /// -1 use global, 0 off, 1 on -- the sentinel every other per-game control uses. static func loadedChain(useCurrent: Bool, iso: String) -> Int { guard has(keys.enabled, useCurrent: useCurrent, iso: iso) else { return -1 } return bool(keys.enabled, useCurrent: useCurrent, iso: iso) ? 1 : 0 @@ -42,9 +42,12 @@ enum PerGameShaderSelection { /// Off keeps the enabled key and drops the preset; absence is the answer that inherits. static func write(chain: Int, presetRef: String, useCurrent: Bool, iso: String) { - setBool(keys.enabled, chain == 1, useCurrent: useCurrent, iso: iso) - guard chain == 1, !presetRef.isEmpty, - let url = ShaderPresetLibrary.resolve(presetRef) else { + // Enabled true with no preset key reads the GLOBAL preset out of the base layer. + let resolved = chain == 1 && !presetRef.isEmpty + ? ShaderPresetLibrary.resolve(presetRef) + : nil + setBool(keys.enabled, resolved != nil, useCurrent: useCurrent, iso: iso) + guard let url = resolved else { delete(keys.presetRef, useCurrent: useCurrent, iso: iso) delete(keys.presetPath, useCurrent: useCurrent, iso: iso) return diff --git a/platforms/ios/app/src/main/swift/Models/ShaderParams.swift b/platforms/ios/app/src/main/swift/Models/ShaderParams.swift index 3126ab6c4b..329bef839c 100644 --- a/platforms/ios/app/src/main/swift/Models/ShaderParams.swift +++ b/platforms/ios/app/src/main/swift/Models/ShaderParams.swift @@ -6,6 +6,7 @@ import Foundation enum ShaderParamsError: LocalizedError { case noName case noSavedRoot + case wouldOverwriteBase var errorDescription: String? { switch self { @@ -13,6 +14,8 @@ enum ShaderParamsError: LocalizedError { return "That name has nothing in it that can become a filename." case .noSavedRoot: return "The Documents shader folder could not be opened." + case .wouldOverwriteBase: + return "That is the preset this one is built from. Give it a different name." } } } @@ -151,7 +154,7 @@ final class ShaderParams: ObservableObject { let text = Self.presetText(base: base, params: params, overrides: overrides) do { let url = try await Task.detached(priority: .userInitiated) { - try Self.write(text, named: safe) + try Self.write(text, named: safe, base: base) }.value savedName = url.deletingPathExtension().lastPathComponent } catch { @@ -159,9 +162,8 @@ final class ShaderParams: ObservableObject { } } - /// Sends the effective value of EVERY parameter, not just the changed ones. librashader - /// has no unset call, so a name dropped from the map leaves the chain on whatever was - /// pushed last, and a reset would never take. + /// Sends EVERY parameter's effective value. librashader has no unset call, so a name + /// dropped from the map would leave the chain on whatever was pushed last. private func pushEffective() { guard !params.isEmpty, let url = ShaderPresetLibrary.resolve(token) else { return } var effective: [String: NSNumber] = [:] @@ -177,7 +179,8 @@ final class ShaderParams: ObservableObject { /// the overrides go down, because a chain built from the preset file already holds that /// preset's number for every name it is not told about, and reading the file here to say /// so again would put a librashader parse on the launch path. - static func pushStored(token: String) { + /// nonisolated so the per-game boot path can push before bootISO, without hopping actors. + nonisolated static func pushStored(token: String) { guard let url = ShaderPresetLibrary.resolve(token) else { return } var values: [String: NSNumber] = [:] for (name, value) in stored()[token] ?? [:] where value.isFinite { @@ -196,7 +199,7 @@ final class ShaderParams: ObservableObject { ARMSX2Bridge.setINIString(Self.section, key: Self.key, value: json) } - private static func stored() -> [String: [String: Float]] { + private nonisolated static func stored() -> [String: [String: Float]] { let json = ARMSX2Bridge.getINIString(section, key: key, defaultValue: "") guard let data = json.data(using: .utf8), let decoded = try? JSONDecoder() @@ -230,8 +233,7 @@ final class ShaderParams: ObservableObject { } /// Relative while the base sits in the same Documents root, so the pair survives the - /// container UUID changing. A bundled base has no such route and gets a path that a - /// reinstall breaks, which is why the save sheet says the reference is a path. + /// container UUID moving. A bundled base gets a path instead, which a reinstall breaks. private static func reference(to base: URL) -> String { let target = base.standardizedFileURL guard let root = ShaderPresetLibrary.userRoot?.standardizedFileURL, @@ -245,7 +247,8 @@ final class ShaderParams: ObservableObject { return (up + to[shared...]).joined(separator: "/") } - private nonisolated static func write(_ text: String, named name: String) throws -> URL { + private nonisolated static func write(_ text: String, named name: String, + base: URL) throws -> URL { guard ShaderPresetLibrary.prepareUserRoots() != nil, let root = ShaderPresetLibrary.savedPresetRoot?.standardizedFileURL else { throw ShaderParamsError.noSavedRoot @@ -255,6 +258,11 @@ final class ShaderParams: ObservableObject { guard url.deletingLastPathComponent().path == root.path else { throw ShaderParamsError.noName } + // Saving onto the base leaves a preset that references itself, and the sheet + // pre-fills the base's name, so the default is what walks into it. + guard url.path != base.standardizedFileURL.path else { + throw ShaderParamsError.wouldOverwriteBase + } try text.write(to: url, atomically: true, encoding: .utf8) return url }