From 6f51411ff91e7ff17e9454922d091171ff2efe33 Mon Sep 17 00:00:00 2001 From: Thomas Cool Date: Wed, 28 Oct 2026 13:29:43 +0100 Subject: [PATCH 1/8] Initial commit building (makefile + audiowatch.swift). audiowatch listens to CoreAudio devices changes and then uses wine reg to change that in the running wineserver. this is only IF the device existed before wine was started (that is a relatively hard limit i think) --- Makefile | 19 ++- helpers/audiowatch.swift | 312 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 327 insertions(+), 4 deletions(-) create mode 100644 helpers/audiowatch.swift diff --git a/Makefile b/Makefile index 128769d1..f2fd258c 100644 --- a/Makefile +++ b/Makefile @@ -77,7 +77,7 @@ DEPS := $(OBJS:.o=.d) featcheck featstagecheck runnervariantcheck conf-fixtures confcheck \ tests-list overlay-shim overlay-shim-install overlay-shim-tests \ overlay-shim-bench iconmaker icon \ - appinfo helpers-install ntdll-resolve bridge runcheck compatcheck envpasscheck \ + appinfo audiowatch helpers-install ntdll-resolve bridge runcheck compatcheck envpasscheck \ compatsvc-check prefixstate-check defaults-check compatpage-check defaults-drift shortcutcheck scriptcheck envcheck buildcheck settingscheck routecheck bridgecheck gamedrivecheck launchercheck bundlecheck launch-shell activatecheck FORCE APP_PAYLOAD := app/Sources/NotProtonApp/Resources/payload @@ -620,10 +620,20 @@ $(APPINFO): helpers/appinfo.swift swiftc -O -target $(ARCH)-apple-macos$(MIN_VER) -o $@ $< @echo "==> Built $@" -helpers-install: $(ICONMAKER) $(APPINFO) +AUDIOWATCH := $(OUT_DIR)/audiowatch + +audiowatch: $(AUDIOWATCH) + +$(AUDIOWATCH): helpers/audiowatch.swift + @mkdir -p $(OUT_DIR) + swiftc -O -target $(ARCH)-apple-macos$(MIN_VER) -o $@ $< + @echo "==> Built $@" + +helpers-install: $(ICONMAKER) $(APPINFO) $(AUDIOWATCH) $(call install_atomically,$(ICONMAKER),$(SUPPORT_DIR)/iconmaker) $(call install_atomically,$(APPINFO),$(SUPPORT_DIR)/appinfo) - @echo "==> Installed: $(SUPPORT_DIR)/{iconmaker,appinfo}" + $(call install_atomically,$(AUDIOWATCH),$(SUPPORT_DIR)/audiowatch) + @echo "==> Installed: $(SUPPORT_DIR)/{iconmaker,appinfo,audiowatch}" dobby: @mkdir -p $(DOBBY_DIR) @@ -694,13 +704,14 @@ bridge: fonts: fonts/build.sh -app-payload: $(TARGET) $(OVERLAY_SHIM) $(ICONMAKER) $(APPINFO) +app-payload: $(TARGET) $(OVERLAY_SHIM) $(ICONMAKER) $(APPINFO) $(AUDIOWATCH) @mkdir -p "$(APP_PAYLOAD)/signatures/macos.arm64" @mkdir -p "$(APP_PAYLOAD)/bridge" cp -f $(TARGET) "$(APP_PAYLOAD)/notproton.dylib" cp -f $(OVERLAY_SHIM) "$(APP_PAYLOAD)/overlay-shim.dylib" cp -f $(ICONMAKER) "$(APP_PAYLOAD)/iconmaker" cp -f $(APPINFO) "$(APP_PAYLOAD)/appinfo" + cp -f $(AUDIOWATCH) "$(APP_PAYLOAD)/audiowatch" cp -f dylib/feats/compat_run.sh "$(APP_PAYLOAD)/run" cp -f signatures/macos.arm64/*.json "$(APP_PAYLOAD)/signatures/macos.arm64/" @set -e; for spec in $(BRIDGE_FILES); do \ diff --git a/helpers/audiowatch.swift b/helpers/audiowatch.swift new file mode 100644 index 00000000..afb6f956 --- /dev/null +++ b/helpers/audiowatch.swift @@ -0,0 +1,312 @@ +import CoreAudio +import Foundation +import System + +if ProcessInfo.processInfo.environment["AUDIOWATCH_DEBUG_ENV"] != nil { + let home = NSHomeDirectory() + let cx = + "\(home)/Library/Application Support/notproton/runners/crossover-27.0.0.41073-fex/CrossOver" + let pfx = + "\(home)/Library/Application Support/CrossOver/Bottles/Steam/drive_c/Program Files (x86)/Steam/steamapps/compatdata/2075800/pfx" + + let wineEnv: [String: String] = [ + "PATH": "\(cx)/bin:/usr/bin:/bin:/usr/sbin:/sbin", + "CX_ROOT": cx, + "CX_HOME": "\(home)/Library/Application Support/CrossOver", + "WINELOADER": + "\(home)/Library/Application Support/notproton/launchers/2075800/STAR WARS Zero Company™.app/Contents/MacOS/wine", + "WINESERVER": "\(cx)/bin/wineserver-x86", + "WINEPREFIX": pfx, + "WINEMSYNC": "1", + "WINEDEBUG": "err+all,fixme-all", + "WINEDLLPATH": + "\(pfx)/drive_c/Program Files (x86)/Steam:\(cx)/lib/wine/x86_64-windows:\(cx)/lib/wine/x86_64-unix", + ] + for (key, value) in wineEnv { + setenv(key, value, 1) + } +} + +guard let gloader = ProcessInfo.processInfo.environment["WINELOADER"] else { + print("WINELOADER isn't set in environment") + exit(1) +} + +guard ProcessInfo.processInfo.environment["WINEPREFIX"] != nil else { + print("WINEPREFIX isn't set in environment") + exit(1) +} + +let rawEnv = ProcessInfo.processInfo.environment["AUDIOWATCH_VERBOSE"] +let verboseLogging: Bool + +if let raw = rawEnv { + if raw == "1" || raw.lowercased() == "true" { + verboseLogging = true + } else if raw == "0" || raw.lowercased() == "false" { + verboseLogging = false + } else { + print( + "[ERROR] audiowatch: AUDIOWATCH_VERBOSE environment variable has to be 0 or 1 if set at all" + ) + exit(1) + } +} else { + verboseLogging = false +} + +if verboseLogging { + print("[DEBUG] audiowatch: verbose logging enabled") +} + +func getChildEnvironmentProcess() -> [String: String] { + let environment = ProcessInfo.processInfo.environment + var filtered = environment.filter { (key, _) in + !key.hasPrefix("DYLD_") + } + + return filtered +} + +func runWineProcess(arguments args: [String]) -> (output: String, status: Int32)? { + let process = Process() + process.executableURL = URL(fileURLWithPath: gloader) + process.arguments = args + + process.environment = getChildEnvironmentProcess() + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + + do { + try process.run() + + process.waitUntilExit() + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: data, encoding: .utf8) ?? "" + return (output, process.terminationStatus) + + } catch { + print("[ERROR] Audiowatch: Failed to initialize process for \(gloader). \(error)") + return nil + } +} + +// This Byte ordering was using AI :) + +func getEndpointId(forGuidHex hex: String, input: Bool) -> String? { + let chars = Array(hex.uppercased()) + guard chars.count == 32, chars.allSatisfy(\.isHexDigit) else { + print("[ERROR] audioWatch: guid \"\(hex)\" is not 32 hex digits") + return nil + } + + func byte(_ i: Int) -> String { String(chars[i * 2...i * 2 + 1]) } + func bytes(_ order: [Int]) -> String { order.map(byte).joined() } + + let guid = [ + bytes([3, 2, 1, 0]), // Data1, reversed + bytes([5, 4]), // Data2, reversed + bytes([7, 6]), // Data3, reversed + bytes([8, 9]), // Data4[0...1], in order + bytes(Array(10...15)), // Data4[2...7], in order + ].joined(separator: "-") + + return "{0.0.\(input ? 1 : 0).00000000}.{\(guid)}" +} + +let driverKey = #"HKCU\Software\Wine\Drivers\winecoreaudio.drv"# + +func setRegistryDefaultValue(valueName: String, value: String) { + let args: [String] = [ + "reg", "add", driverKey, + "/v", valueName, "/t", "REG_SZ", + "/d", value, "/f", + ] + let result = runWineProcess(arguments: args) + guard let (_, status) = result, status == 0 else { + print("[ERROR] audioWatch: Failed to set value_name (\(valueName) on wine") + return + } + print("audioWatch: Set default value on \(valueName)") +} + +func getHexGuidForUID(uid: String, input: Bool) -> String? { + let key = #"\#(driverKey)\devices\\#(input ? 1 : 0),\#(uid)"# + if verboseLogging { print("[DEBUG] audioWatch: Query key is: \(key)") } + + let args: [String] = ["reg", "query", key, "/v", "guid"] + let result = runWineProcess(arguments: args) + if verboseLogging { + print( + "[DEBUG] audioWatch: Output from runWineProcess: \n \(result?.output ?? "no output was captured")" + ) + } + + guard let (output, status) = result, status == 0 else { + return nil + } + + // trying to get GUID out of something like + // guid REG_BINARY B7BDFD16E6EEF345B212B490F21956FB + + guard let sentinelRange = output.range(of: "REG_BINARY") else { + return nil + } + var currentIndex = sentinelRange.upperBound + while currentIndex < output.endIndex && output[currentIndex].isWhitespace { + currentIndex = output.index(after: currentIndex) + } + if currentIndex == output.endIndex { return nil } + let wordStart = currentIndex + while currentIndex < output.endIndex && !output[currentIndex].isWhitespace { + currentIndex = output.index(after: currentIndex) + } + let guid = String(output[wordStart.. String? { + // yay CoreAudio APIs. First let's ask for the Device + var addr = AudioObjectPropertyAddress( + mSelector: input + ? kAudioHardwarePropertyDefaultInputDevice : kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) + + var dev = AudioDeviceID(kAudioObjectUnknown) + var devSize = UInt32(MemoryLayout.stride) + var status = AudioObjectGetPropertyData( + AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &devSize, &dev) + if (status != noErr) || (dev == kAudioDeviceUnknown) { + print("[ERROR] Audiowatch: Unable to get the current audio device") + return nil + } + + //Now let's ask for the UID + var uid: Unmanaged? + var uidSize = UInt32(MemoryLayout?>.stride) + + addr.mSelector = kAudioDevicePropertyDeviceUID + status = AudioObjectGetPropertyData(dev, &addr, 0, nil, &uidSize, &uid) + guard status == noErr, let unmanagedUID = uid else { + print("[ERROR] Audiowatch: Cannot get uid for audio device") + return nil + } + let cfUID = unmanagedUID.takeRetainedValue() + if verboseLogging { print("[DEBUG] audiowatch: uid is: \(cfUID)") } + return String(cfUID) +} + +func handleAudioDeviceChange(input: Bool) { + let uidForDefaultDevice = getUIDFromCoreAudioDefaultDevice(input: input) + if verboseLogging { + print( + "uid from main for \(input ? "Mic":"Speakers") is: \(uidForDefaultDevice ?? "Nothing???")" + ) + } + + guard let uid = uidForDefaultDevice else { + print("[ERROR] audioWatch: Couldn't unwrap uidForDefaultDevice") + return + } + + guard let hexGuid = getHexGuidForUID(uid: uid, input: input) else { + print("[ERROR] audioWatch: couldn't extract hexGuid from output from wine") + return + } + + guard let endPointId = getEndpointId(forGuidHex: hexGuid, input: input) else { + return + } + + setRegistryDefaultValue(valueName: input ? "DefaultInput" : "DefaultOutput", value: endPointId) + setRegistryDefaultValue( + valueName: input ? "DefaultVoiceInput" : "DefaultVoiceOutput", value: endPointId) +} + +func startMonitoring(targetPid: Int) -> DispatchSourceProcess { + let source = DispatchSource.makeProcessSource( + identifier: Int32(targetPid), eventMask: .exit, queue: DispatchQueue.main) + source.setEventHandler { exit(0) } + source.resume() + if kill(Int32(targetPid), 0) != 0 && errno == ESRCH { exit(0) } + return source +} + +let arguments = CommandLine.arguments +var targetPid: Int = 0 +var runOnce = false + +var i = 1 +while i < arguments.count { + let arg = arguments[i] + + if arg == "-w" { + guard i + 1 < arguments.count else { + print("[ERROR] Missing value for \(arg) option.") + exit(1) + } + let pidString = arguments[i + 1] + guard let pid = Int(pidString) else { + print("[ERROR] PID must be a valid integer. Got: '\(pidString)'") + exit(1) + } + targetPid = pid + i += 2 + } else if arg == "--once" { + runOnce = true + i += 1 + } else { + print("[ERROR] Unknown argument: \(arg)") + print("Usage: audiowatch -w [--once]") + exit(1) + } +} + +if runOnce { + handleAudioDeviceChange(input: false) + handleAudioDeviceChange(input: true) + exit(0) +} + +var processMonitoring: DispatchSourceProcess + +if targetPid > 0 { + print("audioWatch: Tracking for Process \(targetPid)") + processMonitoring = startMonitoring(targetPid: targetPid) +} + +var gQueue = DispatchQueue(label: "notproton.audiowatch") +var outAddr = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, + mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) +var inAddr = outAddr +inAddr.mSelector = kAudioHardwarePropertyDefaultInputDevice + +var listenerStatus = AudioObjectAddPropertyListenerBlock( + AudioObjectID(kAudioObjectSystemObject), &outAddr, gQueue +) { + (_, _) in + handleAudioDeviceChange(input: false) +} +if listenerStatus != 0 { + print("[ERROR] audiowatch: Failed to attach output change listener (error: \(listenerStatus))") + exit(1) +} + +listenerStatus = AudioObjectAddPropertyListenerBlock( + AudioObjectID(kAudioObjectSystemObject), &inAddr, gQueue +) { + (_, _) in + handleAudioDeviceChange(input: true) +} + +if listenerStatus != 0 { + print("[ERROR] audiowatch: Failed to attach input change listener (error: \(listenerStatus))") + exit(1) +} +dispatchMain() From 68515546ec249ac9a72575cfcd90c85854f1d15d Mon Sep 17 00:00:00 2001 From: Thomas Cool Date: Wed, 28 Oct 2026 13:30:13 +0100 Subject: [PATCH 2/8] whoops forgot to stage some slight cleanups --- helpers/audiowatch.swift | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/helpers/audiowatch.swift b/helpers/audiowatch.swift index afb6f956..da9ddba4 100644 --- a/helpers/audiowatch.swift +++ b/helpers/audiowatch.swift @@ -61,11 +61,9 @@ if verboseLogging { func getChildEnvironmentProcess() -> [String: String] { let environment = ProcessInfo.processInfo.environment - var filtered = environment.filter { (key, _) in + return environment.filter { (key, _) in !key.hasPrefix("DYLD_") } - - return filtered } func runWineProcess(arguments args: [String]) -> (output: String, status: Int32)? { @@ -81,10 +79,9 @@ func runWineProcess(arguments args: [String]) -> (output: String, status: Int32) do { try process.run() - + let data = pipe.fileHandleForReading.readDataToEndOfFile() process.waitUntilExit() - let data = pipe.fileHandleForReading.readDataToEndOfFile() let output = String(data: data, encoding: .utf8) ?? "" return (output, process.terminationStatus) @@ -130,7 +127,7 @@ func setRegistryDefaultValue(valueName: String, value: String) { print("[ERROR] audioWatch: Failed to set value_name (\(valueName) on wine") return } - print("audioWatch: Set default value on \(valueName)") + print("audioWatch: Set default value on \(valueName) : \(value)") } func getHexGuidForUID(uid: String, input: Bool) -> String? { From 5f76cabaf5e77f9a23b033a443c7a50975453a01 Mon Sep 17 00:00:00 2001 From: Thomas Cool Date: Wed, 28 Oct 2026 13:41:15 +0100 Subject: [PATCH 3/8] making NotProton aware of the audiowatch payload --- app/Sources/NotProtonApp/Model/DeploymentContent.swift | 5 ++++- app/Sources/NotProtonApp/Model/InstallPayload.swift | 7 ++++++- app/Sources/NotProtonApp/Model/PayloadState.swift | 4 ++++ app/Sources/NotProtonApp/Model/SteamInstaller.swift | 2 ++ app/Sources/NotProtonApp/Support/AppLog.swift | 1 + app/Sources/NotProtonApp/Support/SupportPaths.swift | 1 + app/Tests/NotProtonAppTests/DeploymentContentTests.swift | 6 ++++-- app/Tests/NotProtonAppTests/InstallPayloadTests.swift | 8 ++++++-- app/Tests/NotProtonAppTests/PayloadStateTests.swift | 2 ++ app/Tests/NotProtonAppTests/SteamInstallerTests.swift | 8 +++++++- 10 files changed, 37 insertions(+), 7 deletions(-) diff --git a/app/Sources/NotProtonApp/Model/DeploymentContent.swift b/app/Sources/NotProtonApp/Model/DeploymentContent.swift index c5f18f96..ae1333e5 100644 --- a/app/Sources/NotProtonApp/Model/DeploymentContent.swift +++ b/app/Sources/NotProtonApp/Model/DeploymentContent.swift @@ -159,6 +159,7 @@ enum DeploymentContent { static func files( payload: InstallPayload.Located, bridgePayload: BridgePayload.Located, app: URL, bridge: URL, signatures: URL, fonts: URL, overlayShim: URL, iconmaker: URL, appinfo: URL, + audiowatch: URL, compatTools: URL, tools: [InstalledTool], runners: URL ) -> [File] { var files = [ @@ -167,6 +168,7 @@ enum DeploymentContent { File(source: payload.overlayShim, destination: overlayShim, name: "overlay-shim.dylib"), File(source: payload.iconmaker, destination: iconmaker, name: "iconmaker", executable: true), File(source: payload.appinfo, destination: appinfo, name: "appinfo", executable: true), + File(source: payload.audiowatch, destination: audiowatch, name: "audiowatch", executable: true), ] files += payload.signatures.map { File(source: $0, destination: signatures.appending(path: $0.lastPathComponent), @@ -208,7 +210,8 @@ enum DeploymentContent { bridge: SupportPaths.bridge, signatures: SupportPaths.signatures, fonts: SupportPaths.fonts, overlayShim: SupportPaths.overlayShim, iconmaker: SupportPaths.iconmaker, - appinfo: SupportPaths.appinfo, compatTools: SupportPaths.Steam.compatTools, + appinfo: SupportPaths.appinfo, audiowatch: SupportPaths.audiowatch, + compatTools: SupportPaths.Steam.compatTools, tools: CompatToolList.installed(), runners: SupportPaths.runners) return try inspect(files: files, bundled: Build(version: version, builtAt: payload.builtAt), installed: readBuild(at: record(beside: SupportPaths.deployedVersion)), diff --git a/app/Sources/NotProtonApp/Model/InstallPayload.swift b/app/Sources/NotProtonApp/Model/InstallPayload.swift index 20f3c93f..3895a5d1 100644 --- a/app/Sources/NotProtonApp/Model/InstallPayload.swift +++ b/app/Sources/NotProtonApp/Model/InstallPayload.swift @@ -10,6 +10,7 @@ enum InstallPayload { let overlayShim: URL let iconmaker: URL let appinfo: URL + let audiowatch: URL let run: URL let builtAt: Int64 @@ -39,6 +40,7 @@ enum InstallPayload { let shim = root.appending(path: "overlay-shim.dylib") let iconmaker = root.appending(path: "iconmaker") let appinfo = root.appending(path: "appinfo") + let audiowatch = root.appending(path: "audiowatch") let run = root.appending(path: "run") let stamp = try? String(contentsOf: root.appending(path: "build-time"), encoding: .utf8) let builtAt = stamp.flatMap { Int64($0.trimmingCharacters(in: .whitespacesAndNewlines)) } @@ -56,6 +58,9 @@ enum InstallPayload { if !files.fileExists(atPath: appinfo.path(percentEncoded: false)) { missing.append("appinfo") } + if !files.fileExists(atPath: audiowatch.path(percentEncoded: false)) { + missing.append("audiowatch") + } if !files.fileExists(atPath: run.path(percentEncoded: false)) { missing.append("run") } if (builtAt ?? 0) <= 0 { missing.append("build-time") } @@ -80,7 +85,7 @@ enum InstallPayload { return Located( dylib: dylib, overlayShim: shim, iconmaker: iconmaker, - appinfo: appinfo, run: run, builtAt: builtAt, signatures: signatures, + appinfo: appinfo, audiowatch: audiowatch, run: run, builtAt: builtAt, signatures: signatures, fontsRoot: fontsRoot, fonts: fonts ) } diff --git a/app/Sources/NotProtonApp/Model/PayloadState.swift b/app/Sources/NotProtonApp/Model/PayloadState.swift index ab2f8e5c..e45b32d9 100644 --- a/app/Sources/NotProtonApp/Model/PayloadState.swift +++ b/app/Sources/NotProtonApp/Model/PayloadState.swift @@ -9,6 +9,7 @@ struct PayloadState: Sendable { var overlayShimPresent: Bool var iconmakerPresent: Bool var appinfoPresent: Bool + var audiowatchPresent: Bool var signatureDatabase: String? var legacyCompatPresent: Int var legacyCompatExpected: Int @@ -23,6 +24,7 @@ struct PayloadState: Sendable { if !overlayShimPresent { names.append("overlay-shim.dylib") } if !iconmakerPresent { names.append("iconmaker") } if !appinfoPresent { names.append("appinfo") } + if !audiowatchPresent { names.append("audiowatch") } return names } @@ -32,6 +34,7 @@ struct PayloadState: Sendable { var isEmpty: Bool { present == 0 && !overlayShimPresent && !iconmakerPresent && !appinfoPresent + && !audiowatchPresent && signatureDatabase == nil } @@ -79,6 +82,7 @@ enum PayloadInspector { overlayShimPresent: fm.fileExists(atPath: SupportPaths.overlayShim.path(percentEncoded: false)), iconmakerPresent: fm.fileExists(atPath: SupportPaths.iconmaker.path(percentEncoded: false)), appinfoPresent: fm.fileExists(atPath: SupportPaths.appinfo.path(percentEncoded: false)), + audiowatchPresent: fm.fileExists(atPath: SupportPaths.audiowatch.path(percentEncoded: false)), signatureDatabase: newestSignatureDatabase(), legacyCompatPresent: legacyPresent, legacyCompatExpected: legacyNames.count, diff --git a/app/Sources/NotProtonApp/Model/SteamInstaller.swift b/app/Sources/NotProtonApp/Model/SteamInstaller.swift index 504ad0a4..c4138856 100644 --- a/app/Sources/NotProtonApp/Model/SteamInstaller.swift +++ b/app/Sources/NotProtonApp/Model/SteamInstaller.swift @@ -62,6 +62,7 @@ enum SteamInstaller { overlayShim: URL = SupportPaths.overlayShim, iconmaker: URL = SupportPaths.iconmaker, appinfo: URL = SupportPaths.appinfo, + audiowatch: URL = SupportPaths.audiowatch, deployedVersion: URL = SupportPaths.deployedVersion, backups: URL = SupportPaths.backups, compatTools: URL = SupportPaths.Steam.compatTools, @@ -104,6 +105,7 @@ enum SteamInstaller { let files = DeploymentContent.files( payload: payload, bridgePayload: bridgeLocated, app: app, bridge: bridge, signatures: signatures, fonts: fonts, overlayShim: overlayShim, iconmaker: iconmaker, appinfo: appinfo, + audiowatch: audiowatch, compatTools: compatTools, tools: tools, runners: runners) let content = try DeploymentContent.inspect(files: files, bundled: build, installed: previousBuild, legacyVersion: SteamBundle.deployedVersion(at: deployedVersion)) diff --git a/app/Sources/NotProtonApp/Support/AppLog.swift b/app/Sources/NotProtonApp/Support/AppLog.swift index 62cea7df..8e9445df 100644 --- a/app/Sources/NotProtonApp/Support/AppLog.swift +++ b/app/Sources/NotProtonApp/Support/AppLog.swift @@ -50,6 +50,7 @@ enum AppLog { + "overlayShim=\(payload.overlayShimPresent) " + "iconmaker=\(payload.iconmakerPresent) " + "appinfo=\(payload.appinfoPresent) " + + "audiowatch=\(payload.audiowatchPresent) " + "signatures=\(payload.signatureDatabase ?? "none") " + "legacycompat=\(payload.legacyCompatPresent)/\(payload.legacyCompatExpected)" ) diff --git a/app/Sources/NotProtonApp/Support/SupportPaths.swift b/app/Sources/NotProtonApp/Support/SupportPaths.swift index 512eccd6..17c79a9e 100644 --- a/app/Sources/NotProtonApp/Support/SupportPaths.swift +++ b/app/Sources/NotProtonApp/Support/SupportPaths.swift @@ -33,6 +33,7 @@ enum SupportPaths { static var overlayShim: URL { support.appending(path: "overlay-shim.dylib") } static var iconmaker: URL { support.appending(path: "iconmaker") } static var appinfo: URL { support.appending(path: "appinfo") } + static var audiowatch: URL { support.appending(path: "audiowatch") } static var packageDownloads: URL { home.appending(path: "Library/Caches/\(supportDirName)/valve-packages") diff --git a/app/Tests/NotProtonAppTests/DeploymentContentTests.swift b/app/Tests/NotProtonAppTests/DeploymentContentTests.swift index ebf78738..0720a391 100644 --- a/app/Tests/NotProtonAppTests/DeploymentContentTests.swift +++ b/app/Tests/NotProtonAppTests/DeploymentContentTests.swift @@ -143,7 +143,8 @@ struct DeploymentContentTests { func unavailableInstallAction() { let status = SystemStatus() let payload = PayloadState(expected: 0, present: 0, missing: [], overlayShimPresent: true, - iconmakerPresent: true, appinfoPresent: true, signatureDatabase: "fixture", + iconmakerPresent: true, appinfoPresent: true, audiowatchPresent: true, + signatureDatabase: "fixture", legacyCompatPresent: 0, legacyCompatExpected: 0, manifestProblem: nil) status.snapshot = StatusSnapshot(steam: .notInstalled, steamRunning: false, updateBlocked: false, crossOver: [], runner: .none, payload: payload, @@ -160,7 +161,8 @@ struct DeploymentContentTests { func newerInstallBlocksRemoval() { let status = SystemStatus() let payload = PayloadState(expected: 0, present: 0, missing: [], overlayShimPresent: true, - iconmakerPresent: true, appinfoPresent: true, signatureDatabase: "fixture", + iconmakerPresent: true, appinfoPresent: true, audiowatchPresent: true, + signatureDatabase: "fixture", legacyCompatPresent: 0, legacyCompatExpected: 0, manifestProblem: nil) status.snapshot = StatusSnapshot(steam: .notInstalled, steamRunning: false, updateBlocked: false, crossOver: [], runner: .none, payload: payload, diff --git a/app/Tests/NotProtonAppTests/InstallPayloadTests.swift b/app/Tests/NotProtonAppTests/InstallPayloadTests.swift index 6698ecc8..91147fa2 100644 --- a/app/Tests/NotProtonAppTests/InstallPayloadTests.swift +++ b/app/Tests/NotProtonAppTests/InstallPayloadTests.swift @@ -6,7 +6,7 @@ import Testing @Suite("Install payload") struct InstallPayloadTests { - private func stage(_ root: URL, dylib: Bool = true, shim: Bool = true, iconmaker: Bool = true, appinfo: Bool = true, signatures: [String] = ["1788400362.json"], fonts: [String] = ["arial.ttf"]) throws { + private func stage(_ root: URL, dylib: Bool = true, shim: Bool = true, iconmaker: Bool = true, appinfo: Bool = true, audiowatch: Bool = true, signatures: [String] = ["1788400362.json"], fonts: [String] = ["arial.ttf"]) throws { let files = FileManager.default let signatureDir = root.appending(path: "signatures/macos.arm64") try files.createDirectory(at: signatureDir, withIntermediateDirectories: true) @@ -14,6 +14,7 @@ struct InstallPayloadTests { if shim { try Data("shim".utf8).write(to: root.appending(path: "overlay-shim.dylib")) } if iconmaker { try Data("iconmaker".utf8).write(to: root.appending(path: "iconmaker")) } if appinfo { try Data("appinfo".utf8).write(to: root.appending(path: "appinfo")) } + if audiowatch { try Data("audiowatch".utf8).write(to: root.appending(path: "audiowatch")) } try Data("#!/bin/sh\nexit 0\n".utf8).write(to: root.appending(path: "run")) try Data("100\n".utf8).write(to: root.appending(path: "build-time")) for name in signatures { @@ -33,6 +34,7 @@ struct InstallPayloadTests { #expect(located.dylib.lastPathComponent == "notproton.dylib") #expect(located.overlayShim.lastPathComponent == "overlay-shim.dylib") #expect(located.appinfo.lastPathComponent == "appinfo") + #expect(located.audiowatch.lastPathComponent == "audiowatch") #expect(!located.signatures.isEmpty) #expect(located.fonts.contains("arial.ttf")) #expect(located.fonts.contains("alt/arial.ttf")) @@ -94,7 +96,8 @@ struct InstallPayloadTests { func reportsEverythingMissing() throws { let root = try scratchDirectory("payload") defer { try? FileManager.default.removeItem(at: root) } - try stage(root, dylib: false, shim: false, iconmaker: false, appinfo: false, signatures: [], fonts: []) + try stage(root, dylib: false, shim: false, iconmaker: false, appinfo: false, + audiowatch: false, signatures: [], fonts: []) do { _ = try InstallPayload.locate(root: root) @@ -104,6 +107,7 @@ struct InstallPayloadTests { #expect(failure.detail.contains("overlay-shim.dylib")) #expect(failure.detail.contains("iconmaker")) #expect(failure.detail.contains("appinfo")) + #expect(failure.detail.contains("audiowatch")) #expect(failure.detail.contains("signatures/macos.arm64")) #expect(failure.detail.contains("fonts")) } diff --git a/app/Tests/NotProtonAppTests/PayloadStateTests.swift b/app/Tests/NotProtonAppTests/PayloadStateTests.swift index 44d3c3b1..76e46ea5 100644 --- a/app/Tests/NotProtonAppTests/PayloadStateTests.swift +++ b/app/Tests/NotProtonAppTests/PayloadStateTests.swift @@ -109,6 +109,7 @@ struct PayloadStateTests { overlayShimPresent: overlay, iconmakerPresent: true, appinfoPresent: true, + audiowatchPresent: true, signatureDatabase: signatures, legacyCompatPresent: 6, legacyCompatExpected: 6, @@ -140,6 +141,7 @@ struct PayloadStateTests { overlayShimPresent: true, iconmakerPresent: true, appinfoPresent: true, + audiowatchPresent: true, signatureDatabase: "1788400362.json", legacyCompatPresent: 6, legacyCompatExpected: 6, diff --git a/app/Tests/NotProtonAppTests/SteamInstallerTests.swift b/app/Tests/NotProtonAppTests/SteamInstallerTests.swift index 1ff8530c..2949e99f 100644 --- a/app/Tests/NotProtonAppTests/SteamInstallerTests.swift +++ b/app/Tests/NotProtonAppTests/SteamInstallerTests.swift @@ -42,6 +42,9 @@ struct SteamInstallerTests { let appinfo = root.appending(path: "appinfo") try FileManager.default.copyItem(at: dylib, to: appinfo) + + let audiowatch = root.appending(path: "audiowatch") + try FileManager.default.copyItem(at: dylib, to: audiowatch) try Data("#!/bin/sh\nexit 0\n".utf8).write(to: root.appending(path: "run")) try Data("100\n".utf8).write(to: root.appending(path: "build-time")) @@ -68,6 +71,7 @@ struct SteamInstallerTests { var overlayShim: URL { support.appending(path: "overlay-shim.dylib") } var iconmaker: URL { support.appending(path: "iconmaker") } var appinfo: URL { support.appending(path: "appinfo") } + var audiowatch: URL { support.appending(path: "audiowatch") } var deployedVersion: URL { support.appending(path: "dylib.version") } var backups: URL { support.appending(path: "backups") } var bridge: URL { support.appending(path: "bridge") } @@ -116,6 +120,7 @@ struct SteamInstallerTests { overlayShim: fixture.overlayShim, iconmaker: fixture.iconmaker, appinfo: fixture.appinfo, + audiowatch: fixture.audiowatch, deployedVersion: fixture.deployedVersion, backups: fixture.backups, compatTools: fixture.work.appending(path: "compatibilitytools.d"), @@ -621,7 +626,8 @@ struct SteamInstallerTests { let files = DeploymentContent.files(payload: fixture.payload, bridgePayload: bridge, app: fixture.app, bridge: fixture.bridge, signatures: fixture.signatures, fonts: fixture.fonts, overlayShim: fixture.overlayShim, iconmaker: fixture.iconmaker, - appinfo: fixture.appinfo, compatTools: work.appending(path: "compatibilitytools.d"), + appinfo: fixture.appinfo, audiowatch: fixture.audiowatch, + compatTools: work.appending(path: "compatibilitytools.d"), tools: tools, runners: runners) #expect(try files.allSatisfy { try $0.matches() }) } From 963db3ee826d02a5a4039892011471fc350ddaeb Mon Sep 17 00:00:00 2001 From: Thomas Cool Date: Wed, 28 Oct 2026 13:46:32 +0100 Subject: [PATCH 4/8] compat_run updated --- dylib/feats/compat_run.sh | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/dylib/feats/compat_run.sh b/dylib/feats/compat_run.sh index 8f77362c..6ca9ee7c 100755 --- a/dylib/feats/compat_run.sh +++ b/dylib/feats/compat_run.sh @@ -3186,6 +3186,13 @@ NP_FEAT_ENVS fi open_pid=$! /usr/bin/caffeinate -d -i -w "$open_pid" >> "$log" 2>&1 & +np_audiowatch="$np_support/audiowatch" +if [ -x "$np_audiowatch" ]; then + env -u DYLD_INSERT_LIBRARIES -u DYLD_LIBRARY_PATH -u DYLD_FRAMEWORK_PATH \ + "$np_audiowatch" -w "$open_pid" >> "$log" 2>&1 & +else + echo "=== no audiowatch, audio stays on the launch-time device ===" >> "$log" 2>&1 || true +fi status=0 wait "$open_pid" || status=$? echo "=== game exited status=$status, killing wine prefix ===" >> "$log" 2>&1 || true From 082074cf3b695e3e495c4b3d0733474883ba1187 Mon Sep 17 00:00:00 2001 From: Thomas Cool Date: Wed, 28 Oct 2026 18:02:57 +0100 Subject: [PATCH 5/8] splitting some stuff out and making sure the device is actually active before swapping to it --- helpers/audiowatch.swift | 103 +++++++++++++++++++++++++++++++++------ 1 file changed, 89 insertions(+), 14 deletions(-) diff --git a/helpers/audiowatch.swift b/helpers/audiowatch.swift index da9ddba4..79842aad 100644 --- a/helpers/audiowatch.swift +++ b/helpers/audiowatch.swift @@ -92,8 +92,7 @@ func runWineProcess(arguments args: [String]) -> (output: String, status: Int32) } // This Byte ordering was using AI :) - -func getEndpointId(forGuidHex hex: String, input: Bool) -> String? { +func buildGuid(forGuidHex hex: String) -> String? { let chars = Array(hex.uppercased()) guard chars.count == 32, chars.allSatisfy(\.isHexDigit) else { print("[ERROR] audioWatch: guid \"\(hex)\" is not 32 hex digits") @@ -110,10 +109,81 @@ func getEndpointId(forGuidHex hex: String, input: Bool) -> String? { bytes([8, 9]), // Data4[0...1], in order bytes(Array(10...15)), // Data4[2...7], in order ].joined(separator: "-") + return guid +} +func getEndpointId(forGuidHex hex: String, input: Bool) -> String? { + + guard let guid = buildGuid(forGuidHex: hex) else { + return nil + } return "{0.0.\(input ? 1 : 0).00000000}.{\(guid)}" } +enum WineAudioDeviceState: Int { + case active = 0x1 + case disabled = 0x2 + case notPresent = 0x4 + case unplugged = 0x8 +} + +let mmKey = #"HKLM\Software\Microsoft\Windows\CurrentVersion\MMDevices\Audio"# + +func getToken(after sentinel: String, in s: String) -> Substring? { + let fields = s.split(whereSeparator: \.isWhitespace) + guard let i = fields.firstIndex(where: { $0 == sentinel }), i + 1 < fields.count + else { return nil } + return fields[i + 1] +} + +func getDeviceState(forGuidHex: String, input: Bool) -> WineAudioDeviceState? { + guard let guid = buildGuid(forGuidHex: forGuidHex) else { + print("[ERROR] audiowatch: unable to build guid from provided hex guid") + return nil + } + + let key = #"\#(mmKey)\\#(input ? "Capture" : "Render")\{\#(guid)}"# + + let args: [String] = [ + "reg", "query", key, + "/v", "DeviceState", + ] + + let result = runWineProcess(arguments: args) + if verboseLogging { + print( + "[DEBUG] audioWatch: Output from runWineProcess: \n \(result?.output ?? "no output was captured")" + ) + } + + guard let result else { + print("[ERROR] audiowatch, wine couldn't start") + return nil + } + + if result.status != 0 { + print("audiowatch: Device isn't available in mm") + return .notPresent + } + let output = result.output + + // we try to parse the output DWORD here + guard var hexDigits = getToken(after: "REG_DWORD", in: output) else { + print("[ERROR] audiowatch: couldn't find REG_DWORD in output") + return nil + } + + if hexDigits.lowercased().hasPrefix("0x") { + hexDigits = hexDigits.dropFirst(2) + } + + guard let code = Int(hexDigits, radix: 16), let code = WineAudioDeviceState(rawValue: code) + else { + return nil + } + return code +} + let driverKey = #"HKCU\Software\Wine\Drivers\winecoreaudio.drv"# func setRegistryDefaultValue(valueName: String, value: String) { @@ -149,21 +219,14 @@ func getHexGuidForUID(uid: String, input: Bool) -> String? { // trying to get GUID out of something like // guid REG_BINARY B7BDFD16E6EEF345B212B490F21956FB - guard let sentinelRange = output.range(of: "REG_BINARY") else { + guard let guid = getToken(after: "REG_BINARY", in: output) else { + print("[ERROR] audiowatch: couldn't find REG_BINARY in output") return nil } - var currentIndex = sentinelRange.upperBound - while currentIndex < output.endIndex && output[currentIndex].isWhitespace { - currentIndex = output.index(after: currentIndex) - } - if currentIndex == output.endIndex { return nil } - let wordStart = currentIndex - while currentIndex < output.endIndex && !output[currentIndex].isWhitespace { - currentIndex = output.index(after: currentIndex) - } - let guid = String(output[wordStart.. String? { @@ -215,6 +278,18 @@ func handleAudioDeviceChange(input: Bool) { return } + guard let deviceState = getDeviceState(forGuidHex: hexGuid, input: input) else { + print("[ERROR] audiowatch: couldn't get the audio device state from wine") + return + } + + if deviceState != .active { + print( + "[ERROR] audiowatch: device isn't active in wine. Not swapping to it. Make sure it was connected during wine launch" + ) + return + } + guard let endPointId = getEndpointId(forGuidHex: hexGuid, input: input) else { return } From 798c2e223ced2fde7408b6bd60cac774e03d9d28 Mon Sep 17 00:00:00 2001 From: Thomas Cool Date: Wed, 28 Oct 2026 18:33:07 +0100 Subject: [PATCH 6/8] Fancy name printing for devices to make log useful --- helpers/audiowatch.swift | 57 ++++++++++++++++++++++++++-------------- 1 file changed, 37 insertions(+), 20 deletions(-) diff --git a/helpers/audiowatch.swift b/helpers/audiowatch.swift index 79842aad..46d184f9 100644 --- a/helpers/audiowatch.swift +++ b/helpers/audiowatch.swift @@ -229,7 +229,23 @@ func getHexGuidForUID(uid: String, input: Bool) -> String? { return String(guid) } -func getUIDFromCoreAudioDefaultDevice(input: Bool) -> String? { +func getStringProperty(device obj: AudioObjectID, sel: AudioObjectPropertySelector) -> String? { + var addr = AudioObjectPropertyAddress( + mSelector: sel, + mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain + ) + var string: Unmanaged? + var stringSize = UInt32(MemoryLayout?>.stride) + let status = AudioObjectGetPropertyData(obj, &addr, 0, nil, &stringSize, &string) + guard status == noErr, let string else { + print("[ERROR] Audiowatch: Cannot get \(sel) for audio device") + return nil + } + let cfString = string.takeRetainedValue() + return String(cfString) +} + +func getUIDAndNameFromCoreAudioDefaultDevice(input: Bool) -> (uid: String, name: String)? { // yay CoreAudio APIs. First let's ask for the Device var addr = AudioObjectPropertyAddress( mSelector: input @@ -238,7 +254,7 @@ func getUIDFromCoreAudioDefaultDevice(input: Bool) -> String? { var dev = AudioDeviceID(kAudioObjectUnknown) var devSize = UInt32(MemoryLayout.stride) - var status = AudioObjectGetPropertyData( + let status = AudioObjectGetPropertyData( AudioObjectID(kAudioObjectSystemObject), &addr, 0, nil, &devSize, &dev) if (status != noErr) || (dev == kAudioDeviceUnknown) { print("[ERROR] Audiowatch: Unable to get the current audio device") @@ -246,30 +262,31 @@ func getUIDFromCoreAudioDefaultDevice(input: Bool) -> String? { } //Now let's ask for the UID - var uid: Unmanaged? - var uidSize = UInt32(MemoryLayout?>.stride) + guard let uid = getStringProperty(device: dev, sel: kAudioDevicePropertyDeviceUID) else { + print("[ERROR] Audiowatch: Unable to get the current audio device uid") + return nil + } - addr.mSelector = kAudioDevicePropertyDeviceUID - status = AudioObjectGetPropertyData(dev, &addr, 0, nil, &uidSize, &uid) - guard status == noErr, let unmanagedUID = uid else { - print("[ERROR] Audiowatch: Cannot get uid for audio device") + guard let name = getStringProperty(device: dev, sel: kAudioObjectPropertyName) else { + print("[ERROR] Audiowatch: Unable to get the current audio device name") return nil } - let cfUID = unmanagedUID.takeRetainedValue() - if verboseLogging { print("[DEBUG] audiowatch: uid is: \(cfUID)") } - return String(cfUID) -} -func handleAudioDeviceChange(input: Bool) { - let uidForDefaultDevice = getUIDFromCoreAudioDefaultDevice(input: input) + print("audiowatch: Setting \(name) as the Default \(input ? "input": "output") device") + if verboseLogging { - print( - "uid from main for \(input ? "Mic":"Speakers") is: \(uidForDefaultDevice ?? "Nothing???")" - ) + print("[DEBUG] audiowatch: uid is: \(uid)") + print("[DEBUG] audiowatch: uid is: \(name)") } + return (uid, name) +} - guard let uid = uidForDefaultDevice else { - print("[ERROR] audioWatch: Couldn't unwrap uidForDefaultDevice") +func handleAudioDeviceChange(input: Bool) { + guard + let (uid, name) = getUIDAndNameFromCoreAudioDefaultDevice( + input: input) + else { + //Error was already printed in calling function return } @@ -285,7 +302,7 @@ func handleAudioDeviceChange(input: Bool) { if deviceState != .active { print( - "[ERROR] audiowatch: device isn't active in wine. Not swapping to it. Make sure it was connected during wine launch" + "[ERROR] audiowatch: device \(name) isn't active in wine. Not swapping to it. Make sure it was connected during wine launch" ) return } From 531a2d55875571f57df36ad03dba712569cd23e2 Mon Sep 17 00:00:00 2001 From: Thomas Cool Date: Wed, 28 Oct 2026 18:47:19 +0100 Subject: [PATCH 7/8] some log fixes --- helpers/audiowatch.swift | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/helpers/audiowatch.swift b/helpers/audiowatch.swift index 46d184f9..945c2b1d 100644 --- a/helpers/audiowatch.swift +++ b/helpers/audiowatch.swift @@ -1,6 +1,5 @@ import CoreAudio import Foundation -import System if ProcessInfo.processInfo.environment["AUDIOWATCH_DEBUG_ENV"] != nil { let home = NSHomeDirectory() @@ -162,7 +161,6 @@ func getDeviceState(forGuidHex: String, input: Bool) -> WineAudioDeviceState? { } if result.status != 0 { - print("audiowatch: Device isn't available in mm") return .notPresent } let output = result.output @@ -272,11 +270,9 @@ func getUIDAndNameFromCoreAudioDefaultDevice(input: Bool) -> (uid: String, name: return nil } - print("audiowatch: Setting \(name) as the Default \(input ? "input": "output") device") - if verboseLogging { print("[DEBUG] audiowatch: uid is: \(uid)") - print("[DEBUG] audiowatch: uid is: \(name)") + print("[DEBUG] audiowatch: name is: \(name)") } return (uid, name) } @@ -350,7 +346,7 @@ while i < arguments.count { i += 1 } else { print("[ERROR] Unknown argument: \(arg)") - print("Usage: audiowatch -w [--once]") + print("Usage: audiowatch [-w ] [--once]") exit(1) } } From 699e8fac239026d33c9c81648a96965cdcb86b8f Mon Sep 17 00:00:00 2001 From: Thomas Cool Date: Wed, 28 Oct 2026 21:06:25 +0100 Subject: [PATCH 8/8] removing debug env --- helpers/audiowatch.swift | 25 ------------------------- 1 file changed, 25 deletions(-) diff --git a/helpers/audiowatch.swift b/helpers/audiowatch.swift index 945c2b1d..fcd83ae8 100644 --- a/helpers/audiowatch.swift +++ b/helpers/audiowatch.swift @@ -1,31 +1,6 @@ import CoreAudio import Foundation -if ProcessInfo.processInfo.environment["AUDIOWATCH_DEBUG_ENV"] != nil { - let home = NSHomeDirectory() - let cx = - "\(home)/Library/Application Support/notproton/runners/crossover-27.0.0.41073-fex/CrossOver" - let pfx = - "\(home)/Library/Application Support/CrossOver/Bottles/Steam/drive_c/Program Files (x86)/Steam/steamapps/compatdata/2075800/pfx" - - let wineEnv: [String: String] = [ - "PATH": "\(cx)/bin:/usr/bin:/bin:/usr/sbin:/sbin", - "CX_ROOT": cx, - "CX_HOME": "\(home)/Library/Application Support/CrossOver", - "WINELOADER": - "\(home)/Library/Application Support/notproton/launchers/2075800/STAR WARS Zero Company™.app/Contents/MacOS/wine", - "WINESERVER": "\(cx)/bin/wineserver-x86", - "WINEPREFIX": pfx, - "WINEMSYNC": "1", - "WINEDEBUG": "err+all,fixme-all", - "WINEDLLPATH": - "\(pfx)/drive_c/Program Files (x86)/Steam:\(cx)/lib/wine/x86_64-windows:\(cx)/lib/wine/x86_64-unix", - ] - for (key, value) in wineEnv { - setenv(key, value, 1) - } -} - guard let gloader = ProcessInfo.processInfo.environment["WINELOADER"] else { print("WINELOADER isn't set in environment") exit(1)