diff --git a/README.md b/README.md index d2d4a3a..7eadeb9 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,10 @@ repository has actually taken. ## Using it -Open the app. The setup wizard comes up when the seven steps are not finished and stays out of -the way when they are. It runs for tens of minutes, mostly compiling, and sends you to -Apple's download page once, for the toolkit. +Open the app. The setup wizard comes up until the seven steps are first finished, and stays out +of the way afterwards: when an update leaves a step to do again, the sidebar says so instead. +It runs for tens of minutes, mostly compiling, and sends you to Apple's download page once, for +the toolkit. When it is done, the library has a bottle in it. Select the bottle and **Install from an Installer…** runs the game's installer inside it, with Rename and Delete alongside. Once diff --git a/Sources/SakeKit/Setup.swift b/Sources/SakeKit/Setup.swift index 566688a..9cc866b 100644 --- a/Sources/SakeKit/Setup.swift +++ b/Sources/SakeKit/Setup.swift @@ -19,8 +19,18 @@ public enum StepState: Sendable, Equatable { case ready /// Why it cannot be started yet, as a sentence. case blocked(String) + /// Done once and not any more: why, as sentences. + case outdated(String) public var isDone: Bool { self == .done } + + /// What the wizard says about the step, when there is anything to say. + public var reason: String? { + switch self { + case .blocked(let why), .outdated(let why): why + case .done, .ready: nil + } + } } /// How far setup has got, read from what is on disk. @@ -30,10 +40,16 @@ public enum StepState: Sendable, Equatable { public struct Setup: Sendable { private let paths: Paths private let runtimeSources: URL? + private let patches: URL? - public init(paths: Paths = .default, runtimeSources: URL? = GDKRuntimeBuilder.bundled) { + public init( + paths: Paths = .default, + runtimeSources: URL? = GDKRuntimeBuilder.bundled, + patches: URL? = WinePatcher.bundled + ) { self.paths = paths self.runtimeSources = runtimeSources + self.patches = patches } static let machineNotReady = "This Mac is not ready yet." @@ -63,9 +79,9 @@ public struct Setup: Sendable { return .ready case .wine: - return state(WineBuilder(paths: paths).isBuilt, - WineBuilder(paths: paths).missingPrerequisite, - machineIsReady) + let builder = WineBuilder(paths: paths, patcher: WinePatcher(directory: patches)) + return state(builder.isBuilt, builder.missingPrerequisite, machineIsReady, + outdated: builder.outdatedReason) case .d3dMetal: return state(D3DMetalInstaller(paths: paths).isInstalled, @@ -82,10 +98,13 @@ public struct Setup: Sendable { } } - private func state(_ isDone: Bool, _ missing: String?, _ machineIsReady: Bool) -> StepState { + private func state( + _ isDone: Bool, _ missing: String?, _ machineIsReady: Bool, outdated: String? = nil + ) -> StepState { if isDone { return .done } guard machineIsReady else { return .blocked(Self.machineNotReady) } if let missing { return .blocked(missing) } + if let outdated { return .outdated(outdated) } return .ready } @@ -98,4 +117,15 @@ public struct Setup: Sendable { public func isComplete(machineIsReady: Bool) -> Bool { SetupStep.allCases.allSatisfy { state(of: $0, machineIsReady: machineIsReady).isDone } } + + /// Whether the wizard opens itself at launch: only until setup has been finished once, + /// which a bottle says, making one being the last step. A step an update leaves to do + /// again is pointed out instead, because the window was in the way. + public func opensItself(machineIsReady: Bool) -> Bool { + !isComplete(machineIsReady: machineIsReady) && Bottle.all(in: paths).isEmpty + } + + public func needsAttention(machineIsReady: Bool) -> Bool { + !isComplete(machineIsReady: machineIsReady) && !Bottle.all(in: paths).isEmpty + } } diff --git a/Sources/SakeKit/WineBuilder.swift b/Sources/SakeKit/WineBuilder.swift index b00ae6f..f7710af 100644 --- a/Sources/SakeKit/WineBuilder.swift +++ b/Sources/SakeKit/WineBuilder.swift @@ -1,6 +1,7 @@ import Foundation public enum WinePhase: String, Sendable { + case unpack case patch case configure case sonames @@ -63,25 +64,62 @@ public struct WineBuilder: Sendable { private let paths: Paths private let runner: ProcessRunner private let patcher: WinePatcher + private let unpacker: Unpacker public init( paths: Paths = .default, runner: ProcessRunner = ProcessRunner(), - patcher: WinePatcher = WinePatcher() + patcher: WinePatcher = WinePatcher(), + unpacker: Unpacker = Unpacker() ) { self.paths = paths self.runner = runner self.patcher = patcher + self.unpacker = unpacker } public var prefix: URL { paths.engine } public var logURL: URL { paths.build.appending(path: "wine.log") } - public var isBuilt: Bool { + var stampURL: URL { prefix.appending(path: "lib/wine/sake-patches.sha256") } + + /// Built, and from the patches this copy of sake carries. Without patches to compare + /// with, an engine that is there counts. + public var isBuilt: Bool { hasWine && !isOutdated } + + /// Built, but from other patches, or by a sake that kept no record of them. See + /// docs/wine-build.md. + public var isOutdated: Bool { + guard hasWine, let expected = expectedPatches else { return false } + return recordedPatches != expected + } + + /// Why the step has to be done again, when it does. + public var outdatedReason: String? { + guard isOutdated else { return nil } + let which = recordedPatches == nil + ? "This engine was built by an earlier sake and may lack patches this one carries." + : "This engine was built from other patches than this copy of sake carries." + return which + " Building it again takes as long as the first build, and D3DMetal goes back in afterwards with one press." + } + + private var hasWine: Bool { FileManager.default.fileExists(atPath: prefix.appending(path: "bin/wine").path) } + private var recordedPatches: String? { + (try? String(contentsOf: stampURL, encoding: .utf8))?.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// The patches inside the app are hashed once per process: the wizard asks every step's + /// state many times each time it draws. + private var expectedPatches: String? { + patcher.directory == WinePatcher.bundled ? Self.bundledPatches : patcher.fingerprint() + } + + private static let bundledPatches: String? = WinePatcher().fingerprint() + /// What has to exist before this can start, as a sentence, or `nil` when it can. public var missingPrerequisite: String? { if let recipe = BuildRecipe.all.first(where: { !$0.isBuilt(in: prefix) }) { @@ -207,6 +245,13 @@ public struct WineBuilder: Sendable { let make = URL(filePath: "/usr/bin/make") let jobs = ProcessInfo.processInfo.activeProcessorCount + // Only a step that is not done gets here, so an engine that is there is one from + // other patches. + if hasWine { + onPhase(.unpack) + try await unpackAgain(source, log: log, onOutput: onOutput) + } + onPhase(.patch) try await patcher.apply(to: source) { line in log.write(line + "\n") @@ -243,6 +288,32 @@ public struct WineBuilder: Sendable { onPhase(.verify) try await verify(log: log) + + // Last, so that a build stopped part way does not count as one from these patches. + if let expected = expectedPatches { + try Data("\(expected)\n".utf8).write(to: stampURL, options: .atomic) + log.write("=== record \(stampURL.path)\n") + } + } + + /// Back to CrossOver's own tree: a patch that changed or went cannot be taken off a tree + /// that has it. With no archive to start from, the tree is patched as it is, which is + /// enough for a patch that was only added. + private func unpackAgain( + _ source: URL, + log: LogFile, + onOutput: @Sendable (String) -> Void + ) async throws { + let component = Component.crossover + let archive = component.archiveURL(in: paths) + guard FileManager.default.fileExists(atPath: archive.path) else { + log.write("=== unpack no \(archive.path), so the tree is patched as it is\n") + return + } + log.write("=== unpack \(archive.path)\n") + onOutput("unpacking \(archive.lastPathComponent)") + try FileManager.default.removeItem(at: source) + try await unpacker.unpack(archive, into: component.destinationURL(in: paths), members: component.members) } private func runPhase( diff --git a/Sources/SakeKit/WinePatcher.swift b/Sources/SakeKit/WinePatcher.swift index f82c9a2..ae9bfb7 100644 --- a/Sources/SakeKit/WinePatcher.swift +++ b/Sources/SakeKit/WinePatcher.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation public struct WinePatch: Sendable, Equatable, Identifiable { @@ -85,6 +86,18 @@ public struct WinePatcher: Sendable { return patches } + /// Every patch's name and contents as one hash, which the engine keeps to say what it was + /// built from, or `nil` when there are no patches to hash. See docs/wine-build.md. + public func fingerprint() -> String? { + guard let patches = try? patches() else { return nil } + var hasher = SHA256() + for patch in patches { + guard let digest = try? SourceFetcher.sha256(of: patch.url) else { return nil } + hasher.update(data: Data("\(patch.id)\0\(digest)\n".utf8)) + } + return hasher.finalize().map { String(format: "%02x", $0) }.joined() + } + /// Apply every patch to `tree`, skipping the ones already in it. /// /// Whether a patch is already applied is asked of `patch` itself -- if it reverses diff --git a/Sources/sake/AppModel.swift b/Sources/sake/AppModel.swift index d0abdc1..75af57b 100644 --- a/Sources/sake/AppModel.swift +++ b/Sources/sake/AppModel.swift @@ -57,6 +57,12 @@ final class AppModel { var d3dMetal: D3DMetalStatus? var gdkRuntime: GDKRuntimeStatus? + /// Setup as ``survey()`` last read it. A view that asks ``Setup`` itself reads the disk, + /// which SwiftUI does not watch, so the library went on saying a step was left after the + /// wizard had done it. + var setupIsComplete = false + var setupNeedsAttention = false + /// Keyed by bottle name, the way `sources` and `prefix` are keyed by component: the /// wizard watches `default` while the library may be making another one. var bottleStatus: [String: BottleStatus] = [:] @@ -261,7 +267,12 @@ final class AppModel { for recipe in BuildRecipe.all where recipe.isBuilt(in: paths.engine) { prefix[recipe.id] = .alreadyBuilt } - if WineBuilder(paths: paths).isBuilt { wine = .alreadyBuilt } + let wineBuilder = WineBuilder(paths: paths) + if wineBuilder.isBuilt { + wine = .alreadyBuilt + } else if wineBuilder.isOutdated { + wine = .outdated + } let installer = D3DMetalInstaller(paths: paths) if installer.isInstalled { @@ -295,6 +306,9 @@ final class AppModel { measureSelectedBottle() surveyImportSources() + + setupIsComplete = setup.isComplete(machineIsReady: machineIsReady) + setupNeedsAttention = setup.needsAttention(machineIsReady: machineIsReady) } /// Called from ``survey()`` and again when the selection moves: without the second @@ -460,7 +474,10 @@ final class AppModel { case .phase(let phase): wine = .working(phase: phase.rawValue, line: "") case .output(let line): if case .working(let phase, _) = wine { wine = .working(phase: phase, line: line) } - case .installed(let version): wine = .built(version: version) + case .installed(let version): + wine = .built(version: version) + // make install puts Wine's own d3d DLLs back, so what survey() found is gone. + if !D3DMetalInstaller(paths: paths).isInstalled { d3dMetal = nil } case .failed(let reason, _): wine = .failed(reason) case .finished: break } diff --git a/Sources/sake/LibraryWindow.swift b/Sources/sake/LibraryWindow.swift index 6d921be..ea2a4f5 100644 --- a/Sources/sake/LibraryWindow.swift +++ b/Sources/sake/LibraryWindow.swift @@ -34,13 +34,25 @@ struct LibraryWindow: View { .safeAreaInset(edge: .bottom) { // Only what is about the library rather than about one bottle: putting a // game in, or taking a bottle away, belongs to the bottle it happens to. - Button { - model.isCreatingBottle = true - } label: { - Label("New Bottle…", systemImage: "plus") - .frame(maxWidth: .infinity, alignment: .leading) + VStack(alignment: .leading, spacing: 8) { + if model.setupNeedsAttention { + Button { + openWindow(id: WindowID.setup) + } label: { + Label("Setup needs attention", systemImage: "exclamationmark.circle") + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.borderless) + .foregroundStyle(.orange) + } + Button { + model.isCreatingBottle = true + } label: { + Label("New Bottle…", systemImage: "plus") + .frame(maxWidth: .infinity, alignment: .leading) + } + .buttonStyle(.borderless) } - .buttonStyle(.borderless) .padding(10) } } detail: { @@ -99,8 +111,11 @@ struct LibraryWindow: View { model.watchForSignIns() await model.check() // First run lands here with nothing built, so the wizard opens itself rather - // than leaving an empty window and a button to find. - if !isReady { openWindow(id: WindowID.setup) } + // than leaving an empty window and a button to find. Only then: a step an update + // leaves to do again gets the line above New Bottle… instead. + if model.setup.opensItself(machineIsReady: model.machineIsReady) { + openWindow(id: WindowID.setup) + } } } @@ -201,9 +216,7 @@ struct LibraryWindow: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } - private var isReady: Bool { - model.setup.isComplete(machineIsReady: model.machineIsReady) - } + private var isReady: Bool { model.setupIsComplete } private var selected: Bottle? { model.bottle(named: model.selectedBottle) } diff --git a/Sources/sake/SetupWindow.swift b/Sources/sake/SetupWindow.swift index 40b818b..e52a074 100644 --- a/Sources/sake/SetupWindow.swift +++ b/Sources/sake/SetupWindow.swift @@ -56,7 +56,7 @@ struct SetupWindow: View { switch model.state(of: candidate) { case .done: "checkmark.circle.fill" case .blocked: "lock" - case .ready: candidate == step ? "arrowtriangle.right.fill" : "circle" + case .ready, .outdated: candidate == step ? "arrowtriangle.right.fill" : "circle" } } @@ -75,7 +75,7 @@ struct SetupWindow: View { .fixedSize(horizontal: false, vertical: true) } - if case .blocked(let why) = model.state(of: step) { + if let why = model.state(of: step).reason { Text(why) .font(.callout) .foregroundStyle(.orange) diff --git a/Sources/sake/WineView.swift b/Sources/sake/WineView.swift index 8d453af..6177edf 100644 --- a/Sources/sake/WineView.swift +++ b/Sources/sake/WineView.swift @@ -5,6 +5,7 @@ enum WineStatus: Equatable { case working(phase: String, line: String) case built(version: String) case alreadyBuilt + case outdated case failed(String) } @@ -43,6 +44,10 @@ struct WineView: View { Text("already built") .font(.callout) .foregroundStyle(.secondary) + case .outdated: + Text("needs building again") + .font(.callout) + .foregroundStyle(.secondary) case .failed(let reason): Text(reason) .font(.callout) diff --git a/Tests/SakeKitTests/PatchTests.swift b/Tests/SakeKitTests/PatchTests.swift index a0e0366..8cec9b3 100644 --- a/Tests/SakeKitTests/PatchTests.swift +++ b/Tests/SakeKitTests/PatchTests.swift @@ -331,3 +331,26 @@ private func read(_ tree: URL) throws -> String { try await WinePatcher(directory: nil).apply(to: tree) } } + +@Test func theFingerprintFollowsThePatchesAndNothingElse() throws { + let root = temporaryDirectory() + defer { try? FileManager.default.removeItem(at: root) } + let (_, patches) = try makeTree(in: root) + let patcher = WinePatcher(directory: patches) + let first = try #require(patcher.fingerprint()) + + try "What this directory is.\n".write(to: patches.appending(path: "README.md"), atomically: true, encoding: .utf8) + #expect(patcher.fingerprint() == first) + + let patch = patches.appending(path: "0001-fake.patch") + let reworded = try String(contentsOf: patch, encoding: .utf8) + .replacingOccurrences(of: "Say what it does here", with: "Say it differently") + try reworded.write(to: patch, atomically: true, encoding: .utf8) + let second = try #require(patcher.fingerprint()) + #expect(second != first) + + try reworded.write(to: patches.appending(path: "0002-another.patch"), atomically: true, encoding: .utf8) + #expect(patcher.fingerprint() != second) + + #expect(WinePatcher(directory: nil).fingerprint() == nil) +} diff --git a/Tests/SakeKitTests/SetupTests.swift b/Tests/SakeKitTests/SetupTests.swift index 60d3045..7a38ec7 100644 --- a/Tests/SakeKitTests/SetupTests.swift +++ b/Tests/SakeKitTests/SetupTests.swift @@ -119,3 +119,48 @@ private func finish(_ step: SetupStep, in paths: Paths) throws { #expect(setup.current(machineIsReady: false) == .machine) #expect(setup.state(of: .machine, machineIsReady: false) == .ready) } + +@Test func anEngineBuiltFromOtherPatchesSendsSetupBackToWine() throws { + let paths = temporaryRoot() + defer { remove(paths) } + for step in SetupStep.allCases { try finish(step, in: paths) } + let patches = paths.root.deletingLastPathComponent().appending(path: "patches") + try FileManager.default.createDirectory(at: patches, withIntermediateDirectories: true) + try "A patch.\n".write(to: patches.appending(path: "0001-a.patch"), atomically: true, encoding: .utf8) + let setup = Setup(paths: paths, runtimeSources: nil, patches: patches) + + // bin/wine with no record of what it was built from, as every engine before the record. + guard case .outdated(let why) = setup.state(of: .wine, machineIsReady: true) else { + Issue.record("an engine with no record of its patches still counts as built") + return + } + #expect(why.contains("earlier sake")) + #expect(setup.current(machineIsReady: true) == .wine) + // The steps after it stay done until the rebuild's make install undoes D3DMetal. + #expect(setup.state(of: .d3dMetal, machineIsReady: true) == .done) + #expect(setup.state(of: .bottle, machineIsReady: true) == .done) + + let record = try #require(WinePatcher(directory: patches).fingerprint()) + let stamp = paths.engine.appending(path: "lib/wine/sake-patches.sha256") + try FileManager.default.createDirectory(at: stamp.deletingLastPathComponent(), withIntermediateDirectories: true) + try Data("\(record)\n".utf8).write(to: stamp) + #expect(setup.state(of: .wine, machineIsReady: true) == .done) +} + +@Test func theWizardOpensItselfOnlyUntilThereIsABottle() throws { + let paths = temporaryRoot() + defer { remove(paths) } + let setup = Setup(paths: paths, runtimeSources: nil) + + #expect(setup.opensItself(machineIsReady: true)) + #expect(!setup.needsAttention(machineIsReady: true)) + + for step in SetupStep.allCases { try finish(step, in: paths) } + #expect(!setup.opensItself(machineIsReady: true)) + #expect(!setup.needsAttention(machineIsReady: true)) + + // A step an update sends back once there is a bottle is pointed out, not opened. + try FileManager.default.removeItem(at: GDKRuntime(paths: paths).dll) + #expect(!setup.opensItself(machineIsReady: true)) + #expect(setup.needsAttention(machineIsReady: true)) +} diff --git a/Tests/SakeKitTests/WineTests.swift b/Tests/SakeKitTests/WineTests.swift index 776f60a..36c7ffa 100644 --- a/Tests/SakeKitTests/WineTests.swift +++ b/Tests/SakeKitTests/WineTests.swift @@ -346,6 +346,102 @@ private func failure(in events: [WineEvent]) -> (reason: String, log: URL?)? { #expect(!builder.isBuilt) } +/// CrossOver's tarball as the Sources step leaves it in `dl/`, made from the fake tree while +/// nothing has patched it yet. +private func archivePristineTree(in paths: Paths) throws { + let archive = Component.crossover.archiveURL(in: paths) + try FileManager.default.createDirectory( + at: archive.deletingLastPathComponent(), withIntermediateDirectories: true + ) + let tar = Process() + tar.executableURL = URL(filePath: "/usr/bin/tar") + tar.arguments = ["-czf", archive.path, "-C", paths.cache.path, "sources/wine"] + try tar.run() + tar.waitUntilExit() + #expect(tar.terminationStatus == 0) +} + +private func changePatch(in paths: Paths, inserting line: String) throws { + let patch = fakePatches(in: paths).appending(path: "0001-fake.patch") + let text = try String(contentsOf: patch, encoding: .utf8) + .replacingOccurrences(of: "+patched\n", with: "+\(line)\n") + try text.write(to: patch, atomically: true, encoding: .utf8) +} + +@Test func anEngineBuiltFromOtherPatchesIsNoLongerBuilt() async throws { + let paths = temporaryRoot() + defer { remove(paths) } + try makeFakeTree(in: paths) + + let builder = builder(in: paths) + _ = await collect(builder.build()) + #expect(builder.isBuilt) + #expect(builder.outdatedReason == nil) + + try changePatch(in: paths, inserting: "patched again") + #expect(builder.isOutdated) + #expect(!builder.isBuilt) + #expect(builder.outdatedReason?.hasPrefix("This engine was built from other patches") == true) +} + +@Test func anEngineWithNoRecordOfItsPatchesIsTakenForAnOlderOne() throws { + let paths = temporaryRoot() + defer { remove(paths) } + try makeFakeTree(in: paths) + let wine = paths.engine.appending(path: "bin/wine") + try FileManager.default.createDirectory(at: wine.deletingLastPathComponent(), withIntermediateDirectories: true) + FileManager.default.createFile(atPath: wine.path, contents: nil) + + // Every engine built before sake kept the record, which is every engine there is. + let builder = builder(in: paths) + #expect(builder.isOutdated) + #expect(builder.outdatedReason?.hasPrefix("This engine was built by an earlier sake") == true) + + // With no patches to compare with, an engine that is there counts. + let bare = WineBuilder(paths: paths, patcher: WinePatcher(directory: nil)) + #expect(bare.isBuilt) + #expect(!bare.isOutdated) +} + +@Test func aRebuildStartsFromCrossOversOwnTree() async throws { + let paths = temporaryRoot() + defer { remove(paths) } + try makeFakeTree(in: paths) + try archivePristineTree(in: paths) + + let builder = builder(in: paths) + _ = await collect(builder.build()) + + // A changed patch cannot be taken off a tree that has the old one: patched in place, + // this would stop with "applies to neither". + try changePatch(in: paths, inserting: "patched again") + let events = await collect(builder.build()) + + let phases = events.compactMap { event -> WinePhase? in + if case .phase(let phase) = event { phase } else { nil } + } + #expect(phases.first == .unpack) + #expect(failure(in: events) == nil) + let source = Component.crossover.unpackedURL(in: paths) + let patched = try String(contentsOf: source.appending(path: "dlls/ntdll/unix/loader.c"), encoding: .utf8) + #expect(patched == "one\ntwo\npatched again\nthree\n") + #expect(builder.isBuilt) +} + +@Test func aBuildThatFailsItsChecksLeavesNoRecord() async throws { + let paths = temporaryRoot() + defer { remove(paths) } + try makeFakeTree(in: paths, glue: false) + + // make install has written bin/wine by the time verify refuses the tree. + let builder = builder(in: paths) + _ = await collect(builder.build()) + + #expect(FileManager.default.fileExists(atPath: paths.engine.appending(path: "bin/wine").path)) + #expect(!FileManager.default.fileExists(atPath: builder.stampURL.path)) + #expect(!builder.isBuilt) +} + /// Wine's own tools, run in a bottle. The engine ships them as Windows programs under /// `lib/wine/x86_64-windows/`, so what starts them is the bare name and nothing else. @Test func eachWineToolIsStartedByItsBareNameInTheBottle() throws { diff --git a/docs/gdk.md b/docs/gdk.md index ef0bbe5..9a53a19 100644 --- a/docs/gdk.md +++ b/docs/gdk.md @@ -333,7 +333,8 @@ find; and nothing but a GDK title loads the DLL. A copy of sake's own is replace differs from the engine's, and a copy that is not sake's, such as the community stand-in, is left where it is; sake tells the two apart by a string its build carries. The step counts as done only while the engine's copy was built from the source that Sake.app carries, so an -update that changes the runtime sends the person back to it. +update that changes the runtime leaves the step to do again, which the library's sidebar +points out; until 2026-09-30 the wizard opened itself for it. An installer is given it too, because Steam's can start Steam as it finishes, and a game installed in that Steam never passes through sake's Play; that has not been tried in sake. A diff --git a/docs/getting-started.md b/docs/getting-started.md index 80f921f..f1b7546 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -29,7 +29,8 @@ Privacy & Security, or install with `--no-quarantine`. ## 2. Set sake up Open sake. The wizard opens itself until the seven steps are done; afterwards **Set Up…** in the -toolbar brings it back. +toolbar brings it back. If the sidebar says **Setup needs attention** after an update, click it: the +wizard opens on the step to do again. ### This Mac diff --git a/docs/layout.md b/docs/layout.md index a1ac0bc..4ecdbee 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -15,6 +15,8 @@ those were measured in sake. engine/lib/xgameruntime/ sake's GDK runtime, the licence of the libHttpClient in it, and a hash of what it was built from — see gdk.md + engine/lib/wine/sake-patches.sha256 a hash of the patches Wine was built from — + see wine-build.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 afb71e5..e10141b 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -270,7 +270,7 @@ easier to read than it was interleaved with `configure` flags. gnutls only when something asks for TLS, and nothing has yet. See `layout.md`. (This used to be the whole question of whether Wine could load any of them, and path length before that. Both went away.) -- **A built engine does not pick up a change to a patch.** `bin/wine` existing is what says +- ~~**A built engine does not pick up a change to a patch.**~~ `bin/wine` existing is what says the Wine step is done, so changing something in `patches/` means deleting that by hand and rebuilding. ~~And the rebuild then fails to recognise stacked patches as applied.~~ **Answered 2026-09-20**, the same day it was found: `WinePatcher` asked `patch` to reverse @@ -279,6 +279,9 @@ easier to read than it was interleaved with `configure` flags. the measurement. The D3DMetal half of this went away on 2026-09-19 — a rebuild now drops that step back to unfinished, because it asks whether the DLLs are Apple's rather than whether the framework is there — but nothing yet knows that a patch has changed under it. + **Answered 2026-09-30**: the engine records a hash of the patches it was built from, and one + with another hash or none is sent back to the Wine step, which unpacks CrossOver's tree + afresh before patching (`wine-build.md`). - **How much to generalise beyond one title.** The prototype hard-coded Diablo IV in several places (launch arguments, process identification, which directories to import). One of those went away on 2026-09-19 — which directories to import is a difference, not a list — diff --git a/docs/wine-build.md b/docs/wine-build.md index 3f002ff..aaede00 100644 --- a/docs/wine-build.md +++ b/docs/wine-build.md @@ -67,11 +67,11 @@ and the rest are sake's own; each file's header says which it is and where it ca What each one is for, and how to tell that it worked, is in `runtime.md`; why they are a separate directory is in `licensing.md`. -They are applied to the unpacked source tree, which is the only copy sake has of it, so the -build has one step that is not out of tree. Whether a patch is already in is asked of -`patch` itself — a patch that reverses cleanly is applied — rather than recorded in a marker -file, because the tarball is unpacked once and never re-extracted and a marker would have to -be invalidated by hand every time a patch changed. +They are applied to the unpacked source tree, so the build has one step that is not out of +tree. Whether a patch is already in is asked of `patch` itself — a patch that reverses +cleanly is applied — rather than recorded in a marker file, which would have to be +invalidated by hand every time a patch changed. What the engine was built from is +recorded, as a hash of the patches themselves, which nothing has to invalidate (below). **Patches on one file stack, and the check knows it.** Measured 2026-09-20, on the second build after the four winemac.drv patches went in: 0003 no longer reversed on its own once @@ -99,10 +99,29 @@ so did 0009 when it was added on 2026-09-30. compiles, installs and passes every check in this document. What it cannot do is start a game, and that is a long way downstream of here. -An engine that is already built does not pick a new patch up: `make install` is what writes -`bin/wine`, and its presence is what says the step is done. Changing a patch means deleting -that and rebuilding, and that is a full build rather than an incremental one: measured at -4m24s on 2026-09-19, no cheaper than the first. +**An engine built from other patches is built again.** After `verify`, the build records a +hash of every patch's name and contents in `lib/wine/sake-patches.sha256`, and the Wine step +counts as done only while `bin/wine` is there and that hash is the one of the patches +Sake.app carries, as the GDK runtime's step does with its source (`gdk.md`). An engine with +another hash, or with none because an earlier sake built it, leaves the step to do again: +the library's sidebar says so, and the step says which of the two it is. Building again +unpacks CrossOver's tree afresh from its archive in `dl/` before patching, because a patch +that changed or went cannot be taken back off a tree that has it; with no archive there, the +tree is patched as it is, which is enough for a patch that was only added. It is a full +build rather than an incremental one: measured at 4m24s on 2026-09-19, no cheaper than the +first. Two copies of sake that carry different patches each take the other's engine for one +to build again; only a development build run beside a release does that. Until 2026-09-30 +this said an engine that is already built does not pick a new patch up, and that changing +one meant deleting `bin/wine` by hand. + +Measured in sake on 2026-09-30, on an engine built with all nine patches before sake +recorded them: the wizard stayed shut and the sidebar said Setup needs attention; the Wine +step, opened from there, gave the reason for an engine with no record. Build unpacked the +archive and applied all nine patches to the fresh tree within five seconds, none of them +found already in, and the whole step took 5m01s (4m17s when run again with the record +removed). D3DMetal's step was then unfinished with its row at waiting, and one press put +Apple's four DLLs back, each hashing as before. On the rebuilt engine Minecraft Dungeons II +reached character select in the `ex` bottle. ## configure flags that must not be removed diff --git a/patches/README.md b/patches/README.md index a8ff514..3d05072 100644 --- a/patches/README.md +++ b/patches/README.md @@ -7,7 +7,9 @@ own `COPYING.LIB`, which is the licence these files are under. See `docs/licensi They apply to the `sources/wine` tree out of CodeWeavers' CrossOver tarball, with `-p1`. `WinePatcher` applies every `*.patch` here in name order before Wine is configured, and asks `patch` itself whether one is already in rather than keeping a marker — if it reverses -cleanly it is applied. That stays true as patches are added or changed. +cleanly it is applied. That stays true as patches are added or changed. The engine keeps a +hash of the patches it was built from, and one built from others is built again from a +fresh tree (`docs/wine-build.md`). Each file carries its reasoning in a prose header above the diff. **Read that before touching the patch**: sake's own patches were found by measurement, and the header is where