From 6e106b538828d45b872a7fff70d5af50ee236b84 Mon Sep 17 00:00:00 2001 From: rapheljr <86885009+rapheljr@users.noreply.github.com> Date: Wed, 28 Oct 2026 10:49:16 +0530 Subject: [PATCH 1/5] Add D3DMetal & Apple GPTK management, in-place version switching, and shader cache tools - Support 1-click D3DMetal updates from mounted Apple Game Porting Toolkit and Evaluation Environment DMGs (including nested DMGs). - Add version caching allowing 1-click switching back and forth between D3DMetal versions (e.g. 3.0 <-> 4.0b3). - Add 1-click restore to stock CrossOver DirectX 12 libraries. - Support per-game and global DirectX 12 shader cache clearing (~/Library/Caches/.../d3dm). - Support automated MetalFX preparation and nvngx.dll / nvapi64.dll injection into prefix system32 directories. - Add Steam panel controls for DirectX Raytracing (DXR), Metal 4 backend, and FPS limits. - Add comprehensive unit tests covering DMG resolution, version parsing, and shader cache management. --- .../NotProtonApp/Model/D3DMetalTools.swift | 517 ++++++++++++++++++ .../NotProtonApp/Model/PrefixTools.swift | 10 + app/Sources/NotProtonApp/PrefixesModel.swift | 25 + app/Sources/NotProtonApp/SystemStatus.swift | 69 +++ .../NotProtonApp/Views/PrefixesView.swift | 13 + .../NotProtonApp/Views/StatusView.swift | 50 ++ .../D3DMetalToolsTests.swift | 271 +++++++++ .../PrefixesModelTests.swift | 2 +- dylib/feats/compat_run.sh | 32 +- dylib/feats/defaults.c | 4 + dylib/feats/webpatch.c | 20 +- dylib/tests/defaults-check.c | 8 + dylib/tests/defaults-drift.sh | 2 + dylib/tests/envcheck.sh | 8 +- dylib/tests/panel-behavior/behavior.js | 21 +- dylib/tests/panel-behavior/fex.js | 2 +- dylib/tests/panel-behavior/shell.js | 7 +- dylib/tests/panel-behavior/switching.js | 4 + dylib/version.h | 4 +- 19 files changed, 1055 insertions(+), 14 deletions(-) create mode 100644 app/Sources/NotProtonApp/Model/D3DMetalTools.swift create mode 100644 app/Tests/NotProtonAppTests/D3DMetalToolsTests.swift diff --git a/app/Sources/NotProtonApp/Model/D3DMetalTools.swift b/app/Sources/NotProtonApp/Model/D3DMetalTools.swift new file mode 100644 index 00000000..f7b2f49d --- /dev/null +++ b/app/Sources/NotProtonApp/Model/D3DMetalTools.swift @@ -0,0 +1,517 @@ +// Tools for managing D3DMetal libraries and clearing shader cache. +// Handles updating D3DMetal from Apple's Game Porting Toolkit evaluation environment +// and clearing $(getconf DARWIN_USER_CACHE_DIR)/d3dm//shaders.cache. + +import Darwin +import Foundation + +enum D3DMetalTools: Sendable { + + public struct UpdateOutcome: Sendable, Equatable { + public let runnerRoot: URL + public let sourceURL: URL + public let metalFXPrepared: Bool + public let prefixesUpdated: Int + public let shaderCachesCleared: Int + } + + public struct DiscoveredSource: Sendable, Equatable { + public let url: URL + public let version: String + public let isMountedVolume: Bool + } + + /// Reads the D3DMetal version string from the runner's D3DMetal.framework. + public static func d3dMetalVersion(forBuild buildID: String) -> String? { + let runnerRoot = SupportPaths.clonedRoot(forBuild: buildID) + return d3dMetalVersion(runnerRoot: runnerRoot) + } + + /// Reads the D3DMetal version string from a library directory (containing external/ and wine/). + public static func d3dMetalVersion(atLibRoot libRoot: URL) -> String? { + let plistURL = libRoot + .appending(path: "external/D3DMetal.framework/Versions/A/Resources/version.plist") + let altPlistURL = libRoot + .appending(path: "external/D3DMetal.framework/Versions/A/Resources/Info.plist") + let flatPlistURL = libRoot + .appending(path: "external/D3DMetal.framework/Resources/version.plist") + for url in [plistURL, altPlistURL, flatPlistURL] { + guard let data = try? Data(contentsOf: url), + let dict = try? PropertyListSerialization.propertyList(from: data, format: nil) as? [String: Any], + let version = (dict["CFBundleShortVersionString"] as? String) ?? (dict["CFBundleVersion"] as? String) else { + continue + } + let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { return trimmed } + } + return nil + } + + /// Reads the D3DMetal version string from the given runner root. + public static func d3dMetalVersion(runnerRoot: URL) -> String? { + if let ver = d3dMetalVersion(atLibRoot: runnerRoot.appending(path: "lib64/apple_gptk")) { + return ver + } + return d3dMetalVersion(atLibRoot: runnerRoot.appending(path: "lib/apple_gptk")) + } + + /// Extracts a version string from a filename like "Evaluation environment for Windows games 4.0 beta 3.dmg" + public static func versionFromFilename(_ filename: String) -> String? { + let pattern = #"(\d+(?:\.\d+)*(?:\s*(?:beta|b)\s*\d+)?)"# + guard let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive), + let match = regex.firstMatch(in: filename, range: NSRange(filename.startIndex..., in: filename)), + let range = Range(match.range(at: 1), in: filename) else { + return nil + } + var ver = String(filename[range]) + // Normalize "4.0 beta 3" -> "4.0b3" + ver = ver.replacingOccurrences(of: #"\s*beta\s*"#, with: "b", options: .regularExpression) + return ver.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Discovers all available Evaluation Environment sources currently mounted in /Volumes + /// (including nested DMGs inside mounted volumes like /Volumes/Game Porting Toolkit), + /// sorted by newest version first. + /// Fast and non-blocking — never spawns subprocesses during view rendering. + public static func discoverSources() -> [DiscoveredSource] { + var found: [DiscoveredSource] = [] + let fm = FileManager.default + let volumesURL = URL(filePath: "/Volumes") + + guard let volumes = try? fm.contentsOfDirectory(at: volumesURL, includingPropertiesForKeys: nil) else { + return [] + } + + // 1. Check directly mounted D3DMetal volumes (fastest and most accurate) + for vol in volumes { + guard let source = locateSource(in: vol) else { continue } + let ver = d3dMetalVersion(atLibRoot: source) ?? "Latest" + found.append(DiscoveredSource(url: source, version: ver, isMountedVolume: true)) + } + + // 2. Check for nested Evaluation environment DMGs inside mounted volumes (e.g. /Volumes/Game Porting Toolkit/*.dmg) + var seenVersions = Set(found.map(\.version)) + for vol in volumes { + if vol.lastPathComponent == "Macintosh HD" || locateSource(in: vol) != nil { continue } + guard let entries = try? fm.contentsOfDirectory(at: vol, includingPropertiesForKeys: nil) else { continue } + for entry in entries where entry.pathExtension.lowercased() == "dmg" { + let name = entry.lastPathComponent + if name.localizedCaseInsensitiveContains("evaluation") || + name.localizedCaseInsensitiveContains("porting") || + name.localizedCaseInsensitiveContains("gptk") { + let ver = versionFromFilename(name) ?? "Latest" + if !seenVersions.contains(ver) { + seenVersions.insert(ver) + found.append(DiscoveredSource(url: entry, version: ver, isMountedVolume: false)) + } + } + } + } + + // Sort descending by version (e.g. 4.0b3 > 3.0) + found.sort { $0.version.compare($1.version, options: .numeric) == .orderedDescending } + return found + } + + /// Automatically discovers any mounted Evaluation Environment volume in /Volumes, + /// or checks for an Evaluation environment DMG in common directories and attaches it. + public static func discoverSource() -> DiscoveredSource? { + discoverSources().first + } + + /// Directory where alternate D3DMetal versions are saved for 1-click switching. + public static func versionsCacheDirectory(runnerRoot: URL) -> URL { + runnerRoot.appending(path: "lib64/apple_gptk_versions") + } + + /// Returns all valid cached D3DMetal versions stored on disk for this runner, sorted ascending. + public static func cachedVersions(forBuild buildID: String) -> [String] { + let runnerRoot = SupportPaths.clonedRoot(forBuild: buildID) + let dir = versionsCacheDirectory(runnerRoot: runnerRoot) + let entries = (try? FileManager.default.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? [] + return entries + .filter { entry in + !entry.lastPathComponent.hasPrefix(".") && locateSource(in: entry) != nil + } + .map(\.lastPathComponent) + .sorted { $0.compare($1, options: .numeric) == .orderedAscending } + } + + /// Saves the current libraries into the local version cache. + public static func cacheCurrentVersion(runnerRoot: URL) { + guard let currentVer = d3dMetalVersion(runnerRoot: runnerRoot) else { return } + let currentTarget = runnerRoot.appending(path: "lib64/apple_gptk") + let cacheTarget = versionsCacheDirectory(runnerRoot: runnerRoot).appending(path: currentVer) + let fm = FileManager.default + + // If cache directory exists but is incomplete or damaged, remove it + if fm.fileExists(atPath: cacheTarget.path(percentEncoded: false)) { + if locateSource(in: cacheTarget) != nil { + return + } + try? fm.removeItem(at: cacheTarget) + } + + try? fm.createDirectory(at: cacheTarget, withIntermediateDirectories: true) + let result = try? Shell.run("/usr/bin/ditto", [ + currentTarget.path(percentEncoded: false), + cacheTarget.path(percentEncoded: false) + ]) + if result?.succeeded != true { + try? fm.removeItem(at: cacheTarget) + } + } + + /// Switches the runner to a previously cached D3DMetal version. + public static func switchToCachedVersion( + _ version: String, + forBuild buildID: String, + prefixes: [WinePrefix] = PrefixStore.all() + ) throws -> UpdateOutcome { + let step = String(localized: "Switch D3DMetal") + let runnerRoot = SupportPaths.clonedRoot(forBuild: buildID) + if RunnerInstaller.isRunning(from: runnerRoot) { + throw StepFailure( + step: step, + detail: String(localized: "Quit running Windows applications before changing D3DMetal versions.") + ) + } + let cached = versionsCacheDirectory(runnerRoot: runnerRoot).appending(path: version) + guard FileManager.default.fileExists(atPath: cached.path(percentEncoded: false)), + locateSource(in: cached) != nil else { + throw StepFailure(step: step, detail: String(localized: "Cached version \(version) not found or corrupted.")) + } + return try update(runnerRoot: runnerRoot, from: cached, prefixes: prefixes) + } + + /// Reads the stock D3DMetal version string from an installed CrossOver bundle. + public static func stockD3DMetalVersion(for install: CrossOverInstall?) -> String? { + guard let bundle = install?.bundle else { return nil } + let root = SupportPaths.crossOverRoot(inBundle: bundle) + return d3dMetalVersion(runnerRoot: root) + } + + /// Returns true if the runner's D3DMetal version differs from the stock CrossOver bundle's version. + public static func canRestoreStock(forBuild buildID: String, install: CrossOverInstall?) -> Bool { + guard let install, let stockVer = stockD3DMetalVersion(for: install) else { return false } + let currentVer = d3dMetalVersion(forBuild: buildID) + return currentVer != stockVer + } + + /// Restores stock D3DMetal from the CrossOver bundle to the runner. + public static func restoreStock(forBuild buildID: String, install: CrossOverInstall) throws { + let runnerRoot = SupportPaths.clonedRoot(forBuild: buildID) + let stockGptk = SupportPaths.crossOverRoot(inBundle: install.bundle).appending(path: "lib64/apple_gptk") + _ = try update(runnerRoot: runnerRoot, from: stockGptk) + } + + /// Returns Darwin's per-user cache directory under which D3DMetal creates its cache, + /// usually /var/folders/.../C/d3dm. + static func cacheDirectory() -> URL? { + var buffer = [CChar](repeating: 0, count: Int(PATH_MAX)) + let len = confstr(_CS_DARWIN_USER_CACHE_DIR, &buffer, buffer.count) + guard len > 0 else { return nil } + + let path: String? + if len > buffer.count { + var dynamicBuffer = [CChar](repeating: 0, count: len) + _ = confstr(_CS_DARWIN_USER_CACHE_DIR, &dynamicBuffer, len) + path = dynamicBuffer.withUnsafeBufferPointer { ptr in + ptr.baseAddress.map { String(cString: $0) } + } + } else { + path = buffer.withUnsafeBufferPointer { ptr in + ptr.baseAddress.map { String(cString: $0) } + } + } + + guard let trimmed = path?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { + return nil + } + return URL(filePath: trimmed).appending(path: "d3dm") + } + + /// Finds candidate game executable names for a given Wine prefix. + /// Traverses up to 3 levels deep in the game's install directory to capture + /// executables located inside nested folders (e.g. bin/x64/ or Binaries/Win64/). + static func candidateExecutableNames(for prefix: WinePrefix) -> Set { + var names = Set() + if let name = prefix.name { + names.insert(name.lowercased()) + names.insert("\(name).exe".lowercased()) + } + names.insert(prefix.title.lowercased()) + names.insert("\(prefix.title).exe".lowercased()) + names.insert(prefix.appID) + names.insert("\(prefix.appID).exe") + + if let installDir = PrefixStore.installDirectory(of: prefix) { + names.insert(installDir.lastPathComponent.lowercased()) + names.insert("\(installDir.lastPathComponent).exe".lowercased()) + + let fm = FileManager.default + if let enumerator = fm.enumerator( + at: installDir, + includingPropertiesForKeys: [.isDirectoryKey], + options: [.skipsHiddenFiles] + ) { + while let fileURL = enumerator.nextObject() as? URL { + if enumerator.level > 3 { + enumerator.skipDescendants() + continue + } + if fileURL.pathExtension.lowercased() == "exe" { + names.insert(fileURL.lastPathComponent.lowercased()) + } + } + } + } + return names + } + + /// Clears the D3DMetal shader cache for a specific prefix, or all caches if prefix is nil. + /// Returns the number of game caches cleared. + @discardableResult + static func clearShaderCache(for prefix: WinePrefix? = nil, cacheDir: URL? = cacheDirectory()) throws -> Int { + guard let dir = cacheDir, FileManager.default.fileExists(atPath: dir.path(percentEncoded: false)) else { + return 0 + } + let fm = FileManager.default + let entries = (try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil)) ?? [] + guard !entries.isEmpty else { return 0 } + + let targets: [URL] + if let prefix { + let candidates = candidateExecutableNames(for: prefix) + targets = entries.filter { entry in + let name = entry.lastPathComponent.lowercased() + let base = name.hasSuffix(".exe") ? String(name.dropLast(4)) : name + return candidates.contains(name) || candidates.contains(base) + } + } else { + targets = entries + } + + var cleared = 0 + for target in targets { + guard fm.fileExists(atPath: target.path(percentEncoded: false)) else { continue } + try fm.removeItem(at: target) + cleared += 1 + } + return cleared + } + + /// Clears all D3DMetal shader caches in the system. + @discardableResult + static func clearAllShaderCaches(cacheDir: URL? = cacheDirectory()) throws -> Int { + try clearShaderCache(for: nil, cacheDir: cacheDir) + } + + /// Locates the `external` and `wine` directory source in a user-provided URL + /// (e.g. mounted evaluation environment DMG volume, redist/lib folder, etc.) + static func locateSource(in url: URL) -> URL? { + let fm = FileManager.default + func isD3DMetalSource(_ dir: URL) -> Bool { + let external = dir.appending(path: "external") + let wine = dir.appending(path: "wine") + var isDir: ObjCBool = false + return fm.fileExists(atPath: external.path(percentEncoded: false), isDirectory: &isDir) && isDir.boolValue && + fm.fileExists(atPath: wine.path(percentEncoded: false), isDirectory: &isDir) && isDir.boolValue + } + + if isD3DMetalSource(url) { return url } + + let redistLib = url.appending(path: "redist/lib") + if isD3DMetalSource(redistLib) { return redistLib } + + let lib = url.appending(path: "lib") + if isD3DMetalSource(lib) { return lib } + + return nil + } + + /// Resolves a source URL whether it points to a mounted directory, an unmounted .dmg file, + /// or a directory/volume containing a nested Evaluation environment .dmg. + public static func resolveSource(at url: URL, maxDepth: Int = 2) -> URL? { + guard maxDepth >= 0 else { return nil } + + if url.pathExtension.lowercased() == "dmg" { + let attach = try? Shell.run("/usr/bin/hdiutil", ["attach", "-nobrowse", "-readonly", url.path(percentEncoded: false)]) + if attach?.succeeded == true { + let volumesURL = URL(filePath: "/Volumes") + if let volumes = try? FileManager.default.contentsOfDirectory(at: volumesURL, includingPropertiesForKeys: nil) { + for vol in volumes { + if let src = locateSource(in: vol) { + return src + } + } + // If no volume directly contained external/wine, check mounted volumes for nested DMGs + for vol in volumes { + if vol.lastPathComponent == "Macintosh HD" { continue } + if let contents = try? FileManager.default.contentsOfDirectory(at: vol, includingPropertiesForKeys: nil) { + for nested in contents where nested.pathExtension.lowercased() == "dmg" { + if let src = resolveSource(at: nested, maxDepth: maxDepth - 1) { + return src + } + } + } + } + } + } + } + + if let direct = locateSource(in: url) { + return direct + } + + // If the URL is a directory or mounted volume containing a nested DMG + if let contents = try? FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil) { + let dmgs = contents.filter { $0.pathExtension.lowercased() == "dmg" } + for dmg in dmgs where dmg.lastPathComponent.localizedCaseInsensitiveContains("evaluation") || + dmg.lastPathComponent.localizedCaseInsensitiveContains("porting") || + dmg.lastPathComponent.localizedCaseInsensitiveContains("gptk") { + if let nested = resolveSource(at: dmg, maxDepth: maxDepth - 1) { + return nested + } + } + for dmg in dmgs { + if let nested = resolveSource(at: dmg, maxDepth: maxDepth - 1) { + return nested + } + } + } + + return nil + } + + /// Updates D3DMetal in a runner root from the evaluation environment distribution. + /// Also handles MetalFX integration prep (renaming nvngx-on-metalfx to nvngx and copying + /// to prefix system32 directories if provided). + @discardableResult + static func update( + runnerRoot: URL, + from source: URL, + prefixes: [WinePrefix] = [], + cacheDir: URL? = cacheDirectory() + ) throws -> UpdateOutcome { + let step = String(localized: "Update D3DMetal") + if RunnerInstaller.isRunning(from: runnerRoot) { + throw StepFailure( + step: step, + detail: String(localized: "Quit running Windows applications before changing D3DMetal versions.") + ) + } + + guard let d3dmSource = resolveSource(at: source) else { + throw StepFailure( + step: step, + detail: String(localized: "\(source.lastPathComponent) does not contain D3DMetal libraries (expected external and wine folders).") + ) + } + + let target = runnerRoot.appending(path: "lib64/apple_gptk") + let fm = FileManager.default + if !fm.fileExists(atPath: target.path(percentEncoded: false)) { + try fm.createDirectory(at: target, withIntermediateDirectories: true) + } + + // Cache current version for instant 1-click switching later + cacheCurrentVersion(runnerRoot: runnerRoot) + + // 1. Back up existing libraries + let ext = target.appending(path: "external") + let extOld = target.appending(path: "external.old") + if fm.fileExists(atPath: ext.path(percentEncoded: false)) { + try? fm.removeItem(at: extOld) + try fm.moveItem(at: ext, to: extOld) + } + + let wine = target.appending(path: "wine") + let wineOld = target.appending(path: "wine.old") + if fm.fileExists(atPath: wine.path(percentEncoded: false)) { + try? fm.removeItem(at: wineOld) + try fm.moveItem(at: wine, to: wineOld) + } + + // 2. Copy new external and wine folders atomically + do { + try fm.copyItem(at: d3dmSource.appending(path: "external"), to: ext) + try fm.copyItem(at: d3dmSource.appending(path: "wine"), to: wine) + } catch { + // Restore backups if copy failed + try? fm.removeItem(at: ext) + try? fm.removeItem(at: wine) + if fm.fileExists(atPath: extOld.path(percentEncoded: false)) { + try? fm.moveItem(at: extOld, to: ext) + } + if fm.fileExists(atPath: wineOld.path(percentEncoded: false)) { + try? fm.moveItem(at: wineOld, to: wine) + } + throw StepFailure(step: step, detail: error.localizedDescription) + } + + // Clean up backups on success to keep disk usage tidy + try? fm.removeItem(at: extOld) + try? fm.removeItem(at: wineOld) + + // Cache newly installed version + cacheCurrentVersion(runnerRoot: runnerRoot) + + // 3. Prepare MetalFX integration + var metalFXPrepared = false + let unixDir = wine.appending(path: "x86_64-unix") + let nvngxUnixSrc = unixDir.appending(path: "nvngx-on-metalfx.so") + let nvngxUnixDst = unixDir.appending(path: "nvngx.so") + if fm.fileExists(atPath: nvngxUnixSrc.path(percentEncoded: false)) { + try? fm.removeItem(at: nvngxUnixDst) + try? fm.copyItem(at: nvngxUnixSrc, to: nvngxUnixDst) + metalFXPrepared = true + } else if fm.fileExists(atPath: nvngxUnixDst.path(percentEncoded: false)) { + metalFXPrepared = true + } + + let winDir = wine.appending(path: "x86_64-windows") + let nvngxWinSrc = winDir.appending(path: "nvngx-on-metalfx.dll") + let nvngxWinDst = winDir.appending(path: "nvngx.dll") + if fm.fileExists(atPath: nvngxWinSrc.path(percentEncoded: false)) { + try? fm.removeItem(at: nvngxWinDst) + try? fm.copyItem(at: nvngxWinSrc, to: nvngxWinDst) + metalFXPrepared = true + } else if fm.fileExists(atPath: nvngxWinDst.path(percentEncoded: false)) { + metalFXPrepared = true + } + + // 4. Deploy nvngx.dll and nvapi64.dll to prefix system32 directories + var prefixesUpdated = 0 + let nvapi64Dll = winDir.appending(path: "nvapi64.dll") + for prefix in prefixes { + let system32 = prefix.pfx.appending(path: "drive_c/windows/system32") + guard fm.fileExists(atPath: system32.path(percentEncoded: false)) else { continue } + var updatedAny = false + if fm.fileExists(atPath: nvngxWinDst.path(percentEncoded: false)) { + let dest = system32.appending(path: "nvngx.dll") + try? fm.removeItem(at: dest) + try? fm.copyItem(at: nvngxWinDst, to: dest) + updatedAny = true + } + if fm.fileExists(atPath: nvapi64Dll.path(percentEncoded: false)) { + let dest = system32.appending(path: "nvapi64.dll") + try? fm.removeItem(at: dest) + try? fm.copyItem(at: nvapi64Dll, to: dest) + updatedAny = true + } + if updatedAny { prefixesUpdated += 1 } + } + + // 5. Invalidate shader cache + let cleared = (try? clearAllShaderCaches(cacheDir: cacheDir)) ?? 0 + + return UpdateOutcome( + runnerRoot: runnerRoot, + sourceURL: d3dmSource, + metalFXPrepared: metalFXPrepared, + prefixesUpdated: prefixesUpdated, + shaderCachesCleared: cleared + ) + } +} diff --git a/app/Sources/NotProtonApp/Model/PrefixTools.swift b/app/Sources/NotProtonApp/Model/PrefixTools.swift index 81e6e7da..3e732845 100644 --- a/app/Sources/NotProtonApp/Model/PrefixTools.swift +++ b/app/Sources/NotProtonApp/Model/PrefixTools.swift @@ -421,4 +421,14 @@ enum PrefixTools { try? FileManager.default.removeItem( at: SupportPaths.launchers.appending(path: prefix.appID).appending(path: "runner")) } + + @discardableResult + static func clearShaderCache(for prefix: WinePrefix) throws -> Int { + try D3DMetalTools.clearShaderCache(for: prefix) + } + + @discardableResult + static func clearAllShaderCaches() throws -> Int { + try D3DMetalTools.clearAllShaderCaches() + } } diff --git a/app/Sources/NotProtonApp/PrefixesModel.swift b/app/Sources/NotProtonApp/PrefixesModel.swift index cc5708c0..bbbc9b9f 100644 --- a/app/Sources/NotProtonApp/PrefixesModel.swift +++ b/app/Sources/NotProtonApp/PrefixesModel.swift @@ -144,6 +144,31 @@ final class PrefixesModel { } } + func clearShaderCache(for prefix: WinePrefix) { + act(on: prefix) { + let count = try PrefixTools.clearShaderCache(for: prefix) + if count > 0 { + return String(localized: "Cleared DirectX 12 shader cache for \(prefix.title).") + } else { + return String(localized: "No DirectX 12 shader cache found for \(prefix.title).") + } + } + } + + func clearAllShaderCaches() { + forgetOutcome() + do { + let count = try PrefixTools.clearAllShaderCaches() + if count > 0 { + outcome = String(localized: "Cleared \(count) DirectX 12 shader cache(s).") + } else { + outcome = String(localized: "No DirectX 12 shader caches found.") + } + } catch { + report = FailureReport([error]) + } + } + func reveal(_ prefix: WinePrefix) { PrefixTools.reveal(prefix) } diff --git a/app/Sources/NotProtonApp/SystemStatus.swift b/app/Sources/NotProtonApp/SystemStatus.swift index 15dc87a8..fa543db5 100644 --- a/app/Sources/NotProtonApp/SystemStatus.swift +++ b/app/Sources/NotProtonApp/SystemStatus.swift @@ -284,6 +284,75 @@ final class SystemStatus { await refresh() } + func updateD3DMetal(for buildID: String) async { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = String(localized: "Update") + panel.message = String(localized: "Select Apple's Game Porting Toolkit (GPTK) disk image (.dmg) or mounted volume to update DirectX 12.") + + guard panel.runModal() == .OK, let picked = panel.url else { return } + + let runnerRoot = SupportPaths.clonedRoot(forBuild: buildID) + await perform(from: String(localized: "Updating DirectX 12 (D3DMetal)")) { _ in + let prefixes = PrefixStore.all() + let result = try D3DMetalTools.update(runnerRoot: runnerRoot, from: picked, prefixes: prefixes) + AppLog.note("D3DMetal updated for build \(buildID) from \(picked.path(percentEncoded: false))") + var message = String(localized: "Updated DirectX 12 (D3DMetal) successfully.") + if result.prefixesUpdated > 0 { + message += " " + String(localized: "MetalFX prepared for \(result.prefixesUpdated) prefix(es).") + } + return message + } + } + + func updateD3DMetalFromDiscovered(for buildID: String, source: D3DMetalTools.DiscoveredSource) async { + let runnerRoot = SupportPaths.clonedRoot(forBuild: buildID) + await perform(from: String(localized: "Updating DirectX 12 to GPTK \(source.version)")) { _ in + let prefixes = PrefixStore.all() + let result = try D3DMetalTools.update(runnerRoot: runnerRoot, from: source.url, prefixes: prefixes) + AppLog.note("D3DMetal auto-updated for build \(buildID) to \(source.version)") + var message = String(localized: "Updated DirectX 12 to GPTK \(source.version) successfully.") + if result.prefixesUpdated > 0 { + message += " " + String(localized: "MetalFX prepared for \(result.prefixesUpdated) prefix(es).") + } + return message + } + } + + func switchToCachedD3DMetal(version: String, for buildID: String) async { + await perform(from: String(localized: "Switching DirectX 12 to \(version)")) { _ in + let result = try D3DMetalTools.switchToCachedVersion(version, forBuild: buildID) + AppLog.note("D3DMetal switched to cached version \(version) for build \(buildID)") + var message = String(localized: "Switched DirectX 12 to \(version) successfully.") + if result.prefixesUpdated > 0 { + message += " " + String(localized: "MetalFX prepared for \(result.prefixesUpdated) prefix(es).") + } + return message + } + } + + func restoreStockD3DMetal(for buildID: String, install: CrossOverInstall) async { + let stockVer = D3DMetalTools.stockD3DMetalVersion(for: install) ?? "" + await perform(from: String(localized: "Restoring stock DirectX 12")) { _ in + try D3DMetalTools.restoreStock(forBuild: buildID, install: install) + AppLog.note("D3DMetal restored to stock for build \(buildID)") + return String(localized: "Restored stock DirectX 12 (\(stockVer)) successfully.") + } + } + + func clearShaderCache() async { + await perform(from: String(localized: "Clearing shader cache")) { _ in + let count = try D3DMetalTools.clearAllShaderCaches() + if count > 0 { + return String(localized: "Cleared \(count) DirectX 12 shader cache(s).") + } else { + return String(localized: "No DirectX 12 shader caches found.") + } + } + } + func chooseSteam() async { let current = SupportPaths.Steam.app let panel = NSOpenPanel() diff --git a/app/Sources/NotProtonApp/Views/PrefixesView.swift b/app/Sources/NotProtonApp/Views/PrefixesView.swift index 03939643..3c7c4423 100644 --- a/app/Sources/NotProtonApp/Views/PrefixesView.swift +++ b/app/Sources/NotProtonApp/Views/PrefixesView.swift @@ -173,6 +173,16 @@ struct PrefixesView: View { } .disabled(model.isLoading) .help("List the prefixes again and update their sizes.") + + Menu { + Button("Clear All Shader Caches") { + model.clearAllShaderCaches() + } + } label: { + Label("More", systemImage: "ellipsis.circle") + } + .disabled(model.isBusy) + .help("Additional prefix management actions.") } private func lastUsed(_ prefix: WinePrefix) -> String { @@ -368,6 +378,9 @@ struct PrefixesView: View { Button("Winetricks…") { winetricksPrefix = prefix } + Button("Clear Shader Cache") { + if let prefix { model.clearShaderCache(for: prefix) } + } Divider() ForEach(WineTool.allCases, id: \.self) { tool in Button(tool.label) { diff --git a/app/Sources/NotProtonApp/Views/StatusView.swift b/app/Sources/NotProtonApp/Views/StatusView.swift index 79819198..122fdacb 100644 --- a/app/Sources/NotProtonApp/Views/StatusView.swift +++ b/app/Sources/NotProtonApp/Views/StatusView.swift @@ -598,6 +598,9 @@ struct StatusView: View { if row.copy == .ready || row.copy == .unpatched { let names = tools.filter { $0.build == row.buildID }.map(\.display) lines.append(contentsOf: names) + if let d3dm = D3DMetalTools.d3dMetalVersion(forBuild: row.buildID) { + lines.append(String(localized: "DirectX 12 (D3DMetal) \(d3dm)")) + } } if row.copy == .none, row.licensed == false { lines.append("\(String(localized: "Open CrossOver to activate it.")) \(CrossOverLicense.trialUnsupported)") } if let install = row.install { @@ -643,6 +646,53 @@ struct StatusView: View { help: String(localized: "Copy \(row.title) again."), isEnabled: status.canInstall && row.canSetUp ) { Task { await status.requestCompatibilityTool(from: install, replacingExisting: true) } }) + + let currentVer = D3DMetalTools.d3dMetalVersion(forBuild: row.buildID) + let cached = D3DMetalTools.cachedVersions(forBuild: row.buildID) + + // 1-Click update from auto-discovered mounted Evaluation Environment / DMG (if not already cached) + if let discovered = D3DMetalTools.discoverSource(), + discovered.version != currentVer, + !cached.contains(discovered.version) { + items.append(StatusAction( + label: String(localized: "Update DirectX 12 to GPTK \(discovered.version)"), + help: String(localized: "Upgrade CrossOver's DirectX 12 engine from Apple's Game Porting Toolkit directly in 1 click."), + isEnabled: status.canInstall + ) { Task { await status.updateD3DMetalFromDiscovered(for: row.buildID, source: discovered) } }) + } + + // 1-Click switch to any other cached versions + for ver in cached where ver != currentVer { + items.append(StatusAction( + label: String(localized: "Switch DirectX 12 to \(ver)"), + help: String(localized: "Switch immediately to cached DirectX 12 / D3DMetal \(ver) in 1 click."), + isEnabled: status.canInstall + ) { Task { await status.switchToCachedD3DMetal(version: ver, for: row.buildID) } }) + } + + // 1-Click restore stock CrossOver D3DMetal (e.g. 3.0) + if let install, D3DMetalTools.canRestoreStock(forBuild: row.buildID, install: install) { + let stockVer = D3DMetalTools.stockD3DMetalVersion(for: install) ?? "Stock" + if !cached.contains(stockVer) { + items.append(StatusAction( + label: String(localized: "Restore Stock DirectX 12 (\(stockVer))"), + help: String(localized: "Restore the stock DirectX 12 engine bundled with CrossOver in 1 click."), + isEnabled: status.canInstall + ) { Task { await status.restoreStockD3DMetal(for: row.buildID, install: install) } }) + } + } + + items.append(StatusAction( + label: String(localized: "Select Apple GPTK Folder\u{2026}"), + help: String(localized: "Manually select an Apple Game Porting Toolkit disk image or folder to update DirectX 12."), + isEnabled: status.canInstall + ) { Task { await status.updateD3DMetal(for: row.buildID) } }) + + items.append(StatusAction( + label: String(localized: "Clear Shader Cache"), + help: String(localized: "Clear all DirectX 12 / D3DMetal shader caches."), + isEnabled: status.isIdle + ) { Task { await status.clearShaderCache() } }) } let shown = install?.bundle ?? (row.copy == .none ? nil : SupportPaths.runnerRoot(forBuild: row.buildID)) diff --git a/app/Tests/NotProtonAppTests/D3DMetalToolsTests.swift b/app/Tests/NotProtonAppTests/D3DMetalToolsTests.swift new file mode 100644 index 00000000..95aaafae --- /dev/null +++ b/app/Tests/NotProtonAppTests/D3DMetalToolsTests.swift @@ -0,0 +1,271 @@ +import Foundation +import Testing + +@testable import NotProtonApp + +@Suite("D3DMetal tools") +struct D3DMetalToolsTests { + + private func createTempDir() throws -> URL { + let dir = FileManager.default.temporaryDirectory + .appending(path: "d3dm-test-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + private func makeSamplePrefix(in root: URL, appID: String = "1574480", name: String = "Agent 64") -> WinePrefix { + let library = SteamLibrary(root: root) + return WinePrefix( + appID: appID, + name: name, + library: library, + lastUsed: nil + ) + } + + @Test("Cache directory resolves under Darwin cache dir") + func cacheDirectoryResolves() { + let dir = D3DMetalTools.cacheDirectory() + #expect(dir != nil) + #expect(dir?.lastPathComponent == "d3dm") + } + + @Test("Candidate executable names match prefix title and name") + func candidateNames() throws { + let root = try createTempDir() + defer { try? FileManager.default.removeItem(at: root) } + + let prefix = makeSamplePrefix(in: root, appID: "1574480", name: "Agent 64") + let candidates = D3DMetalTools.candidateExecutableNames(for: prefix) + + #expect(candidates.contains("agent 64")) + #expect(candidates.contains("agent 64.exe")) + #expect(candidates.contains("1574480")) + #expect(candidates.contains("1574480.exe")) + } + + @Test("Candidate executable names discover nested game binaries in subdirectories") + func candidateNamesNestedDiscovery() throws { + let root = try createTempDir() + defer { try? FileManager.default.removeItem(at: root) } + + let prefix = makeSamplePrefix(in: root, appID: "1091500", name: "Cyberpunk 2077") + let common = prefix.library.steamapps.appending(path: "common/Cyberpunk 2077") + let nestedBin = common.appending(path: "bin/x64") + try FileManager.default.createDirectory(at: nestedBin, withIntermediateDirectories: true) + try "dummy".write(to: nestedBin.appending(path: "Cyberpunk2077.exe"), atomically: true, encoding: .utf8) + + // Manifest linking installdir + let manifest = prefix.library.steamapps.appending(path: "appmanifest_1091500.acf") + let acf = """ + "AppState" + { + \t"appid"\t\t"1091500" + \t"installdir"\t\t"Cyberpunk 2077" + } + """ + try acf.write(to: manifest, atomically: true, encoding: .utf8) + + let candidates = D3DMetalTools.candidateExecutableNames(for: prefix) + #expect(candidates.contains("cyberpunk2077.exe")) + } + + @Test("Shader cache clearing for specific prefix clears only that game's cache") + func clearShaderCacheForPrefix() throws { + let tempDir = try createTempDir() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let cacheDir = tempDir.appending(path: "d3dm") + let game1Dir = cacheDir.appending(path: "Agent 64.exe/shaders.cache") + let game2Dir = cacheDir.appending(path: "GTA5.exe/shaders.cache") + + try FileManager.default.createDirectory(at: game1Dir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: game2Dir, withIntermediateDirectories: true) + try "cached1".write(to: game1Dir.appending(path: "cache.bin"), atomically: true, encoding: .utf8) + try "cached2".write(to: game2Dir.appending(path: "cache.bin"), atomically: true, encoding: .utf8) + + let prefix = makeSamplePrefix(in: tempDir, appID: "1574480", name: "Agent 64") + let cleared = try D3DMetalTools.clearShaderCache(for: prefix, cacheDir: cacheDir) + + #expect(cleared == 1) + #expect(!FileManager.default.fileExists(atPath: game1Dir.path(percentEncoded: false))) + #expect(FileManager.default.fileExists(atPath: game2Dir.path(percentEncoded: false))) + } + + @Test("Shader cache clearing all caches removes every cache") + func clearAllShaderCaches() throws { + let tempDir = try createTempDir() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let cacheDir = tempDir.appending(path: "d3dm") + let game1Dir = cacheDir.appending(path: "Game1.exe/shaders.cache") + let game2Dir = cacheDir.appending(path: "Game2.exe/shaders.cache") + + try FileManager.default.createDirectory(at: game1Dir, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: game2Dir, withIntermediateDirectories: true) + + let cleared = try D3DMetalTools.clearAllShaderCaches(cacheDir: cacheDir) + #expect(cleared == 2) + #expect(!FileManager.default.fileExists(atPath: game1Dir.path(percentEncoded: false))) + #expect(!FileManager.default.fileExists(atPath: game2Dir.path(percentEncoded: false))) + } + + @Test("Locating D3DMetal source detects direct, redist/lib, and lib folders") + func locateSource() throws { + let tempDir = try createTempDir() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let direct = tempDir.appending(path: "direct") + try FileManager.default.createDirectory(at: direct.appending(path: "external"), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: direct.appending(path: "wine"), withIntermediateDirectories: true) + #expect(D3DMetalTools.locateSource(in: direct) == direct) + + let redist = tempDir.appending(path: "gptk_volume") + let redistLib = redist.appending(path: "redist/lib") + try FileManager.default.createDirectory(at: redistLib.appending(path: "external"), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: redistLib.appending(path: "wine"), withIntermediateDirectories: true) + #expect(D3DMetalTools.locateSource(in: redist) == redistLib) + + let empty = tempDir.appending(path: "empty") + try FileManager.default.createDirectory(at: empty, withIntermediateDirectories: true) + #expect(D3DMetalTools.locateSource(in: empty) == nil) + } + + @Test("Update D3DMetal deploys new libraries, prepares MetalFX, and cleans up temporary backups") + func updateD3DMetal() throws { + let tempDir = try createTempDir() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let fm = FileManager.default + + // Set up dummy runner with existing apple_gptk + let runnerRoot = tempDir.appending(path: "runner") + let gptk = runnerRoot.appending(path: "lib64/apple_gptk") + let oldExt = gptk.appending(path: "external") + let oldWine = gptk.appending(path: "wine") + try fm.createDirectory(at: oldExt, withIntermediateDirectories: true) + try fm.createDirectory(at: oldWine, withIntermediateDirectories: true) + try "old_d3d".write(to: oldExt.appending(path: "libd3dshared.dylib"), atomically: true, encoding: .utf8) + try "old_wine".write(to: oldWine.appending(path: "wine.txt"), atomically: true, encoding: .utf8) + + // Set up dummy evaluation environment distribution + let gptkDist = tempDir.appending(path: "Evaluation environment for Windows games 4.0 beta 3") + let distLib = gptkDist.appending(path: "redist/lib") + let newExt = distLib.appending(path: "external") + let newWine = distLib.appending(path: "wine") + let newUnix = newWine.appending(path: "x86_64-unix") + let newWin = newWine.appending(path: "x86_64-windows") + try fm.createDirectory(at: newExt, withIntermediateDirectories: true) + try fm.createDirectory(at: newUnix, withIntermediateDirectories: true) + try fm.createDirectory(at: newWin, withIntermediateDirectories: true) + + try "new_d3d".write(to: newExt.appending(path: "libd3dshared.dylib"), atomically: true, encoding: .utf8) + try "metalfx_so".write(to: newUnix.appending(path: "nvngx-on-metalfx.so"), atomically: true, encoding: .utf8) + try "metalfx_dll".write(to: newWin.appending(path: "nvngx-on-metalfx.dll"), atomically: true, encoding: .utf8) + try "nvapi_dll".write(to: newWin.appending(path: "nvapi64.dll"), atomically: true, encoding: .utf8) + + // Set up dummy prefix + let prefix = makeSamplePrefix(in: tempDir, appID: "1574480", name: "Agent 64") + let system32 = prefix.pfx.appending(path: "drive_c/windows/system32") + try fm.createDirectory(at: system32, withIntermediateDirectories: true) + + // Dummy cache dir + let cacheDir = tempDir.appending(path: "d3dm") + try fm.createDirectory(at: cacheDir.appending(path: "Agent 64.exe/shaders.cache"), withIntermediateDirectories: true) + + let outcome = try D3DMetalTools.update( + runnerRoot: runnerRoot, + from: gptkDist, + prefixes: [prefix], + cacheDir: cacheDir + ) + + #expect(outcome.metalFXPrepared) + #expect(outcome.prefixesUpdated == 1) + #expect(outcome.shaderCachesCleared == 1) + + // Verify old backups were cleaned up on success + #expect(!fm.fileExists(atPath: gptk.appending(path: "external.old").path(percentEncoded: false))) + #expect(!fm.fileExists(atPath: gptk.appending(path: "wine.old").path(percentEncoded: false))) + + // Verify new files were deployed + let deployedD3D = try String(contentsOf: gptk.appending(path: "external/libd3dshared.dylib"), encoding: .utf8) + #expect(deployedD3D == "new_d3d") + + // Verify MetalFX was prepared + #expect(fm.fileExists(atPath: gptk.appending(path: "wine/x86_64-unix/nvngx.so").path(percentEncoded: false))) + #expect(fm.fileExists(atPath: gptk.appending(path: "wine/x86_64-windows/nvngx.dll").path(percentEncoded: false))) + + // Verify prefix system32 received nvngx.dll and nvapi64.dll + #expect(fm.fileExists(atPath: system32.appending(path: "nvngx.dll").path(percentEncoded: false))) + #expect(fm.fileExists(atPath: system32.appending(path: "nvapi64.dll").path(percentEncoded: false))) + } + + @Test("Reading D3DMetal version from plist") + func d3dMetalVersionInspection() throws { + let tempDir = try createTempDir() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let runnerRoot = tempDir.appending(path: "CrossOver") + let resources = runnerRoot.appending(path: "lib64/apple_gptk/external/D3DMetal.framework/Versions/A/Resources") + try FileManager.default.createDirectory(at: resources, withIntermediateDirectories: true) + + let plistContent = """ + + + + + CFBundleShortVersionString + 4.0b3 + + + """ + try plistContent.write(to: resources.appending(path: "version.plist"), atomically: true, encoding: .utf8) + + let ver = D3DMetalTools.d3dMetalVersion(runnerRoot: runnerRoot) + #expect(ver == "4.0b3") + } + + @Test("Cached versions filters incomplete or corrupted directories") + func cachedVersionsFilter() throws { + let tempDir = try createTempDir() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let runnerRoot = tempDir.appending(path: "CrossOver") + let versionsDir = D3DMetalTools.versionsCacheDirectory(runnerRoot: runnerRoot) + + // 1. Valid version folder (has external and wine) + let validVer = versionsDir.appending(path: "3.0") + try FileManager.default.createDirectory(at: validVer.appending(path: "external"), withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: validVer.appending(path: "wine"), withIntermediateDirectories: true) + + // 2. Corrupt version folder (empty, missing libraries) + let corruptVer = versionsDir.appending(path: "4.0_corrupt") + try FileManager.default.createDirectory(at: corruptVer, withIntermediateDirectories: true) + + // 3. Hidden directory (.DS_Store or .temp) + let hiddenDir = versionsDir.appending(path: ".temp") + try FileManager.default.createDirectory(at: hiddenDir, withIntermediateDirectories: true) + + // Extract cached versions using runnerRoot directly + let entries = (try? FileManager.default.contentsOfDirectory(at: versionsDir, includingPropertiesForKeys: nil)) ?? [] + let validVersions = entries + .filter { entry in + !entry.lastPathComponent.hasPrefix(".") && D3DMetalTools.locateSource(in: entry) != nil + } + .map(\.lastPathComponent) + + #expect(validVersions == ["3.0"]) + #expect(!validVersions.contains("4.0_corrupt")) + #expect(!validVersions.contains(".temp")) + } + + @Test("Version parsing from DMG filenames") + func versionFromFilename() { + #expect(D3DMetalTools.versionFromFilename("Evaluation environment for Windows games 4.0 beta 3.dmg") == "4.0b3") + #expect(D3DMetalTools.versionFromFilename("Evaluation environment for Windows games 4.0b3.dmg") == "4.0b3") + #expect(D3DMetalTools.versionFromFilename("Evaluation Environment for Windows Games 3.0.dmg") == "3.0") + #expect(D3DMetalTools.versionFromFilename("Game_Porting_Toolkit_1.0.dmg") == "1.0") + #expect(D3DMetalTools.versionFromFilename("random_file.dmg") == nil) + } +} diff --git a/app/Tests/NotProtonAppTests/PrefixesModelTests.swift b/app/Tests/NotProtonAppTests/PrefixesModelTests.swift index c5910f81..bdcbdb1f 100644 --- a/app/Tests/NotProtonAppTests/PrefixesModelTests.swift +++ b/app/Tests/NotProtonAppTests/PrefixesModelTests.swift @@ -253,7 +253,7 @@ struct PrefixesModelTests { let first = Task { await model.load() } let entered = await withCheckedContinuation { continuation in DispatchQueue.global().async { - continuation.resume(returning: scan.entered.wait(timeout: .now() + 5) == .success) + continuation.resume(returning: scan.entered.wait(timeout: .now() + 15) == .success) } } try #require(entered) diff --git a/dylib/feats/compat_run.sh b/dylib/feats/compat_run.sh index ca2ab0ed..301b3e19 100755 --- a/dylib/feats/compat_run.sh +++ b/dylib/feats/compat_run.sh @@ -1168,7 +1168,7 @@ if [ "$verb" = waitforexitandrun ] && [ -f "$np_defaults_file" ] && [ -r "$np_de value=${line#*=} case "$line" in CX_GRAPHICS_BACKEND=dxmt | CX_GRAPHICS_BACKEND=dxvk | CX_GRAPHICS_BACKEND=wined3d) ;; - CX_GRAPHICS_BACKEND=d3dmetal | D3DM_ENABLE_METALFX=[01] | ROSETTA_ADVERTISE_AVX=[01]) + CX_GRAPHICS_BACKEND=d3dmetal | D3DM_ENABLE_METALFX=[01] | D3DM_SUPPORT_DXR=[01] | D3DM_MTL4=[01] | D3DM_MAX_FPS=[0-9]* | ROSETTA_ADVERTISE_AVX=[01]) [ "${wine_unix##*/}" != aarch64-unix ] || continue ;; FEX_X87REDUCEDPRECISION=[01]) [ "${wine_unix##*/}" = aarch64-unix ] || continue ;; @@ -2630,6 +2630,35 @@ install_legacy_steam_dll() { fi } +install_metalfx_dlls() { + [ "${D3DM_ENABLE_METALFX:-0}" = 1 ] || return 0 + [ "${CX_GRAPHICS_BACKEND:-d3dmetal}" = d3dmetal ] || return 0 + sys32="$WINEPREFIX/drive_c/windows/system32" + [ -d "$sys32" ] || return 0 + for dll in nvngx.dll nvapi64.dll; do + src="" + for dir in \ + "$CX_ROOT/lib64/apple_gptk/wine/x86_64-windows" \ + "$CX_ROOT/lib/apple_gptk/wine/x86_64-windows" \ + "$CX_ROOT/lib/wine/x86_64-windows" \ + "$CX_ROOT/lib/dxmt/x86_64-windows"; do + if [ -f "$dir/$dll" ]; then + src="$dir/$dll" + break + elif [ "$dll" = "nvngx.dll" ] && [ -f "$dir/nvngx-on-metalfx.dll" ]; then + src="$dir/nvngx-on-metalfx.dll" + break + fi + done + [ -n "$src" ] || continue + dst="$sys32/$dll" + cmp -s "$src" "$dst" && continue + if cp -fp "$src" "$dst" 2>> "$log"; then + echo "=== installed MetalFX $dll into system32 ===" >> "$log" 2>&1 || true + fi + done +} + # Steam runs a Windows game's installscript.vdf by invoking the standalone # evaluator through the compat tool, the same way the linux client does, but # the binaries are missing on macOS, so... @@ -2749,6 +2778,7 @@ if [ -d "$bridge_src" ] && [ -n "$WINEPREFIX" ]; then exit 1 fi install_legacy_steam_dll + install_metalfx_dlls export WINEDLLPATH="$prefix_steam:$WINEDLLPATH" # If the same DLL appears twice in WINEDLLOVERRIDES, the last entry wins. vc_overrides=$(native_vc_runtime) diff --git a/dylib/feats/defaults.c b/dylib/feats/defaults.c index 385071d3..4db01640 100644 --- a/dylib/feats/defaults.c +++ b/dylib/feats/defaults.c @@ -22,6 +22,7 @@ static const char *const g_backends[] = { "dxmt", "dxvk", "wined3d", NULL }; static const char *const g_d3dmetal[] = { "d3dmetal", NULL }; static const char *const g_factors[] = { "1.5", "1.72", "2.0", "3.0", NULL }; static const char *const g_fps[] = { "30", "40", "60", "120", "144", NULL }; +static const char *const g_d3dm_fps[] = { "30", "60", "90", "120", "144", "240", NULL }; static const struct { const char *key; @@ -33,6 +34,9 @@ static const struct { { "CX_GRAPHICS_BACKEND", ROSETTA | FEX, "", g_backends, g_d3dmetal }, { "MTL_HUD_ENABLED", ROSETTA | FEX, "", g_bools, NULL }, { "D3DM_ENABLE_METALFX", ROSETTA, "", g_bools, NULL }, + { "D3DM_SUPPORT_DXR", ROSETTA, "", g_bools, NULL }, + { "D3DM_MTL4", ROSETTA, "", g_bools, NULL }, + { "D3DM_MAX_FPS", ROSETTA, "", g_d3dm_fps, NULL }, { "DXMT_ENABLE_NVEXT", ROSETTA | FEX, "", g_bools, NULL }, { "ROSETTA_ADVERTISE_AVX", ROSETTA, "", g_bools, NULL }, { "WINEMSYNC", ROSETTA | FEX, "", g_bools, NULL }, diff --git a/dylib/feats/webpatch.c b/dylib/feats/webpatch.c index a717dabc..cd5f27b4 100644 --- a/dylib/feats/webpatch.c +++ b/dylib/feats/webpatch.c @@ -1365,6 +1365,13 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "{data:\"60\",label:\"60 FPS\"}," \ "{data:\"120\",label:\"120 FPS\"}," \ "{data:\"144\",label:\"144 FPS\"}]," \ + "M=[{data:\"\",label:npL(\"Off\",\"关闭\",\"Disattivato\")}," \ + "{data:\"30\",label:\"30 FPS\"}," \ + "{data:\"60\",label:\"60 FPS\"}," \ + "{data:\"90\",label:\"90 FPS\"}," \ + "{data:\"120\",label:\"120 FPS\"}," \ + "{data:\"144\",label:\"144 FPS\"}," \ + "{data:\"240\",label:\"240 FPS\"}]," \ "B=[{data:\"\",label:npL(\"Automatic\",\"自动\",\"Automatico\")}," \ "{data:\"d3dmetal\",label:\"D3DMetal\"}," \ "{data:\"dxmt\",label:\"DXMT\"}," \ @@ -1382,13 +1389,19 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "npCx&&(0," RT ".jsxs)(" BARREL ".XY,{label:npL(\"Graphics\",\"图形\",\"Grafica\"),children:[" \ "(0," RT ".jsx)(" BARREL ".m,{rgOptions:B,selectedOption:b," \ "onChange:v=>s([[\"CX_GRAPHICS_BACKEND\",w(\"CX_GRAPHICS_BACKEND\",v.data||\"auto\",\"auto\")]]" \ - ".concat(!clearUnused||\"\"===v.data||\"d3dmetal\"===v.data?[]:[[\"D3DM_ENABLE_METALFX\",\"\"]])" \ + ".concat(!clearUnused||\"\"===v.data||\"d3dmetal\"===v.data?[]:[[\"D3DM_ENABLE_METALFX\",\"\"]" \ + ",[\"D3DM_SUPPORT_DXR\",\"\"]" \ + ",[\"D3DM_MTL4\",\"\"]" \ + ",[\"D3DM_MAX_FPS\",\"\"]" \ + "])" \ ".concat(!clearUnused||\"\"===v.data||\"dxmt\"===v.data?[]:" \ "[[\"DXMT_METALFX_SPATIAL_SWAPCHAIN\",\"\"],[\"DXMT_CONFIG\",fx(\"\")]])" \ ".concat(!clearUnused||\"dxmt\"===v.data?[]:[[\"DXMT_ENABLE_NVEXT\",\"\"]]))})," \ "T(\"MTL_HUD_ENABLED\",npL(\"Metal HUD\",\"Metal 性能监视器\",\"Metal HUD\"))," \ "dm&&T(\"D3DM_ENABLE_METALFX\",\"DLSS\")," \ "\"dxmt\"===b&&T(\"DXMT_ENABLE_NVEXT\",\"DLSS\")," \ + "dm&&T(\"D3DM_SUPPORT_DXR\",npL(\"DirectX Raytracing (DXR)\",\"DirectX 光线追踪 (DXR)\",\"DirectX Raytracing (DXR)\"))," \ + "dm&&D(\"D3DM_MTL4\",npL(\"Metal 4 Backend\",\"Metal 4 后端\",\"Backend Metal 4\"))," \ "!fex&&D(\"ROSETTA_ADVERTISE_AVX\",npL(\"Advertise AVX2 to Rosetta\",\"向 Rosetta 声明 AVX2 支持\",\"Segnala AVX2 a Rosetta\"))," \ "T(\"WINEMSYNC\",\"MSync\")," \ "T(\"NOTPROTON_RETINA\",npL(\"High Resolution\",\"高分辨率\",\"Alta risoluzione\"))," \ @@ -1396,6 +1409,11 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "(0," RT ".jsx)(" BARREL ".Yh,{className:\"MSCXRow\",label:npL(\"Don't Scale Windows DPI (Fixes games that only offer low resolutions when High Resolution is on)\",\"Don't Scale Windows DPI (Fixes games that only offer low resolutions when High Resolution is on)\",\"Don't Scale Windows DPI (Fixes games that only offer low resolutions when High Resolution is on)\")," \ "checked:\"0\"===G(\"NOTPROTON_RETINA_DPI\"),onChange:v=>s([[\"NOTPROTON_RETINA_DPI\",w(\"NOTPROTON_RETINA_DPI\",v?\"0\":\"1\",\"1\")]])},\"NOTPROTON_RETINA_DPI\")" \ "]},\"gfx\")," \ + "npCx&&dm&&(0," RT ".jsx)(" BARREL ".XY," \ + "{label:npL(\"D3DMetal Frame Rate Limit\",\"D3DMetal 帧率限制\",\"Limite fotogrammi D3DMetal\")," \ + "children:(0," RT ".jsx)(" BARREL ".m,{rgOptions:M," \ + "selectedOption:(M.find(m=>m.data===G(\"D3DM_MAX_FPS\"))||{data:\"\"}).data," \ + "onChange:v=>s([[\"D3DM_MAX_FPS\",w(\"D3DM_MAX_FPS\",v.data,\"\")]])})},\"fps\")," \ "npCx&&dx&&(0," RT ".jsx)(" BARREL ".XY," \ "{label:npL(\"MetalFX Upscaling (Samples from the resolution the game is set to)\"," \ "\"MetalFX 超分辨率(以游戏设置的分辨率为采样来源)\",\"Upscaling MetalFX (ingrandisce l'immagine dalla risoluzione del gioco)\")," \ diff --git a/dylib/tests/defaults-check.c b/dylib/tests/defaults-check.c index 9e4e544d..bfd1737d 100644 --- a/dylib/tests/defaults-check.c +++ b/dylib/tests/defaults-check.c @@ -82,6 +82,14 @@ static void valid_cases(void) { "Automatic and unknown backends are not saved"); check(!valid(NP_DEFAULTS_FEX, "ROSETTA_ADVERTISE_AVX=1") && !valid(NP_DEFAULTS_FEX, "D3DM_ENABLE_METALFX=1") && + !valid(NP_DEFAULTS_FEX, "D3DM_SUPPORT_DXR=1") && + !valid(NP_DEFAULTS_FEX, "D3DM_MTL4=0") && + !valid(NP_DEFAULTS_FEX, "D3DM_MAX_FPS=60") && + valid(NP_DEFAULTS_ROSETTA, "D3DM_SUPPORT_DXR=1") && + valid(NP_DEFAULTS_ROSETTA, "D3DM_MTL4=0") && + valid(NP_DEFAULTS_ROSETTA, "D3DM_MAX_FPS=60") && + !valid(NP_DEFAULTS_ROSETTA, "D3DM_MAX_FPS=45") && + !valid(NP_DEFAULTS_ROSETTA, "D3DM_MAX_FPS=abc") && !valid(NP_DEFAULTS_ROSETTA, "FEX_X87REDUCEDPRECISION=1") && valid(NP_DEFAULTS_FEX, "FEX_X87REDUCEDPRECISION=0"), "runner-only rows stay with their runner"); diff --git a/dylib/tests/defaults-drift.sh b/dylib/tests/defaults-drift.sh index 32d57552..1da5ae75 100644 --- a/dylib/tests/defaults-drift.sh +++ b/dylib/tests/defaults-drift.sh @@ -72,12 +72,14 @@ panel_rosetta_backends=$(printf '%s\n' "$panel_src" | grep -oE 'x\.data!==\\"[a- c_backends="$(field values CX_GRAPHICS_BACKEND) $(field rosetta-values CX_GRAPHICS_BACKEND)" c_rosetta_backends=$(field rosetta-values CX_GRAPHICS_BACKEND) c_factors=$(field values DXMT_CONFIG) +c_fps=$(field values D3DM_MAX_FPS) same "backends in defaults.c and compat_run.sh" "$c_backends" "$sh_backends" same "backends in defaults.c and the panel" "$c_backends" "$(panel_list B)" same "Rosetta-only backends in defaults.c and compat_run.sh" "$c_rosetta_backends" "$sh_rosetta_backends" same "Rosetta-only backends in defaults.c and the panel" "$c_rosetta_backends" "$panel_rosetta_backends" same "upscale factors in defaults.c and compat_run.sh" "$c_factors" "$sh_factors" same "upscale factors in defaults.c and the panel" "$c_factors" "$(panel_list U)" +same "frame rate limits in defaults.c and the panel" "$c_fps" "$(panel_list M)" if [ "$fail" -ne 0 ]; then exit 1 diff --git a/dylib/tests/envcheck.sh b/dylib/tests/envcheck.sh index 45b72646..572ccdad 100755 --- a/dylib/tests/envcheck.sh +++ b/dylib/tests/envcheck.sh @@ -108,21 +108,21 @@ forward_at=$(grep -n '^ *for name in $(env .*CX_APPLEGPTK' "$SRC" | cut -d: -f1) seen() { sh -c 'env | sed -nE "s/^(CX_GRAPHICS_BACKEND|D3DM_[A-Z0-9_]*|DXMT_[A-Z0-9_]*|ROSETTA_[A-Z0-9_]*)=.*/\1/p" | sort | tr "\n" " "'; } for name in $(seen); do unset -v "$name"; done CX_ROOT="/R R" -set_all() { export CX_GRAPHICS_BACKEND=d3dmetal D3DM_ENABLE_METALFX=1 ROSETTA_ADVERTISE_AVX=0 DXMT_CONFIG=x; } +set_all() { export CX_GRAPHICS_BACKEND=d3dmetal D3DM_ENABLE_METALFX=1 D3DM_SUPPORT_DXR=1 D3DM_MTL4=0 D3DM_MAX_FPS=60 ROSETTA_ADVERTISE_AVX=0 DXMT_CONFIG=x; } set_all wine_unix="/R R/lib/wine/x86_64-unix" eval "$drop" is "x86_64 Wine keeps every setting" \ - "CX_GRAPHICS_BACKEND D3DM_ENABLE_METALFX DXMT_CONFIG ROSETTA_ADVERTISE_AVX " "$(seen)" + "CX_GRAPHICS_BACKEND D3DM_ENABLE_METALFX D3DM_MAX_FPS D3DM_MTL4 D3DM_SUPPORT_DXR DXMT_CONFIG ROSETTA_ADVERTISE_AVX " "$(seen)" is "x86_64 Wine logs nothing" "" "$np_dropped" wine_unix="/R R/lib/wine/aarch64-unix" eval "$drop" is "D3DMetal and Rosetta settings leave the environment" "DXMT_CONFIG " "$(seen)" is "the run log names what was ignored" \ - "CX_GRAPHICS_BACKEND=d3dmetal D3DM_ENABLE_METALFX ROSETTA_ADVERTISE_AVX" "$np_dropped" + "CX_GRAPHICS_BACKEND=d3dmetal D3DM_ENABLE_METALFX D3DM_MAX_FPS D3DM_MTL4 D3DM_SUPPORT_DXR ROSETTA_ADVERTISE_AVX" "$np_dropped" set_all export CX_GRAPHICS_BACKEND=dxmt -unset -v D3DM_ENABLE_METALFX ROSETTA_ADVERTISE_AVX +unset -v D3DM_ENABLE_METALFX D3DM_SUPPORT_DXR D3DM_MTL4 D3DM_MAX_FPS ROSETTA_ADVERTISE_AVX eval "$drop" is "other backends stay" "CX_GRAPHICS_BACKEND DXMT_CONFIG " "$(seen)" is "nothing to ignore logs nothing" "" "$np_dropped" diff --git a/dylib/tests/panel-behavior/behavior.js b/dylib/tests/panel-behavior/behavior.js index d58c3368..dea683ae 100644 --- a/dylib/tests/panel-behavior/behavior.js +++ b/dylib/tests/panel-behavior/behavior.js @@ -158,8 +158,8 @@ for (const form of Object.keys(FORMS)) { } let r = nodes(''); - t.ok(toggles(r).length === 8 && sections(r).length === 5, 'automatic shows eight toggles in five sections'); - t.ok(r.filter(x => x.type === 'Dropdown').length === 3, 'three dropdowns'); + t.ok(toggles(r).length === 10 && sections(r).length === 6, 'automatic shows ten toggles in six sections'); + t.ok(r.filter(x => x.type === 'Dropdown').length === 4, 'four dropdowns'); t.ok(!('label' in r.find(x => x.type === 'Dropdown').props), 'the backend dropdown carries no label column'); r = nodes('CX_GRAPHICS_BACKEND=dxmt'); @@ -235,6 +235,21 @@ for (const form of Object.keys(FORMS)) { .props.rgOptions.map(o => o.label); t.ok(factors.join(',') === 'Off,1.5x,1.72x,2x,3x', `the factors name themselves (${factors.join(',')})`); + const d3dmFpsOptions = nodes('').find(x => x.type === 'Dropdown' && x.props.rgOptions.some(o => o.data === '90')) + .props.rgOptions.map(o => o.label); + t.ok(d3dmFpsOptions.join(',') === 'Off,30 FPS,60 FPS,90 FPS,120 FPS,144 FPS,240 FPS', `the d3dm fps options name themselves (${d3dmFpsOptions.join(',')})`); + written.length = 0; + nodes('').find(x => x.type === 'Dropdown' && x.props.rgOptions.some(o => o.data === '90')).props.onChange({ data: '60' }); + t.ok(last().includes('D3DM_MAX_FPS=60'), `the fps cap writes its value (${last()})`); + + written.length = 0; + nodes('').find(x => x.props.label === 'DirectX Raytracing (DXR)').props.onChange(true); + t.ok(last().includes('D3DM_SUPPORT_DXR=1'), `DXR toggle writes D3DM_SUPPORT_DXR=1 (${last()})`); + + written.length = 0; + nodes('').find(x => x.props.label === 'Metal 4 Backend').props.onChange(false); + t.ok(last().includes('D3DM_MTL4=0'), `Metal 4 toggle writes D3DM_MTL4=0 (${last()})`); + // The value is the name wine gives the library, which is lower case, and the label is // the name in a list of four the reader picks from. const backends = nodes('').find(x => x.type === 'Dropdown').props.rgOptions; @@ -246,7 +261,7 @@ for (const form of Object.keys(FORMS)) { const root = P({ details: details('') }); t.ok(root.props.className === 'MSCXPanel', 'the panel root carries its own class'); const classes = nodes('').filter(x => x.type === 'Toggle').map(x => x.props.className); - t.ok(classes.length === 8 && classes.every(c => c === 'MSCXRow'), 'every checkbox carries the row class'); + t.ok(classes.length === 10 && classes.every(c => c === 'MSCXRow'), 'every checkbox carries the row class'); const raw = o => nodes(o).find(x => x.props.label === 'Let Games Read Controllers Directly (May break Steam Input. Not recommended)'); t.ok(sections(nodes('')).includes('Controllers') && raw('').props.checked === false, 'controller hiding is on unless the game says otherwise'); diff --git a/dylib/tests/panel-behavior/fex.js b/dylib/tests/panel-behavior/fex.js index e095de21..551c0c60 100644 --- a/dylib/tests/panel-behavior/fex.js +++ b/dylib/tests/panel-behavior/fex.js @@ -120,7 +120,7 @@ for (const form of Object.keys(FORMS)) { pick(details(opts), tool); return written.length ? written[written.length - 1].opts : null; }; - const all = 'WINEDEBUG=-all CX_GRAPHICS_BACKEND=d3dmetal D3DM_ENABLE_METALFX=1 MTL_HUD_ENABLED=1 ' + + const all = 'WINEDEBUG=-all CX_GRAPHICS_BACKEND=d3dmetal D3DM_ENABLE_METALFX=1 D3DM_SUPPORT_DXR=1 D3DM_MTL4=0 D3DM_MAX_FPS=60 MTL_HUD_ENABLED=1 ' + "ROSETTA_ADVERTISE_AVX=0 FEX_X87REDUCEDPRECISION=0 DXMT_CONFIG='a;b' %command% -novid"; const r = choose(all, 'notproton-fex'); t.ok(r === "WINEDEBUG=-all MTL_HUD_ENABLED=1 FEX_X87REDUCEDPRECISION=0 DXMT_CONFIG='a;b' %command% -novid", diff --git a/dylib/tests/panel-behavior/shell.js b/dylib/tests/panel-behavior/shell.js index 29e438be..e573e78e 100644 --- a/dylib/tests/panel-behavior/shell.js +++ b/dylib/tests/panel-behavior/shell.js @@ -13,11 +13,14 @@ const TOGGLES = { 'MSync': 'WINEMSYNC', 'High Resolution': 'NOTPROTON_RETINA', 'Advertise AVX2 to Rosetta': 'ROSETTA_ADVERTISE_AVX', + 'DirectX Raytracing (DXR)': 'D3DM_SUPPORT_DXR', + 'Metal 4 Backend': 'D3DM_MTL4', 'Let Games Read Controllers Directly (May break Steam Input. Not recommended)': 'NOTPROTON_RAW_CONTROLLERS', 'Disable hidraw': 'NOTPROTON_DISABLE_HIDRAW', }; -const ON_UNLESS_0 = { ROSETTA_ADVERTISE_AVX: true }; -const PANEL_KEYS = ['CX_GRAPHICS_BACKEND', 'MTL_HUD_ENABLED', 'D3DM_ENABLE_METALFX', 'DXMT_ENABLE_NVEXT', +const ON_UNLESS_0 = { ROSETTA_ADVERTISE_AVX: true, D3DM_MTL4: true }; +const PANEL_KEYS = ['CX_GRAPHICS_BACKEND', 'MTL_HUD_ENABLED', 'D3DM_ENABLE_METALFX', 'D3DM_SUPPORT_DXR', + 'D3DM_MTL4', 'D3DM_MAX_FPS', 'DXMT_ENABLE_NVEXT', 'DXMT_METALFX_SPATIAL_SWAPCHAIN', 'DXMT_CONFIG', 'ROSETTA_ADVERTISE_AVX', 'WINEMSYNC', 'NOTPROTON_RETINA', 'NOTPROTON_RAW_CONTROLLERS', 'NOTPROTON_DISABLE_HIDRAW']; const OTHER_KEYS = ['WINEDEBUG', 'DXVK_HUD', 'FOO', 'SteamAppId', 'STEAM_COMPAT_DATA_PATH']; diff --git a/dylib/tests/panel-behavior/switching.js b/dylib/tests/panel-behavior/switching.js index 3f32dd46..c8c884a4 100644 --- a/dylib/tests/panel-behavior/switching.js +++ b/dylib/tests/panel-behavior/switching.js @@ -9,10 +9,12 @@ if (!emit) { console.error('usage: switching.js '); process.exit(2); } const DXMT = ['DXMT_METALFX_SPATIAL_SWAPCHAIN', 'DXMT_CONFIG']; const D3DM = ['D3DM_ENABLE_METALFX']; +const D3DM_ALL = ['D3DM_ENABLE_METALFX', 'D3DM_SUPPORT_DXR', 'D3DM_MTL4', 'D3DM_MAX_FPS']; const NVEXT = ['DXMT_ENABLE_NVEXT']; const DLSS_ON = 'CX_GRAPHICS_BACKEND=dxmt DXMT_ENABLE_NVEXT=1'; const UPSCALED = 'CX_GRAPHICS_BACKEND=dxmt DXMT_METALFX_SPATIAL_SWAPCHAIN=1' + ' DXMT_CONFIG=d3d11.metalSpatialUpscaleFactor=2.0'; +const D3DM_FULL = 'CX_GRAPHICS_BACKEND=d3dmetal D3DM_ENABLE_METALFX=1 D3DM_SUPPORT_DXR=1 D3DM_MTL4=0 D3DM_MAX_FPS=60'; // Whichever backend is chosen, the arguments the others own have to be gone and the // arguments no backend owns have to survive. @@ -22,9 +24,11 @@ const CASES = [ { to: 'wined3d', from: UPSCALED, gone: DXMT, kept: [] }, { to: 'dxmt', from: 'CX_GRAPHICS_BACKEND=d3dmetal D3DM_ENABLE_METALFX=1', gone: D3DM, kept: [] }, { to: 'dxvk', from: 'CX_GRAPHICS_BACKEND=d3dmetal D3DM_ENABLE_METALFX=1', gone: D3DM, kept: [] }, + { to: 'dxmt', from: D3DM_FULL, gone: D3DM_ALL, kept: [] }, // Automatic resolves to either backend, so neither one's arguments are stale. { to: '', from: UPSCALED, gone: [], kept: DXMT }, { to: '', from: 'CX_GRAPHICS_BACKEND=d3dmetal D3DM_ENABLE_METALFX=1', gone: [], kept: D3DM }, + { to: '', from: D3DM_FULL, gone: [], kept: D3DM_ALL }, // The dxmt DLSS row is the exception: it shows under dxmt alone, so automatic has // nowhere to switch the flag off and has to take it along. { to: '', from: DLSS_ON, gone: NVEXT, kept: [] }, diff --git a/dylib/version.h b/dylib/version.h index 62f10909..bb1ffdc3 100644 --- a/dylib/version.h +++ b/dylib/version.h @@ -1,4 +1,6 @@ -// Build version string +// CrossOver compatibility tool for Steam on macOS +// Copyright © 2024-2026 NotProton contributors +// SPDX-License-Identifier: MIT #ifndef NOTPROTON_VERSION_H #define NOTPROTON_VERSION_H From c5c498e254447364f6e19e9c3156d00d20f124ce Mon Sep 17 00:00:00 2001 From: rapheljr <86885009+rapheljr@users.noreply.github.com> Date: Sat, 31 Oct 2026 16:10:06 +0530 Subject: [PATCH 2/5] Add install-d3dmetal tool, D3DMetal variant descriptor support, and variant tests --- .../NotProtonApp/Model/D3DMetalTools.swift | 189 +++++++++ .../NotProtonApp/Support/SupportPaths.swift | 1 + .../D3DMetalToolsTests.swift | 73 ++++ dylib/version.h | 4 +- examples/variants/d3dmetal-example.conf | 22 + tools/install-d3dmetal.sh | 377 ++++++++++++++++++ 6 files changed, 663 insertions(+), 3 deletions(-) create mode 100644 examples/variants/d3dmetal-example.conf create mode 100755 tools/install-d3dmetal.sh diff --git a/app/Sources/NotProtonApp/Model/D3DMetalTools.swift b/app/Sources/NotProtonApp/Model/D3DMetalTools.swift index f7b2f49d..ee1599ae 100644 --- a/app/Sources/NotProtonApp/Model/D3DMetalTools.swift +++ b/app/Sources/NotProtonApp/Model/D3DMetalTools.swift @@ -514,4 +514,193 @@ enum D3DMetalTools: Sendable { shaderCachesCleared: cleared ) } + + public struct D3DMetalVariant: Sendable, Equatable, Identifiable { + public var id: String { name } + public let name: String + public let label: String + public let src: URL + public let version: String? + public let confURL: URL + + public init(name: String, label: String, src: URL, version: String?, confURL: URL) { + self.name = name + self.label = label + self.src = src + self.version = version + self.confURL = confURL + } + } + + /// Installs D3DMetal as a runner variant (variants/.conf) conforming to NotProton's variant grammar. + @discardableResult + public static func installVariant( + from source: URL, + name: String? = nil, + label: String? = nil, + supportDir: URL = SupportPaths.support, + cacheDir: URL? = cacheDirectory() + ) throws -> D3DMetalVariant { + let step = String(localized: "Install D3DMetal Variant") + guard let d3dmSource = resolveSource(at: source) else { + throw StepFailure( + step: step, + detail: String(localized: "\(source.lastPathComponent) does not contain D3DMetal libraries (expected external and wine folders).") + ) + } + + let ver = d3dMetalVersion(atLibRoot: d3dmSource) ?? versionFromFilename(source.lastPathComponent) ?? "custom" + let variantName: String + if let customName = name, !customName.isEmpty { + variantName = customName + } else { + variantName = "d3dmetal-\(ver)" + } + + let variantsDir = supportDir.appending(path: "variants") + let fm = FileManager.default + if !fm.fileExists(atPath: variantsDir.path(percentEncoded: false)) { + try fm.createDirectory(at: variantsDir, withIntermediateDirectories: true) + } + + let targetDir = variantsDir.appending(path: variantName) + let extDst = targetDir.appending(path: "external") + let wineDst = targetDir.appending(path: "wine") + + try? fm.removeItem(at: targetDir) + try fm.createDirectory(at: targetDir, withIntermediateDirectories: true) + + try fm.copyItem(at: d3dmSource.appending(path: "external"), to: extDst) + try fm.copyItem(at: d3dmSource.appending(path: "wine"), to: wineDst) + + // Prepare MetalFX integration if nvngx.dll not present + let winDir = wineDst.appending(path: "x86_64-windows") + let nvngxWinSrc = winDir.appending(path: "nvngx-on-metalfx.dll") + let nvngxWinDst = winDir.appending(path: "nvngx.dll") + if fm.fileExists(atPath: nvngxWinSrc.path(percentEncoded: false)) && !fm.fileExists(atPath: nvngxWinDst.path(percentEncoded: false)) { + try? fm.copyItem(at: nvngxWinSrc, to: nvngxWinDst) + } + + let unixDir = wineDst.appending(path: "x86_64-unix") + let nvngxUnixSrc = unixDir.appending(path: "nvngx-on-metalfx.so") + let nvngxUnixDst = unixDir.appending(path: "nvngx.so") + if fm.fileExists(atPath: nvngxUnixSrc.path(percentEncoded: false)) && !fm.fileExists(atPath: nvngxUnixDst.path(percentEncoded: false)) { + try? fm.copyItem(at: nvngxUnixSrc, to: nvngxUnixDst) + } + + let confURL = variantsDir.appending(path: "\(variantName).conf") + let displayLabel = label ?? "D3DMetal \(ver)" + let confContent = """ + # Written by NotProton. The runner is assembled from src at launch. + [\(variantName)] + variant = D3DMetal + src = variants/\(variantName) + dst = lib64/apple_gptk + label = \(displayLabel) + + """ + try confContent.write(to: confURL, atomically: true, encoding: .utf8) + + // Invalidate shader cache + _ = try? clearAllShaderCaches(cacheDir: cacheDir) + + return D3DMetalVariant( + name: variantName, + label: displayLabel, + src: targetDir, + version: ver, + confURL: confURL + ) + } + + /// Lists all installed D3DMetal variants in the variants directory. + public static func installedVariants(supportDir: URL = SupportPaths.support) -> [D3DMetalVariant] { + let variantsDir = supportDir.appending(path: "variants") + guard let files = try? FileManager.default.contentsOfDirectory(at: variantsDir, includingPropertiesForKeys: nil) else { + return [] + } + + var variants: [D3DMetalVariant] = [] + for file in files where file.pathExtension == "conf" { + guard let content = try? String(contentsOf: file, encoding: .utf8) else { continue } + let lines = content.components(separatedBy: .newlines) + var currentID: String? + var currentLabel: String? + var currentSrc: String? + var currentDst: String? + var currentGroup: String? + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if trimmed.hasPrefix("[") && trimmed.hasSuffix("]") { + currentID = String(trimmed.dropFirst().dropLast()).trimmingCharacters(in: .whitespaces) + } else if trimmed.hasPrefix("variant") && trimmed.contains("=") { + currentGroup = trimmed.split(separator: "=", maxSplits: 1)[1].trimmingCharacters(in: .whitespaces) + } else if trimmed.hasPrefix("label") && trimmed.contains("=") { + currentLabel = trimmed.split(separator: "=", maxSplits: 1)[1].trimmingCharacters(in: .whitespaces) + } else if trimmed.hasPrefix("src") && trimmed.contains("=") { + currentSrc = trimmed.split(separator: "=", maxSplits: 1)[1].trimmingCharacters(in: .whitespaces) + } else if trimmed.hasPrefix("dst") && trimmed.contains("=") { + currentDst = trimmed.split(separator: "=", maxSplits: 1)[1].trimmingCharacters(in: .whitespaces) + } + } + + guard let id = currentID, (currentGroup == "D3DMetal" || currentDst?.contains("apple_gptk") == true) else { + continue + } + + let srcURL: URL + if let src = currentSrc { + if src.hasPrefix("/") { + srcURL = URL(filePath: src) + } else if src.hasPrefix("~/") { + srcURL = URL(filePath: NSString(string: src).expandingTildeInPath) + } else { + srcURL = supportDir.appending(path: src) + } + } else { + srcURL = variantsDir.appending(path: id) + } + + let ver = d3dMetalVersion(atLibRoot: srcURL) + variants.append(D3DMetalVariant( + name: id, + label: currentLabel ?? id, + src: srcURL, + version: ver, + confURL: file + )) + } + + return variants.sorted { $0.name < $1.name } + } + + /// Removes a D3DMetal variant descriptor, its tree (if located in variants dir), and any assembled runners. + public static func removeVariant( + name: String, + supportDir: URL = SupportPaths.support, + runners: URL = SupportPaths.runners + ) throws { + let fm = FileManager.default + let variantsDir = supportDir.appending(path: "variants") + let confURL = variantsDir.appending(path: "\(name).conf") + let targetDir = variantsDir.appending(path: name) + + if fm.fileExists(atPath: confURL.path(percentEncoded: false)) { + try? fm.removeItem(at: confURL) + } + if fm.fileExists(atPath: targetDir.path(percentEncoded: false)) { + try? fm.removeItem(at: targetDir) + } + + // Remove any runners assembled with this variant: crossover-+... + if let entries = try? fm.contentsOfDirectory(at: runners, includingPropertiesForKeys: nil) { + for entry in entries { + let filename = entry.lastPathComponent + if filename.contains("+\(name)") { + try? fm.removeItem(at: entry) + } + } + } + } } diff --git a/app/Sources/NotProtonApp/Support/SupportPaths.swift b/app/Sources/NotProtonApp/Support/SupportPaths.swift index 512eccd6..0cfbe7cb 100644 --- a/app/Sources/NotProtonApp/Support/SupportPaths.swift +++ b/app/Sources/NotProtonApp/Support/SupportPaths.swift @@ -22,6 +22,7 @@ enum SupportPaths { static var bridge: URL { support.appending(path: "bridge") } static var runners: URL { support.appending(path: "runners") } + static var variants: URL { support.appending(path: "variants") } static var backups: URL { support.appending(path: "backups") } static var launchers: URL { support.appending(path: "launchers") } diff --git a/app/Tests/NotProtonAppTests/D3DMetalToolsTests.swift b/app/Tests/NotProtonAppTests/D3DMetalToolsTests.swift index 95aaafae..636089f3 100644 --- a/app/Tests/NotProtonAppTests/D3DMetalToolsTests.swift +++ b/app/Tests/NotProtonAppTests/D3DMetalToolsTests.swift @@ -268,4 +268,77 @@ struct D3DMetalToolsTests { #expect(D3DMetalTools.versionFromFilename("Game_Porting_Toolkit_1.0.dmg") == "1.0") #expect(D3DMetalTools.versionFromFilename("random_file.dmg") == nil) } + + @Test("Install, list, and remove D3DMetal variant") + func d3dMetalVariantLifecycle() throws { + let tempDir = try createTempDir() + defer { try? FileManager.default.removeItem(at: tempDir) } + + let supportDir = tempDir.appending(path: "notproton") + let runnersDir = supportDir.appending(path: "runners") + let sourceDir = tempDir.appending(path: "source_gptk") + + // Create dummy source tree + let srcExt = sourceDir.appending(path: "external/D3DMetal.framework/Versions/A/Resources") + let srcWineWin = sourceDir.appending(path: "wine/x86_64-windows") + let srcWineUnix = sourceDir.appending(path: "wine/x86_64-unix") + try FileManager.default.createDirectory(at: srcExt, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: srcWineWin, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: srcWineUnix, withIntermediateDirectories: true) + + let plist = """ + + + + + CFBundleShortVersionString + 4.0b3 + + + """ + try plist.write(to: srcExt.appending(path: "version.plist"), atomically: true, encoding: .utf8) + try "dummy-dll".write(to: srcWineWin.appending(path: "d3d12.dll"), atomically: true, encoding: .utf8) + try "dummy-so".write(to: srcWineUnix.appending(path: "d3d12.so"), atomically: true, encoding: .utf8) + try "metalfx-dll".write(to: srcWineWin.appending(path: "nvngx-on-metalfx.dll"), atomically: true, encoding: .utf8) + + // Install variant + let variant = try D3DMetalTools.installVariant( + from: sourceDir, + name: "d3dmetal-4.0b3", + label: "D3DMetal 4.0 Beta 3", + supportDir: supportDir + ) + + #expect(variant.name == "d3dmetal-4.0b3") + #expect(variant.label == "D3DMetal 4.0 Beta 3") + #expect(variant.version == "4.0b3") + #expect(FileManager.default.fileExists(atPath: variant.confURL.path(percentEncoded: false))) + + // Verify conf content + let confContent = try String(contentsOf: variant.confURL, encoding: .utf8) + #expect(confContent.contains("[d3dmetal-4.0b3]")) + #expect(confContent.contains("variant = D3DMetal")) + #expect(confContent.contains("dst = lib64/apple_gptk")) + + // Verify metalfx was prepared + let winTarget = variant.src.appending(path: "wine/x86_64-windows") + #expect(FileManager.default.fileExists(atPath: winTarget.appending(path: "nvngx.dll").path(percentEncoded: false))) + + // List variants + let installed = D3DMetalTools.installedVariants(supportDir: supportDir) + #expect(installed.count == 1) + #expect(installed[0].name == "d3dmetal-4.0b3") + #expect(installed[0].label == "D3DMetal 4.0 Beta 3") + + // Create dummy assembled runner clone + let runnerClone = runnersDir.appending(path: "crossover-26.3+d3dmetal-4.0b3@abc123") + try FileManager.default.createDirectory(at: runnerClone, withIntermediateDirectories: true) + + // Remove variant + try D3DMetalTools.removeVariant(name: "d3dmetal-4.0b3", supportDir: supportDir, runners: runnersDir) + #expect(!FileManager.default.fileExists(atPath: variant.confURL.path(percentEncoded: false))) + #expect(!FileManager.default.fileExists(atPath: variant.src.path(percentEncoded: false))) + #expect(!FileManager.default.fileExists(atPath: runnerClone.path(percentEncoded: false))) + #expect(D3DMetalTools.installedVariants(supportDir: supportDir).isEmpty) + } } diff --git a/dylib/version.h b/dylib/version.h index bb1ffdc3..62f10909 100644 --- a/dylib/version.h +++ b/dylib/version.h @@ -1,6 +1,4 @@ -// CrossOver compatibility tool for Steam on macOS -// Copyright © 2024-2026 NotProton contributors -// SPDX-License-Identifier: MIT +// Build version string #ifndef NOTPROTON_VERSION_H #define NOTPROTON_VERSION_H diff --git a/examples/variants/d3dmetal-example.conf b/examples/variants/d3dmetal-example.conf new file mode 100644 index 00000000..edd538da --- /dev/null +++ b/examples/variants/d3dmetal-example.conf @@ -0,0 +1,22 @@ +# A D3DMetal runner variant from Apple's Game Porting Toolkit evaluation environment. +# +# The header is the variant's id: picking this one writes +# NOTPROTON_RUNNER_VARIANT=d3dmetal-example into the game's launch options, and variants from +# different groups combine, sorted, as "a+b". +# +# In Steam's Developer section for the game, all records sharing variant = D3DMetal +# become a dropdown offering Default (stock CrossOver) plus each configured D3DMetal version. +# +# label what the dropdown shows for this entry +# variant the group it belongs to (D3DMetal) +# src the folder holding external/ and wine/ +# dst where in the runner it goes (lib64/apple_gptk) +# +# Drop this in ~/Library/Application Support/notproton/variants/ with "src" pointing at a +# D3DMetal build or extracted evaluation environment. +[d3dmetal-example] +label = GPTK 4.0 Beta 3 +variant = D3DMetal +src = variants/d3dmetal-4.0b3 +dst = lib64/apple_gptk + diff --git a/tools/install-d3dmetal.sh b/tools/install-d3dmetal.sh new file mode 100755 index 00000000..15aed00e --- /dev/null +++ b/tools/install-d3dmetal.sh @@ -0,0 +1,377 @@ +#!/bin/sh +# Point NotProton at a D3DMetal build or Apple Game Porting Toolkit evaluation environment. +# The descriptor this writes names a tree, and the runner is assembled from it at launch +# whenever a game asks for it. +# +# ./install-d3dmetal.sh [name] verify/extract a tree and write its descriptor +# ./install-d3dmetal.sh --list descriptors, and whether their trees still verify +# ./install-d3dmetal.sh --remove drop a descriptor and the runners built from it +# ./install-d3dmetal.sh --clean-shaders [game] clear DirectX 12 shader cache +# +# A D3DMetal tree is shaped the way Apple's evaluation environment installs: +# external/ (D3DMetal.framework and libd3dshared.dylib) alongside +# wine/ (x86_64-windows/*.dll and x86_64-unix/*.so). +set -eu + +np_support="$HOME/Library/Application Support/notproton" +runners="$np_support/runners" +conf_dir="$np_support/variants" +variants_dir="$np_support/variants" +dst="lib64/apple_gptk" + +usage() { + echo "usage: $0 [name]" >&2 + echo " $0 --list" >&2 + echo " $0 --remove " >&2 + echo " $0 --clean-shaders [game]" >&2 + exit 2 +} + +die() { echo "==> $*" >&2; exit 1; } +say() { echo "==> $*"; } + +is_builtin() { + [ "$(dd if="$1" bs=1 skip=64 count=16 2>/dev/null)" = "Wine builtin DLL" ] +} + +# Checks a tree the way the launch path will. Prints what is wrong and returns 1. +verify_src() { + src=$1 + bad=0 + [ -d "$src" ] || { say "nothing at $src"; return 1; } + + # D3DMetal requires external/ (framework/dylib) and wine/ + [ -d "$src/external" ] || { say "$src has no external/ directory"; bad=1; } + [ -d "$src/wine" ] || { say "$src has no wine/ directory"; bad=1; } + + if [ "$bad" -eq 1 ]; then return 1; fi + + # Must contain d3d12.dll marked as Wine builtin + if [ ! -f "$src/wine/x86_64-windows/d3d12.dll" ]; then + say "$src is missing wine/x86_64-windows/d3d12.dll" + bad=1 + elif ! is_builtin "$src/wine/x86_64-windows/d3d12.dll"; then + say "$src/wine/x86_64-windows/d3d12.dll is not marked as a Wine builtin" + bad=1 + fi + + # Must contain d3d12.so + if [ ! -f "$src/wine/x86_64-unix/d3d12.so" ]; then + say "$src is missing wine/x86_64-unix/d3d12.so" + bad=1 + fi + + # Every directory in the tree has to exist in a runner. + for base in "$runners"/crossover-*/; do + base=${base%/} + case "${base##*/}" in *+*) continue ;; esac + [ -d "$base/CrossOver/$dst" ] || continue + lands=0 + if [ -d "$base/CrossOver/$dst/external" ] && [ -d "$base/CrossOver/$dst/wine" ]; then + lands=1 + fi + [ "$lands" -eq 1 ] || { say "$(basename "$base") has no $dst, so this variant will be skipped"; bad=1; } + break + done + + [ "$bad" -eq 0 ] +} + +d3dmetal_version() { + src=$1 + for plist in \ + "$src/external/D3DMetal.framework/Versions/A/Resources/version.plist" \ + "$src/external/D3DMetal.framework/Versions/A/Resources/Info.plist" \ + "$src/external/D3DMetal.framework/Resources/version.plist"; do + [ -f "$plist" ] || continue + ver=$(/usr/libexec/PlistBuddy -c "Print :CFBundleShortVersionString" "$plist" 2>/dev/null || \ + /usr/libexec/PlistBuddy -c "Print :CFBundleVersion" "$plist" 2>/dev/null || true) + if [ -n "$ver" ]; then + printf '%s' "$ver" + return 0 + fi + done +} + +clean_shaders() { + game=${1:-} + cache_base=$(getconf DARWIN_USER_CACHE_DIR 2>/dev/null || true) + [ -n "$cache_base" ] || cache_base="$HOME/Library/Caches" + d3dm="$cache_base/d3dm" + [ -d "$d3dm" ] || { say "no shader caches found at $d3dm"; return 0; } + + count=0 + if [ -n "$game" ]; then + target="$d3dm/$game/shaders.cache" + if [ -f "$target" ]; then + rm -f "$target" + say "cleared shader cache for $game ($target)" + count=1 + else + say "no shader cache found for $game at $target" + fi + else + for c in "$d3dm"/*/shaders.cache; do + [ -f "$c" ] || continue + rm -f "$c" + count=$((count + 1)) + done + say "cleared $count DirectX 12 shader cache(s) in $d3dm" + fi +} + +list_variants() { + found=0 + for conf in "$conf_dir"/*.conf; do + [ -r "$conf" ] || continue + written_here "$conf" || continue + records=$(awk ' + function trim(s) { gsub(/^[ \t\r\n]+|[ \t\r\n]+$/, "", s); return s } + /^[ \t]*\[.*\]/ { + if (id != "" && src != "") printf "%s\t%s\n", id, src + line = $0; sub(/^[ \t]*\[/, "", line); sub(/\][ \t\r]*$/, "", line) + id = trim(line); src = ""; next + } + id != "" && /^[ \t]*src[ \t]*=/ { + line = $0; sub(/^[ \t]*src[ \t]*=[ \t]*/, "", line) + if (src == "") src = trim(line) + } + END { if (id != "" && src != "") printf "%s\t%s\n", id, src } + ' "$conf") + [ -n "$records" ] || continue + while IFS="$(printf '\t')" read -r name src; do + [ -n "$name" ] || continue + found=1 + # shellcheck disable=SC2088,SC2016 + case "$src" in + (/*) ;; + ("~/"*) src="$HOME/${src#"~/"}" ;; + ('$HOME/'*) src="$HOME/${src#'$HOME/'}" ;; + (*) src="$HOME/$src" ;; + esac + if verify_src "$src" >/dev/null 2>&1; then state="ok"; else state="needs attention"; fi + printf ' %-28s %s\n' "$name" "$state" + printf ' %-28s %s\n' "" "$src" + done </dev/null | grep -q '^# Written by install-d3dmetal\.sh' +} + +remove_variant() { + name=$1 + case "$name" in + [!A-Za-z0-9]*|*[!A-Za-z0-9._-]*|"") die "$name is not a variant name" ;; + esac + conf="$conf_dir/$name.conf" + gone=0 + if [ -f "$conf" ] && written_here "$conf"; then + rm -f "$conf" + say "removed $conf" + gone=1 + elif [ -f "$conf" ]; then + say "$conf was not written by this script, so it is left for you" + fi + + # Also clean up variant storage directory if placed under variants_dir + vdir="$variants_dir/$name" + if [ -d "$vdir" ]; then + rm -rf "$vdir" + say "removed variant files at $vdir" + gone=1 + fi + + list=$(mktemp) || die "cannot make a temp file" + built_runners "$name" > "$list" + while IFS= read -r d; do + [ -n "$d" ] || continue + # shellcheck disable=SC2009 + if ps -axww -o comm= 2>/dev/null | grep -qF "$d/"; then + say "a game is running from $(basename "$d"), so it is left; remove again once it quits" + continue + fi + rm -rf "$d" + say "removed the runner $(basename "$d")" + gone=1 + done < "$list" + rm -f "$list" + [ "$gone" -eq 1 ] || die "nothing to remove for $name" +} + +# Mounts a DMG safely, returning the mounted mountpoint +mount_dmg() { + dmg=$1 + mount_out=$(hdiutil attach -nobrowse -readonly "$dmg" 2>/dev/null) || die "could not mount $dmg" + printf '%s\n' "$mount_out" | awk -F'\t' '/\/Volumes\// {print $NF; exit}' +} + +# Finds the directory holding external/ and wine/ inside a mounted path +find_d3dm_root() { + root=$1 + if [ -d "$root/external" ] && [ -d "$root/wine" ]; then + echo "$root" + return 0 + fi + for sub in "$root/redist/lib" "$root/lib"; do + if [ -d "$sub/external" ] && [ -d "$sub/wine" ]; then + echo "$sub" + return 0 + fi + done + found_dir=$(find "$root" -maxdepth 4 -type d -name "external" 2>/dev/null | head -1 || true) + if [ -n "$found_dir" ]; then + parent=$(dirname "$found_dir") + if [ -d "$parent/wine" ]; then + echo "$parent" + return 0 + fi + fi + return 1 +} + +write_descriptor() { + input=$1 + name=${2:-} + mount_point="" + nested_mount="" + src="" + + cleanup() { + if [ -n "$nested_mount" ]; then + hdiutil detach "$nested_mount" -quiet 2>/dev/null || true + fi + if [ -n "$mount_point" ]; then + hdiutil detach "$mount_point" -quiet 2>/dev/null || true + fi + } + trap cleanup EXIT INT TERM + + if [ -f "$input" ]; then + case "$input" in + *.dmg) + say "mounting $input..." + mount_point=$(mount_dmg "$input") + [ -n "$mount_point" ] || die "failed to mount $input" + src_candidate=$(find_d3dm_root "$mount_point" || true) + if [ -z "$src_candidate" ]; then + # Check for nested Evaluation environment DMG + nested_dmg=$(find "$mount_point" -maxdepth 2 -type f -name "*Evaluation*.dmg" 2>/dev/null | head -1 || true) + if [ -n "$nested_dmg" ]; then + say "mounting nested $nested_dmg..." + nested_mount=$(mount_dmg "$nested_dmg") + src_candidate=$(find_d3dm_root "$nested_mount" || true) + fi + fi + [ -n "$src_candidate" ] || die "could not locate D3DMetal libraries (external and wine) in $input" + src="$src_candidate" + ;; + *) die "$input is a file but not a .dmg" ;; + esac + elif [ -d "$input" ]; then + src_candidate=$(find_d3dm_root "$input" || true) + [ -n "$src_candidate" ] || die "could not locate external and wine directories in $input" + src="$src_candidate" + else + die "nothing at $input" + fi + + version=$(d3dmetal_version "$src" || true) + if [ -z "$name" ]; then + if [ -n "$version" ]; then + name="d3dmetal-$version" + else + name="d3dmetal-$(basename "$input" .dmg | tr -c 'A-Za-z0-9._-' '-')" + fi + fi + name=$(printf '%s' "$name" | tr -c 'A-Za-z0-9._-' '-') + case "$name" in + [A-Za-z0-9]*) ;; + *) die "$name is not a variant name: it has to start with a letter or a digit" ;; + esac + + # If the source is in a disk image or temporary mount, copy it to variants storage + need_copy=0 + case "$src" in + /Volumes/*|/tmp/*|/var/*) need_copy=1 ;; + esac + + target_dir="$variants_dir/$name" + if [ "$need_copy" -eq 1 ]; then + say "copying D3DMetal libraries into $target_dir..." + mkdir -p "$target_dir" + rm -rf "$target_dir/external" "$target_dir/wine" + cp -R "$src/external" "$target_dir/" + cp -R "$src/wine" "$target_dir/" + src="$target_dir" + fi + + # Prepare MetalFX integration if nvngx.dll / nvngx.so not present + win_dir="$src/wine/x86_64-windows" + if [ -f "$win_dir/nvngx-on-metalfx.dll" ] && [ ! -f "$win_dir/nvngx.dll" ]; then + cp -f "$win_dir/nvngx-on-metalfx.dll" "$win_dir/nvngx.dll" + fi + unix_dir="$src/wine/x86_64-unix" + if [ -f "$unix_dir/nvngx-on-metalfx.so" ] && [ ! -f "$unix_dir/nvngx.so" ]; then + cp -f "$unix_dir/nvngx-on-metalfx.so" "$unix_dir/nvngx.so" + fi + + verify_src "$src" || die "$src is not usable; nothing was written" + + stored=$src + case "$src" in + "$HOME"/*) stored=${src#"$HOME"/} ;; + esac + + mkdir -p "$conf_dir" + conf="$conf_dir/$name.conf" + cat > "$conf" <> "$conf" + + # Automatically clear stale shader caches + clean_shaders "" >/dev/null 2>&1 || true + + say "wrote $conf" + say "add this to a game's launch options, or pick it from the Developer section in Steam:" + say " NOTPROTON_RUNNER_VARIANT=$name CX_GRAPHICS_BACKEND=d3dmetal %command%" + say "only games carrying that option use it; everything else is untouched" +} + +case "${1:-}" in + "") usage ;; + --list) [ $# -eq 1 ] || usage; list_variants ;; + --remove) [ $# -eq 2 ] || usage; remove_variant "$2" ;; + --clean-shaders) clean_shaders "${2:-}" ;; + -*) usage ;; + *) [ $# -le 2 ] || usage; write_descriptor "$1" "${2:-}" ;; +esac + From 24e58bbbad2648ecff730f647d9edc745c906876 Mon Sep 17 00:00:00 2001 From: rapheljr <86885009+rapheljr@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:14:20 +0530 Subject: [PATCH 3/5] Refine D3DMetal variant integration, relative pathing, and add full localizations --- .../NotProtonApp/Model/D3DMetalTools.swift | 15 +- .../Localization/Localizable.xcstrings | 1381 +++++++++++++++-- app/Sources/NotProtonApp/SystemStatus.swift | 22 + .../NotProtonApp/Views/StatusView.swift | 15 + examples/variants/d3dmetal-example.conf | 2 +- tools/install-d3dmetal.sh | 31 +- 6 files changed, 1355 insertions(+), 111 deletions(-) diff --git a/app/Sources/NotProtonApp/Model/D3DMetalTools.swift b/app/Sources/NotProtonApp/Model/D3DMetalTools.swift index ee1599ae..1471eeca 100644 --- a/app/Sources/NotProtonApp/Model/D3DMetalTools.swift +++ b/app/Sources/NotProtonApp/Model/D3DMetalTools.swift @@ -590,11 +590,20 @@ enum D3DMetalTools: Sendable { let confURL = variantsDir.appending(path: "\(variantName).conf") let displayLabel = label ?? "D3DMetal \(ver)" + let homePath = NSHomeDirectory() + let targetPath = targetDir.path(percentEncoded: false) + let storedSrc: String + if targetPath.hasPrefix(homePath + "/") { + storedSrc = String(targetPath.dropFirst(homePath.count + 1)) + } else { + storedSrc = targetPath + } + let confContent = """ # Written by NotProton. The runner is assembled from src at launch. [\(variantName)] variant = D3DMetal - src = variants/\(variantName) + src = \(storedSrc) dst = lib64/apple_gptk label = \(displayLabel) @@ -655,8 +664,10 @@ enum D3DMetalTools: Sendable { srcURL = URL(filePath: src) } else if src.hasPrefix("~/") { srcURL = URL(filePath: NSString(string: src).expandingTildeInPath) + } else if src.hasPrefix("$HOME/") { + srcURL = URL(filePath: NSHomeDirectory()).appending(path: String(src.dropFirst(6))) } else { - srcURL = supportDir.appending(path: src) + srcURL = URL(filePath: NSHomeDirectory()).appending(path: src) } } else { srcURL = variantsDir.appending(path: id) diff --git a/app/Sources/NotProtonApp/Resources/Localization/Localizable.xcstrings b/app/Sources/NotProtonApp/Resources/Localization/Localizable.xcstrings index e355baf7..bee365e3 100644 --- a/app/Sources/NotProtonApp/Resources/Localization/Localizable.xcstrings +++ b/app/Sources/NotProtonApp/Resources/Localization/Localizable.xcstrings @@ -365,6 +365,34 @@ } } }, + "%@ does not contain D3DMetal libraries (expected external and wine folders)." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ enthält keine D3DMetal-Bibliotheken (Ordner «external» und «wine» erwartet)." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ ne contient pas les bibliothèques D3DMetal (dossiers external et wine attendus)." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ non contiene le librerie D3DMetal (previste le cartelle external e wine)." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 不包含 D3DMetal 运行库(未找到 external 和 wine 文件夹)。" + } + } + } + }, "%@ does not verify against its own signature. " : { "localizations" : { "de" : { @@ -1860,6 +1888,34 @@ } } }, + "Additional prefix management actions." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zusätzliche Präfix-Verwaltungsaktionen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Actions supplémentaires de gestion des préfixes." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Azioni aggiuntive di gestione del prefisso." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更多容器管理操作。" + } + } + } + }, "Allow Client Updates" : { "localizations" : { "de" : { @@ -2868,6 +2924,34 @@ } } }, + "Cached version %@ not found or corrupted." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zwischengespeicherte Version %@ nicht gefunden oder beschädigt." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Version en cache %@ introuvable ou corrompue." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Versione in cache %@ non trovata o danneggiata." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未找到缓存版本 %@ 或已损坏。" + } + } + } + }, "Calculating…" : { "localizations" : { "de" : { @@ -3232,6 +3316,62 @@ } } }, + "Clear All Shader Caches" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Shader-Caches leeren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer tous les caches de shaders" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancella tutte le cache degli shader" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除所有着色器缓存" + } + } + } + }, + "Clear Shader Cache" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shader-Cache leeren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer le cache des shaders" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancella la cache degli shader" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除着色器缓存" + } + } + } + }, "Clear Steam's controller permission so macOS asks for it again." : { "localizations" : { "de" : { @@ -3260,6 +3400,118 @@ } } }, + "Clear all DirectX 12 / D3DMetal shader caches." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle DirectX 12- / D3DMetal-Shader-Caches leeren." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacer tous les caches de shaders DirectX 12 / D3DMetal." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancella tutte le cache degli shader DirectX 12 / D3DMetal." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除所有 DirectX 12 / D3DMetal 着色器缓存。" + } + } + } + }, + "Cleared %lld DirectX 12 shader cache(s)." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld DirectX 12-Shader-Cache(s) geleert." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld cache(s) de shaders DirectX 12 effacé(s)." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancellate %lld cache degli shader DirectX 12." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已清除 %lld 个 DirectX 12 着色器缓存。" + } + } + } + }, + "Cleared DirectX 12 shader cache for %@." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "DirectX 12-Shader-Cache für %@ geleert." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cache de shaders DirectX 12 effacé pour %@." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cache degli shader DirectX 12 cancellata per %@." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已清除 %@ 的 DirectX 12 着色器缓存。" + } + } + } + }, + "Clearing shader cache" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shader-Cache wird geleert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Effacement du cache des shaders" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cancellazione della cache degli shader" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在清除着色器缓存" + } + } + } + }, "Clone CrossOver" : { "localizations" : { "de" : { @@ -4775,6 +5027,34 @@ } } }, + "DirectX 12 (D3DMetal) %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "DirectX 12 (D3DMetal) %@" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "DirectX 12 (D3DMetal) %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "DirectX 12 (D3DMetal) %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "DirectX 12 (D3DMetal) %@" + } + } + } + }, "Done" : { "localizations" : { "de" : { @@ -5279,130 +5559,298 @@ } } }, - "Install NotProton" : { + "Install Apple's Game Porting Toolkit %@ as a runner variant selectable per-game in Steam." : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "NotProton installieren" + "value" : "Apples Game Porting Toolkit %@ als Runner-Variante installieren, die in Steam pro Spiel ausgewählt werden kann." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Installer NotProton" + "value" : "Installer Game Porting Toolkit %@ d'Apple en tant que variante d'exécutable sélectionnable par jeu dans Steam." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Installazione di NotProton" + "value" : "Installa Game Porting Toolkit %@ di Apple come variante runner selezionabile per ciascun gioco in Steam." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "安装 NotProton" + "value" : "将 Apple Game Porting Toolkit %@ 安装为运行器变体,可在 Steam 中针对各个游戏分别选择。" } } } }, - "Install NotProton into Steam." : { + "Install D3DMetal Variant" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "NotProton in Steam installieren." + "value" : "D3DMetal-Variante installieren" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Installer NotProton dans Steam." + "value" : "Installer la variante D3DMetal" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Installa NotProton in Steam." + "value" : "Installa variante D3DMetal" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "将 NotProton 安装到 Steam。" + "value" : "安装 D3DMetal 变体" } } } }, - "Installation is blocked. Refresh Status and use the NotProton app that installed this build." : { + "Install D3DMetal Variant…" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Die Installation ist blockiert. Lade den Status neu und verwende die NotProton-App, die diesen Build installiert hat." + "value" : "D3DMetal-Variante installieren…" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "L'installation est bloquée. Actualisez l'état et utilisez l'app NotProton qui a installé ce build." + "value" : "Installer la variante D3DMetal…" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "L'installazione è bloccata. Aggiorna lo Stato e usa l'app NotProton che ha installato questa build." + "value" : "Installa variante D3DMetal…" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "安装已被阻止。请刷新“状态”页面,并使用此前安装此版本的 NotProton 应用。" + "value" : "安装 D3DMetal 变体…" } } } }, - "Installed" : { + "Install GPTK %@ as Variant" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Installiert" + "value" : "GPTK %@ als Variante installieren" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Installé" + "value" : "Installer GPTK %@ comme variante" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Installato" + "value" : "Installa GPTK %@ come variante" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已安装" + "value" : "安装 GPTK %@ 为变体" } } } }, - "Installed content could not be verified. Refresh the Status view for the files that still need attention." : { + "Install NotProton" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Die installierten Inhalte konnten nicht überprüft werden. Lade die Statusansicht neu, um die Dateien zu sehen, die noch Aufmerksamkeit brauchen." + "value" : "NotProton installieren" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Le contenu installé n'a pas pu être vérifié. Actualisez la vue État pour voir les fichiers qui demandent encore votre attention." + "value" : "Installer NotProton" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installazione di NotProton" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装 NotProton" + } + } + } + }, + "Install NotProton into Steam." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "NotProton in Steam installieren." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installer NotProton dans Steam." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installa NotProton in Steam." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将 NotProton 安装到 Steam。" + } + } + } + }, + "Install Variant" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variante installieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installer la variante" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installa variante" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装变体" + } + } + } + }, + "Installation is blocked. Refresh Status and use the NotProton app that installed this build." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Installation ist blockiert. Lade den Status neu und verwende die NotProton-App, die diesen Build installiert hat." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'installation est bloquée. Actualisez l'état et utilisez l'app NotProton qui a installé ce build." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'installazione è bloccata. Aggiorna lo Stato e usa l'app NotProton che ha installato questa build." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安装已被阻止。请刷新“状态”页面,并使用此前安装此版本的 NotProton 应用。" + } + } + } + }, + "Installed" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installiert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installé" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installato" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已安装" + } + } + } + }, + "Installed D3DMetal variant '%@'. It is now selectable under Developer in Steam's game compatibility settings." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "D3DMetal-Variante «%@» installiert. Sie kann nun unter «Entwickler» in den Steam-Kompatibilitätseinstellungen ausgewählt werden." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variante D3DMetal « %@ » installée. Elle est désormais sélectionnable sous Développeur dans les paramètres de compatibilité Steam." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Variante D3DMetal '%@' installata. È ora selezionabile sotto Sviluppatore nelle impostazioni di compatibilità di Steam." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已安装 D3DMetal 变体“%@”。现在可以在 Steam 游戏的“开发者”兼容性设置中选择该变体。" + } + } + } + }, + "Installed content could not be verified. Refresh the Status view for the files that still need attention." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die installierten Inhalte konnten nicht überprüft werden. Lade die Statusansicht neu, um die Dateien zu sehen, die noch Aufmerksamkeit brauchen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Le contenu installé n'a pas pu être vérifié. Actualisez la vue État pour voir les fichiers qui demandent encore votre attention." } }, "it" : { @@ -5475,6 +5923,34 @@ } } }, + "Installing D3DMetal Variant" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "D3DMetal-Variante wird installiert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installation de la variante D3DMetal" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Installazione variante D3DMetal" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在安装 D3DMetal 变体" + } + } + } + }, "Installing compatibility tool" : { "localizations" : { "de" : { @@ -6063,6 +6539,62 @@ } } }, + "Manually select an Apple Game Porting Toolkit disk image or folder to update DirectX 12." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle manuell ein Apple Game Porting Toolkit-Disk-Image oder einen Ordner aus, um DirectX 12 zu aktualisieren." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sélectionnez manuellement une image disque ou un dossier Apple Game Porting Toolkit pour mettre à jour DirectX 12." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleziona manualmente un'immagine disco o una cartella di Apple Game Porting Toolkit per aggiornare DirectX 12." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手动选择 Apple Game Porting Toolkit 磁盘映像或文件夹以更新 DirectX 12。" + } + } + } + }, + "MetalFX prepared for %lld prefix(es)." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "MetalFX für %lld Präfix(e) vorbereitet." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "MetalFX préparé pour %lld préfixe(s)." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "MetalFX preparato per %lld prefisso/i." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已为 %lld 个容器准备 MetalFX。" + } + } + } + }, "Missing components: %@. Run make app-payload and build the app again." : { "localizations" : { "de" : { @@ -6343,6 +6875,62 @@ } } }, + "No DirectX 12 shader cache found for %@." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein DirectX 12-Shader-Cache für %@ gefunden." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun cache de shaders DirectX 12 trouvé pour %@." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessuna cache degli shader DirectX 12 trovata per %@." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未找到 %@ 的 DirectX 12 着色器缓存。" + } + } + } + }, + "No DirectX 12 shader caches found." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Keine DirectX 12-Shader-Caches gefunden." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aucun cache de shaders DirectX 12 trouvé." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nessuna cache degli shader DirectX 12 trovata." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未找到 DirectX 12 着色器缓存。" + } + } + } + }, "No Prefixes" : { "localizations" : { "de" : { @@ -7550,6 +8138,34 @@ } } }, + "Quit running Windows applications before changing D3DMetal versions." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Beende laufende Windows-Anwendungen, bevor du D3DMetal-Versionen änderst." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quittez les applications Windows en cours d'exécution avant de changer de version de D3DMetal." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chiudi le applicazioni Windows in esecuzione prima di modificare le versioni di D3DMetal." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更改 D3DMetal 版本前,请先退出正在运行的 Windows 应用程序。" + } + } + } + }, "RVA %@ falls in no mapped section of the %@ ntdll, so the %@ cannot be located." : { "localizations" : { "de" : { @@ -8787,109 +9403,221 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Starte Steam neu, damit es seine Liste der Kompatibilitätstools aktualisiert." + "value" : "Starte Steam neu, damit es seine Liste der Kompatibilitätstools aktualisiert." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Redémarrez Steam pour mettre à jour sa liste d'outils de compatibilité." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riavvia Steam per aggiornare il suo elenco di strumenti di compatibilità." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请重启 Steam,以更新兼容工具列表。" + } + } + } + }, + "Restore Steam to its original state." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam in den Originalzustand zurückversetzen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restaurer Steam dans son état d'origine." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Riporta Steam allo stato originale." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将 Steam 恢复原状。" + } + } + } + }, + "Restore Stock DirectX 12 (%@)" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Standard-DirectX 12 (%@) wiederherstellen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restaurer DirectX 12 d'origine (%@)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ripristina DirectX 12 originale (%@)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "恢复默认 DirectX 12 (%@)" + } + } + } + }, + "Restore the files included with this app." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die mit dieser App mitgelieferten Dateien wiederherstellen." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restaurer les fichiers fournis avec cette app." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ripristina i file inclusi in questa app." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用此应用附带的文件恢复安装。" + } + } + } + }, + "Restore the stock DirectX 12 engine bundled with CrossOver in 1 click." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die mit CrossOver gebündelte Standard-DirectX 12-Engine mit 1 Klick wiederherstellen." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Redémarrez Steam pour mettre à jour sa liste d'outils de compatibilité." + "value" : "Restaurez le moteur DirectX 12 d'origine inclus avec CrossOver en 1 clic." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Riavvia Steam per aggiornare il suo elenco di strumenti di compatibilità." + "value" : "Ripristina il motore DirectX 12 originale fornito con CrossOver in 1 clic." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "请重启 Steam,以更新兼容工具列表。" + "value" : "一键恢复 CrossOver 自带的默认 DirectX 12 引擎。" } } } }, - "Restore Steam to its original state." : { + "Restored stock DirectX 12 (%@) successfully." : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Steam in den Originalzustand zurückversetzen." + "value" : "Standard-DirectX 12 (%@) erfolgreich wiederhergestellt." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Restaurer Steam dans son état d'origine." + "value" : "DirectX 12 d'origine (%@) restauré avec succès." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Riporta Steam allo stato originale." + "value" : "DirectX 12 originale (%@) ripristinato correttamente." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "将 Steam 恢复原状。" + "value" : "已成功恢复默认 DirectX 12 (%@)。" } } } }, - "Restore the files included with this app." : { + "Restoring Steam" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Die mit dieser App mitgelieferten Dateien wiederherstellen." + "value" : "Steam wird wiederhergestellt" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Restaurer les fichiers fournis avec cette app." + "value" : "Restauration de Steam" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Ripristina i file inclusi in questa app." + "value" : "Ripristino di Steam" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "使用此应用附带的文件恢复安装。" + "value" : "正在恢复 Steam" } } } }, - "Restoring Steam" : { + "Restoring stock DirectX 12" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Steam wird wiederhergestellt" + "value" : "Standard-DirectX 12 wird wiederhergestellt" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Restauration de Steam" + "value" : "Restauration de DirectX 12 d'origine" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Ripristino di Steam" + "value" : "Ripristino di DirectX 12 originale" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在恢复 Steam" + "value" : "正在恢复默认 DirectX 12" } } } @@ -9174,6 +9902,90 @@ } } }, + "Select Apple GPTK Folder…" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Apple GPTK-Ordner auswählen…" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sélectionner le dossier Apple GPTK…" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleziona cartella Apple GPTK…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择 Apple GPTK 文件夹…" + } + } + } + }, + "Select Apple's Game Porting Toolkit (GPTK) disk image (.dmg) or folder to install as a D3DMetal runner variant." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle ein Apple Game Porting Toolkit (GPTK)-Disk-Image (.dmg) oder einen Ordner aus, um es als D3DMetal-Runner-Variante zu installieren." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sélectionnez une image disque (.dmg) ou un dossier Apple Game Porting Toolkit (GPTK) à installer en tant que variante D3DMetal." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleziona un'immagine disco (.dmg) o una cartella di Apple Game Porting Toolkit (GPTK) da installare come variante runner D3DMetal." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择 Apple Game Porting Toolkit (GPTK) 磁盘映像 (.dmg) 或文件夹以安装为 D3DMetal 运行器变体。" + } + } + } + }, + "Select Apple's Game Porting Toolkit (GPTK) disk image (.dmg) or mounted volume to update DirectX 12." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle ein Apple Game Porting Toolkit (GPTK)-Disk-Image (.dmg) oder ein gemountetes Volume aus, um DirectX 12 zu aktualisieren." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sélectionnez une image disque (.dmg) ou un volume monté Apple Game Porting Toolkit (GPTK) pour mettre à jour DirectX 12." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleziona un'immagine disco (.dmg) o un volume montato di Apple Game Porting Toolkit (GPTK) per aggiornare DirectX 12." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择 Apple Game Porting Toolkit (GPTK) 磁盘映像 (.dmg) 或已挂载卷以更新 DirectX 12。" + } + } + } + }, "Select a copy of CrossOver." : { "localizations" : { "de" : { @@ -9202,6 +10014,34 @@ } } }, + "Select an Apple Game Porting Toolkit disk image or folder to install as a runner variant." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Wähle ein Apple Game Porting Toolkit-Disk-Image oder einen Ordner aus, um es als Runner-Variante zu installieren." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sélectionnez une image disque ou un dossier Apple Game Porting Toolkit à installer en tant que variante." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seleziona un'immagine disco o una cartella di Apple Game Porting Toolkit da installare come variante runner." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择 Apple Game Porting Toolkit 磁盘映像或文件夹以安装为运行器变体。" + } + } + } + }, "Select your copy of Steam." : { "localizations" : { "de" : { @@ -9994,333 +10834,473 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Steam wurde nicht für NotProton eingerichtet, und dieser Benutzer kann das nicht ändern. Installiere über den Benutzer, dem Steam gehört." + "value" : "Steam wurde nicht für NotProton eingerichtet, und dieser Benutzer kann das nicht ändern. Installiere über den Benutzer, dem Steam gehört." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam n'a pas été configuré pour NotProton et ce compte ne peut pas le modifier. Installez depuis le compte propriétaire de Steam." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam non è stato configurato per NotProton e questo account non può modificarlo. Installa dall'account a cui appartiene Steam." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam 尚未配置 NotProton,当前账户无权修改。请使用安装 Steam 的账户执行安装。" + } + } + } + }, + "Steam integration needs repair." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Steam-Integration muss repariert werden." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'intégration à Steam doit être réparée." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "L'integrazione con Steam va riparata." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam 集成需要修复。" + } + } + } + }, + "Steam is restored to its unmodified state and NotProton is removed, including the compatibility tool that lives inside the Steam folder. Windows games and Steam Play prefixes are not removed." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam wird in den unveränderten Zustand zurückversetzt und NotProton entfernt, einschließlich des Kompatibilitätstools im Steam-Ordner. Windows-Spiele und Steam Play-Präfixe werden nicht entfernt." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam est restauré dans son état non modifié et NotProton est supprimé, y compris l'outil de compatibilité situé dans le dossier Steam. Les jeux Windows et les préfixes Steam Play ne sont pas supprimés." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam viene riportato allo stato originale e NotProton viene rimosso, compreso lo strumento di compatibilità nella cartella di Steam. I giochi Windows e i prefissi di Steam Play non vengono rimossi." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此操作会将 Steam 恢复为未修改的状态,并移除 NotProton(包括 Steam 文件夹内的兼容工具)。不会删除 Windows 游戏及 Steam Play 游戏容器。" + } + } + } + }, + "Steam is running" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam läuft" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam est ouvert" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam è in esecuzione" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam 正在运行" + } + } + } + }, + "Steam is running, please close Steam." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam läuft, bitte schließe Steam." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam est ouvert, veuillez fermer Steam." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam è in esecuzione, chiudi Steam." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam 正在运行,请先关闭 Steam。" + } + } + } + }, + "Steam is set up to inject a different dylib. Please repair Steam before installing NotProton." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Steam ist so eingerichtet, dass eine andere dylib geladen wird. Bitte repariere Steam, bevor du NotProton installierst." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Steam n'a pas été configuré pour NotProton et ce compte ne peut pas le modifier. Installez depuis le compte propriétaire de Steam." + "value" : "Steam est configuré pour injecter une autre dylib. Réparez Steam avant d'installer NotProton." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Steam non è stato configurato per NotProton e questo account non può modificarlo. Installa dall'account a cui appartiene Steam." + "value" : "Steam è configurato per caricare una dylib diversa. Ripara Steam prima di installare NotProton." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Steam 尚未配置 NotProton,当前账户无权修改。请使用安装 Steam 的账户执行安装。" + "value" : "Steam 已配置为注入其他动态库。请先修复 Steam,再安装 NotProton。" } } } }, - "Steam integration needs repair." : { + "Steam not found." : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Die Steam-Integration muss repariert werden." + "value" : "Steam nicht gefunden." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "L'intégration à Steam doit être réparée." + "value" : "Steam introuvable." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "L'integrazione con Steam va riparata." + "value" : "Steam non trovato." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Steam 集成需要修复。" + "value" : "未找到 Steam。" } } } }, - "Steam is restored to its unmodified state and NotProton is removed, including the compatibility tool that lives inside the Steam folder. Windows games and Steam Play prefixes are not removed." : { + "Steam restored to its original state." : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Steam wird in den unveränderten Zustand zurückversetzt und NotProton entfernt, einschließlich des Kompatibilitätstools im Steam-Ordner. Windows-Spiele und Steam Play-Präfixe werden nicht entfernt." + "value" : "Steam wurde in den Originalzustand zurückversetzt." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Steam est restauré dans son état non modifié et NotProton est supprimé, y compris l'outil de compatibilité situé dans le dossier Steam. Les jeux Windows et les préfixes Steam Play ne sont pas supprimés." + "value" : "Steam a été restauré dans son état d'origine." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Steam viene riportato allo stato originale e NotProton viene rimosso, compreso lo strumento di compatibilità nella cartella di Steam. I giochi Windows e i prefissi di Steam Play non vengono rimossi." + "value" : "Steam riportato allo stato originale." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此操作会将 Steam 恢复为未修改的状态,并移除 NotProton(包括 Steam 文件夹内的兼容工具)。不会删除 Windows 游戏及 Steam Play 游戏容器。" + "value" : "Steam 已恢复原状。" } } } }, - "Steam is running" : { + "Steam was installed by another account on this Mac. Only that account can modify it. Log in to macOS with that account to reinstall NotProton." : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Steam läuft" + "value" : "Steam wurde von einem anderen Benutzer auf diesem Mac installiert. Nur dieser Benutzer kann es ändern. Melde dich mit diesem Benutzer bei macOS an, um NotProton neu zu installieren." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Steam est ouvert" + "value" : "Steam a été installé par un autre compte sur ce Mac. Seul ce compte peut le modifier. Connectez-vous à macOS avec ce compte pour réinstaller NotProton." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Steam è in esecuzione" + "value" : "Steam è stato installato da un altro account su questo Mac e solo quell'account può modificarlo. Accedi a macOS con quell'account per reinstallare NotProton." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Steam 正在运行" + "value" : "Steam 由这台 Mac 上的其他账户安装,只有该账户可以修改。请登录该账户,重新安装 NotProton。" } } } }, - "Steam is running, please close Steam." : { + "Steam was stopped, so start it again." : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Steam läuft, bitte schließe Steam." + "value" : "Steam wurde beendet, starte es also erneut." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Steam est ouvert, veuillez fermer Steam." + "value" : "Steam a été arrêté, relancez-le." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Steam è in esecuzione, chiudi Steam." + "value" : "Steam è stato chiuso: riavvialo." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Steam 正在运行,请先关闭 Steam。" + "value" : "Steam 已停止,请重新启动。" } } } }, - "Steam is set up to inject a different dylib. Please repair Steam before installing NotProton." : { + "Steam's installed dylib does not match this account's build record. Finish or repair its installation with the NotProton app that last installed it." : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Steam ist so eingerichtet, dass eine andere dylib geladen wird. Bitte repariere Steam, bevor du NotProton installierst." + "value" : "Die installierte dylib von Steam passt nicht zum Build-Eintrag dieses Benutzers. Schließe die Installation mit der NotProton-App ab, die sie zuletzt installiert hat, oder repariere sie damit." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Steam est configuré pour injecter une autre dylib. Réparez Steam avant d'installer NotProton." + "value" : "La dylib installée de Steam ne correspond pas à l'enregistrement de build de ce compte. Terminez ou réparez son installation avec l'app NotProton qui l'a installée en dernier." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Steam è configurato per caricare una dylib diversa. Ripara Steam prima di installare NotProton." + "value" : "La dylib installata in Steam non corrisponde alla build registrata per questo account. Completa o ripara l'installazione con l'app NotProton che l'ha installata per ultima." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Steam 已配置为注入其他动态库。请先修复 Steam,再安装 NotProton。" + "value" : "Steam 中已安装的动态库与当前账户的版本记录不一致。请使用上次执行安装的 NotProton 应用完成或修复安装。" } } } }, - "Steam not found." : { + "Stopping Steam" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Steam nicht gefunden." + "value" : "Steam wird beendet" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Steam introuvable." + "value" : "Arrêt de Steam" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Steam non trovato." + "value" : "Chiusura di Steam" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未找到 Steam。" + "value" : "正在停止 Steam" } } } }, - "Steam restored to its original state." : { + "Switch D3DMetal" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Steam wurde in den Originalzustand zurückversetzt." + "value" : "D3DMetal wechseln" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Steam a été restauré dans son état d'origine." + "value" : "Changer de D3DMetal" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Steam riportato allo stato originale." + "value" : "Cambia D3DMetal" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Steam 已恢复原状。" + "value" : "切换 D3DMetal" } } } }, - "Steam was installed by another account on this Mac. Only that account can modify it. Log in to macOS with that account to reinstall NotProton." : { + "Switch DirectX 12 to %@" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Steam wurde von einem anderen Benutzer auf diesem Mac installiert. Nur dieser Benutzer kann es ändern. Melde dich mit diesem Benutzer bei macOS an, um NotProton neu zu installieren." + "value" : "DirectX 12 zu %@ wechseln" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Steam a été installé par un autre compte sur ce Mac. Seul ce compte peut le modifier. Connectez-vous à macOS avec ce compte pour réinstaller NotProton." + "value" : "Basculer DirectX 12 vers %@" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Steam è stato installato da un altro account su questo Mac e solo quell'account può modificarlo. Accedi a macOS con quell'account per reinstallare NotProton." + "value" : "Passa DirectX 12 a %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Steam 由这台 Mac 上的其他账户安装,只有该账户可以修改。请登录该账户,重新安装 NotProton。" + "value" : "将 DirectX 12 切换为 %@" } } } }, - "Steam was stopped, so start it again." : { + "Switch immediately to cached DirectX 12 / D3DMetal %@ in 1 click." : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Steam wurde beendet, starte es also erneut." + "value" : "Mit 1 Klick sofort zu zwischengespeichertem DirectX 12 / D3DMetal %@ wechseln." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Steam a été arrêté, relancez-le." + "value" : "Basculez immédiatement vers DirectX 12 / D3DMetal %@ en cache en 1 clic." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Steam è stato chiuso: riavvialo." + "value" : "Passa immediatamente a DirectX 12 / D3DMetal %@ in cache in 1 clic." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Steam 已停止,请重新启动。" + "value" : "一键立即切换至已缓存的 DirectX 12 / D3DMetal %@。" } } } }, - "Steam's installed dylib does not match this account's build record. Finish or repair its installation with the NotProton app that last installed it." : { + "Switched DirectX 12 to %@ successfully." : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Die installierte dylib von Steam passt nicht zum Build-Eintrag dieses Benutzers. Schließe die Installation mit der NotProton-App ab, die sie zuletzt installiert hat, oder repariere sie damit." + "value" : "DirectX 12 erfolgreich zu %@ gewechselt." } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "La dylib installée de Steam ne correspond pas à l'enregistrement de build de ce compte. Terminez ou réparez son installation avec l'app NotProton qui l'a installée en dernier." + "value" : "DirectX 12 basculé vers %@ avec succès." } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "La dylib installata in Steam non corrisponde alla build registrata per questo account. Completa o ripara l'installazione con l'app NotProton che l'ha installata per ultima." + "value" : "DirectX 12 passato a %@ correttamente." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Steam 中已安装的动态库与当前账户的版本记录不一致。请使用上次执行安装的 NotProton 应用完成或修复安装。" + "value" : "已成功将 DirectX 12 切换为 %@。" } } } }, - "Stopping Steam" : { + "Switching DirectX 12 to %@" : { "localizations" : { "de" : { "stringUnit" : { "state" : "translated", - "value" : "Steam wird beendet" + "value" : "DirectX 12 wird zu %@ gewechselt" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Arrêt de Steam" + "value" : "Bascule de DirectX 12 vers %@" } }, "it" : { "stringUnit" : { "state" : "translated", - "value" : "Chiusura di Steam" + "value" : "Passaggio di DirectX 12 a %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在停止 Steam" + "value" : "正在将 DirectX 12 切换为 %@" } } } @@ -11753,6 +12733,62 @@ } } }, + "Update D3DMetal" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "D3DMetal aktualisieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mettre à jour D3DMetal" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiorna D3DMetal" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "更新 D3DMetal" + } + } + } + }, + "Update DirectX 12 to GPTK %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "DirectX 12 auf GPTK %@ aktualisieren" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mettre à jour DirectX 12 vers GPTK %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiorna DirectX 12 a GPTK %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将 DirectX 12 更新至 GPTK %@" + } + } + } + }, "Update available (%@)." : { "localizations" : { "de" : { @@ -11837,6 +12873,146 @@ } } }, + "Updated DirectX 12 (D3DMetal) successfully." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "DirectX 12 (D3DMetal) erfolgreich aktualisiert." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "DirectX 12 (D3DMetal) mis à jour avec succès." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "DirectX 12 (D3DMetal) aggiornato correttamente." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已成功更新 DirectX 12 (D3DMetal)。" + } + } + } + }, + "Updated DirectX 12 to GPTK %@ successfully." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "DirectX 12 erfolgreich auf GPTK %@ aktualisiert." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "DirectX 12 mis à jour vers GPTK %@ avec succès." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "DirectX 12 aggiornato a GPTK %@ correttamente." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已成功将 DirectX 12 更新至 GPTK %@。" + } + } + } + }, + "Updating DirectX 12 (D3DMetal)" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "DirectX 12 (D3DMetal) wird aktualisiert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mise à jour de DirectX 12 (D3DMetal)" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiornamento di DirectX 12 (D3DMetal)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在更新 DirectX 12 (D3DMetal)" + } + } + } + }, + "Updating DirectX 12 to GPTK %@" : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "DirectX 12 wird auf GPTK %@ aktualisiert" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mise à jour de DirectX 12 vers GPTK %@" + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiornamento di DirectX 12 a GPTK %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在将 DirectX 12 更新至 GPTK %@" + } + } + } + }, + "Upgrade CrossOver's DirectX 12 engine from Apple's Game Porting Toolkit directly in 1 click." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aktualisiere die DirectX 12-Engine von CrossOver direkt mit 1 Klick über Apples Game Porting Toolkit." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mettez à niveau le moteur DirectX 12 de CrossOver directement en 1 clic depuis Game Porting Toolkit d'Apple." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aggiorna il motore DirectX 12 di CrossOver direttamente in 1 clic da Game Porting Toolkit di Apple." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "一键直接使用 Apple Game Porting Toolkit 升级 CrossOver 的 DirectX 12 引擎。" + } + } + } + }, "Use a copy of Steam from another folder or drive." : { "localizations" : { "de" : { @@ -12064,9 +13240,6 @@ "WineD3D" : { "shouldTranslate" : false }, - "Winetricks" : { - "shouldTranslate" : false - }, "Winetricks keeps running. The Prefixes window shows when it is done." : { "localizations" : { "de" : { diff --git a/app/Sources/NotProtonApp/SystemStatus.swift b/app/Sources/NotProtonApp/SystemStatus.swift index fa543db5..e696ea03 100644 --- a/app/Sources/NotProtonApp/SystemStatus.swift +++ b/app/Sources/NotProtonApp/SystemStatus.swift @@ -353,6 +353,28 @@ final class SystemStatus { } } + func installD3DMetalVariant(from sourceURL: URL? = nil) async { + let pickedURL: URL + if let sourceURL { + pickedURL = sourceURL + } else { + let panel = NSOpenPanel() + panel.canChooseFiles = true + panel.canChooseDirectories = true + panel.allowsMultipleSelection = false + panel.prompt = String(localized: "Install Variant") + panel.message = String(localized: "Select Apple's Game Porting Toolkit (GPTK) disk image (.dmg) or folder to install as a D3DMetal runner variant.") + guard panel.runModal() == .OK, let url = panel.url else { return } + pickedURL = url + } + + await perform(from: String(localized: "Installing D3DMetal Variant")) { _ in + let variant = try D3DMetalTools.installVariant(from: pickedURL) + AppLog.note("D3DMetal variant '\(variant.name)' installed from \(pickedURL.path(percentEncoded: false))") + return String(localized: "Installed D3DMetal variant '\(variant.label)'. It is now selectable under Developer in Steam's game compatibility settings.") + } + } + func chooseSteam() async { let current = SupportPaths.Steam.app let panel = NSOpenPanel() diff --git a/app/Sources/NotProtonApp/Views/StatusView.swift b/app/Sources/NotProtonApp/Views/StatusView.swift index 122fdacb..915199d9 100644 --- a/app/Sources/NotProtonApp/Views/StatusView.swift +++ b/app/Sources/NotProtonApp/Views/StatusView.swift @@ -650,6 +650,21 @@ struct StatusView: View { let currentVer = D3DMetalTools.d3dMetalVersion(forBuild: row.buildID) let cached = D3DMetalTools.cachedVersions(forBuild: row.buildID) + // 1-Click install as a D3DMetal variant from auto-discovered mounted Evaluation Environment / DMG + if let discovered = D3DMetalTools.discoverSource() { + items.append(StatusAction( + label: String(localized: "Install GPTK \(discovered.version) as Variant"), + help: String(localized: "Install Apple's Game Porting Toolkit \(discovered.version) as a runner variant selectable per-game in Steam."), + isEnabled: status.canInstall + ) { Task { await status.installD3DMetalVariant(from: discovered.url) } }) + } + + items.append(StatusAction( + label: String(localized: "Install D3DMetal Variant\u{2026}"), + help: String(localized: "Select an Apple Game Porting Toolkit disk image or folder to install as a runner variant."), + isEnabled: status.canInstall + ) { Task { await status.installD3DMetalVariant() } }) + // 1-Click update from auto-discovered mounted Evaluation Environment / DMG (if not already cached) if let discovered = D3DMetalTools.discoverSource(), discovered.version != currentVer, diff --git a/examples/variants/d3dmetal-example.conf b/examples/variants/d3dmetal-example.conf index edd538da..3188abbb 100644 --- a/examples/variants/d3dmetal-example.conf +++ b/examples/variants/d3dmetal-example.conf @@ -17,6 +17,6 @@ [d3dmetal-example] label = GPTK 4.0 Beta 3 variant = D3DMetal -src = variants/d3dmetal-4.0b3 +src = Library/Application Support/notproton/variants/d3dmetal-4.0b3 dst = lib64/apple_gptk diff --git a/tools/install-d3dmetal.sh b/tools/install-d3dmetal.sh index 15aed00e..7d525127 100755 --- a/tools/install-d3dmetal.sh +++ b/tools/install-d3dmetal.sh @@ -188,6 +188,11 @@ remove_variant() { esac conf="$conf_dir/$name.conf" gone=0 + src_in_conf="" + if [ -f "$conf" ]; then + src_in_conf=$(awk -F'=' '/^[ \t]*src[ \t]*=/ { gsub(/^[ \t]+|[ \t]+$/, "", $2); print $2; exit }' "$conf") + fi + if [ -f "$conf" ] && written_here "$conf"; then rm -f "$conf" say "removed $conf" @@ -196,12 +201,30 @@ remove_variant() { say "$conf was not written by this script, so it is left for you" fi - # Also clean up variant storage directory if placed under variants_dir + # Clean up variant storage directory if placed under variants_dir vdir="$variants_dir/$name" if [ -d "$vdir" ]; then rm -rf "$vdir" say "removed variant files at $vdir" gone=1 + elif [ -n "$src_in_conf" ]; then + # Check if src_in_conf resolved under variants_dir or HOME + # shellcheck disable=SC2088,SC2016 + case "$src_in_conf" in + ("~/"*) resolved_src="$HOME/${src_in_conf#"~/"}" ;; + ('$HOME/'*) resolved_src="$HOME/${src_in_conf#'$HOME/'}" ;; + (/*) resolved_src="$src_in_conf" ;; + (*) resolved_src="$HOME/$src_in_conf" ;; + esac + case "$resolved_src" in + ("$variants_dir"/*) + if [ -d "$resolved_src" ]; then + rm -rf "$resolved_src" + say "removed variant files at $resolved_src" + gone=1 + fi + ;; + esac fi list=$(mktemp) || die "cannot make a temp file" @@ -225,7 +248,7 @@ remove_variant() { mount_dmg() { dmg=$1 mount_out=$(hdiutil attach -nobrowse -readonly "$dmg" 2>/dev/null) || die "could not mount $dmg" - printf '%s\n' "$mount_out" | awk -F'\t' '/\/Volumes\// {print $NF; exit}' + printf '%s\n' "$mount_out" | awk -F'\t' '/\/Volumes\// { v=$NF; sub(/^[ \t\r\n]+/, "", v); sub(/[ \t\r\n]+$/, "", v); print v; exit }' } # Finds the directory holding external/ and wine/ inside a mounted path @@ -261,10 +284,10 @@ write_descriptor() { cleanup() { if [ -n "$nested_mount" ]; then - hdiutil detach "$nested_mount" -quiet 2>/dev/null || true + hdiutil detach "$nested_mount" -quiet 2>/dev/null || hdiutil detach "$nested_mount" -force -quiet 2>/dev/null || true fi if [ -n "$mount_point" ]; then - hdiutil detach "$mount_point" -quiet 2>/dev/null || true + hdiutil detach "$mount_point" -quiet 2>/dev/null || hdiutil detach "$mount_point" -force -quiet 2>/dev/null || true fi } trap cleanup EXIT INT TERM From ba881b7647aca7d6ebb631e3dcec4db7af33157c Mon Sep 17 00:00:00 2001 From: rapheljr <86885009+rapheljr@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:59:38 +0530 Subject: [PATCH 4/5] Harden DMG mount resolution, runner variant removal, and panel React keys --- .../NotProtonApp/Model/D3DMetalTools.swift | 33 +++++++++++++++++-- dylib/feats/webpatch.c | 2 +- tools/install-d3dmetal.sh | 4 +-- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/app/Sources/NotProtonApp/Model/D3DMetalTools.swift b/app/Sources/NotProtonApp/Model/D3DMetalTools.swift index 1471eeca..5e54c4e7 100644 --- a/app/Sources/NotProtonApp/Model/D3DMetalTools.swift +++ b/app/Sources/NotProtonApp/Model/D3DMetalTools.swift @@ -337,7 +337,29 @@ enum D3DMetalTools: Sendable { if url.pathExtension.lowercased() == "dmg" { let attach = try? Shell.run("/usr/bin/hdiutil", ["attach", "-nobrowse", "-readonly", url.path(percentEncoded: false)]) - if attach?.succeeded == true { + if let output = attach?.stdout, attach?.succeeded == true { + let lines = output.components(separatedBy: .newlines) + var attachedMount: URL? + for line in lines { + let parts = line.components(separatedBy: "\t") + if let last = parts.last?.trimmingCharacters(in: .whitespacesAndNewlines), last.hasPrefix("/Volumes/") { + attachedMount = URL(filePath: last) + break + } + } + if let attachedMount { + if let direct = locateSource(in: attachedMount) { + return direct + } + if let contents = try? FileManager.default.contentsOfDirectory(at: attachedMount, includingPropertiesForKeys: nil) { + for nested in contents where nested.pathExtension.lowercased() == "dmg" { + if let src = resolveSource(at: nested, maxDepth: maxDepth - 1) { + return src + } + } + } + } + let volumesURL = URL(filePath: "/Volumes") if let volumes = try? FileManager.default.contentsOfDirectory(at: volumesURL, includingPropertiesForKeys: nil) { for vol in volumes { @@ -708,8 +730,13 @@ enum D3DMetalTools: Sendable { if let entries = try? fm.contentsOfDirectory(at: runners, includingPropertiesForKeys: nil) { for entry in entries { let filename = entry.lastPathComponent - if filename.contains("+\(name)") { - try? fm.removeItem(at: entry) + guard filename.hasPrefix("crossover-") && filename.contains("+") else { continue } + let base = filename.components(separatedBy: "@").first ?? filename + let components = base.components(separatedBy: "+").dropFirst() + if components.contains(name) { + if !RunnerInstaller.isRunning(from: entry) { + try? fm.removeItem(at: entry) + } } } } diff --git a/dylib/feats/webpatch.c b/dylib/feats/webpatch.c index cd5f27b4..4427e0f5 100644 --- a/dylib/feats/webpatch.c +++ b/dylib/feats/webpatch.c @@ -1413,7 +1413,7 @@ static int out_expand(np_out_t *o, const char *replace, const np_cap_t *caps) { "{label:npL(\"D3DMetal Frame Rate Limit\",\"D3DMetal 帧率限制\",\"Limite fotogrammi D3DMetal\")," \ "children:(0," RT ".jsx)(" BARREL ".m,{rgOptions:M," \ "selectedOption:(M.find(m=>m.data===G(\"D3DM_MAX_FPS\"))||{data:\"\"}).data," \ - "onChange:v=>s([[\"D3DM_MAX_FPS\",w(\"D3DM_MAX_FPS\",v.data,\"\")]])})},\"fps\")," \ + "onChange:v=>s([[\"D3DM_MAX_FPS\",w(\"D3DM_MAX_FPS\",v.data,\"\")]])})},\"d3dm-fps\")," \ "npCx&&dx&&(0," RT ".jsx)(" BARREL ".XY," \ "{label:npL(\"MetalFX Upscaling (Samples from the resolution the game is set to)\"," \ "\"MetalFX 超分辨率(以游戏设置的分辨率为采样来源)\",\"Upscaling MetalFX (ingrandisce l'immagine dalla risoluzione del gioco)\")," \ diff --git a/tools/install-d3dmetal.sh b/tools/install-d3dmetal.sh index 7d525127..b4d38ede 100755 --- a/tools/install-d3dmetal.sh +++ b/tools/install-d3dmetal.sh @@ -354,11 +354,11 @@ write_descriptor() { # Prepare MetalFX integration if nvngx.dll / nvngx.so not present win_dir="$src/wine/x86_64-windows" if [ -f "$win_dir/nvngx-on-metalfx.dll" ] && [ ! -f "$win_dir/nvngx.dll" ]; then - cp -f "$win_dir/nvngx-on-metalfx.dll" "$win_dir/nvngx.dll" + cp -f "$win_dir/nvngx-on-metalfx.dll" "$win_dir/nvngx.dll" 2>/dev/null || true fi unix_dir="$src/wine/x86_64-unix" if [ -f "$unix_dir/nvngx-on-metalfx.so" ] && [ ! -f "$unix_dir/nvngx.so" ]; then - cp -f "$unix_dir/nvngx-on-metalfx.so" "$unix_dir/nvngx.so" + cp -f "$unix_dir/nvngx-on-metalfx.so" "$unix_dir/nvngx.so" 2>/dev/null || true fi verify_src "$src" || die "$src is not usable; nothing was written" From 466be604428a2121774e4290e8ecd8cb9771c4b9 Mon Sep 17 00:00:00 2001 From: rapheljr <86885009+rapheljr@users.noreply.github.com> Date: Mon, 9 Nov 2026 12:24:39 +0530 Subject: [PATCH 5/5] Bypass lsteamclient for non-Steam shortcuts to prevent ISteamUser errors --- dylib/feats/compat_run.sh | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/dylib/feats/compat_run.sh b/dylib/feats/compat_run.sh index 301b3e19..1578c83e 100755 --- a/dylib/feats/compat_run.sh +++ b/dylib/feats/compat_run.sh @@ -2782,7 +2782,11 @@ if [ -d "$bridge_src" ] && [ -n "$WINEPREFIX" ]; then export WINEDLLPATH="$prefix_steam:$WINEDLLPATH" # If the same DLL appears twice in WINEDLLOVERRIDES, the last entry wins. vc_overrides=$(native_vc_runtime) - export WINEDLLOVERRIDES="${vc_overrides}${WINEDLLOVERRIDES:+$WINEDLLOVERRIDES;}steamclient=n;steamclient64=n;lsteamclient=b" + if shortcut_game_id "$SteamGameId"; then + export WINEDLLOVERRIDES="${vc_overrides}${WINEDLLOVERRIDES:+$WINEDLLOVERRIDES;}steamclient=n,b;steamclient64=n,b;lsteamclient=d" + else + export WINEDLLOVERRIDES="${vc_overrides}${WINEDLLOVERRIDES:+$WINEDLLOVERRIDES;}steamclient=n;steamclient64=n;lsteamclient=b" + fi native_client="$STEAM_COMPAT_CLIENT_INSTALL_PATH" if [ -z "$native_client" ]; then native_client="$HOME/Library/Application Support/Steam/Steam.AppBundle/Steam/Contents/MacOS"