mirror of
https://github.com/ARMSX2/ARMSX2.git
synced 2026-08-24 16:50:16 -07:00
iOS: let one game keep its own shader preset
A preset was a single global value, so picking crt-geom for a 2D fighter also applied it to the next 3D game booted. The per-game subsystem is the right home; the blocker was that its bridge exposed Int, Bool and Float and no String, while a preset is a string token. Four String accessors added, in the forISO and the current-game shapes the twenty existing per-game settings already use. A Shaders section on the per- game Graphics tab, where the global Shader Chain section sits, on the same tri-state sentinel every other control there uses: use global, off, on. Scope is preset only, decided with both arms in front of the developer. Parameter values stay global and stay keyed by preset token, and the panel says so on screen rather than leaving it to be discovered. A preset exposes up to twenty-two values and per-game copies of those would multiply the storage and the UI. The identity is the same root token the global tier uses, re-rooted at boot before bootISO reads the file, so a per-game choice survives a reinstall for the same reason a global one does. ShaderPresetLibrary.resolve stays the only token-to-path resolver; nothing here reimplements containment. The rule that matters, and the one the fence exists for: a token that no longer names a file turns the chain off for that game rather than falling through to the global preset. A different CRT shader looks like a CRT shader, so a substitution is invisible -- the player sees a filter, assumes it is theirs, and never learns their choice is gone. Six source checks over the six files the selection lives in, and four mutations run against the real source with every restore byte identical. Also here, because it landed in the same wave: the prescale fence widens to .inc and .h. A .slangp names its stages, but a stage includes whatever it likes, so that bug can sit in a header and never appear in a .slang -- which is exactly where the whole-tree catalogue run found it.
This commit is contained in:
@@ -293,17 +293,21 @@ typedef void (^ARMSX2RetroAchievementsCompletion)(BOOL success, NSString * _Nonn
|
||||
+ (int)getPerGameINIInt:(nonnull NSString *)section key:(nonnull NSString *)key defaultValue:(int)def forISO:(nonnull NSString *)isoName NS_SWIFT_NAME(getPerGameINIInt(_:key:defaultValue:forISO:));
|
||||
+ (BOOL)getPerGameINIBool:(nonnull NSString *)section key:(nonnull NSString *)key defaultValue:(BOOL)def forISO:(nonnull NSString *)isoName NS_SWIFT_NAME(getPerGameINIBool(_:key:defaultValue:forISO:));
|
||||
+ (float)getPerGameINIFloat:(nonnull NSString *)section key:(nonnull NSString *)key defaultValue:(float)def forISO:(nonnull NSString *)isoName NS_SWIFT_NAME(getPerGameINIFloat(_:key:defaultValue:forISO:));
|
||||
+ (nonnull NSString *)getPerGameINIString:(nonnull NSString *)section key:(nonnull NSString *)key defaultValue:(nonnull NSString *)def forISO:(nonnull NSString *)isoName NS_SWIFT_NAME(getPerGameINIString(_:key:defaultValue:forISO:));
|
||||
+ (void)setPerGameINIInt:(nonnull NSString *)section key:(nonnull NSString *)key value:(int)value forISO:(nonnull NSString *)isoName NS_SWIFT_NAME(setPerGameINIInt(_:key:value:forISO:));
|
||||
+ (void)setPerGameINIBool:(nonnull NSString *)section key:(nonnull NSString *)key value:(BOOL)value forISO:(nonnull NSString *)isoName NS_SWIFT_NAME(setPerGameINIBool(_:key:value:forISO:));
|
||||
+ (void)setPerGameINIFloat:(nonnull NSString *)section key:(nonnull NSString *)key value:(float)value forISO:(nonnull NSString *)isoName NS_SWIFT_NAME(setPerGameINIFloat(_:key:value:forISO:));
|
||||
+ (void)setPerGameINIString:(nonnull NSString *)section key:(nonnull NSString *)key value:(nonnull NSString *)value forISO:(nonnull NSString *)isoName NS_SWIFT_NAME(setPerGameINIString(_:key:value:forISO:));
|
||||
+ (void)deletePerGameINIValue:(nonnull NSString *)section key:(nonnull NSString *)key forISO:(nonnull NSString *)isoName NS_SWIFT_NAME(deletePerGameINIValue(_:key:forISO:));
|
||||
+ (BOOL)hasPerGameINIValueForCurrentGame:(nonnull NSString *)section key:(nonnull NSString *)key NS_SWIFT_NAME(hasPerGameINIValueForCurrentGame(_:key:));
|
||||
+ (int)getPerGameINIIntForCurrentGame:(nonnull NSString *)section key:(nonnull NSString *)key defaultValue:(int)def NS_SWIFT_NAME(getPerGameINIIntForCurrentGame(_:key:defaultValue:));
|
||||
+ (BOOL)getPerGameINIBoolForCurrentGame:(nonnull NSString *)section key:(nonnull NSString *)key defaultValue:(BOOL)def NS_SWIFT_NAME(getPerGameINIBoolForCurrentGame(_:key:defaultValue:));
|
||||
+ (float)getPerGameINIFloatForCurrentGame:(nonnull NSString *)section key:(nonnull NSString *)key defaultValue:(float)def NS_SWIFT_NAME(getPerGameINIFloatForCurrentGame(_:key:defaultValue:));
|
||||
+ (nonnull NSString *)getPerGameINIStringForCurrentGame:(nonnull NSString *)section key:(nonnull NSString *)key defaultValue:(nonnull NSString *)def NS_SWIFT_NAME(getPerGameINIStringForCurrentGame(_:key:defaultValue:));
|
||||
+ (void)setPerGameINIIntForCurrentGame:(nonnull NSString *)section key:(nonnull NSString *)key value:(int)value NS_SWIFT_NAME(setPerGameINIIntForCurrentGame(_:key:value:));
|
||||
+ (void)setPerGameINIBoolForCurrentGame:(nonnull NSString *)section key:(nonnull NSString *)key value:(BOOL)value NS_SWIFT_NAME(setPerGameINIBoolForCurrentGame(_:key:value:));
|
||||
+ (void)setPerGameINIFloatForCurrentGame:(nonnull NSString *)section key:(nonnull NSString *)key value:(float)value NS_SWIFT_NAME(setPerGameINIFloatForCurrentGame(_:key:value:));
|
||||
+ (void)setPerGameINIStringForCurrentGame:(nonnull NSString *)section key:(nonnull NSString *)key value:(nonnull NSString *)value NS_SWIFT_NAME(setPerGameINIStringForCurrentGame(_:key:value:));
|
||||
+ (void)deletePerGameINIValueForCurrentGame:(nonnull NSString *)section key:(nonnull NSString *)key NS_SWIFT_NAME(deletePerGameINIValueForCurrentGame(_:key:));
|
||||
|
||||
// Identity the accessors above key on, or "" when there isn't one. The current-game
|
||||
|
||||
@@ -4686,6 +4686,57 @@ static void ARMSX2RequestPerGameSettingsReload()
|
||||
ARMSX2RequestPerGameSettingsReload();
|
||||
}
|
||||
|
||||
// The per-game family had no string type until a shader preset needed one: a selection is a
|
||||
// root token such as "bundle:presets/crt/crt-geom.slangp", not a number.
|
||||
+ (nonnull NSString *)getPerGameINIString:(nonnull NSString *)section key:(nonnull NSString *)key defaultValue:(nonnull NSString *)def forISO:(nonnull NSString *)isoName {
|
||||
std::string serial;
|
||||
u32 crc = 0;
|
||||
if (!ARMSX2PerGameIdentityForISO(isoName, &serial, &crc))
|
||||
return def;
|
||||
INISettingsInterface si(ARMSX2PerGameSettingsPath(serial, crc));
|
||||
if (!si.Load())
|
||||
return def;
|
||||
return ARMSX2NSStringFromStdString(si.GetStringValue(section.UTF8String, key.UTF8String, def.UTF8String));
|
||||
}
|
||||
|
||||
+ (void)setPerGameINIString:(nonnull NSString *)section key:(nonnull NSString *)key value:(nonnull NSString *)value forISO:(nonnull NSString *)isoName {
|
||||
std::string serial;
|
||||
u32 crc = 0;
|
||||
if (!ARMSX2PerGameIdentityForISO(isoName, &serial, &crc))
|
||||
return;
|
||||
INISettingsInterface si(ARMSX2PerGameSettingsPath(serial, crc));
|
||||
si.Load();
|
||||
si.SetStringValue(section.UTF8String, key.UTF8String, value.UTF8String);
|
||||
ARMSX2SyncClaimsIfPinnedHackKey(si, section, key);
|
||||
Error error;
|
||||
si.Save(&error);
|
||||
}
|
||||
|
||||
+ (nonnull NSString *)getPerGameINIStringForCurrentGame:(nonnull NSString *)section key:(nonnull NSString *)key defaultValue:(nonnull NSString *)def {
|
||||
std::string serial;
|
||||
u32 crc = 0;
|
||||
if (!ARMSX2PerGameIdentityForCurrentGame(&serial, &crc))
|
||||
return def;
|
||||
INISettingsInterface si(ARMSX2PerGameSettingsPath(serial, crc));
|
||||
if (!si.Load())
|
||||
return def;
|
||||
return ARMSX2NSStringFromStdString(si.GetStringValue(section.UTF8String, key.UTF8String, def.UTF8String));
|
||||
}
|
||||
|
||||
+ (void)setPerGameINIStringForCurrentGame:(nonnull NSString *)section key:(nonnull NSString *)key value:(nonnull NSString *)value {
|
||||
std::string serial;
|
||||
u32 crc = 0;
|
||||
if (!ARMSX2PerGameIdentityForCurrentGame(&serial, &crc))
|
||||
return;
|
||||
INISettingsInterface si(ARMSX2PerGameSettingsPath(serial, crc));
|
||||
si.Load();
|
||||
si.SetStringValue(section.UTF8String, key.UTF8String, value.UTF8String);
|
||||
ARMSX2SyncClaimsIfPinnedHackKey(si, section, key);
|
||||
Error error;
|
||||
si.Save(&error);
|
||||
ARMSX2RequestPerGameSettingsReload();
|
||||
}
|
||||
|
||||
+ (void)deletePerGameINIValueForCurrentGame:(nonnull NSString *)section key:(nonnull NSString *)key {
|
||||
std::string serial;
|
||||
u32 crc = 0;
|
||||
|
||||
@@ -170,6 +170,8 @@ final class AppState: @unchecked Sendable {
|
||||
Task { @MainActor in
|
||||
StikDebugLauncher.autoOpenIfNeeded(reason: "game boot")
|
||||
}
|
||||
// Before, not after: the boot reads the per-game file, stale absolute and all.
|
||||
PerGameShaderSelection.repair(forISO: isoName)
|
||||
ARMSX2Bridge.bootISO(isoName)
|
||||
ARMSX2Bridge.prepareGameRenderViewForCurrentRenderer()
|
||||
runningGameName = isoName
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// PerGameShaderSelection.swift — the shader chain one game gets, and how it survives a reinstall
|
||||
// SPDX-License-Identifier: GPL-3.0+
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Silence gets the global chain, `ShaderChainEnabled = false` gets no chain, and a token that no
|
||||
/// longer names a file gets no chain either — never the global preset, which is not what was picked.
|
||||
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.
|
||||
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.
|
||||
static func repair(forISO isoName: String) {
|
||||
let token = string(keys.presetRef, useCurrent: false, iso: isoName)
|
||||
guard !token.isEmpty else { return }
|
||||
guard let url = ShaderPresetLibrary.resolve(token) else {
|
||||
delete(keys.presetRef, useCurrent: false, iso: isoName)
|
||||
delete(keys.presetPath, useCurrent: false, iso: isoName)
|
||||
setBool(keys.enabled, false, useCurrent: false, iso: isoName)
|
||||
return
|
||||
}
|
||||
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.
|
||||
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
|
||||
}
|
||||
|
||||
static func loadedPresetRef(useCurrent: Bool, iso: String) -> String {
|
||||
string(keys.presetRef, useCurrent: useCurrent, iso: iso)
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
delete(keys.presetRef, useCurrent: useCurrent, iso: iso)
|
||||
delete(keys.presetPath, useCurrent: useCurrent, iso: iso)
|
||||
return
|
||||
}
|
||||
setString(keys.presetRef, presetRef, useCurrent: useCurrent, iso: iso)
|
||||
setString(keys.presetPath, url.path, useCurrent: useCurrent, iso: iso)
|
||||
}
|
||||
|
||||
static func clear(useCurrent: Bool, iso: String) {
|
||||
for key in [keys.enabled, keys.presetRef, keys.presetPath] {
|
||||
delete(key, useCurrent: useCurrent, iso: iso)
|
||||
}
|
||||
}
|
||||
|
||||
private static func has(_ key: String, useCurrent: Bool, iso: String) -> Bool {
|
||||
useCurrent
|
||||
? ARMSX2Bridge.hasPerGameINIValueForCurrentGame(section, key: key)
|
||||
: ARMSX2Bridge.hasPerGameINIValue(section, key: key, forISO: iso)
|
||||
}
|
||||
|
||||
private static func bool(_ key: String, useCurrent: Bool, iso: String) -> Bool {
|
||||
useCurrent
|
||||
? ARMSX2Bridge.getPerGameINIBoolForCurrentGame(section, key: key, defaultValue: false)
|
||||
: ARMSX2Bridge.getPerGameINIBool(section, key: key, defaultValue: false, forISO: iso)
|
||||
}
|
||||
|
||||
private static func string(_ key: String, useCurrent: Bool, iso: String) -> String {
|
||||
useCurrent
|
||||
? ARMSX2Bridge.getPerGameINIStringForCurrentGame(section, key: key, defaultValue: "")
|
||||
: ARMSX2Bridge.getPerGameINIString(section, key: key, defaultValue: "", forISO: iso)
|
||||
}
|
||||
|
||||
private static func setBool(_ key: String, _ value: Bool, useCurrent: Bool, iso: String) {
|
||||
if useCurrent {
|
||||
ARMSX2Bridge.setPerGameINIBoolForCurrentGame(section, key: key, value: value)
|
||||
} else {
|
||||
ARMSX2Bridge.setPerGameINIBool(section, key: key, value: value, forISO: iso)
|
||||
}
|
||||
}
|
||||
|
||||
private static func setString(_ key: String, _ value: String, useCurrent: Bool, iso: String) {
|
||||
if useCurrent {
|
||||
ARMSX2Bridge.setPerGameINIStringForCurrentGame(section, key: key, value: value)
|
||||
} else {
|
||||
ARMSX2Bridge.setPerGameINIString(section, key: key, value: value, forISO: iso)
|
||||
}
|
||||
}
|
||||
|
||||
private static func delete(_ key: String, useCurrent: Bool, iso: String) {
|
||||
if useCurrent {
|
||||
ARMSX2Bridge.deletePerGameINIValueForCurrentGame(section, key: key)
|
||||
} else {
|
||||
ARMSX2Bridge.deletePerGameINIValue(section, key: key, forISO: iso)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,13 @@ import PhotosUI
|
||||
import UniformTypeIdentifiers
|
||||
import UIKit
|
||||
|
||||
/// A constant id on purpose. The panel's body re-runs on every fingerprint change, and an
|
||||
/// item-bound sheet whose identity moved would rebuild the browser's search field under the
|
||||
/// keyboard, which is the tearing the global section documents at its own save sheet.
|
||||
private struct ShaderPresetBrowserRequest: Identifiable {
|
||||
let id = "shader-preset-browser"
|
||||
}
|
||||
|
||||
struct PerGameSettingsPanel: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State private var settings = SettingsStore.shared
|
||||
@@ -49,6 +56,9 @@ struct PerGameSettingsPanel: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// A compile-time fact, cached here so the Graphics tab never has to name the bridge.
|
||||
private static let shaderChainSupported = ARMSX2Bridge.isShaderChainSupported()
|
||||
|
||||
private static let useGlobalSentinel = -1
|
||||
private static let upscaleUseGlobalSentinel: Float = -1.0
|
||||
private static let aspectUseGlobalSentinel = ""
|
||||
@@ -131,6 +141,9 @@ struct PerGameSettingsPanel: View {
|
||||
@State private var perGameShadeBoostContrast: Int
|
||||
@State private var perGameShadeBoostSaturation: Int
|
||||
@State private var perGameShadeBoostGamma: Int
|
||||
@State private var perGameShaderChain: Int
|
||||
@State private var perGameShaderPresetRef: String
|
||||
@State private var shaderPresetRequest: ShaderPresetBrowserRequest?
|
||||
@State private var perGameDithering: Int
|
||||
@State private var perGameFastForwardVolume: Int
|
||||
@State private var perGameIOP: Int
|
||||
@@ -300,6 +313,8 @@ struct PerGameSettingsPanel: View {
|
||||
_perGameShadeBoostContrast = State(initialValue: Self.loadedPerGameInt("EmuCore/GS", "ShadeBoost_Contrast", globalDefault: 50, useCurrent: useCurrent, iso: perGameISO))
|
||||
_perGameShadeBoostSaturation = State(initialValue: Self.loadedPerGameInt("EmuCore/GS", "ShadeBoost_Saturation", globalDefault: 50, useCurrent: useCurrent, iso: perGameISO))
|
||||
_perGameShadeBoostGamma = State(initialValue: Self.loadedPerGameInt("EmuCore/GS", "ShadeBoost_Gamma", globalDefault: 50, useCurrent: useCurrent, iso: perGameISO))
|
||||
_perGameShaderChain = State(initialValue: PerGameShaderSelection.loadedChain(useCurrent: useCurrent, iso: perGameISO))
|
||||
_perGameShaderPresetRef = State(initialValue: PerGameShaderSelection.loadedPresetRef(useCurrent: useCurrent, iso: perGameISO))
|
||||
_perGameDithering = State(initialValue: Self.loadedPerGameInt("EmuCore/GS", "dithering_ps2", globalDefault: 2, useCurrent: useCurrent, iso: perGameISO))
|
||||
_perGameFastForwardVolume = State(initialValue: Self.clampedPerGameInt(Self.loadedPerGameInt("SPU2/Output", "FastForwardVolume", globalDefault: 100, useCurrent: useCurrent, iso: perGameISO), to: SettingsStore.fastForwardVolumeRange))
|
||||
_perGameIOP = State(initialValue: Self.loadedPerGameBool("EmuCore/CPU/Recompiler", "EnableIOP", useCurrent: useCurrent, iso: perGameISO))
|
||||
@@ -346,7 +361,7 @@ struct PerGameSettingsPanel: View {
|
||||
/// 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)|\(alignSprite)|\(mergeSprite)|\(wildArmsOffset)|\(textureOffsetXOverride)|\(textureOffsetX)|\(textureOffsetYOverride)|\(textureOffsetY)|\(skipDrawStartOverride)|\(skipDrawStart)|\(skipDrawEndOverride)|\(skipDrawEnd)|\(volumeOverride)|\(volumePercent)|\(eeCoreType)|\(mtvu)|\(eeCycleRate)|\(eeCycleSkip)|\(fastBoot)|\(enableCheats)|\(enablePatches)|\(enableGameFixes)|\(enableGameDBHardwareFixes)|\(perGameAAT)|\(perGameTextureInsideRt)|\(perGameDisableDepth)|\(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)"
|
||||
return "\(enabled)|\(upscaleMultiplier)|\(aspectRatio)|\(textureFiltering)|\(hardwareMipmapping)|\(blendingAccuracy)|\(interlaceMode)|\(trilinearFiltering)|\(halfPixelOffset)|\(roundSprite)|\(alignSprite)|\(mergeSprite)|\(wildArmsOffset)|\(textureOffsetXOverride)|\(textureOffsetX)|\(textureOffsetYOverride)|\(textureOffsetY)|\(skipDrawStartOverride)|\(skipDrawStart)|\(skipDrawEndOverride)|\(skipDrawEnd)|\(volumeOverride)|\(volumePercent)|\(eeCoreType)|\(mtvu)|\(eeCycleRate)|\(eeCycleSkip)|\(fastBoot)|\(enableCheats)|\(enablePatches)|\(enableGameFixes)|\(enableGameDBHardwareFixes)|\(perGameAAT)|\(perGameTextureInsideRt)|\(perGameDisableDepth)|\(perGameRenderer)|\(perGameFXAA)|\(perGameUpscaler)|\(perGameShadeBoost)|\(perGameTVShader)|\(perGameCASMode)|\(perGameMaxAnisotropy)|\(perGameCASSharpness)|\(perGamePCRTCOffsets)|\(perGameIntegerScaling)|\(perGameSkipDupFrames)|\(perGamePCRTCOverscan)|\(perGamePCRTCAntiBlur)|\(perGameDisableInterlaceOffset)|\(perGameWidescreen)|\(perGameNoInterlace)|\(perGameShadeBoostBrightness)|\(perGameShadeBoostContrast)|\(perGameShadeBoostSaturation)|\(perGameShadeBoostGamma)|\(perGameShaderChain)|\(perGameShaderPresetRef)|\(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)"
|
||||
}
|
||||
|
||||
private var hasPendingChanges: Bool {
|
||||
@@ -423,6 +438,17 @@ struct PerGameSettingsPanel: View {
|
||||
context: perGamePadLayoutEditorContext
|
||||
)
|
||||
}
|
||||
.sheet(item: $shaderPresetRequest) { _ in
|
||||
NavigationStack {
|
||||
ShaderPresetBrowserView(
|
||||
title: settings.localized("Shader Presets"),
|
||||
folder: nil,
|
||||
selectedToken: perGameShaderPresetRef,
|
||||
localized: { settings.localized($0) },
|
||||
onSelect: { perGameShaderPresetRef = $0 }
|
||||
)
|
||||
}
|
||||
}
|
||||
.fullScreenCover(isPresented: $showCheatsManager) {
|
||||
CheatsPatchesManagerView(
|
||||
isoName: game.bootName,
|
||||
@@ -645,6 +671,8 @@ struct PerGameSettingsPanel: View {
|
||||
perGameShadeBoostContrast: $perGameShadeBoostContrast,
|
||||
perGameShadeBoostSaturation: $perGameShadeBoostSaturation,
|
||||
perGameShadeBoostGamma: $perGameShadeBoostGamma,
|
||||
perGameShaderChain: $perGameShaderChain,
|
||||
perGameShaderPresetRef: $perGameShaderPresetRef,
|
||||
perGameDithering: $perGameDithering,
|
||||
perGameTVShader: $perGameTVShader,
|
||||
perGameCASMode: $perGameCASMode,
|
||||
@@ -664,6 +692,8 @@ struct PerGameSettingsPanel: View {
|
||||
perGameLoadTextureReplacementsAsync: $perGameLoadTextureReplacementsAsync,
|
||||
perGamePrecacheTextureReplacements: $perGamePrecacheTextureReplacements,
|
||||
savesToRunningGame: savesToRunningGame,
|
||||
shaderChainSupported: Self.shaderChainSupported,
|
||||
onBrowseShaderPreset: { shaderPresetRequest = ShaderPresetBrowserRequest() },
|
||||
settings: settings
|
||||
)
|
||||
}
|
||||
@@ -1270,6 +1300,12 @@ struct PerGameSettingsPanel: View {
|
||||
} else {
|
||||
Self.clearPerGameValue("EmuCore/GS", "dithering_ps2", useCurrent: useCurrent, iso: iso)
|
||||
}
|
||||
// All three shader keys go through the one type that knows what each state means.
|
||||
if enabled && perGameShaderChain != -1 {
|
||||
PerGameShaderSelection.write(chain: perGameShaderChain, presetRef: perGameShaderPresetRef, useCurrent: useCurrent, iso: iso)
|
||||
} else {
|
||||
PerGameShaderSelection.clear(useCurrent: useCurrent, iso: iso)
|
||||
}
|
||||
if enabled && perGameFastForwardVolume != -1 {
|
||||
Self.setPerGameIntValue("SPU2/Output", "FastForwardVolume", perGameFastForwardVolume, useCurrent: useCurrent, iso: iso)
|
||||
} else {
|
||||
|
||||
@@ -44,6 +44,8 @@ struct GraphicsTab: View {
|
||||
@Binding var perGameShadeBoostContrast: Int
|
||||
@Binding var perGameShadeBoostSaturation: Int
|
||||
@Binding var perGameShadeBoostGamma: Int
|
||||
@Binding var perGameShaderChain: Int
|
||||
@Binding var perGameShaderPresetRef: String
|
||||
@Binding var perGameDithering: Int
|
||||
@Binding var perGameTVShader: Int
|
||||
@Binding var perGameCASMode: Int
|
||||
@@ -64,6 +66,8 @@ struct GraphicsTab: View {
|
||||
@Binding var perGamePrecacheTextureReplacements: Int
|
||||
|
||||
let savesToRunningGame: Bool
|
||||
let shaderChainSupported: Bool
|
||||
let onBrowseShaderPreset: () -> Void
|
||||
let settings: SettingsStore
|
||||
|
||||
// MARK: Static option tables (moved from the panel)
|
||||
@@ -275,6 +279,15 @@ struct GraphicsTab: View {
|
||||
.disabled(!enabled)
|
||||
}
|
||||
|
||||
if shaderChainSupported {
|
||||
PerGameShaderSection(
|
||||
enabled: enabled,
|
||||
chain: $perGameShaderChain,
|
||||
presetRef: $perGameShaderPresetRef,
|
||||
settings: settings,
|
||||
onBrowse: onBrowseShaderPreset)
|
||||
}
|
||||
|
||||
Section(settings.localized("Advanced Upscaling Hacks")) {
|
||||
Text(settings.localized("A hack you set here outranks the game database for this game, and everything on Use Global stays automatic. " + (savesToRunningGame ? "Changes apply when you save." : "Changes apply on next boot.")))
|
||||
.font(.caption)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// PerGameShaderSection.swift — the shader chain rows on the per-game Graphics tab
|
||||
// SPDX-License-Identifier: GPL-3.0+
|
||||
|
||||
import SwiftUI
|
||||
|
||||
/// Three states, not the global section's two: Use Global, Off, and a preset of this game's own.
|
||||
/// The preset row is a Button and not a link, because the wide panel has no navigation stack.
|
||||
struct PerGameShaderSection: View {
|
||||
let enabled: Bool
|
||||
@Binding var chain: Int
|
||||
@Binding var presetRef: String
|
||||
let settings: SettingsStore
|
||||
let onBrowse: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Section {
|
||||
Picker(settings.localized("Shader Chain"), selection: $chain) {
|
||||
Text(settings.localized("Use Global")).tag(-1)
|
||||
Text(settings.localized("Off")).tag(0)
|
||||
Text(settings.localized("On")).tag(1)
|
||||
}
|
||||
.disabled(!enabled)
|
||||
|
||||
if chain == 1 {
|
||||
Button(action: onBrowse) {
|
||||
HStack {
|
||||
Text(settings.localized("Preset"))
|
||||
Spacer()
|
||||
Text(presetName)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.disabled(!enabled)
|
||||
|
||||
if !presetRef.isEmpty {
|
||||
Button(role: .destructive) {
|
||||
presetRef = ""
|
||||
} label: {
|
||||
Text(settings.localized("Clear Preset"))
|
||||
}
|
||||
.disabled(!enabled)
|
||||
}
|
||||
}
|
||||
} header: {
|
||||
Text(settings.localized("Shaders"))
|
||||
} footer: {
|
||||
Text(settings.localized("A game with no shader of its own uses the global chain. Off means no shader for this game even while the global chain is running. Parameter values belong to the preset and not to the game, so two games on the same preset share them."))
|
||||
}
|
||||
}
|
||||
|
||||
/// The tail of the token, which is the file name a player picked it by. Four lines the global
|
||||
/// section also carries, copied rather than shared so its fenced initializer stays shut.
|
||||
private var presetName: String {
|
||||
guard let separator = presetRef.firstIndex(of: ShaderPresetLibrary.markerSeparator) else {
|
||||
return settings.localized("None")
|
||||
}
|
||||
let relative = presetRef[presetRef.index(after: separator)...]
|
||||
let name = URL(fileURLWithPath: String(relative))
|
||||
.deletingPathExtension().lastPathComponent
|
||||
return name.isEmpty ? settings.localized("None") : name
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import re
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# Repo root, same as the other tests here.
|
||||
ROOT = Path(__file__).resolve().parents[4]
|
||||
SWIFT = ROOT / "platforms/ios/app/src/main/swift"
|
||||
CPP = ROOT / "platforms/ios/app/src/main/cpp"
|
||||
|
||||
BRIDGE_H = CPP / "ARMSX2Bridge.h"
|
||||
BRIDGE_MM = CPP / "ARMSX2Bridge.mm"
|
||||
SELECTION = SWIFT / "Models/PerGameShaderSelection.swift"
|
||||
APP_STATE = SWIFT / "Models/AppState.swift"
|
||||
PANEL = SWIFT / "Views/PerGameSettingsPanel.swift"
|
||||
SECTION = SWIFT / "Views/Settings/PerGame/PerGameShaderSection.swift"
|
||||
|
||||
STRING_METHODS = (
|
||||
"getPerGameINIString:",
|
||||
"setPerGameINIString:",
|
||||
"getPerGameINIStringForCurrentGame:",
|
||||
"setPerGameINIStringForCurrentGame:",
|
||||
)
|
||||
RESOLVER = "ShaderPresetLibrary.resolve"
|
||||
TOKEN_KEY = "ShaderChainPresetRef"
|
||||
ENABLED_KEY = "ShaderChainEnabled"
|
||||
BOOT = "ARMSX2Bridge.bootISO("
|
||||
REPAIR = "PerGameShaderSelection.repair"
|
||||
FINGERPRINT = "func perGameFingerprint"
|
||||
PANEL_STATES = ("perGameShaderChain", "perGameShaderPresetRef")
|
||||
|
||||
|
||||
def without_comments(source):
|
||||
"""What the compiler sees, so prose naming a symbol cannot satisfy a check."""
|
||||
return re.sub(r"//[^\n]*", "", re.sub(r"/\*.*?\*/", "", source, flags=re.S))
|
||||
|
||||
|
||||
def func_body(source, signature):
|
||||
"""A Swift function from its signature to the brace that closes it."""
|
||||
if signature not in source:
|
||||
return None
|
||||
start = source.index(signature)
|
||||
opening = source.index("{", start)
|
||||
depth = 0
|
||||
for i in range(opening, len(source)):
|
||||
if source[i] == "{":
|
||||
depth += 1
|
||||
elif source[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return source[start:i + 1]
|
||||
return None
|
||||
|
||||
|
||||
def else_block(body, anchor):
|
||||
"""The block the `guard <anchor> ... else {` opens, brace-matched."""
|
||||
if anchor not in body:
|
||||
return None
|
||||
start = body.index(anchor)
|
||||
if "else" not in body[start:]:
|
||||
return None
|
||||
opening = body.index("{", body.index("else", start))
|
||||
depth = 0
|
||||
for i in range(opening, len(body)):
|
||||
if body[i] == "{":
|
||||
depth += 1
|
||||
elif body[i] == "}":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return body[opening:i + 1]
|
||||
return None
|
||||
|
||||
|
||||
def swift_sources():
|
||||
return sorted(SWIFT.rglob("*.swift"))
|
||||
|
||||
|
||||
class PerGameShaderSelectionPolicy(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.header = BRIDGE_H.read_text(encoding="utf-8")
|
||||
cls.impl = BRIDGE_MM.read_text(encoding="utf-8")
|
||||
cls.selection = without_comments(SELECTION.read_text(encoding="utf-8"))
|
||||
cls.app_state = without_comments(APP_STATE.read_text(encoding="utf-8"))
|
||||
cls.panel = without_comments(PANEL.read_text(encoding="utf-8"))
|
||||
cls.section = without_comments(SECTION.read_text(encoding="utf-8"))
|
||||
|
||||
def test_the_per_game_bridge_can_carry_a_string(self):
|
||||
for method in STRING_METHODS:
|
||||
with self.subTest(method=method):
|
||||
self.assertIn(
|
||||
method, self.header,
|
||||
f"{method} is not declared, so Swift cannot see it. A preset selection is a "
|
||||
"token and not a number, and the per-game family is Int, Bool and Float "
|
||||
"without these: the per-game tier cannot store a shader choice at all.")
|
||||
self.assertIn(
|
||||
method, self.impl,
|
||||
f"{method} is declared and never implemented, which is a link error at best "
|
||||
"and an unrecognized selector on device at worst.")
|
||||
|
||||
def test_one_resolver_owns_the_token(self):
|
||||
self.assertIn(
|
||||
RESOLVER, self.selection,
|
||||
"PerGameShaderSelection no longer resolves through ShaderPresetLibrary, which is the "
|
||||
"only code that refuses .. components, re-checks containment under a root and confirms "
|
||||
"the file exists. A second resolver is a second set of those rules to get right.")
|
||||
for stray in (".appendingPathComponent(", "FileManager", "bundleRoot", "userRoot"):
|
||||
with self.subTest(stray=stray):
|
||||
self.assertNotIn(
|
||||
stray, self.selection,
|
||||
f"PerGameShaderSelection builds paths itself ({stray}), so a per-game token "
|
||||
"takes a route to disk that plan 05's eleven mutations never tested.")
|
||||
self.assertIn(
|
||||
TOKEN_KEY, self.selection,
|
||||
f"the per-game file no longer carries {TOKEN_KEY}. An absolute path stops naming its "
|
||||
"preset the moment the container UUID changes, and this build is sideloaded, so that "
|
||||
"is every reinstall rather than a rare event.")
|
||||
|
||||
def test_the_repair_runs_before_the_iso_reaches_core(self):
|
||||
sites = []
|
||||
for path in swift_sources():
|
||||
source = without_comments(path.read_text(encoding="utf-8"))
|
||||
sites += [path] * source.count(BOOT)
|
||||
self.assertEqual(
|
||||
len(sites), 1,
|
||||
"Swift reaches ARMSX2Bridge.bootISO from "
|
||||
+ ", ".join(sorted({p.name for p in sites}))
|
||||
+ ". The re-rooting hooks one call site, so any other one boots a game on the absolute "
|
||||
"path last install wrote. This counts Swift only: the native auto-boot path never "
|
||||
"calls bootISO and is knowingly left uncovered.")
|
||||
|
||||
self.assertIn(
|
||||
REPAIR, self.app_state,
|
||||
"AppState never calls the repair, so a per-game token is never turned back into a "
|
||||
"path under this install's container and the chain fails to build.")
|
||||
self.assertIn(
|
||||
BOOT, self.app_state,
|
||||
"AppState no longer boots through ARMSX2Bridge.bootISO, so the ordering assertion "
|
||||
"below is measuring nothing. Re-anchor it on whatever replaced the call.")
|
||||
self.assertLess(
|
||||
self.app_state.index(REPAIR), self.app_state.index(BOOT),
|
||||
"the repair runs after the boot. UpdateGameSettingsLayer reads the per-game file "
|
||||
"during bootISO, so core already holds the stale path by the time the fix lands.")
|
||||
|
||||
def test_an_unresolvable_token_turns_the_chain_off(self):
|
||||
body = func_body(self.selection, "static func repair(forISO")
|
||||
self.assertIsNotNone(
|
||||
body, "PerGameShaderSelection.repair is gone, so nothing re-roots a per-game token.")
|
||||
branch = else_block(body, RESOLVER)
|
||||
self.assertIsNotNone(
|
||||
branch, "the repair no longer guards the resolve, so a token naming nothing is treated "
|
||||
"as if it named something.")
|
||||
for key in ("presetRef", "presetPath"):
|
||||
with self.subTest(key=key):
|
||||
self.assertRegex(
|
||||
branch, r"delete\w*\([^)]*" + key,
|
||||
f"an unresolvable token leaves the {key} key in the per-game file. Both string "
|
||||
"keys go, or the next boot re-reads the same dead selection and the panel "
|
||||
"keeps reporting a preset that names nothing.")
|
||||
self.assertRegex(
|
||||
branch, r"enabled,\s*(value:\s*)?false",
|
||||
"an unresolvable token does not write the enabled key false, so the per-game file "
|
||||
"falls through to the global preset. That puts a shader on screen the player never "
|
||||
"chose for this game, and one frame cannot be told from another by looking at it. "
|
||||
"Off is visible and reads back as Off the next time the panel opens.")
|
||||
|
||||
def test_save_notices_a_shader_only_change(self):
|
||||
self.assertIn(
|
||||
FINGERPRINT, self.panel,
|
||||
"the panel no longer fingerprints its state, so Save cannot tell a change from none.")
|
||||
body = func_body(self.panel, FINGERPRINT)
|
||||
self.assertIsNotNone(body, "perGameFingerprint has no readable body; update this test.")
|
||||
for state in PANEL_STATES:
|
||||
with self.subTest(state=state):
|
||||
self.assertIn(
|
||||
state, self.panel,
|
||||
f"{state} is not held by the panel, so nothing loads it and nothing saves it.")
|
||||
self.assertIn(
|
||||
state, body,
|
||||
f"{state} is missing from the fingerprint, so Save stays greyed out over the "
|
||||
"only change the player made and it is discarded on dismiss. The token is the "
|
||||
"half that matters most: picking a preset while the chain already reads On "
|
||||
"moves nothing else the fingerprint can see.")
|
||||
|
||||
def test_the_per_game_section_is_its_own_view(self):
|
||||
for pushed in ("NavigationLink(", "NavigationLink {"):
|
||||
with self.subTest(pushed=pushed):
|
||||
self.assertNotIn(
|
||||
pushed, self.section,
|
||||
"the preset row pushes onto a navigation stack. The panel builds one in "
|
||||
"portrait only; landscape is a rail and a detail pane in a plain HStack, so on "
|
||||
"a wide panel the row renders and does nothing at all.")
|
||||
self.assertNotIn(
|
||||
"ShaderChainSection(", self.section,
|
||||
"the global shader section is mounted under a per-game heading. Its only empty state "
|
||||
"is an empty presetRef, and the per-game tier needs three: use global, off, and a "
|
||||
"preset of its own. Its parameter rows also write the global override map.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -30,7 +30,18 @@ PRESCALE = re.compile(
|
||||
|
||||
|
||||
def shaders():
|
||||
return sorted(PRESETS.rglob("*.slang"))
|
||||
"""Every file a preset can pull in, not only the stages.
|
||||
|
||||
A `.slangp` names its stages, but a stage `#include`s whatever it likes, so the
|
||||
bug can sit in an .inc or a .h and never appear in a .slang. Scanning stages only
|
||||
was a real gap: the whole-tree catalogue run found this bug class in an upstream
|
||||
header, in a file the stage-only glob would have walked straight past.
|
||||
"""
|
||||
return sorted(
|
||||
path
|
||||
for suffix in ("*.slang", "*.inc", "*.h")
|
||||
for path in PRESETS.rglob(suffix)
|
||||
)
|
||||
|
||||
|
||||
class PrescaleGuard(unittest.TestCase):
|
||||
|
||||
Reference in New Issue
Block a user