Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,31 @@ This tool is intended to be used with Steam Client 1788652215 or 1790121765 and
20261006 or 2026082**. Both the FEX build and the Rosetta build are supported. The Rosetta build is
the recommended version, as the FEX one is in an early state.

## Free Wine engine (no CrossOver needed)

NotProton can also run entirely on free software. Under Compatibility Tool, **Download & Set Up**
fetches the WineHQ 11.15 macOS build from [Gcenx/macOS_Wine_builds](https://github.com/Gcenx/macOS_Wine_builds)
(hash pinned), unpacks it into `~/Library/Application Support/notproton/engines/` and sets the
compatibility tool up from it exactly like a CrossOver copy. No CrossOver license is involved; the
license check is skipped only for loaders whose hash pins the free build.

11.15 is chosen because it is the Wine the bridge components (`lsteamclient`, `steam.exe`) are built
against. Differences from CrossOver to be aware of:

- It is an x86_64 build and runs under Rosetta 2, like the Rosetta build of CrossOver.
- Direct3D 10/11 goes through [DXMT](https://github.com/3Shain/dxmt) (MIT), downloaded and pinned
alongside Wine, which translates straight to Metal. Wine's own wined3d cannot reach feature level 11
on macOS OpenGL. D3DMetal is Apple's and only works with CrossOver's Wine, so it is not used.
- DXMT needs macdrv functions upstream Wine hides; NotProton ships a rebuilt `winemac.so`
that exports them (see `winemac-patch/`).
- D3D 12 has no fast path; vkd3d over the bundled MoltenVK is limited.
- WineHQ has no msync/esync, so CPU-heavy games can run slower than under CrossOver or Sikarugir.
- The graphics backend options in the Steam panel have no effect on this engine.

To support another WineHQ build, run `ntdll-patch/resolve.py` against its Wine tree, build the
payloads with `ntdll-patch/build.sh` / `build32.sh`, and add the hashes to `SupportedRunners`,
`NtdllPatcher.byBuild` and `FreeEngine`.

The macOS app itself is located in the ```app``` folder. The core logic is in ```dylib```.
```lsteamclient``` is a macOS port of Valve's lsteamclient. ```steam-shim```is a port of Valve's
steam-helper from Proton 9. ntdll-patch patches the copy of CrossOver that the app
Expand Down
3 changes: 3 additions & 0 deletions app/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ let package = Package(
.copy("Resources/detour32-41069.bin"),
.copy("Resources/detour32-fex-41069.bin"),
.copy("Resources/detour64-fex-41069.bin"),
.copy("Resources/detour2-winehq-11.15.bin"),
.copy("Resources/detour32-winehq-11.15.bin"),
.copy("Resources/winemac-winehq-11.15.so"),
],
swiftSettings: [.swiftLanguageMode(.v6)]
),
Expand Down
19 changes: 19 additions & 0 deletions app/Sources/NotProtonApp/Model/CrossOverLicense.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ enum CrossOverLicense {
searchDirs: [URL] = defaultSearchDirs,
openssl: String = defaultOpenssl
) -> Status {
if let build = freeBuild(at: crossOverRoot) {
let status = Status(
licensed: true,
detail: "\(build.releaseVersion) is free software and needs no license.",
diagnostic: "free engine \(build.id), no license required"
)
AppLog.note("license: \(status.diagnostic)")
return status
}
let status = evaluate(
crossOverRoot: crossOverRoot, searchDirs: searchDirs, openssl: openssl
)
Expand Down Expand Up @@ -123,6 +132,16 @@ enum CrossOverLicense {
)
}

// Only a loader whose hash pins a free build counts, so this never applies to
// a CrossOver install, wherever it sits.
static func freeBuild(at root: URL) -> RunnerBuild? {
let loader = Clean.copy(of: CrossOverSource.unixLoader(inRoot: root))
guard let hash = Digest.sha256IfPresent(loader),
let build = SupportedRunners.build(loaderSHA256: hash), build.isFree
else { return nil }
return build
}

static func requireValid(for install: CrossOverInstall) throws {
let status = check(crossOverRoot: install.crossOverRoot)
guard status.licensed else {
Expand Down
13 changes: 13 additions & 0 deletions app/Sources/NotProtonApp/Model/CrossOverSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ struct CrossOverInstall: Sendable, Identifiable {

var isPreview: Bool { name.localizedCaseInsensitiveContains("Preview") }

var isFree: Bool {
if case .supported(let build) = support { return build.isFree }
return false
}

var isUsable: Bool {
if case .supported = support { return true }
return false
Expand All @@ -35,6 +40,8 @@ enum CrossOverSource {
SupportPaths.home.appending(path: "Applications", directoryHint: .isDirectory),
]

static var engineRoot: URL { SupportPaths.engines }

private static let manualKey = "manualCrossOverPath"

static var manualBundle: URL? {
Expand Down Expand Up @@ -75,12 +82,18 @@ enum CrossOverSource {
}
}

let engines = (try? fm.contentsOfDirectory(at: engineRoot, includingPropertiesForKeys: nil)) ?? []
for entry in engines where entry.pathExtension == "app" {
consider(entry, isManual: false)
}

return found.sorted(by: preferred)
}

static func preferred(_ a: CrossOverInstall, _ b: CrossOverInstall) -> Bool {
if a.isManual != b.isManual { return a.isManual }
if a.isUsable != b.isUsable { return a.isUsable }
if a.isFree != b.isFree { return a.isFree }
if a.isPreview != b.isPreview { return a.isPreview }
if a.name != b.name { return a.name < b.name }
return a.id < b.id
Expand Down
217 changes: 217 additions & 0 deletions app/Sources/NotProtonApp/Model/FreeEngine.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
// Downloads a free WineHQ build and lays it out like a CrossOver bundle, so the
// rest of the runner setup treats it as just another source to clone from.

import Foundation

struct FreeEngineRelease: Sendable {
let build: String
let archive: String
let sha256: String
let bases: [URL]
let bundleName: String
// Path of the Wine tree inside the archive.
let wineTree: String
let renderer: RendererRelease
// sha256 of the winemac.so NotProton ships for this build (winemac-patch/), which
// exports the macdrv entry points DXMT needs to put Metal into a window.
let macDriver: String
}

// DXMT replaces Wine's own D3D10/11 and DXGI. Wine's wined3d goes through macOS
// OpenGL 4.1, which tops out below feature level 11 and so fails any Unreal or
// Unity game that needs it, and DXVK pays for a second translation through
// MoltenVK. DXMT goes to Metal directly, the way D3DMetal does, and is MIT licensed.
struct RendererRelease: Sendable {
let name: String
let archive: String
let sha256: String
let bases: [URL]
let tree: String
// arch/file -> sha256 of the file the engine should end up carrying under lib/wine.
let files: [String: String]
}

enum FreeEngine {

static let step = "Download free Wine engine"

// Gcenx's WineHQ packages. 11.15 is the Wine the bridge components are built
// against (bridge/setup-wine-tree.sh), so the unix halves speak its protocol.
static let current = FreeEngineRelease(
build: "winehq-11.15",
archive: "wine-devel-11.15-osx64.tar.xz",
sha256: "3f8e89a4874953a69e7be3dcdcc262d0346e462a9374bd116770fffb9e2a99d2",
bases: [URL(string: "https://github.com/Gcenx/macOS_Wine_builds/releases/download/11.15")!],
bundleName: "WineHQ 11.15.app",
wineTree: "Wine Devel.app/Contents/Resources/wine",
renderer: RendererRelease(
name: "DXMT 0.80",
archive: "dxmt-v0.80-builtin.tar.gz",
sha256: "8f260e36b5739e68f3bad613381441385c4dc7b85b78ba8de653d5a6a264529d",
bases: [URL(string: "https://github.com/3Shain/dxmt/releases/download/v0.80")!],
tree: "v0.80",
// nvapi64 and nvngx are left out: they only serve DLSS to MetalFX, and an
// nvapi that answers makes some games take NVIDIA-only paths.
files: [
"x86_64-unix/winemetal.so": "3d50d7f39c64778c71d0af2fce1cde818d09ffbce7c4f7b8ae24ae1df567c0ca",
"x86_64-windows/d3d10core.dll": "833d26971abc8661efc8b5cc18548c70ce8e482e4ea4b1d3f69a367612264d26",
"x86_64-windows/d3d11.dll": "7ca382af0eb32d8a432f6efb14d594fefb45673663be1f7e6682254bff885c47",
"x86_64-windows/dxgi.dll": "fc58aae0aba511a1ec4d2417e5bba6adb888bb14315d0f59cccdfd27f670d544",
"x86_64-windows/winemetal.dll": "514245d533c750599614311a792c45ed600aef52948571d98c0fc70fd3df16e0",
"i386-windows/d3d10core.dll": "c5a26310d14a30c3e1700a4d0c9865be28a11351a5c4dc5b83ab1bd8fcf7662f",
"i386-windows/d3d11.dll": "9afc2b3419818618c4c87274435a28935b0df002caa4b9a8d3a88d3dc846b17d",
"i386-windows/dxgi.dll": "7df8cdf66e12a108410002abc77b01f70aa59b8a36a70fa0bc6ae3b056841319",
"i386-windows/winemetal.dll": "20a6865facebdaac92b6c06fadf37d2efb5a242b22ca1c349bb57d7ad43df8e3",
]
),
macDriver: "68d1c10cb4ec80beb2e97dcb8df5f81768f4f6e62848932d6bee5270dcf54982"
)

static let macDriverPath = "lib/wine/x86_64-unix/winemac.so"

static var downloads: URL {
SupportPaths.home.appending(path: "Library/Caches/\(SupportPaths.supportDirName)/engines")
}

static func bundle(for release: FreeEngineRelease = current, engines: URL = SupportPaths.engines) -> URL {
engines.appending(path: release.bundleName, directoryHint: .isDirectory)
}

static func isInstalled(_ release: FreeEngineRelease = current, engines: URL = SupportPaths.engines) -> Bool {
let install = CrossOverSource.inspect(bundle: bundle(for: release, engines: engines))
guard case .supported(let build) = install.support, build.id == release.build else { return false }
return hasRenderer(root: install.crossOverRoot, release: release)
}

// An engine unpacked before this renderer was part of it still runs, but D3D11
// games either fail on it or run slowly.
static func hasRenderer(root: URL, release: FreeEngineRelease = current) -> Bool {
release.renderer.files.allSatisfy { path, hash in
Digest.sha256IfPresent(root.appending(path: "lib/wine/\(path)")) == hash
} && Digest.sha256IfPresent(root.appending(path: macDriverPath)) == release.macDriver
}

// Upstream Wine hides the macdrv functions DXMT looks up, so without this DXMT can
// create a device but no swapchain, and games abort on their first window.
static func installMacDriver(root: URL, release: FreeEngineRelease) throws {
guard let source = Bundle.module.url(forResource: "winemac-\(release.build)", withExtension: "so"),
Digest.sha256IfPresent(source) == release.macDriver
else {
throw StepFailure(step: step, detail: "NotProton is missing its winemac.so for \(release.build).")
}
let destination = root.appending(path: macDriverPath)
try? FileManager.default.removeItem(at: destination)
try FileManager.default.copyItem(at: source, to: destination)
}

enum Phase: Sendable {
case downloading(PinnedDownload.Progress)
case unpacking

var label: String {
switch self {
case .downloading(let progress): progress.label
case .unpacking: "Unpacking Wine"
}
}
}

static func install(
_ release: FreeEngineRelease = current,
engines: URL = SupportPaths.engines,
report: @escaping @Sendable (Phase) -> Void = { _ in }
) async throws -> CrossOverInstall {
let archive = try await PinnedDownload.obtain(
file: release.archive, sha256: release.sha256, bases: release.bases,
into: downloads, step: step, source: "GitHub",
report: { report(.downloading($0)) }
)
let renderer = try await PinnedDownload.obtain(
file: release.renderer.archive, sha256: release.renderer.sha256, bases: release.renderer.bases,
into: downloads, step: step, source: "GitHub",
report: { report(.downloading($0)) }
)
report(.unpacking)
return try unpack(archive, renderer: renderer, release: release, engines: engines)
}

static func unpack(
_ archive: URL, renderer: URL?, release: FreeEngineRelease, engines: URL
) throws -> CrossOverInstall {
let fm = FileManager.default
let target = bundle(for: release, engines: engines)
let staging = engines.appending(path: ".\(release.bundleName).new", directoryHint: .isDirectory)
try? fm.removeItem(at: staging)
try fm.createDirectory(at: staging, withIntermediateDirectories: true)
defer { try? fm.removeItem(at: staging) }

let extracted = staging.appending(path: "extract", directoryHint: .isDirectory)
try fm.createDirectory(at: extracted, withIntermediateDirectories: true)
try untar(archive, into: extracted)

let wine = extracted.appending(path: release.wineTree, directoryHint: .isDirectory)
guard fm.fileExists(atPath: wine.appending(path: "lib/wine").path(percentEncoded: false)) else {
throw StepFailure(step: step, detail: "\(release.archive) has no Wine tree at \(release.wineTree).")
}

let shaped = staging.appending(path: release.bundleName, directoryHint: .isDirectory)
let root = SupportPaths.crossOverRoot(inBundle: shaped)
try fm.createDirectory(at: root.deletingLastPathComponent(), withIntermediateDirectories: true)
try fm.moveItem(at: wine, to: root)

if let renderer {
try untar(renderer, into: extracted)
let tree = extracted.appending(path: release.renderer.tree, directoryHint: .isDirectory)
for (path, hash) in release.renderer.files.sorted(by: { $0.key < $1.key }) {
let source = tree.appending(path: path)
guard Digest.sha256IfPresent(source) == hash else {
throw StepFailure(step: step, detail: "\(release.renderer.archive) has no pinned \(path).")
}
let destination = root.appending(path: "lib/wine/\(path)")
try? fm.removeItem(at: destination)
try fm.copyItem(at: source, to: destination)
}
try installMacDriver(root: root, release: release)
}

// WineHQ already keeps its unix loader beside the unix libraries, where
// CrossOver keeps its own, so the Wine tree needs no reshaping inside.
let info: [String: Any] = [
"CFBundleIdentifier": "org.winehq.wine.notproton",
"CFBundleName": release.bundleName.replacingOccurrences(of: ".app", with: ""),
"CFBundleShortVersionString": release.build.replacingOccurrences(of: "winehq-", with: ""),
"CFBundleVersion": release.build,
]
let plist = try PropertyListSerialization.data(fromPropertyList: info, format: .xml, options: 0)
try plist.write(to: shaped.appending(path: "Contents/Info.plist"))
RunnerInstaller.scrubDownloadMarkers(at: shaped)

let install = CrossOverSource.inspect(bundle: shaped)
guard case .supported(let build) = install.support, build.id == release.build else {
throw StepFailure(
step: step,
detail: "The unpacked Wine loader is not the \(release.build) build NotProton was made for."
)
}

try fm.createDirectory(at: engines, withIntermediateDirectories: true)
if fm.fileExists(atPath: target.path(percentEncoded: false)) {
try fm.removeItem(at: target)
}
try fm.moveItem(at: shaped, to: target)
return CrossOverSource.inspect(bundle: target)
}

private static func untar(_ archive: URL, into directory: URL) throws {
let result = try Shell.run("/usr/bin/tar", [
"-xf", archive.path(percentEncoded: false), "-C", directory.path(percentEncoded: false),
])
guard result.status == 0 else {
throw StepFailure(
step: step,
detail: "Unpacking \(archive.lastPathComponent) failed. "
+ result.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
)
}
}
}
33 changes: 33 additions & 0 deletions app/Sources/NotProtonApp/Model/NtdllPatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,39 @@ enum NtdllPatcher {
imageBase: 0x1_8000_0000
),
],
"winehq-11.15": [
NtdllPatch(
arch: .x86_64Windows,
payloadResource: "detour2-winehq-11.15",
payloadSHA256: "d394a6b7f4c55e2bb8b99aeb51cd4555527f8e10fa49339e95a8156486c5e2e5",
caveRVA: 0xb3000,
payloadRVA: 0xb3000,
hooks: [
NtdllHook(rva: 0x3512c, stolen: [0x48, 0x8b, 0x85, 0x90, 0x00, 0x00, 0x00]),
],
caveSize: 0x1000,
cavePad: 0x00,
machine: 0x8664,
magic: 0x20b,
imageBase: 0x1_7000_0000,
placement: .section
),
NtdllPatch(
arch: .i386Windows,
payloadResource: "detour32-winehq-11.15",
payloadSHA256: "527431a3a74e9dff7bc3582277ddfa7b44bcac6fba285c028fd2c94fd9202418",
caveRVA: 0x6a075,
payloadRVA: 0x6a080,
hooks: [
NtdllHook(rva: 0x2edcd, stolen: [0x8b, 0x45, 0x14, 0xa8, 0x02]),
],
caveSize: 3979,
cavePad: 0xcc,
machine: 0x14c,
magic: 0x10b,
imageBase: 0x7bc0_0000
),
],
]

static func patches(for build: RunnerBuild) -> [NtdllPatch] {
Expand Down
Loading