From 88864788a3f17f36df93004d0b3a4d8e2000d984 Mon Sep 17 00:00:00 2001 From: KAMEDAkyosuke Date: Thu, 24 Sep 2026 09:59:46 +0900 Subject: [PATCH] feat: start titles from a game bundle, because macOS only puts a process in Game Mode when its bundle says it is a game macOS puts a process in Game Mode only when its bundle says it is a game, and the engine's wine has none. Without a bundle the Dock already showed each program under its own name and icon; once a process is in one, the Dock and the Game Overlay take both from the bundle instead, so every program needs a bundle of its own. A title now starts from engine/SakeGame.app, a copy of Wine's unix side with LSApplicationCategoryType games and LSUIElement, and patches/0007 has Wine start every program the title starts from a bundle of its own in engine/SakePrograms//.app: hard links to the parent's bundle, named after the exe, keyed by a hash of its path, and carrying the exe's icon from before its first launch. Measured on 2026-09-23: Diablo IV started from Battle.net shows as "Diablo IV" with its icon in the Dock and the Game Overlay, and goes into Game Mode full-screen. docs/runtime.md has why each piece is there; the patch header has the measurements. --- README.md | 2 +- Sources/SakeKit/GameBundle.swift | 201 ++++++++ Sources/SakeKit/Paths.swift | 8 + Sources/SakeKit/TitleLauncher.swift | 25 +- Tests/SakeKitTests/GameBundleTests.swift | 201 ++++++++ Tests/SakeKitTests/PatchTests.swift | 3 +- docs/layout.md | 6 + docs/roadmap.md | 6 + docs/runtime.md | 58 ++- docs/wine-build.md | 4 +- ...am-from-a-game-bundle-named-after-it.patch | 465 ++++++++++++++++++ patches/README.md | 3 +- 12 files changed, 964 insertions(+), 18 deletions(-) create mode 100644 Sources/SakeKit/GameBundle.swift create mode 100644 Tests/SakeKitTests/GameBundleTests.swift create mode 100644 patches/0007-ntdll-start-each-program-from-a-game-bundle-named-after-it.patch diff --git a/README.md b/README.md index 320ea19..5c143f9 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ unmounts it again. See `docs/licensing.md`. | | | |---|---| -| `Sources/SakeKit/` | the layout, a subprocess runner, the preflight checks, the source fetcher, the prefix build, the Wine build, the patch step, the D3DMetal step, the bottle, the import, the installer, the titles, starting one, how big a tree is, and the uninstall | +| `Sources/SakeKit/` | the layout, a subprocess runner, the preflight checks, the source fetcher, the prefix build, the Wine build, the patch step, the D3DMetal step, the bottle, the import, the installer, the titles, starting one, the bundle that puts it in Game Mode, how big a tree is, and the uninstall | | `Sources/sake/` | the SwiftUI app — two windows, kept thin | | `patches/` | the changes sake makes to Wine's own code — LGPL-2.1-or-later, not MIT; `patches/README.md` says where each came from | | `docs/` | how the thing actually has to work, what breaks when it doesn't, and how to play one game | diff --git a/Sources/SakeKit/GameBundle.swift b/Sources/SakeKit/GameBundle.swift new file mode 100644 index 0000000..f6d9591 --- /dev/null +++ b/Sources/SakeKit/GameBundle.swift @@ -0,0 +1,201 @@ +import Foundation + +/// The engine's `wine` inside an `.app` that declares itself a game, because Game Mode reads +/// a process's bundle and the engine's `wine` has none. +/// +/// A bundle that merely starts `wine` is not enough: Wine starts every child process from its +/// own loader, so the loader itself has to be in `Contents/MacOS`. `patches/0007` gives each +/// program a title starts a bundle of its own mirrored from this one, so this one is shared +/// and never seen. It lives in the engine so that uninstalling the engine takes it with it. +/// See docs/runtime.md. +public struct GameBundle: Sendable, Equatable { + public let paths: Paths + + public init(paths: Paths = .default) { + self.paths = paths + } + + public static let bundleIdentifier = "dev.typester.sake.game" + + public var url: URL { paths.gameBundle } + + public var executableURL: URL { url.appending(path: "Contents/MacOS/wine") } + + /// The bundle's `wine`, made or brought up to date, or `nil` when there is no engine to + /// make it from. + /// + /// Left alone when it already matches the engine: a running title executes out of it, + /// and every program bundle is a set of hard links to its files. + @discardableResult + public func prepare() throws -> URL? { + let manager = FileManager.default + guard manager.fileExists(atPath: paths.wineUnixLibraries.appending(path: "wine").path) + else { return nil } + + let wanted = entries() + if !isCurrent(wanted) { + let staging = paths.engine.appending(path: ".SakeGame-\(UUID().uuidString)") + defer { try? manager.removeItem(at: staging) } + for entry in wanted { + try entry.make(in: staging) + } + if manager.fileExists(atPath: url.path) { + _ = try manager.replaceItemAt(url, withItemAt: staging) + } else { + try manager.moveItem(at: staging, to: url) + } + } + sweepPrograms() + return executableURL + } + + /// Removes the staging and stale copies the patched ntdll leaves beside program bundles, + /// and unkeyed bundles from an older layout. They are hard links, so a program still + /// running from one is unaffected. + private func sweepPrograms() { + let manager = FileManager.default + let programs = paths.programBundles + for key in Self.names(in: programs) { + let directory = programs.appending(path: key) + if key.hasPrefix(".") || key.hasSuffix(".app") { + try? manager.removeItem(at: directory) + continue + } + for name in Self.names(in: directory) where name.hasPrefix(".") { + try? manager.removeItem(at: directory.appending(path: name)) + } + } + } + + /// Wine derives every other directory from the real path of `ntdll.so` — including the + /// loader it starts child processes with — so the unix side is copied, not linked: a + /// link resolves back into the engine and the children leave the bundle. Once `ntdll.so` + /// is in a directory not called `lib/wine/x86_64-unix`, Wine looks for the rest relative + /// to it, which is what the links are for. Measured on 2026-09-23, wine-11.0. + func entries() -> [Entry] { + let manager = FileManager.default + let engine = paths.engine + let lib = engine.appending(path: "lib") + let unix = paths.wineUnixLibraries + var entries: [Entry] = [] + + for name in Self.names(in: unix) { + let source = unix.appending(path: name) + if let destination = try? manager.destinationOfSymbolicLink(atPath: source.path) { + // Relative ones stay relative: `../../external/…` is what the `.so` links + // D3DMetal installs, and the bundle has an `external` of its own for it. + entries.append(Entry(path: "Contents/MacOS/\(name)", kind: .link(destination))) + } else { + entries.append(Entry(path: "Contents/MacOS/\(name)", kind: .copy(source))) + } + } + // `/x86_64-unix/.so` is where a PE module's unix half is looked for. + entries.append(Entry(path: "Contents/MacOS/x86_64-unix", kind: .link("."))) + for name in Self.names(in: lib.appending(path: "wine")) where name.hasSuffix("-windows") { + entries.append(Entry( + path: "Contents/MacOS/\(name)", kind: .link(lib.appending(path: "wine/\(name)").path) + )) + } + // `ntdll.so` names `../external/libd3dshared.dylib` from its own directory. + entries.append(Entry(path: "Contents/external", kind: .link(lib.appending(path: "external").path))) + + // The `.so` files name their dylibs `@loader_path/../../`, which from + // `Contents/MacOS` is the top of the bundle. + for name in Self.names(in: lib) where name.hasSuffix(".dylib") || name == "external" { + entries.append(Entry(path: name, kind: .link(lib.appending(path: name).path))) + } + entries.append(Entry(path: "share", kind: .link(engine.appending(path: "share").path))) + entries.append(Entry(path: "bin", kind: .link(engine.appending(path: "bin").path))) + + entries.append(Entry(path: "Contents/Info.plist", kind: .data(Data(infoPlist().utf8)))) + return entries + } + + private func isCurrent(_ entries: [Entry]) -> Bool { + let top = Set(entries.map { $0.path.split(separator: "/").first.map(String.init) ?? $0.path }) + let inMacOS = Set(entries.compactMap { entry -> String? in + guard entry.path.hasPrefix("Contents/MacOS/") else { return nil } + return String(entry.path.dropFirst("Contents/MacOS/".count)) + }) + // An entry the engine no longer has would otherwise survive every refresh. + guard Set(Self.names(in: url)) == top, + Set(Self.names(in: url.appending(path: "Contents/MacOS"))) == inMacOS + else { return false } + return entries.allSatisfy { $0.matches(in: url) } + } + + private static func names(in directory: URL) -> [String] { + (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? [] + } + + /// `LSUIElement` because every process of a prefix runs this executable: without it + /// each one that touches the window server — `explorer.exe`, a CEF helper, Agent — is a + /// Dock tile of its own. Wine makes a process a regular app when it shows a window, so + /// the game still gets its tile. + public func infoPlist() -> String { + """ + + + + + CFBundleExecutable + wine + CFBundleIdentifier + \(Self.bundleIdentifier) + CFBundleName + Sake Game + CFBundlePackageType + APPL + LSApplicationCategoryType + public.app-category.games + LSUIElement + + NSHighResolutionCapable + + + + """ + } + + struct Entry: Equatable { + enum Kind: Equatable { + case copy(URL) + case link(String) + case data(Data) + } + + let path: String + let kind: Kind + + func make(in root: URL) throws { + let manager = FileManager.default + let target = root.appending(path: path) + try manager.createDirectory( + at: target.deletingLastPathComponent(), withIntermediateDirectories: true + ) + switch kind { + case .copy(let source): + try manager.copyItem(at: source, to: target) + case .link(let destination): + try manager.createSymbolicLink(atPath: target.path, withDestinationPath: destination) + case .data(let data): + try data.write(to: target) + } + } + + func matches(in root: URL) -> Bool { + let manager = FileManager.default + let target = root.appending(path: path) + let link = try? manager.destinationOfSymbolicLink(atPath: target.path) + switch kind { + case .copy(let source): + return link == nil && manager.contentsEqual(atPath: source.path, andPath: target.path) + case .link(let destination): + return link == destination + case .data(let data): + return link == nil && manager.contents(atPath: target.path) == data + } + } + } +} diff --git a/Sources/SakeKit/Paths.swift b/Sources/SakeKit/Paths.swift index a85cdc7..d5c3413 100644 --- a/Sources/SakeKit/Paths.swift +++ b/Sources/SakeKit/Paths.swift @@ -42,6 +42,14 @@ public struct Paths: Sendable, Equatable { /// beside it for i386: under WoW64 the unix side is x86_64 only. public var wineUnixLibraries: URL { engine.appending(path: "lib/wine/x86_64-unix") } + /// See ``GameBundle`` and docs/runtime.md. + public var gameBundle: URL { engine.appending(path: "SakeGame.app") } + + /// `/.app` per program a title starts, keyed by a hash of the exe's path. + /// The patched ntdll makes these (`patches/0007`); sake only passes the directory as + /// `SAKE_GAME_BUNDLES`. + public var programBundles: URL { engine.appending(path: "SakePrograms") } + /// `redist/lib` out of Apple's image, kept as the user's own copy so the toolkit does /// not have to stay mounted to put D3DMetal back after a rebuild. See docs/licensing.md /// for why keeping it is inside the line and shipping it is not. diff --git a/Sources/SakeKit/TitleLauncher.swift b/Sources/SakeKit/TitleLauncher.swift index 71897d1..039d737 100644 --- a/Sources/SakeKit/TitleLauncher.swift +++ b/Sources/SakeKit/TitleLauncher.swift @@ -55,8 +55,11 @@ public struct TitleLauncher: Sendable { /// Started from the game's own directory and named by its bare leaf, which is how the /// prototype started it and what keeps `argv[0]` distinguishable from the full Windows /// path the Battle.net Agent passes. - public func command() -> Command { - bottle.command( + /// + /// A ``GameBundle`` `wine` also sets `SAKE_GAME_BUNDLES`, so every program it starts + /// gets a bundle of its own. `nil` runs the engine's `bin/wine`. + public func command(wine: URL? = nil) -> Command { + var command = bottle.command( "wine", [title.program] + title.arguments, workingDirectory: title.directoryURL(in: bottle), @@ -65,6 +68,17 @@ public struct TitleLauncher: Sendable { inheriting: ProcessInfo.processInfo.environment .merging(title.environment) { _, mine in mine } ) + if let wine { + command.executable = wine + command.environment?["SAKE_GAME_BUNDLES"] = paths.programBundles.path + } + return command + } + + /// A failure to make the bundle is not a failure to start the game: the game runs + /// either way, and only Game Mode is lost. + public func gameWine() -> URL? { + (try? GameBundle(paths: paths).prepare()) ?? nil } public func launch() -> AsyncStream { @@ -78,7 +92,7 @@ public struct TitleLauncher: Sendable { let log = try LogFile(at: logURL) defer { log.close() } - let command = self.command() + let command = self.command(wine: self.gameWine()) log.write("=== launch \(command.arguments.joined(separator: " "))\n") continuation.yield(.started(title)) @@ -137,8 +151,9 @@ public struct TitleLauncher: Sendable { /// wineserver is matched on the engine root and the name rather than on /// `engine/bin/wineserver`, because it does not spell itself that way: measured on - /// 2026-09-19, `ps` shows `/lib/wine/../../bin/wineserver`. The tidier path - /// never matches, and a survivor check that never matches reports success. + /// 2026-09-19, `ps` shows `/lib/wine/../../bin/wineserver`, and one a title + /// started shows `/SakeGame.app/Contents/MacOS/../../bin/wineserver`. The + /// tidier path never matches, and a survivor check that never matches reports success. static func ours(in lines: [String], program: String, engine: URL) -> [String] { lines.filter { line in matches(line, program: program) diff --git a/Tests/SakeKitTests/GameBundleTests.swift b/Tests/SakeKitTests/GameBundleTests.swift new file mode 100644 index 0000000..54a4a95 --- /dev/null +++ b/Tests/SakeKitTests/GameBundleTests.swift @@ -0,0 +1,201 @@ +import Foundation +import Testing + +@testable import SakeKit + +private func temporaryRoot() -> Paths { + let root = FileManager.default.temporaryDirectory.appending(path: "sake-bundle-\(UUID().uuidString)") + return Paths(root: root.appending(path: "support"), cache: root.appending(path: "cache")) +} + +private func remove(_ paths: Paths) { + try? FileManager.default.removeItem(at: paths.root.deletingLastPathComponent()) +} + +private let diablo = Title( + id: "diablo-iv", + name: "Diablo IV", + executable: "Program Files (x86)/Diablo IV/Diablo IV.exe" +) + +/// The shape of an installed engine, with D3DMetal's relative link in it. +private func makeEngine(_ paths: Paths) throws { + let manager = FileManager.default + let engine = paths.engine + let unix = paths.wineUnixLibraries + for directory in [ + unix, engine.appending(path: "lib/wine/x86_64-windows"), + engine.appending(path: "lib/wine/i386-windows"), engine.appending(path: "lib/external"), + engine.appending(path: "share/wine"), engine.appending(path: "bin"), + ] { + try manager.createDirectory(at: directory, withIntermediateDirectories: true) + } + try Data("loader".utf8).write(to: unix.appending(path: "wine")) + try Data("ntdll".utf8).write(to: unix.appending(path: "ntdll.so")) + try Data("bridge".utf8).write(to: paths.d3dSharedLibrary) + try Data("freetype".utf8).write(to: engine.appending(path: "lib/libfreetype.6.dylib")) + try Data("archive".utf8).write(to: engine.appending(path: "lib/libgmp.a")) + try Data("server".utf8).write(to: engine.appending(path: "bin/wineserver")) + try manager.createSymbolicLink( + atPath: unix.appending(path: "d3d11.so").path, + withDestinationPath: "../../external/libd3dshared.dylib" + ) +} + +private func isLink(_ url: URL) -> Bool { + (try? FileManager.default.destinationOfSymbolicLink(atPath: url.path)) != nil +} + +@Test func withoutAnEngineThereIsNoBundle() throws { + let paths = temporaryRoot() + defer { remove(paths) } + let bundle = GameBundle(paths: paths) + + #expect(try bundle.prepare() == nil) + #expect(!FileManager.default.fileExists(atPath: bundle.url.path)) +} + +@Test func theBundleIsAGameWhoseExecutableIsWine() throws { + let paths = temporaryRoot() + defer { remove(paths) } + try makeEngine(paths) + let bundle = GameBundle(paths: paths) + + let wine = try bundle.prepare() + + #expect(wine == bundle.executableURL) + #expect(bundle.url.path.hasPrefix(paths.engine.path)) + let plist = try PropertyListSerialization.propertyList( + from: try Data(contentsOf: bundle.url.appending(path: "Contents/Info.plist")), format: nil + ) as? [String: Any] + #expect(plist?["CFBundleExecutable"] as? String == "wine") + #expect(plist?["CFBundleIdentifier"] as? String == GameBundle.bundleIdentifier) + #expect(plist?["LSApplicationCategoryType"] as? String == "public.app-category.games") + #expect(plist?["LSUIElement"] as? Bool == true) +} + +@Test func theUnixSideIsCopiedBecauseWineFollowsLinksOutOfTheBundle() throws { + let paths = temporaryRoot() + defer { remove(paths) } + try makeEngine(paths) + let bundle = GameBundle(paths: paths) + try bundle.prepare() + let macOS = bundle.url.appending(path: "Contents/MacOS") + + for name in ["wine", "ntdll.so"] { + #expect(!isLink(macOS.appending(path: name))) + #expect(FileManager.default.contentsEqual( + atPath: macOS.appending(path: name).path, + andPath: paths.wineUnixLibraries.appending(path: name).path + )) + } +} + +@Test func everythingWineLooksForFromTheBundleResolves() throws { + let paths = temporaryRoot() + defer { remove(paths) } + try makeEngine(paths) + let bundle = GameBundle(paths: paths) + try bundle.prepare() + let macOS = bundle.url.appending(path: "Contents/MacOS") + let manager = FileManager.default + + func resolves(_ relative: String, to expected: URL) { + let found = macOS.appending(path: relative).resolvingSymlinksInPath() + #expect(found.path == expected.resolvingSymlinksInPath().path, "\(relative)") + } + resolves("x86_64-windows", to: paths.engine.appending(path: "lib/wine/x86_64-windows")) + resolves("i386-windows", to: paths.engine.appending(path: "lib/wine/i386-windows")) + resolves("../../share/wine", to: paths.engine.appending(path: "share/wine")) + resolves("../../bin/wineserver", to: paths.engine.appending(path: "bin/wineserver")) + resolves("../../libfreetype.6.dylib", to: paths.engine.appending(path: "lib/libfreetype.6.dylib")) + resolves("../external/libd3dshared.dylib", to: paths.d3dSharedLibrary) + resolves("d3d11.so", to: paths.d3dSharedLibrary) + #expect(manager.fileExists(atPath: macOS.appending(path: "x86_64-unix/ntdll.so").path)) + #expect(macOS.appending(path: "x86_64-unix/ntdll.so").resolvingSymlinksInPath().path + == macOS.appending(path: "ntdll.so").resolvingSymlinksInPath().path) + // Build leftovers are not what Wine loads. + #expect(!manager.fileExists(atPath: bundle.url.appending(path: "libgmp.a").path)) +} + +@Test func aBundleThatMatchesTheEngineIsLeftAlone() throws { + let paths = temporaryRoot() + defer { remove(paths) } + try makeEngine(paths) + let bundle = GameBundle(paths: paths) + try bundle.prepare() + let before = try FileManager.default.attributesOfItem(atPath: bundle.url.path)[.systemFileNumber] + + try bundle.prepare() + + let after = try FileManager.default.attributesOfItem(atPath: bundle.url.path)[.systemFileNumber] + #expect(before as? Int == after as? Int) +} + +@Test func aRebuiltEngineIsCopiedAgain() throws { + let paths = temporaryRoot() + defer { remove(paths) } + try makeEngine(paths) + let bundle = GameBundle(paths: paths) + try bundle.prepare() + + try Data("ntdll, rebuilt".utf8).write(to: paths.wineUnixLibraries.appending(path: "ntdll.so")) + try FileManager.default.removeItem(at: paths.wineUnixLibraries.appending(path: "d3d11.so")) + try bundle.prepare() + + let macOS = bundle.url.appending(path: "Contents/MacOS") + #expect(FileManager.default.contents(atPath: macOS.appending(path: "ntdll.so").path) + == Data("ntdll, rebuilt".utf8)) + #expect(!isLink(macOS.appending(path: "d3d11.so"))) + #expect(!FileManager.default.fileExists(atPath: macOS.appending(path: "d3d11.so").path)) +} + +@Test func oneBundleServesEveryTitleAndProgramBundlesGoBesideIt() { + let paths = temporaryRoot() + + #expect(GameBundle(paths: paths).url == paths.engine.appending(path: "SakeGame.app")) + #expect(paths.programBundles == paths.engine.appending(path: "SakePrograms")) + // Hard links need the same volume, which is what being in the engine guarantees. + #expect(paths.programBundles.deletingLastPathComponent() == paths.gameBundle.deletingLastPathComponent()) +} + +@Test func launchingThroughTheBundleChangesOnlyTheExecutable() { + let paths = temporaryRoot() + defer { remove(paths) } + let launcher = TitleLauncher(paths: paths, name: "default", title: diablo) + let wine = GameBundle(paths: paths).executableURL + + let direct = launcher.command() + let through = launcher.command(wine: wine) + + #expect(direct.executable == paths.engine.appending(path: "bin/wine")) + #expect(through.executable == wine) + #expect(through.arguments == direct.arguments) + #expect(through.workingDirectory == direct.workingDirectory) + #expect(through.environment?["SAKE_GAME_BUNDLES"] == paths.programBundles.path) + #expect(direct.environment?["SAKE_GAME_BUNDLES"] == nil) + var rest = through.environment + rest?["SAKE_GAME_BUNDLES"] = nil + #expect(rest == direct.environment) +} + +@Test func whatThePatchedNtdllLeftBehindIsSweptAndItsBundlesAreNot() throws { + let paths = temporaryRoot() + defer { remove(paths) } + try makeEngine(paths) + let programs = paths.programBundles + let manager = FileManager.default + let key = "dd402fb5687d8ece" + for name in [ + "\(key)/Diablo IV.app", "\(key)/.staging-123", "\(key)/.stale-456", + // From before bundles were keyed by the exe's path. + "Battle.net.app", ".stale-Battle.net-789", + ] { + try manager.createDirectory(at: programs.appending(path: name), withIntermediateDirectories: true) + } + + try GameBundle(paths: paths).prepare() + + #expect(try manager.contentsOfDirectory(atPath: programs.path) == [key]) + #expect(try manager.contentsOfDirectory(atPath: programs.appending(path: key).path) == ["Diablo IV.app"]) +} diff --git a/Tests/SakeKitTests/PatchTests.swift b/Tests/SakeKitTests/PatchTests.swift index b1735ff..8d48c71 100644 --- a/Tests/SakeKitTests/PatchTests.swift +++ b/Tests/SakeKitTests/PatchTests.swift @@ -139,7 +139,7 @@ private func read(_ tree: URL) throws -> String { try String(contentsOf: tree.appending(path: "dlls/ntdll/unix/loader.c"), encoding: .utf8) } -@Test func theRepositoryCarriesTheSixPatchesAndSaysTheyAreNotMIT() throws { +@Test func theRepositoryCarriesTheSevenPatchesAndSaysTheyAreNotMIT() throws { let patcher = WinePatcher(directory: repositoryPatches) let patches = try patcher.patches() @@ -150,6 +150,7 @@ private func read(_ tree: URL) throws -> String { "0004-winemac-cross-process-MetalViewSwapChain-via-CALayerHost.patch", "0005-winemac-cross-process-child-window-swapchains.patch", "0006-winemac-give-D3DMetal-a-hosted-swapchain-for-a-window-it-does-not-own.patch", + "0007-ntdll-start-each-program-from-a-game-bundle-named-after-it.patch", ]) // Each one says what it does on its first line, which is where the reasoning starts, // and names the module it changes the way a Wine commit does. diff --git a/docs/layout.md b/docs/layout.md index 8db839d..5593e2a 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -12,6 +12,12 @@ those were measured in sake. /Applications/Sake.app the app, and nothing else ~/Library/Sake/ engine/ Wine, its libraries and D3DMetal, 1.1 GB + engine/SakeGame.app Wine's unix side again, as a bundle macOS + sees as a game, which titles start from; + generated, 5.6 MB — see runtime.md + engine/SakePrograms//.app the same, one per program a title starts, + made by Wine itself; hard links, so nothing + on disk — see runtime.md bottles// one prefix each, and the games in them; empty is 1 GB, and a cloned game adds nothing bottles//sake-titles.json what was added to the library by hand, if diff --git a/docs/roadmap.md b/docs/roadmap.md index ee004dd..31a7c71 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -118,6 +118,12 @@ and the argument suggestion is a heuristic for one launcher rather than a rule f Nothing yet knows that starting Diablo IV directly fails on the token — `runtime.md` says so, the app does not. +**Titles run in Game Mode since 2026-09-23.** macOS decides it from the bundle a process's +executable is in, and Wine's has none, so a title starts from `engine/SakeGame.app`, a copy +of Wine's unix side, and `patches/0007` gives every program it starts a bundle of its own +named after the program, carrying its icon. Diablo IV started from Battle.net shows as +"Diablo IV" in the Dock and the Game Overlay. `runtime.md` has why each piece is there. + ### Phase 4 — the GUI proper (under way) Setup flow, library, per-title configuration, uninstall. `layout.md` covers where things go diff --git a/docs/runtime.md b/docs/runtime.md index f492599..518f1c2 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -189,9 +189,11 @@ Three things in that list defeat a naive process check: appears as `start.exe`, never as the game. That is precisely the case the "cut `argv[0]` at its first `.exe`" rule exists for, and it drops out as intended. - **wineserver does not spell itself `/bin/wineserver`.** `ps` shows - `/lib/wine/../../bin/wineserver`. A teardown check matching the tidy path matches - nothing and so reports success every time — sake's first version did exactly that, and - only a real run showed it. + `/lib/wine/../../bin/wineserver`, or, since titles start from a bundle, + `/SakeGame.app/Contents/MacOS/../../bin/wineserver`. A teardown check matching the + tidy path matches nothing and so reports success every time — sake's first version did + exactly that, and only a real run showed it. Both are under the engine, which is what the + check matches now. - **`--in-process-gpu` does not mean one process.** It folds the GPU into the browser process; the renderer and the two utility processes remain their own. Four `Battle.net.exe` is what a healthy run has. @@ -207,11 +209,11 @@ and the renderer that draws it was still alive seventy-five seconds later. ## Two patches to ntdll -sake carries six patches in `patches/`, all LGPL-2.1-or-later because all are derivatives of -Wine. The two in ntdll are this section's; they came from the prototype unchanged and go in -before configure. The four in winemac.drv arrived with Steam on 2026-09-20 and are in the -Steam section below. The build side of patching is in `wine-build.md` and the licence side in -`licensing.md`. +sake carries seven patches in `patches/`, all LGPL-2.1-or-later because all are derivatives +of Wine. The two in ntdll are this section's; they came from the prototype unchanged and go +in before configure. The four in winemac.drv arrived with Steam on 2026-09-20 and are in the +Steam section below. The seventh, in ntdll, is Game Mode's, and is in that section. The +build side of patching is in `wine-build.md` and the licence side in `licensing.md`. **sake measured both on 2026-09-19**, against its own engine and bottle, the day it started carrying them. The prototype's numbers are kept beside sake's because they are the @@ -311,6 +313,46 @@ directory and nothing else. So "start Diablo IV" as a title in sake need not mea `Diablo IV.exe`: it can mean starting the launcher Blizzard ships, which talks to the client the way the Play button does. **Untested** — the shortcut was read, not run. +## Game Mode needs a bundle, and Wine has none + +macOS turns Game Mode on for a full-screen process whose bundle's `Info.plist` has +`LSApplicationCategoryType` ending in `games`; there is no other key for it. +`/bin/wine` has no bundle. So a title is started by +`/SakeGame.app/Contents/MacOS/wine`, and `patches/0007` starts every program the +title starts from a bundle of its own. Measured on 2026-09-23: Diablo IV started from +Battle.net showed as "Diablo IV", with its icon, in the Dock and the Game Overlay, and went +into Game Mode full-screen. + +**`SakeGame.app` holds a copy of Wine's unix side, not links to it.** A bundle that only +`execv`s wine labels that one process: Wine starts every other from its own loader, which it +finds from `realpath()` of `ntdll.so`. So `Contents/MacOS` is a 5.6 MB copy of +`lib/wine/x86_64-unix`, and what Wine then looks for relative to it — the PE directories, +`x86_64-unix` (a link to `.`), `external`, the dylibs, `share`, `bin` — is linked into the +engine. `GameBundle` makes it when a title starts and leaves it alone while it matches. + +**`LSUIElement` is set**, or every process that touches the window server gets a blank Dock +tile. Wine promotes a process with a window to a regular app itself, and sets its icon while +doing so. + +**A program's bundle is `/SakePrograms//.app`**, made by ntdll the +first time it starts that program, when `SAKE_GAME_BUNDLES` is set — `TitleLauncher` sets it +for titles only. It mirrors the parent's bundle with **hard links**, so `realpath()` stays +inside it, and has an `Info.plist` of its own: + +- the Dock label is the bundle's file name and nothing else, so the file is named after the + exe; +- `` hashes the exe's path, and is in the identifier, so two games' `Launcher.exe` are + two bundles; +- the exe's icon is written as `AppIcon.ico` before the first exec, because the Game Overlay + keeps what a bundle had when macOS first registered it; +- CrossOver's Hack 22144 is skipped for it, since with `WINEDLLPATH` set it would move the + program back out. + +A stale bundle — a rebuilt engine, or no `SakeBundleFormat` 2 — is set aside as +`/.stale-` and made again; `GameBundle` sweeps those and `.staging-*` when a title +starts. Use `lsappinfo`, not `lsof`, to see which bundle a process is in: with hard links +`lsof` shows one path for all of them. `patches/0007`'s header has the measurements. + ## Controllers need SDL2 `winebus.sys` has two backends. **IOHID** is built either way and handles anything behaving diff --git a/docs/wine-build.md b/docs/wine-build.md index f43eded..a25dda8 100644 --- a/docs/wine-build.md +++ b/docs/wine-build.md @@ -58,8 +58,8 @@ Notes that cost time to find: ## The patches go in before configure -`patches/` holds the changes sake makes to Wine's own code — six of them as of 2026-09-20, -two in ntdll and four in winemac.drv, all LGPL-2.1-or-later rather than this repository's +`patches/` holds the changes sake makes to Wine's own code — seven of them as of 2026-09-23, +three in ntdll and four in winemac.drv, all LGPL-2.1-or-later rather than this repository's MIT. Two of the four are upstream Wine commits carried only until the CrossOver sources sake builds catch up with wine-11.11, one is the reference implementation attached to Wine bug 60263, and the rest are sake's own; each file's header says which it is and where it came diff --git a/patches/0007-ntdll-start-each-program-from-a-game-bundle-named-after-it.patch b/patches/0007-ntdll-start-each-program-from-a-game-bundle-named-after-it.patch new file mode 100644 index 0000000..6205207 --- /dev/null +++ b/patches/0007-ntdll-start-each-program-from-a-game-bundle-named-after-it.patch @@ -0,0 +1,465 @@ +ntdll: start each program from a game bundle named after it. + +sake starts a title from /SakeGame.app/Contents/MacOS/wine, a copy of Wine's +unix side inside an .app that declares LSApplicationCategoryType games, because macOS +decides Game Mode from the bundle a process's executable is in. Every process the title +starts afterwards comes from the same loader, since init_paths() derives the loader for +the next process from realpath() of ntdll.so -- so without this, Diablo IV started from +Battle.net is in that one bundle, and the Dock labels its tile with that bundle's name. +The Dock takes that label from the bundle's file name and nothing else: measured on +2026-09-23, neither CFBundleName nor an _kLSDisplayNameKey the process sets on itself +changes it. + +So when SAKE_GAME_BUNDLES is set, a process being started whose loader sits in +/Contents/MacOS is started from $SAKE_GAME_BUNDLES//.app instead, +made on first use as a mirror of the parent's bundle: directories made, symlinks copied +as they are, files hard-linked, and an Info.plist of its own naming the program. Hard +links and not symlinks, because through a symlink realpath() would lead back into the +parent's bundle and the program's own children would leave its. They cost directory +entries only. + +The name is extract_exe_name(), which CW HACK 22144 already uses for the same purpose: +that hack names a link after the program so the Dock shows it, and does so only when +WINEDLLPATH is set, in a temporary directory that is no bundle. This runs before it, and +when it has put the program in a bundle the hack is skipped: its link would take the +program back out, which with WINEDLLPATH set is what happened, measured on 2026-09-23. + + is a 64-bit FNV-1a of the exe's unix path, lower-cased, and the bundle identifier +is dev.typester.sake.program... The name alone is not the program: two +games may both ship a Launcher.exe or a crashpad_handler.exe, and a bundle is one +identity to macOS -- name, icon and Game Mode session -- so keyed by name the second +would have been shown with the first one's icon. Measured the same day with notepad.exe +and winemine.exe both copied in as Launcher.exe: two bundles, two identifiers, two +icons, and the same exe under another spelling of its path found its own. The extension +is cut here: extract_exe_name() measures the name without it and then returns the whole +string, which made the first bundles notepad.exe.app. + +Measured on 2026-09-23 against a lab bundle and a fresh prefix, `wine start notepad`: +notepad came up from notepad.app as its own bundle identifier, Foreground, and explorer +from explorer.app as UIElement; the Dock labelled the tile "notepad", and full-screen +turned Game Mode on. The bundle's ntdll.so was the parent's inode. + +The bundle also carries the program's own icon, because the Game Overlay shows the icon +a bundle had when macOS first registered it and nothing later: an icon added afterwards +stayed blank even through lsregister -f. So it has to be in place before the first exec, +and it is: the exe's first RT_GROUP_ICON and the RT_ICON images it lists, put back +together as Contents/Resources/AppIcon.ico and named in CFBundleIconFile. A .ico is +enough; the file is read through $WINEPREFIX/dosdevices, since image_path is the Windows +path. An exe with no icon, or one this cannot read, gets a bundle without one. Measured +on 2026-09-23: notepad.exe gave a ten-image .ico and the overlay showed notepad's icon. + +A bundle is current when its loader is the same file as the running one and its +Info.plist carries SakeBundleFormat 2. One without the marker was made before the icon +was, and is made again under a new identifier, dev.typester.sake.program.: +registered once without an icon, the old identifier would stay without it. One that is +not current -- the title's bundle was remade after the engine was rebuilt, or the format +is older -- is renamed aside to /.stale- and made again, and a failed or lost +attempt leaves /.staging-; sake sweeps both. Nothing changes when the variable +is unset, when the running loader is not in a bundle, or when any step fails: the +process is then started from the loader it would have been. + +--- a/dlls/ntdll/unix/loader.c ++++ b/dlls/ntdll/unix/loader.c +@@ -70,6 +70,7 @@ + # include + # include + # include ++# include + # ifndef _POSIX_SPAWN_DISABLE_ASLR + # define _POSIX_SPAWN_DISABLE_ASLR 0x0100 + # endif +@@ -646,8 +647,384 @@ + /* Pass the app name to the preloader through an env var. */ + setenv("WINEPRELOADERAPPNAME", app_name, 1); + free(app_name); ++ } ++} ++ ++/* sake: run each program from an .app named after it. ++ * ++ * The Dock labels a bundled process with the bundle's file name, and the Game Overlay shows ++ * the name and icon a bundle had when macOS first registered it. So a program started from ++ * a game bundle (/Contents/MacOS/wine, as sake starts titles) is started here from ++ * $SAKE_GAME_BUNDLES/.app instead: a mirror of that bundle made of hard links, with ++ * an Info.plist naming the program and the program's own icon, both in place before the ++ * first exec. Hard links rather than symlinks because init_paths() takes realpath() of ++ * ntdll.so as the base for everything, including the loader for the next process: through a ++ * symlink it would land back in the parent's bundle. ++ */ ++ ++/* Written into every Info.plist made here. A bundle without it predates something a ++ * registered bundle cannot pick up in place, so it is made again rather than reused. */ ++static const char sake_format[] = " SakeBundleFormat\n 2\n"; ++ ++static char *sake_path( const char *format, ... ) ++{ ++ va_list args; ++ char *ret; ++ int len; ++ ++ va_start( args, format ); ++ len = vasprintf( &ret, format, args ); ++ va_end( args ); ++ return len < 0 ? NULL : ret; ++} ++ ++static UINT sake_u16( const unsigned char *p ) { return p[0] | (p[1] << 8); } ++static UINT sake_u32( const unsigned char *p ) { return p[0] | (p[1] << 8) | (p[2] << 16) | ((UINT)p[3] << 24); } ++ ++static void sake_put32( unsigned char *p, UINT value ) ++{ ++ p[0] = value; p[1] = value >> 8; p[2] = value >> 16; p[3] = value >> 24; ++} ++ ++/* The entry for id in the resource directory at dir, or the first one when id is 0. */ ++static BOOL sake_res_entry( const unsigned char *rsrc, size_t size, UINT dir, UINT id, UINT *out ) ++{ ++ UINT i, count; ++ ++ if (dir > size || size - dir < 16) return FALSE; ++ count = sake_u16( rsrc + dir + 12 ) + sake_u16( rsrc + dir + 14 ); ++ if ((size - dir - 16) / 8 < count) return FALSE; ++ for (i = 0; i < count; i++) ++ { ++ const unsigned char *entry = rsrc + dir + 16 + i * 8; ++ if (id && sake_u32( entry ) != id) continue; ++ *out = sake_u32( entry + 4 ); ++ return TRUE; ++ } ++ return FALSE; ++} ++ ++/* type, then name, then the first language, to the RVA and size of the data. */ ++static BOOL sake_res_data( const unsigned char *rsrc, size_t size, UINT type, UINT name, ++ UINT *rva, UINT *len ) ++{ ++ UINT off; ++ ++ if (!sake_res_entry( rsrc, size, 0, type, &off ) || !(off & 0x80000000)) return FALSE; ++ if (!sake_res_entry( rsrc, size, off & 0x7fffffff, name, &off ) || !(off & 0x80000000)) return FALSE; ++ if (!sake_res_entry( rsrc, size, off & 0x7fffffff, 0, &off ) || (off & 0x80000000)) return FALSE; ++ if (off > size || size - off < 16) return FALSE; ++ *rva = sake_u32( rsrc + off ); ++ *len = sake_u32( rsrc + off + 4 ); ++ return TRUE; ++} ++ ++/* image_path is the Windows path; the drive letter is the prefix's dosdevices entry. */ ++static char *sake_unix_path( const char *win ) ++{ ++ const char *prefix = getenv( "WINEPREFIX" ), *home = getenv( "HOME" ); ++ char drive, *ret, *p; ++ ++ if (!strncmp( win, "\\??\\", 4 )) win += 4; ++ if (win[0] == '/') return strdup( win ); ++ if (!win[0] || win[1] != ':' || win[2] != '\\') return NULL; ++ drive = (win[0] >= 'A' && win[0] <= 'Z') ? win[0] - 'A' + 'a' : win[0]; ++ if (prefix && *prefix) ret = sake_path( "%s/dosdevices/%c:", prefix, drive ); ++ else if (home) ret = sake_path( "%s/.wine/dosdevices/%c:", home, drive ); ++ else return NULL; ++ if (!ret) return NULL; ++ p = ret; ++ ret = sake_path( "%s%s", p, win + 2 ); ++ free( p ); ++ if (!ret) return NULL; ++ for (p = ret + strlen( ret ) - strlen( win + 2 ); *p; p++) if (*p == '\\') *p = '/'; ++ return ret; ++} ++ ++/* The section holding the resource directory, read whole: a game's resources are icons, ++ * strings and a manifest, and anything over the cap is not worth an exec's delay. */ ++static unsigned char *sake_read_resources( int fd, UINT *va, UINT *size, UINT *rsrc_rva ) ++{ ++ unsigned char dos[64], nt[24], opt[240], section[40], *raw; ++ UINT lfanew, sections, opt_size, dir, i, ptr = 0; ++ ++ if (pread( fd, dos, sizeof(dos), 0 ) != sizeof(dos) || dos[0] != 'M' || dos[1] != 'Z') return NULL; ++ lfanew = sake_u32( dos + 0x3c ); ++ if (pread( fd, nt, sizeof(nt), lfanew ) != sizeof(nt) || memcmp( nt, "PE\0\0", 4 )) return NULL; ++ sections = sake_u16( nt + 6 ); ++ opt_size = sake_u16( nt + 20 ); ++ if (opt_size < 2 || opt_size > sizeof(opt) || pread( fd, opt, opt_size, lfanew + 24 ) != opt_size) return NULL; ++ if (sake_u16( opt ) == 0x20b) dir = 112; ++ else if (sake_u16( opt ) == 0x10b) dir = 96; ++ else return NULL; ++ if (opt_size < dir + 3 * 8 || !(*rsrc_rva = sake_u32( opt + dir + 2 * 8 ))) return NULL; ++ ++ for (i = 0; i < sections; i++) ++ { ++ if (pread( fd, section, sizeof(section), lfanew + 24 + opt_size + i * 40 ) != sizeof(section)) return NULL; ++ *va = sake_u32( section + 12 ); ++ *size = sake_u32( section + 16 ); ++ ptr = sake_u32( section + 20 ); ++ if (*rsrc_rva >= *va && *rsrc_rva - *va < *size) break; ++ } ++ if (i == sections || *size > 64 * 1024 * 1024 || !(raw = malloc( *size ))) return NULL; ++ if (pread( fd, raw, *size, ptr ) != *size) ++ { ++ free( raw ); ++ return NULL; ++ } ++ return raw; ++} ++ ++/* The exe's first RT_GROUP_ICON with the RT_ICON images it lists, put back together as a ++ * .ico, which is enough for CFBundleIconFile. */ ++static BOOL sake_write_icon( const char *exe, const char *path ) ++{ ++ unsigned char *raw = NULL, *rsrc, *group, entry[16]; ++ UINT va, size, rsrc_rva, group_rva, group_len, count, i, offset, rvas[64], lens[64]; ++ size_t rsrc_size; ++ BOOL ok = FALSE; ++ FILE *f; ++ int fd; ++ ++ if (!exe || (fd = open( exe, O_RDONLY )) == -1) return FALSE; ++ if (!(raw = sake_read_resources( fd, &va, &size, &rsrc_rva ))) goto done; ++ rsrc = raw + (rsrc_rva - va); ++ rsrc_size = size - (rsrc_rva - va); ++ ++#define SAKE_IN_SECTION(rva, len) ((rva) >= va && (rva) - va <= size && size - ((rva) - va) >= (len)) ++ if (!sake_res_data( rsrc, rsrc_size, 14 /* RT_GROUP_ICON */, 0, &group_rva, &group_len )) goto done; ++ if (group_len < 6 || !SAKE_IN_SECTION( group_rva, group_len )) goto done; ++ group = raw + (group_rva - va); ++ count = sake_u16( group + 4 ); ++ if (!count || count > 64 || (group_len - 6) / 14 < count) goto done; ++ for (i = 0; i < count; i++) ++ { ++ if (!sake_res_data( rsrc, rsrc_size, 3 /* RT_ICON */, sake_u16( group + 6 + i * 14 + 12 ), ++ &rvas[i], &lens[i] )) goto done; ++ if (!SAKE_IN_SECTION( rvas[i], lens[i] )) goto done; ++ } ++#undef SAKE_IN_SECTION ++ ++ if (!(f = fopen( path, "w" ))) goto done; ++ fwrite( "\0\0\1\0", 1, 4, f ); ++ fputc( count & 0xff, f ); ++ fputc( count >> 8, f ); ++ offset = 6 + count * 16; ++ for (i = 0; i < count; i++) ++ { ++ memcpy( entry, group + 6 + i * 14, 8 ); /* width, height, colours, reserved, planes, bits */ ++ sake_put32( entry + 8, lens[i] ); ++ sake_put32( entry + 12, offset ); ++ fwrite( entry, 1, sizeof(entry), f ); ++ offset += lens[i]; ++ } ++ for (i = 0; i < count; i++) fwrite( raw + (rvas[i] - va), 1, lens[i], f ); ++ if (!(ok = !fclose( f ))) unlink( path ); ++ ++done: ++ free( raw ); ++ close( fd ); ++ return ok; ++} ++ ++static void sake_write_escaped( FILE *f, const char *text ) ++{ ++ for (; *text; text++) ++ { ++ if (*text == '&') fputs( "&", f ); ++ else if (*text == '<') fputs( "<", f ); ++ else if (*text == '>') fputs( ">", f ); ++ else fputc( *text, f ); ++ } ++} ++ ++static BOOL sake_write_plist( const char *path, const char *name, const char *key, BOOL icon ) ++{ ++ FILE *f; ++ const char *c; ++ ++ if (!(f = fopen( path, "w" ))) return FALSE; ++ fputs( "\n" ++ "\n" ++ "\n\n" ++ " CFBundleExecutable\n wine\n" ++ " CFBundleIdentifier\n dev.typester.sake.program.", f ); ++ for (c = name; *c; c++) ++ fputc( ((*c >= 'a' && *c <= 'z') || (*c >= 'A' && *c <= 'Z') || (*c >= '0' && *c <= '9') || ++ *c == '-' || *c == '.') ? *c : '-', f ); ++ fprintf( f, ".%s\n CFBundleName\n ", key ); ++ sake_write_escaped( f, name ); ++ fputs( "\n", f ); ++ if (icon) fputs( " CFBundleIconFile\n AppIcon.ico\n", f ); ++ fputs( sake_format, f ); ++ fputs( " CFBundlePackageType\n APPL\n" ++ " LSApplicationCategoryType\n public.app-category.games\n" ++ " LSUIElement\n \n" ++ " NSHighResolutionCapable\n \n" ++ "\n\n", f ); ++ return !fclose( f ); ++} ++ ++/* Directories are made, symlinks are copied as they are, and files are hard-linked. */ ++static BOOL sake_mirror( const char *from, const char *to ) ++{ ++ struct dirent *entry; ++ BOOL ok = TRUE; ++ DIR *dir; ++ ++ if (mkdir( to, 0755 ) && errno != EEXIST) return FALSE; ++ if (!(dir = opendir( from ))) return FALSE; ++ while (ok && (entry = readdir( dir ))) ++ { ++ char *src, *dst, target[PATH_MAX]; ++ struct stat st; ++ ssize_t len; ++ ++ if (!strcmp( entry->d_name, "." ) || !strcmp( entry->d_name, ".." )) continue; ++ src = build_path( from, entry->d_name ); ++ dst = build_path( to, entry->d_name ); ++ if (lstat( src, &st )) ok = FALSE; ++ else if (S_ISLNK( st.st_mode )) ++ { ++ if ((len = readlink( src, target, sizeof(target) - 1 )) < 0) ok = FALSE; ++ else ++ { ++ target[len] = 0; ++ ok = !symlink( target, dst ); ++ } ++ } ++ else if (S_ISDIR( st.st_mode )) ok = sake_mirror( src, dst ); ++ else ok = !link( src, dst ); ++ free( src ); ++ free( dst ); + } ++ closedir( dir ); ++ return ok; + } ++ ++/* The parent's bundle with this program's Info.plist and icon in place of the parent's, ++ * which came across with the hard links. */ ++static BOOL sake_make_bundle( const char *source, const char *staging, const char *name, ++ const char *key, const char *exe ) ++{ ++ char *resources = sake_path( "%s/Contents/Resources", staging ); ++ char *icon = sake_path( "%s/Contents/Resources/AppIcon.ico", staging ); ++ char *plist = sake_path( "%s/Contents/Info.plist", staging ); ++ BOOL ok = FALSE; ++ ++ if (resources && icon && plist && sake_mirror( source, staging )) ++ { ++ mkdir( resources, 0755 ); ++ unlink( icon ); ++ unlink( plist ); ++ ok = sake_write_plist( plist, name, key, sake_write_icon( exe, icon ) ); ++ } ++ free( resources ); ++ free( icon ); ++ free( plist ); ++ return ok; ++} ++ ++/* Which program this is, beyond its name: two games may both ship a Launcher.exe, and a ++ * bundle is one identity to macOS -- name, icon and Game Mode session. So the bundle sits ++ * under a hash of the exe's path, lower-cased because the drive is not case-sensitive and ++ * Windows programs do not agree on how to spell a path. FNV-1a, 64 bits. */ ++static char *sake_key( const char *exe, const char *image_path ) ++{ ++ char *real = exe ? realpath( exe, NULL ) : NULL; ++ const char *c = real ? real : exe ? exe : image_path; ++ ULONGLONG hash = 0xcbf29ce484222325ull; ++ ++ for (; *c; c++) ++ { ++ hash ^= (unsigned char)((*c >= 'A' && *c <= 'Z') ? *c - 'A' + 'a' : *c); ++ hash *= 0x100000001b3ull; ++ } ++ free( real ); ++ return sake_path( "%016llx", (unsigned long long)hash ); ++} ++ ++/* Its loader is the very file the running one is -- which also covers a program starting ++ * another copy of itself -- and it is of this format. */ ++static BOOL sake_is_current( const char *bundle, const char *running ) ++{ ++ char *loader = sake_path( "%s/Contents/MacOS/wine", bundle ); ++ char *plist = sake_path( "%s/Contents/Info.plist", bundle ); ++ struct stat a, b; ++ BOOL ok = FALSE; ++ char text[4096]; ++ size_t len; ++ FILE *f; ++ ++ if (loader && plist && !stat( loader, &a ) && !stat( running, &b ) && ++ a.st_dev == b.st_dev && a.st_ino == b.st_ino && (f = fopen( plist, "r" ))) ++ { ++ len = fread( text, 1, sizeof(text) - 1, f ); ++ fclose( f ); ++ text[len] = 0; ++ ok = strstr( text, sake_format ) != NULL; ++ } ++ free( loader ); ++ free( plist ); ++ return ok; ++} ++ ++/* TRUE when the program is to start from its own bundle, which *wineloader_path now names. */ ++static BOOL sake_use_game_bundle( char **wineloader_path, const char *image_path ) ++{ ++ const char *root = getenv( "SAKE_GAME_BUNDLES" ); ++ char *name = NULL, *macos = NULL, *source = NULL, *exe = NULL, *key = NULL, *dir = NULL; ++ char *target = NULL, *staging = NULL, *stale, *p; ++ BOOL ok = FALSE; ++ ++ if (!root || !*root || !(name = extract_exe_name( image_path ))) goto done; ++ /* extract_exe_name() measures the name without its extension and then returns it with. */ ++ if ((p = strrchr( name, '.' )) && p != name) *p = 0; ++ if (!(macos = realpath_dirname( *wineloader_path ))) goto done; ++ if (!(source = remove_tail( macos, "/Contents/MacOS" ))) goto done; ++ exe = sake_unix_path( image_path ); ++ if (!(key = sake_key( exe, image_path ))) goto done; ++ if (!(dir = sake_path( "%s/%s", root, key ))) goto done; ++ if (!(target = sake_path( "%s/%s.app", dir, name ))) goto done; ++ ++ if (!sake_is_current( target, *wineloader_path )) ++ { ++ mkdir( root, 0755 ); ++ mkdir( dir, 0755 ); ++ /* A failure leaves the staging copy behind for sake to sweep; it costs nothing but ++ * directory entries. */ ++ if (!(staging = sake_path( "%s/.staging-%d", dir, (int)getpid() ))) goto done; ++ if (!sake_make_bundle( source, staging, name, key, exe )) goto done; ++ if (!access( target, F_OK ) && (stale = sake_path( "%s/.stale-%d", dir, (int)getpid() ))) ++ { ++ rename( target, stale ); ++ free( stale ); ++ } ++ /* Losing a race to another process making the same bundle is fine if what won is ++ * current. */ ++ if (rename( staging, target ) && !sake_is_current( target, *wineloader_path )) goto done; ++ } ++ ++ if ((p = sake_path( "%s/Contents/MacOS/wine", target ))) ++ { ++ free( *wineloader_path ); ++ *wineloader_path = p; ++ ok = TRUE; ++ } ++ ++done: ++ free( name ); ++ free( macos ); ++ free( source ); ++ free( exe ); ++ free( key ); ++ free( dir ); ++ free( target ); ++ free( staging ); ++ return ok; ++} + #endif + + +@@ -679,7 +1056,8 @@ + * When the preloader is not being used, WINEDLLPATH needs to be set correctly for + * the loader to find ntdll.so, so don't even attempt this unless WINEDLLPATH is set. + */ +- if (getenv("WINEDLLPATH")) ++ /* Hack 22144's link would take the program back out of its bundle. */ ++ if (!sake_use_game_bundle( &(argv[1]), image_path ) && getenv("WINEDLLPATH")) + replace_wineloader_path_with_link( &(argv[1]), image_path ); + #endif + diff --git a/patches/README.md b/patches/README.md index d9be969..8273d38 100644 --- a/patches/README.md +++ b/patches/README.md @@ -16,7 +16,8 @@ worked. Three kinds of file live here, and the header of each says which it is: -- **sake's own** (`0001`, `0002`, `0006`): written here, against something measured here. +- **sake's own** (`0001`, `0002`, `0006`, `0007`): written here, against something measured + here. - **Upstream Wine commits carried early** (`0003`, `0004`): named by hash and author in the header, applied as upstream wrote them apart from hunks the header says were moved. They exist because CrossOver's sources lag upstream, and **each is dropped the moment the