diff --git a/.github/ISSUE_TEMPLATE/compatibility.yml b/.github/ISSUE_TEMPLATE/compatibility.yml new file mode 100644 index 0000000..2ddc8a5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/compatibility.yml @@ -0,0 +1,60 @@ +name: Compatibility report +description: How a game ran with sake — working or not. +title: "Compatibility: " +labels: ["compatibility"] +body: + - type: markdown + attributes: + value: | + A game that runs is worth reporting as much as one that does not: these reports are what the README's list of games is made from. + - type: input + id: game + attributes: + label: Game + placeholder: Diablo IV + validations: + required: true + - type: input + id: store + attributes: + label: Store or launcher + placeholder: Steam, Battle.net, the publisher's own installer… + validations: + required: true + - type: dropdown + id: result + attributes: + label: How far did it get? + options: + - It plays + - It plays, with problems + - It starts but cannot be played + - It does not start + validations: + required: true + - type: input + id: version + attributes: + label: sake version + description: In the app menu, About Sake. + placeholder: 0.2.0 + validations: + required: true + - type: input + id: mac + attributes: + label: Mac and macOS version + placeholder: MacBook Air (M2), macOS 26.1 + validations: + required: true + - type: textarea + id: details + attributes: + label: What you did, and what happened + description: Anything you changed on the way — arguments, environment variables, winecfg settings — and anything that did not work, such as online play or a controller. + - type: textarea + id: log + attributes: + label: Log + description: If it did not work, the title's log from `~/Library/Caches/Sake/build/` (`title-.log`). Paths in it show your macOS user name. + render: text diff --git a/CLAUDE.md b/CLAUDE.md index 0d89279..89d7a0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -123,28 +123,44 @@ implementing anything it covers. | file | what it covers | |---|---| -| `docs/getting-started.md` | Diablo IV from an empty Mac to a keypress; the only file here for using sake | -| `docs/roadmap.md` | phases, and the settled boundary between Swift and subprocesses | +| `docs/getting-started.md` | Diablo IV from an empty Mac to a keypress; instructions for using sake | +| `docs/troubleshooting.md` | what to do when something goes wrong; instructions, like getting-started | +| `docs/how-it-works.md` | the overview: engine, bottles, titles, the setup steps, what Play does, the patches at a glance | +| `docs/roadmap.md` | where the project stands, what is next, open questions, and the settled Swift/subprocess boundary | | `docs/wine-build.md` | building Wine; the configure flags that must not be removed | -| `docs/runtime.md` | the three settings games need, the Play-button root cause, controllers, failure states | +| `docs/runtime.md` | the settings every run needs, starting and stopping a title, controllers, and what each patch fixes and how to tell | | `docs/gdk.md` | GDK titles: the runtime DLL and the sign-in sake provides in place of Gaming Services | +| `docs/debugging.md` | telling failure states apart, and the instruments and techniques that found things | | `docs/licensing.md` | what may not be redistributed, and what the app may not do for the user | | `docs/layout.md` | on-disk layout, why nothing mutable goes in the bundle, relocatability | | `docs/releasing.md` | how a release is cut, and the three things it needs that are not in this repository | -Three standing rules about that content: - -- **Claims in `docs/` carry their source and date.** Most were measured in the prototype and - not in sake; do not restate those as sake's own behaviour. Where sake has measured - something itself the section says so and gives the date — keep that distinction, and date - what you add. +Standing rules about that content: + +- **`docs/` is written for people first.** An AI reads it too, but it is laid out for somebody + who opens one file to find one thing. A section's first sentence is the rule, the fact or + what to do; the measurement behind it comes after. One topic has one home, and other files + link to it. There is no revision history: a correction replaces the wrong sentence, git keeps + the old one, and a wrong answer that looks right stays only as a trap worth naming. An + answered question leaves Open questions, and its answer goes where the topic lives. +- **Claims in `docs/` carry their source and date**, as a tag at the end — + `(sake, 2026-09-19)`, `(prototype, 2026-09-17)`, `(the owner's report, 2026-09-20)`, + `(read in CrossOver 26.3.0's sources, 2026-09-29)` — or once, in an italic line under the + heading, when a whole section was measured in one sitting. Most findings were measured in + the prototype and not in sake; do not restate those as sake's own, and date what you add. - **Do not relitigate the Swift/subprocess boundary** without new information; `docs/roadmap.md` records why it is where it is. -- **`docs/getting-started.md` is instructions, and stays that way.** It is written for somebody - using sake rather than building it: steps rather than prose, and **no dated claims at all** — - what has been run here, what has not, and on which hardware belongs in the files that already - carry it, because a page people follow is where that goes stale first. Screenshots go in - `assets/getting-started/`. +- **`docs/getting-started.md` and `docs/troubleshooting.md` are instructions, and stay that + way.** They are written for somebody using sake rather than building it: steps rather than + prose, and **no dated claims at all** — what has been run here, what has not, and on which + hardware belongs in the files that already carry it, because a page people follow is where + that goes stale first. Screenshots go in `assets/getting-started/`. +- **`runtime.md` and `gdk.md` keep their names.** `patches/*.patch` names the first and + `xgameruntime/src/` the second, and neither can follow a rename: a patch's bytes are the + engine's fingerprint and `xgameruntime/` but its README is the runtime's stamp, so every + user's setup would ask for a rebuild. Keep what those comments point at in the file they name. +- **The README's "Verified by me" is the owner's**: a game goes there once the owner has + played it. A community entry comes from a report and links it. ## Licence boundary diff --git a/README.md b/README.md index 7eadeb9..37d72f5 100644 --- a/README.md +++ b/README.md @@ -5,22 +5,27 @@ GUI, so none of it takes a terminal. ![The library window: a bottle in the sidebar with the titles in it, one selected, with a Play button and the arguments it starts with](assets/library.png) -## Status +## What runs -Playing here now: +### Verified by me -- **Diablo IV** -- **Steam** +- **Diablo IV** (Battle.net) — start it from the Battle.net launcher +- **Stardew Valley** (Steam) +- **Minecraft Dungeons II** (Steam, 0.2.0+) — party codes work -I wrote sake to play those two, and that is as far as it has been taken — one person, one -Mac. If you get something else running with it I would like to hear about it, and if you -cannot, that is worth hearing too. +That is one person on one Mac, an Apple M5 on macOS 27. The Battle.net and Steam clients +themselves install, sign in and run. + +### Verified by the community + +Nothing yet — [report it](https://github.com/typester/sake/issues/new?template=compatibility.yml)! +A game that runs is worth hearing about, and so is one that does not. ## What it does -The app answers whether this Mac can do the rest — Apple silicon, Rosetta 2, the Command -Line Tools, Apple's Game Porting Toolkit, room on disk — then walks seven steps, one screen -at a time: +The setup wizard walks seven steps, one screen each. The first checks this Mac — Apple +silicon, Rosetta 2, the Command Line Tools, Apple's Game Porting Toolkit, room on disk — and +the other six build everything else: 1. download the sources and check them against known hashes 2. unpack them and build the tools and libraries Wine is configured against @@ -28,16 +33,15 @@ at a time: 4. guide Apple's D3DMetal in from an image you mounted, and unmount it again 5. build what games made with Microsoft's GDK load in place of Xbox Gaming Services 6. make a bottle — one Wine prefix, which is where a game lives -7. put a game in it — its own installer, the one you downloaded, runs inside the bottle -After that the library is where you live. A bottle holds titles you added; a title is a -program in that bottle, its name, the arguments it starts with and any environment of its -own. Picking a program that -carries `libcef.dll` fills those arguments in with what a Chromium client needs, because -that is the one thing this stack is known to require and easy to forget. A title's name and -arguments can be changed afterwards, a bottle can be renamed or thrown away, and the app -menu has an Uninstall that takes away everything sake made. All of it goes to the Trash, so -it can be put back. +After that the library is where you live. A game goes into a bottle through its own +installer, the one you downloaded, run inside the bottle. A bottle holds titles you added; a +title is a program in that bottle, its name, the arguments it starts with and any environment +of its own. Picking a program that carries `libcef.dll` fills those arguments in with what a +Chromium client needs, because that is the one thing this stack is known to require and easy +to forget. A title's name and arguments can be changed afterwards, a bottle can be renamed or +thrown away, and the app menu has an Uninstall that takes the engine, the bottles and the +caches away. All of it goes to the Trash, so it can be put back. ## Requirements @@ -89,10 +93,12 @@ Play button. For a launcher like Battle.net, start the launcher and press Play inside it. sake deliberately does not offer a button that starts Diablo IV directly: the client only hands out a login token after that press, so a direct start reaches the game and then fails on the -token. `docs/runtime.md` has the measurements behind that. +token. [`docs/runtime.md`](docs/runtime.md#pressing-play-does-two-separable-things) has the +measurements behind that. **[`docs/getting-started.md`](docs/getting-started.md) walks one game through all of this with screenshots** — Diablo IV, from a Mac with nothing on it to a character on screen. +When something goes wrong, [Troubleshooting](docs/troubleshooting.md) is where to look. ## Why build Wine at all @@ -104,7 +110,7 @@ wishful thinking. Upstream Wine does not have it. **D3DMetal itself is not redistributable**, so sake will never ship it, download it for you, or take it out of an installed CrossOver — it guides you through downloading Apple's Game Porting Toolkit yourself, mounts the image inside it, copies out the part it needs, and -unmounts it again. See `docs/licensing.md`. +unmounts it again. See [`docs/licensing.md`](docs/licensing.md). ## What is here @@ -133,24 +139,34 @@ Requires the Xcode Command Line Tools. Xcode is not needed, and there is no Xcod On macOS 27 the Command Line Tools default to the macOS 27.0 SDK, which SwiftUI cannot be built against without a macro plugin the CLT do not ship. `build-app.sh` detects this and falls back to a macOS 26 SDK, printing what it did. Override with `SDKROOT` if needed. See -`CLAUDE.md` for the full story. +[`CLAUDE.md`](CLAUDE.md) for the full story. ## Documentation -`docs/` is the real content of this repository. Every claim in it names where and when it was -measured. Much of it is still the prototype's; the sections sake has measured itself say so -and carry their own date. +Using sake: -| file | what it covers | -|---|---| -| `docs/getting-started.md` | the one written for using sake rather than building it: Diablo IV, step by step, with screenshots | -| `docs/roadmap.md` | the goal, the phases, and where Swift stops and subprocesses start | -| `docs/wine-build.md` | building Wine from CrossOver's sources; the flags that cannot be dropped | -| `docs/runtime.md` | creating a prefix, the three settings that make games run, what pressing Play actually does, controllers, taking a bottle down, and how to tell four failure states apart | -| `docs/licensing.md` | what may and may not be redistributed, and why D3DMetal is unavoidable | -| `docs/layout.md` | where files go, why importing a 100 GB game costs nothing and removing it returns nothing either, why nothing mutable lives in the app bundle, and what pins a built tree to its path | +- [Getting started](docs/getting-started.md) — Diablo IV from an empty Mac to a character on screen, with screenshots +- [Troubleshooting](docs/troubleshooting.md) — where the logs are, and what the known problems look like + +How it works: + +- [How sake works](docs/how-it-works.md) — engine, bottles and titles, the setup steps, what Play does, and what sake changes in Wine +- [Licensing](docs/licensing.md) — what sake may not ship or fetch for you, and why D3DMetal cannot be avoided +- [Where files live](docs/layout.md) — the on-disk layout, and why nothing mutable goes in the app + +Working on sake: + +- [Building Wine](docs/wine-build.md) — CrossOver's sources, the patches, and the configure flags that must stay +- [Running games](docs/runtime.md) — the settings every run needs, and what each patch fixes +- [GDK titles](docs/gdk.md) — the runtime and the Xbox sign-in sake provides in place of Gaming Services +- [Debugging](docs/debugging.md) — telling failure states apart, and the instruments that found things +- [Roadmap](docs/roadmap.md) — where it stands, what is next, and the questions still open +- [Releasing](docs/releasing.md) — how a release is cut + +Most of what `docs/` says about Wine was first measured in the shell prototype sake replaced; +each claim says whose measurement it is and when. ## Licence MIT, except `patches/`: patches against Wine's own source are derivatives of LGPL code and -are LGPL-2.1-or-later. See `docs/licensing.md`. +are LGPL-2.1-or-later. See [`docs/licensing.md`](docs/licensing.md). diff --git a/docs/debugging.md b/docs/debugging.md new file mode 100644 index 0000000..5a2c784 --- /dev/null +++ b/docs/debugging.md @@ -0,0 +1,128 @@ +# Debugging a game in a bottle + +How to tell what state a failing run is in, and the instruments that answered questions here. + +*Unless an entry says otherwise, it was learned in the d4-mac prototype on Diablo IV and +Battle.net, by 2026-09-18.* + +## Telling failure states apart + +Thread count separates the top three states and RSS the bottom two, and +`vmmap $pid | grep -icE 'Metal|AGX'` says whether graphics was ever reached: about 50 mappings +means never, 100 or more means rendering. RSS alone misleads, and a hang and a slow start look +nothing alike. + +| state | threads | RSS | Metal/AGX maps | +|---|---|---|---| +| no Rosetta registration, or a mismatched-ABI module | 9-11 | 125-155 MB | ~50 | +| the `\DosDevices` loop (before the BOOLEAN fix) | 12 | 235-245 MB | ~50 | +| graphics up, waiting on the client | 17-19 | 390-410 MB | 74-81 | +| running and rendering | 83-98 | 1.7-4.6 GB | 100+ | + +Four traps in that table, each of which produced a wrong conclusion: + +- **Read the running row as "well past 40"**, not as a window to match. It read 83-90, then + 83-96, then 83-98, widened each time someone measured again. +- **The counts include the process row** (`ps -M -p $pid | tail -n +2 | grep -c .`). Count + thread rows alone and everything reads one low: the stall comes out at 11, lands in the row + above, and a reproducing hang gets reported as a dead build. +- **Threads do not separate the bottom two states**, 9-11 against 12. RSS does: 125-155 MB + against 235-245 MB. +- **Sample, and keep the peak.** Looked at only at the end, a build that clears the check and + then dies of something unrelated is identical to one that never cleared it. + +Which process is the game is its own question, with its own traps: +[runtime.md](runtime.md#starting-and-recognising-a-title). + +## Wine's own tracing + +sake starts everything with `WINEDEBUG=-all`, so a trace is something added to one run. + +- **`+loaddll` names the last DLL before a hang**, and is safe on its own. `+server`, + `+syscall`, `+module`, `+seh` and `+file` are light enough to keep a failure reproducing. +- **`err+all` causes crashes rather than revealing them.** A failed `dlopen` of a missing dylib + produces a `dlerror()` string long enough to overflow Wine's debug buffer; the exception + cannot be dispatched and the process dies. Raising the log level turns a cleanly handled + failure into a crash. +- **A whole-module relay trace can hide the bug.** `RelayFromInclude` on the loader logs about + 7M calls, and the game then starts fine. That was read as timing sensitivity and was not: the + overhead changes what callees leave on the stack. A Heisenbug limits which instrument you may + use; it is not evidence about the cause. +- **`+pid` before anything else when there is more than one process.** Without it every line + is prefixed by a thread id, and four Chromium processes cannot be told apart (sake, on Steam, + 2026-09-20). +- **`:+` traces one process.** An option with a name and a colon in front + applies only where the executable has that name (`parse_options` in + `dlls/ntdll/unix/debug.c`), so a game Steam starts can be traced without Steam's own + processes. `-all,Dungeons-Win64-Shipping.exe:+pid,Dungeons-Win64-Shipping.exe:+file` in + Steam's environment gave 24 MB by the time the game showed its first dialog (sake, + 2026-09-30). +- **`+macdrv_d3dmtl` is D3DMetal's half of the conversation**: the channel of + `dlls/winemac.drv/d3dmetal.c`, the only Wine code D3DMetal calls. A `get_win_data` with no + `create_metal_device` after it means winemac returned NULL, and the six calls of a + swapchain's creation read like a checklist (sake, 2026-09-20). +- **`+win` names the owner of an HWND**, class and parent included, which is how "whose window + is the GPU process drawing into" was answered (sake, 2026-09-20). +- **A fault inside a dylib has no module name in `+seh`.** `vmmap` a live process for the + `__TEXT` ranges of `libd3dshared`, `D3DMetal` and `winemac.so`, then `objdump -d` the dylib + at `rip` minus its start; `+loaddll` knows only PE modules (sake, 2026-09-20). +- **Crashpad eats the crash.** A CEF process never reaches `winedbg --auto`, so there is no + backtrace to wait for, and `+seh` is the only view of where it died (sake, 2026-09-20). + +## Looking from the Mac side + +- **`vmmap` says what is loaded**, where `WINEDEBUG=+module` may not. The two `TRACE`s in + `init_non_native_support` run only once something calls `pe_module_loaded`, which + `wine cmd /c exit` never does, and during a real game start they did not reach a filter on + wine's own stderr either. Two attempts went that way before the mapping was looked at, which + took one command (sake, 2026-09-19). +- **Wine keeps its sockets in `wineserver`**, not in the Windows-side process. `lsof` against + the Battle.net pid shows no connections while it talks to Blizzard happily, which produced + three wrong network conclusions in a row. +- **`wineserver -k` without `WINEPREFIX` goes after `~/.wine`**, exits 0 and reports success + having killed nothing. Even with it, it is only the first half of taking a bottle down + ([runtime.md](runtime.md#taking-a-bottle-down)). +- **Attaching a debugger to Diablo IV is destructive.** The protected loader answers with an + unhandled `0xc00000e5` and obfuscated registers, and the process drops from 58% CPU to 1.8%: + the state you came to read is gone. `sample(1)` is safe, but cannot unwind through + `__wine_syscall_dispatcher`. +- **A Wine window can be photographed behind the terminal.** With Screen Recording granted to + the terminal, `screencapture -x -o -l ` captures an occluded window; + `CGWindowListCopyWindowInfo` gives the id, and Wine's windows have the owner `wine`. A + full-screen capture shows whatever is in front, which here is always the terminal. Capturing + by id is what turned "black" from a report into 100.00% of 1,232,000 pixels. **Not once the window + hosts another process's layer**: then `-l` fails with "could not create image from window", + and `-R` never worked here at all, so raise the window + (`set frontmost of (first process whose unix id is …)` through System Events), take the full + screen, crop to the window's bounds, and hand focus back to the terminal (sake, 2026-09-20). + +## An application's own logs + +- **Read them first.** Battle.net writes + `drive_c/users/crossover/AppData/Local/Battle.net/Logs/{battle.net,libcef}-*.log`, and the + libcef log named the real problem after a lot of guessing had not. +- **`--remote-debugging-port=9222`** tells "not painting" from "painting but not shown". + Battle.net forwards arguments it does not know to CEF, and `Page.captureScreenshot` proved + the renderer was drawing the login form perfectly while the window was black. +- **MoltenVK's `Created N swapchain images with size (W, H)` lines are a free instrument.** + Comparing which surface sizes appear between runs exposed the missing content-sized surface, + and then confirmed the fix. + +## Method + +- **Search first, then measure.** The `WINE_SIMULATE_WRITECOPY` fix is documented across + Lutris, GamingOnLinux and CodeWeavers' own forum, and hours of first-principles crash analysis + went in before anyone searched. The lesson was then ignored on the Play button and the same + bill arrived, and that time the search found the game update rather than the cause: hence + both halves. +- **Diff against a working implementation on the same machine.** With CrossOver installed and + the tree built from its sources, anything that differs is configuration or build flags. + Running CrossOver's binaries against the prototype's bottle answered "build or bottle?" in one + command. Its Perl `bin/wine` is readable, and `--bottle NAME --ux-app /usr/bin/env` dumps the + environment its launcher builds: bisect the environment from the side that works, rather than + guessing single variables against a failing run. +- **Make the two candidates produce different observable output before believing either.** The + Play button was blamed on `Agent.exe` not passing an environment variable. The variable + arrives; that diagnosis stood only because the symptom it predicted was the symptom present. +- **Check that the control actually ran.** A control run whose log is zero bytes reproduced + nothing; it failed to start. diff --git a/docs/gdk.md b/docs/gdk.md index 517752e..8faf5fe 100644 --- a/docs/gdk.md +++ b/docs/gdk.md @@ -1,284 +1,460 @@ # GDK titles: what sake provides in place of Gaming Services A title built on Microsoft's Game Development Kit (GDK) calls into `xgameruntime.dll`, the -Gaming Runtime that Xbox Gaming Services installs on Windows, for its task queues, its user -and its tokens. Wine has neither. This file is how sake means to provide both itself: a -runtime DLL it builds, and a sign-in it performs. **Both exist, and Minecraft Dungeons II -reached character select with them and nothing else on 2026-09-30: the runtime, in -`xgameruntime/`, asks sake to sign the person in when the game first wants a token, sake does -it through files in the bottle, and the runtime hands the game its user and tokens. Later that -day the app built the runtime in setup and put it in the bottle itself, and the game reached -character select again.** That evening its account link worked from the Mac too, once PlayFab's -token came from the same user token as the rest, and two players who joined a party by code -played a mission together. What was measured says so and gives the date; the rest is a -decision or an open question. - -## What was measured - -On 2026-09-29, in the `ex` bottle, with Minecraft Dungeons II started through Steam and the -community stand-in for Gaming Services in place (`runtime.md` has the WinHTTP half of that -run). The stand-in's repository has no licence, so its code is not a source for anything -here; its log is used only as a record of what the game asked for and what the stand-in -answered. +Gaming Runtime that Xbox Gaming Services installs on Windows, for its task queues, its user and +its tokens. Wine has neither. sake provides both itself: a runtime DLL it builds, in +[`xgameruntime/`](../xgameruntime/README.md), and an Xbox sign-in it performs with the title's +own app ID. The runtime asks sake to sign the person in when the game first wants a token, sake +does it through files in the bottle, and the runtime hands the game its user and tokens. + +Minecraft Dungeons II plays on them (sake, 2026-09-30): + +| | | +|---|---| +| signing in | a code the first time the game wants a token, silent after that; character select with no community stand-in | +| the runtime | built by setup and put in the bottle by sake, with nothing done by hand | +| account link | unlinking the Steam account from the Microsoft one and linking it again works | +| playing with others | joining a party by code works both ways with the game on a Nintendo Switch 2, and the two played a mission with the Mac hosting | +| Xbox multiplayer activity | refused: it needs a title token sake cannot get; what a player loses without it was not tried | +| a session past 16 hours | not tried | + +What was measured says so and gives the date; the rest is a decision or an open question. + +## How a title reaches the runtime + +**No module imports `xgameruntime.dll`: the GDK's thunks in each one load it themselves with +`LoadLibraryExW`, and `system32` is where it goes.** The launcher's thunks look up six +exports and search `system32`, and the executable's own folder as well only when a copy is +already there (`GetFileAttributesW`) and either `ForceUseLocalServices` is set under +`HKLM\Software\Microsoft\GamingServices` or the Microsoft Store is not installed. They tell the +runtime which through bit 0x8 of their initialisation flags. With sake's copy in `system32` +alone, the launcher and the game both passed 0x2: the folder was not searched (sake, +2026-09-29). From then on every call asks `QueryApiImpl` for an object by class ID and interface +ID, calls one slot of its vtable, and releases it. + +## The runtime + +**sake builds its own `xgameruntime.dll`**, C++, with the engine's toolchain and outside Wine's +build. It is not a Wine patch: it replaces no Wine code. The task queue and `XAsync` are +libHttpClient's, compiled unmodified from a pinned commit; the interfaces' IDs and slot order +are WineGDK's, each checked against the title's thunks +([measurements](#the-title-and-what-it-calls)); the rest, `XUser` included, is written there. +llvm-mingw's `clang++` links it with libc++ inside, importing nothing but the UCRT, `KERNEL32` +and `ole32`, the last because libHttpClient waits for an async call through COM when the +waiting thread is in a single-threaded apartment (sake, 2026-09-29). + +**Where it goes.** Setup builds it in a step of its own, from the copy of `xgameruntime/` inside +Sake.app, and keeps it in the engine with libHttpClient's licence beside it. Before sake starts +a title or an installer in any bottle, it puts both in that bottle's `system32`, the one place +the launcher looks. That is every bottle, and not only one where a `MicrosoftGame.config` is +found: sake starts Steam before Steam installs the game, so at the only moment sake can put +anything there, there is no config to find, and nothing but a GDK title loads the DLL. A copy of +sake's own is replaced when it 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 leaves the step to do again, which the +library's sidebar points out. + +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. A copy is +written beside the one it replaces and renamed over it, so nothing ever reads half a DLL. On +APFS a game that has the old one loaded keeps it; on exFAT, where the `ex` bottle is, what that +rename does under a running game has not been measured, and sake cannot yet see what runs in a +symlinked bottle ([#15](https://github.com/typester/sake/issues/15)) to wait for it. The engine +is one per Mac, so two copies of sake that carry different runtime source each take the other's +build for stale and build their own again; only a development build run beside a release does +that. + +**libHttpClient is pinned by what it unpacks to.** The step fetches it itself rather than with +the other sources, so that nothing else in setup waits on it. GitHub's page on downloading +source code archives promises that an archive of a commit ID always has the same files, and not +the same bytes: the compression may change, with six months' notice (read 2026-09-30). A hash of +the archive would be a way for setup to stop one day with nothing wrong, so SakeKit hashes the +unpacked tree instead — the path and contents of every file in it that is not hidden — and +compares that with the hash it carries. The archive itself hashed the same three times in two +days (sake, 2026-09-29 and 2026-09-30). + +**The user it hands over.** One user, whose handle is one object's address. The silent add +returns at once: with the person the session names while its identity token lasts five more +minutes, and otherwise with a placeholder, XUID 1, the way the stand-in answers, which takes the +XUID the game then adds by ID. After it, the runtime sends the one `SignedInAgain` and, on every +registration for connectivity changes, the initial notification, both as the stand-in does +([measurements](#the-runtime-in-the-game)). A token request looks the URL's host up in the +session; a token that is missing or lasts less than five minutes sends the runtime to sake, and +a URL no relying party covers gets `E_GAMEUSER_NO_TOKEN_REQUIRED`. Every answer is completed +inside `XAsyncBegin` or from the runtime's own thread, never from the caller's queue. + +**`ForceRefresh` goes to sake only when no ask of sake has ended in the last 15 minutes**, +whatever that ask's answer; otherwise the request is answered as if the option were not there. +XSAPI puts that option on the person's next token request after any 401, whatever its URL, and +retries the refused call once (`Source/Shared/http_call_wrapper_internal.cpp` and `user.cpp` in +Microsoft's xbox-live-api, read 2026-09-30). So a refusal no new token cures, such as +multiplayer activity's, had cost a silent sign-in every few seconds +([measurements](#forcerefresh)), and since the option can ride on another service's request, +what it gets is the session's token rather than a failure. + +## The sign-in + +**The game starts it, and it looks like the stand-in's**: the person signs in when the game +does, in the browser, rather than before it starts. The runtime asks sake at the game's first +token request; sake opens `microsoft.com/link` and shows the code to type there, in a panel of +its own that floats above the browser, since there is no sign-in without a code +([measurements](#signing-in-against-the-real-services)). Asking at the silent add instead holds +the game's start until the sign-in is done, before the game has drawn a window +([measurements](#the-runtime-in-the-game)). + +**The device-code flow uses the title's own `MSAAppId`**, from its `MicrosoftGame.config`. The +title declares that ID for exactly this, and sake signs in as no other application. In SakeKit, +`XboxSignIn` goes from a refresh token or a code to the tokens. + +**XSTS tokens** are minted for `http://xboxlive.com`, for `http://playfab.xboxlive.com/`, and for +whatever a table sake keeps names for the title, which for Minecraft Dungeons II is +`rp://api.minecraftservices.com/`: the title's own table cannot be read without a title token +([measurements](#relying-parties-and-the-endpoint-table)). Only PlayFab's is bound to a device, +because the stand-in's README says PlayFab will not link an account otherwise, and it comes from +the same user token as the rest: the runtime labels every token with one user hash, and a user +token bound to the key would give PlayFab's another, which made account link fail +([measurements](#account-link-and-playing-with-others)). The rest are bound to nothing, every +token goes to the game unsigned, and the device's key never leaves sake. + +**What sake keeps.** The refresh token and the device, in `~/Library/Sake/sign-ins`, one file per +app ID, readable by the person alone: not the Keychain, which asks for the login password on +every read under an ad hoc signature +([measurements](#the-keychain-under-an-ad-hoc-signature)). A file costs this: any program the +person runs can read it, every Windows program in every bottle included, through `Z:`. With it, +someone can sign in to Xbox Live as the person, with this title's app ID, until it is revoked; +changing the account's password should do that, and has not been tried. Nothing measured says +whether it reaches anything beyond Xbox Live. With a Developer ID signature the Keychain may stop +asking, which is not measured either. + +**Files between the runtime and sake.** In `%LOCALAPPDATA%\Sake\\` in the bottle, +which from sake's side is `drive_c/users/crossover/AppData/Local/Sake/`, since every sake +bottle's Windows user is `crossover`. The runtime writes `request`, holding an ID of its own; +sake writes `answer` and, once the person is signed in, `session`. Each file is `key value` +lines under a first line of `sake 1`, and each is written whole and renamed into place. `answer` +says `waiting` as soon as sake has the request, then `signed-in`, `failed` with a reason, or +`cancelled`. `session` carries the XUID, gamertag, user hash, age group and privileges, a line +`token <relying party> <expiry in Unix seconds> <token>` for each relying party, and a line +`endpoint <host> <relying party>` for each host a relying party covers: the host itself, or one +ending in it after a dot, so that the runtime keeps no table of its own. A title asks for several +tokens at once and there is one `request` per title, so a request made while another is out +joins it rather than replacing it. The runtime gives up when nothing says `waiting` within 10 +seconds, and waits 20 minutes at most for the rest, a device code lasting 15. Files, because a +Windows DLL cannot reach a socket sake listens on: Wine's Winsock converts no `AF_UNIX` address +(read in CrossOver 26.3.0's sources, 2026-09-29). The refresh token stays out of the bottle. + +## WineGDK + +`Weather-OS/WineGDK` implements `xgameruntime` inside Wine 11.14. Its author declares their own +code CC0 — "derive, redistribute and reimplement ... without any attributions" — except code by +Olivia Ryan and the Xodus interop, which stay under Wine's LGPL. At its 2026-08-22 head: + +- task queues, `XAsync`, threading, `XSystem`, feature queries and networking are implemented, + and its IDL describes the runtime's COM-style interfaces; +- `XUser` is not: `XUserAddAsync`, `XUserGetId` and `XUserGetTokenAndSignatureAsync` return + `E_NOTIMPL`, and the sign-in in progress hands token requests to Xodus, a separate GPL-3.0 + service; +- it is C++, built by Wine's C++ support for PE modules, which the Wine 11.0 in CrossOver + 26.3.0's sources does not have, so it cannot become a patch against sake's tree. + +**Not all of it is its author's to declare.** The files behind its task queues and `XAsync` open +with "From https://github.com/microsoft/libHttpClient" — Microsoft's code, MIT-licensed — +beneath an LGPL header; `InitInternalGDKC.cpp`, `UserImpl.*` and `XodusService.cpp` carry Olivia +Ryan's copyright; and its IDL has the Wine project's LGPL header rather than the CC0 +declaration. What sake takes from WineGDK is therefore facts and no text: the interfaces' IDs and +the order of their slots. libHttpClient it compiles from Microsoft's own repository. + +## Open questions + +- **Whether tokens outlive a session.** XSTS tokens last 16 hours and the user token 96. The + runtime asks sake again for a token with less than five minutes left, and a kept sign-in makes + that silent, but no session has yet run long enough to need it. +- **sake has to be running.** A request that nobody picks up fails after 10 seconds, and the game + goes on without a user. A sake quit while its game runs cannot answer; whether quitting should + be refused, or warned about, while a title runs is open. +- **Whether the game needs `SignedInAgain`.** The runtime sends it because the stand-in does, and + the game answers it; a run with the connectivity notification and no change event would say + whether it has to. +- **The game executable's own thunks.** Its code is encrypted on disk, so only the calls it made + have been checked. +- **Security information for a URL** is answered with TLS 1.2 and no pinned certificates. The + game asks for it, in UTF-16, before every request it sends, and its requests went through with + that answer (sake, 2026-09-30). +- **What a player loses without multiplayer activity.** Not tried: the game's own parties and + invites are Mojang's and were answered, and joining a party by code worked both ways. +- **Linking a Steam account PlayFab does not know yet.** Unlinking and linking again works, with + PlayFab's token bound to the device the way the stand-in's README asks; a PlayFab token bound + to nothing was not tried, and neither was an account PlayFab has not seen. +- **A first install through Steam**, which is the reason the runtime goes into every bottle, has + not been run, since the game was installed already; nor has Steam's installer starting Steam + as it finishes, or replacing sake's copy under a running game on exFAT (The runtime, above). +- **What stops a game before any of this matters.** The launcher's Gaming Services check is + answered by the runtime. The VC++ false positive is still open: the `ex` bottle gets past it + with a DLL override sake does not set. + +## Measurements + +### The title and what it calls + +*2026-09-29, in the `ex` bottle, with Minecraft Dungeons II started through Steam. What the game +calls and asks tokens for was recorded with the community stand-in for Gaming Services in place +([runtime.md](runtime.md#gdk-titles-winhttp-options-xcurl-cannot-do-without) has the WinHTTP +half of that run); the GDK edition and the IDs were read from the title's binaries the same day, +and `GDKTitle` found the title that night. The stand-in's repository has no licence, so its code +is not a source for anything here; its log is used only as a record of what the game asked for +and what the stand-in answered.* - **The title carries its own identity.** `MicrosoftGame.config` beside the executable has `<MSAAppId>00000000497C1B94</MSAAppId>`, `<TitleId>6B9DE498</TitleId>` and - `<RequiresXboxLive>false</RequiresXboxLive>`. The stand-in signed in with that same - `MSAAppId` as its OAuth client — Microsoft's device-code flow on `login.live.com`, scope - `service::user.auth.xboxlive.com::MBI_SSL`, then a user token from - `user.auth.xboxlive.com`, a device token from `device.auth.xboxlive.com` and XSTS tokens - from `xsts.auth.xboxlive.com` — and the game reached character select. + `<RequiresXboxLive>false</RequiresXboxLive>`. The stand-in signed in with that same `MSAAppId` + as its OAuth client — Microsoft's device-code flow on `login.live.com`, scope + `service::user.auth.xboxlive.com::MBI_SSL`, then a user token from `user.auth.xboxlive.com`, a + device token from `device.auth.xboxlive.com` and XSTS tokens from `xsts.auth.xboxlive.com` — + and the game reached character select. - **What the game calls.** The `XTaskQueue` family (create, composite, register monitor, terminate); `XUserAddAsync` and `XUserAddResult`, `XUserAddByIdWithUiAsync`, `XUserGetId`, `XUserRegisterForChangeEvent`; `XSystemGetXboxLiveSandboxId`, `XNetworkingGetConnectivityHint`, `XGameProtocolRegisterForActivation`, `XGameGetXboxTitleId`, `XGameRuntimeIsFeatureAvailable`, `XErrorSetOptions`, `XErrorSetCallback`; and `XblMultiplayerActivitySetActivityAsync` and - `XblMultiplayerActivityDeleteActivityAsync`. XCurl, the GDK's HTTP client, asks the - runtime for each URL's TLS requirements before it connects. + `XblMultiplayerActivityDeleteActivityAsync`. XCurl, the GDK's HTTP client, asks the runtime for + each URL's TLS requirements before it connects. - **What it asks tokens for.** `XUserGetTokenAndSignature` for `https://playfabapi.com/`, `https://api.minecraftservices.com` and `multiplayeractivity.xboxlive.com`. -- **The public endpoint table covers two of those.** Xbox Live maps each service to the - relying party its token must name. `title.mgt.xboxlive.com/titles/default/endpoints?type=1` +- **Which GDK edition.** The `.xbld` section says: "April 2026 GRDK Update 1" for the launcher, + `XCurl.dll` and `GameChat2.dll`. The game's executable holds objects from that edition and from + "October 2025 GRDK", which is what `libHttpClient.GDK.dll` was built with. +- **What the game can ask for.** Its executable's code is encrypted on disk, but its `.rdata` + lists the 18 pairs of class and interface ID its thunks pass to `QueryApiImpl`. WineGDK's IDL + describes five of those classes — XThreading (`XAsync`, `XTaskQueue`, `XThread`), + XGameRuntimeFeature, XSystem, XUser and XNetworking — and every interface version the game + names for them is one WineGDK lists. XError and XGameProtocol, which the game also uses before + signing in, are in no IDL. Their slots come from the argument shapes of the thunks in + `libHttpClient.GDK.dll`, which carries thunks for 23 classes, far more than it calls. Where + both exist, the shapes agree with WineGDK's slot order for every slot the game used. A title + built with a later edition may name another version; the runtime refuses a version it does not + know and logs it, which is where to look. +- **Finding the title.** `GDKTitle` found Minecraft Dungeons II by its config in + `steamapps/common`, five levels under `Program Files (x86)`, which is as deep as it looks. + +### Relying parties and the endpoint table + +- **The public endpoint table covers two of the three hosts.** Xbox Live maps each service to + the relying party its token must name. `title.mgt.xboxlive.com/titles/default/endpoints?type=1` answers without authentication, with 88 entries: `playfabapi.com` names `http://playfab.xboxlive.com/` and no signature policy, and `*.xboxlive.com` names `http://xboxlive.com` and signature policy 0 (version 1, ES256, the first 8192 bytes of the - body). Nothing names `api.minecraftservices.com`. Until 2026-09-29 this said the table had - nothing for PlayFab; it has the host the game asks a token for, while the game's requests go - to `83156.playfabapi.com`, which no entry names. The title's own table is under the sign-in, - below. -- **The engine's toolchain builds C++ that runs here.** llvm-mingw's `clang++` links a DLL - with libc++ inside it, importing nothing but the UCRT and `KERNEL32`. The runtime imports - `ole32` as well: libHttpClient waits for an async call through COM when the waiting thread - is in a single-threaded apartment. - -Later the same day sake's own runtime took the stand-in's place: `xgameruntime/`, built by -hand and put in the bottle's `system32`, with the stand-in's three copies set aside. - -- **How a title finds the runtime.** No module imports `xgameruntime.dll`; the GDK's thunks in - each one load it themselves with `LoadLibraryExW`. The launcher's look up six exports and - search `system32`, and the executable's own folder as well only when a copy is already there - (`GetFileAttributesW`) and either `ForceUseLocalServices` is set under - `HKLM\Software\Microsoft\GamingServices` or the Microsoft Store is not installed. They tell - the runtime which through bit 0x8 of their initialisation flags. With sake's copy in - `system32` alone, the launcher and the game both passed 0x2: the folder was not searched. - `system32` is where it goes. -- **Which GDK edition.** The `.xbld` section says: "April 2026 GRDK Update 1" for the - launcher, `XCurl.dll` and `GameChat2.dll`. The game's executable holds objects from that - edition and from "October 2025 GRDK", which is what `libHttpClient.GDK.dll` was built with. -- **What the game can ask for.** Its executable's code is encrypted on disk, but its `.rdata` - lists the 18 pairs of class and interface ID its thunks pass to `QueryApiImpl`. WineGDK's - IDL describes five of those classes — XThreading (`XAsync`, `XTaskQueue`, `XThread`), - XGameRuntimeFeature, XSystem, XUser and XNetworking — and every interface version the game - names for them is one WineGDK lists. XError and XGameProtocol, which the game also uses - before signing in, are in no IDL. Their slots come from the argument shapes of the thunks - in `libHttpClient.GDK.dll`, which carries thunks for 23 classes, far more than it calls. - Where both exist, the shapes agree with WineGDK's slot order for every slot the game used. -- **The run.** The launcher passed its Gaming Services check and started the game a second - later. The game called XError, `XTaskQueue`, XNetworking, XGameProtocol and XUser, and - nothing it asked for was refused. XGameProtocol is the class `95fd18d2`, registered right - after the XGame feature check; the other class of its shape, `0651aae2`, is then XGameInvite, - which the runtime reports absent and the game never asked for. XUser was asked for under its - base interface ID, `01acd177`. The silent `XUserAddAsync` failed with - `E_GAMEUSER_NO_DEFAULT_USER`, and the game drew its title scene with "SIGNING IN .." and - waited: 3.5 minutes with no further `XUser` call, no sign-in window asked for and no HTTP - request. Closed from its window, it ran its XSAPI and libHttpClient cleanups through the - runtime in a quarter of a second and was gone at the next three-second sample. - `GamingRepair.exe`, which Steam runs before every start, still exited `0x80040154`, as it - had with the stand-in. -- **So the silent add must not fail.** Once it has, the game does not ask again. Until - 2026-09-29 this said that whoever signs in must therefore have done so before the game - starts. The stand-in had already shown otherwise, and its way is what sake's - sign-in should look like: in its log the silent add succeeds with a placeholder user (XUID - 1), the game goes on to `LoginWithSteam` and `XUserAddByIdWithUiAsync`, and the stand-in - starts Microsoft's device-code sign-in when the game first asks for a token — by the owner's - account, with a browser opening for it — after which the game reached character select. - -Later that night SakeKit's own sign-in was run against the real services, with the owner's -account, from a throwaway program compiled against SakeKit. Nothing was sent to PlayFab, -Minecraft's services or `multiplayeractivity.xboxlive.com`, so "issued" below means the -token service agreed, and says nothing about whether the service a token names will. -Microsoft documents some of these requests and not others; where one of its pages covers a -shape it is named, and everything else is here because the service accepted it. + body). Nothing names `api.minecraftservices.com`. The table has the host the game asks a + PlayFab token for, while the game's requests go to `83156.playfabapi.com`, which no entry + names (sake, 2026-09-29). +- **Anything else needs a table sake keeps.** For Minecraft Dungeons II that is + `api.minecraftservices.com` → `rp://api.minecraftservices.com/`, the relying party the + Minecraft Wiki's "Microsoft authentication" page gives for that host (read 2026-09-29). It was + what the game's own calls needed: they reached character select (sake, 2026-09-30). +- **A relying party is spelled as the table spells it**: PlayFab's without its trailing slash was + a 400 (sake, 2026-09-29). + +### Signing in against the real services + +*The night of 2026-09-29, with the owner's account, from a throwaway program compiled against +SakeKit. Nothing was sent to PlayFab, Minecraft's services or +`multiplayeractivity.xboxlive.com`, so "issued" below means the token service agreed, and says +nothing about whether the service a token names will. Microsoft documents some of these +requests and not others; where one of its pages covers a shape it is named, and everything else +is here because the service accepted it.* - **The signature** is specified by Microsoft's "Title service calls to Xbox services" (GDK - documentation, read 2026-09-29): the policy version as four big-endian bytes, a Windows - file time as eight, then the raw r‖s of an ES256 signature, in standard base64. What is - signed is the version, the time, the method, the path and query, the `Authorization` value - and the body, each followed by a zero byte. `device.auth.xboxlive.com` issued a device - token to a request signed that way, answered 403 when the signature's last byte was flipped - and 400 when it was missing. -- **The device token**: `ProofOfPossession`, `DeviceType` `Win32`, `Version` `10.0.19045` — - what a sake bottle reports in `system.reg` — an `Id` in braces and the key as a JWK, all in + documentation, read 2026-09-29): the policy version as four big-endian bytes, a Windows file + time as eight, then the raw r‖s of an ES256 signature, in standard base64. What is signed is + the version, the time, the method, the path and query, the `Authorization` value and the body, + each followed by a zero byte. `device.auth.xboxlive.com` issued a device token to a request + signed that way, answered 403 when the signature's last byte was flipped and 400 when it was + missing. +- **The device token**: `ProofOfPossession`, `DeviceType` `Win32`, `Version` `10.0.19045` — what a + sake bottle reports in `system.reg` — an `Id` in braces and the key as a JWK, all in `Properties`, signed. It lasts 14 days. -- **Microsoft's device code**: `oauth20_connect.srf` with the title's `MSAAppId` as the - client and `service::user.auth.xboxlive.com::MBI_SSL` as the scope. It gave a code for +- **Microsoft's device code**: `oauth20_connect.srf` with the title's `MSAAppId` as the client and + `service::user.auth.xboxlive.com::MBI_SSL` as the scope. It gave a code for `https://www.microsoft.com/link`, valid 15 minutes and polled every 5 seconds, and no - `verification_uri_complete`, so the code is always typed. The access token lasts 24 hours, - and refreshing it handed back a new refresh token as well. + `verification_uri_complete`, so the code is always typed. The access token lasts 24 hours, and + refreshing it handed back a new refresh token as well. - **No sign-in without a code.** An authorization-code flow could run in a window of sake's own with nothing to type, but it needs a redirect the app has registered, and `oauth20_authorize.srf` refused `https://login.live.com/oauth20_desktop.srf` and - `ms-xal-<id>://auth` in three spellings: "The expected value is a URI which matches a - redirect URI registered for this client application". It says so without anyone signing in. -- **The user token.** Microsoft's "Xbox services sign-in for title websites" gives the - request, with a `d=` ticket for an Entra token; a login.live.com ticket goes as `t=`. It was - issued both signed with the key in `Properties` and unsigned without it, and lasts 96 hours. -- **XSTS tokens**, lasting 16 hours where Microsoft's "Xbox services authentication" gives - four as the default: `http://xboxlive.com`, whose claims name the person (XUID, gamertag, - age group, privileges), and `http://playfab.xboxlive.com/` and - `rp://api.minecraftservices.com/`, which carry the user hash alone. The last is the relying - party the Minecraft Wiki's "Microsoft authentication" page (read 2026-09-29) gives for - `api.minecraftservices.com`, since no table sake can read names one. Each was issued bound - to the device's key and bound to nothing. A relying party is spelled as the table spells - it: PlayFab's without its trailing slash was a 400. + `ms-xal-<id>://auth` in three spellings: "The expected value is a URI which matches a redirect + URI registered for this client application". It says so without anyone signing in. +- **The user token.** Microsoft's "Xbox services sign-in for title websites" gives the request, + with a `d=` ticket for an Entra token; a login.live.com ticket goes as `t=`. It was issued both + signed with the key in `Properties` and unsigned without it, and lasts 96 hours. +- **XSTS tokens**, lasting 16 hours where Microsoft's "Xbox services authentication" gives four + as the default: `http://xboxlive.com`, whose claims name the person (XUID, gamertag, age group, + privileges), and `http://playfab.xboxlive.com/` and `rp://api.minecraftservices.com/`, which + carry the user hash alone. Each was issued bound to the device's key and bound to nothing. - **No title token, so no title table.** `title.auth.xboxlive.com` answered 403 with an empty - body, and SISU's `/authorize` 401. Until 2026-09-30 this said `/authorize` presumably wanted - a session from `/authenticate`, which would want a registered redirect; `/authenticate` hands - out a session without one, and `/authorize` refuses the device code's token with it too - (below). The title's own table answers 414 to `?type=1` with or without a token. Without the - query it is 401 to nobody and 403 to every XSTS token above, none of which was minted with a - title token. + body, and SISU's `/authorize` 401. The title's own table answers 414 to `?type=1` with or + without a token. Without the query it is 401 to nobody and 403 to every XSTS token above, none + of which was minted with a title token. SISU's `/authenticate` hands out a session without a + registered redirect, and `/authorize` refuses the device code's token with that session too + (sake, 2026-09-30; [below](#account-link-and-playing-with-others)). - **Where a signature is checked.** `title.mgt.xboxlive.com` refused a key-bound token sent - unsigned or with a corrupted signature, 401 `invalid_request_signature`, and got as far as - its 403 for a token bound to nothing sent unsigned. `profile.xboxlive.com` read the person's + unsigned or with a corrupted signature, 401 `invalid_request_signature`, and got as far as its + 403 for a token bound to nothing sent unsigned. `profile.xboxlive.com` read the person's gamertag all three ways: bound and signed, bound and unsigned, unbound and unsigned. -- **Finding the title.** `GDKTitle` found Minecraft Dungeons II by its config in - `steamapps/common`, five levels under `Program Files (x86)`, which is as deep as it looks. -Before anything kept the refresh token, the Keychain was measured, the same night, with a -throwaway app signed the way `scripts/build-app.sh` signs sake: ad hoc. Three builds of it -differed in one constant, and so in their code directory hash alone. - -- **A build that did not make an item is asked for the login password to read it.** The - build that added a generic password read it back with no dialog. The next build's read - brought SecurityAgent's "wants to use your confidential information … To allow this, enter - the “login” keychain password", with Always Allow, Deny and Allow. -- **Always Allow does not stick.** With the password entered and Always Allow pressed, that - same build was asked again on its next read. The item's access list still named only the - build that made it; what had grown was its partition list, by the second build's `cdhash:`. - Under an ad hoc signature, then, sake would be asked every time it read. -- **The dialog outlives the process that asked, and takes the keyboard.** Ending the process - left the dialog up, and it answered the next request from the same app. Keys typed - elsewhere while it was up went into its password field. -- **Two things asked nothing**: a query for attributes alone from a build the item did not - trust, and deleting the item from the build that made it. The Data Protection Keychain - refused the app outright, `-34018`, "A required entitlement is not present". - -So the refresh token is in a file (the design, below). - -On 2026-09-30 the runtime asked sake to sign in, first from a probe in the `ex` bottle and -then from the game, with sake's runtime in `system32` and the stand-in's three copies set -aside for the game's runs. +### The Keychain under an ad hoc signature + +*The night of 2026-09-29, with a throwaway app signed the way `scripts/build-app.sh` signs sake: +ad hoc. Three builds of it differed in one constant, and so in their code directory hash alone.* + +- **A build that did not make an item is asked for the login password to read it.** The build + that added a generic password read it back with no dialog. The next build's read brought + SecurityAgent's "wants to use your confidential information … To allow this, enter the “login” + keychain password", with Always Allow, Deny and Allow. +- **Always Allow does not stick.** With the password entered and Always Allow pressed, that same + build was asked again on its next read. The item's access list still named only the build that + made it; what had grown was its partition list, by the second build's `cdhash:`. Under an ad + hoc signature, then, sake would be asked every time it read. +- **The dialog outlives the process that asked, and takes the keyboard.** Ending the process left + the dialog up, and it answered the next request from the same app. Keys typed elsewhere while + it was up went into its password field. +- **Two things asked nothing**: a query for attributes alone from a build the item did not trust, + and deleting the item from the build that made it. The Data Protection Keychain refused the app + outright, `-34018`, "A required entitlement is not present". + +### The runtime in the game + +*In the `ex` bottle, with sake's runtime in `system32` and the stand-in's three copies set aside +for the game's runs.* + +**The first run** (2026-09-29), with the runtime built by hand and no sign-in yet: + +- The launcher passed its Gaming Services check and started the game a second later. The game + called XError, `XTaskQueue`, XNetworking, XGameProtocol and XUser, and nothing it asked for was + refused. XGameProtocol is the class `95fd18d2`, registered right after the XGame feature check; + the other class of its shape, `0651aae2`, is then XGameInvite, which the runtime reports absent + and the game never asked for. XUser was asked for under its base interface ID, `01acd177`. +- The silent `XUserAddAsync` failed with `E_GAMEUSER_NO_DEFAULT_USER`, and the game drew its + title scene with "SIGNING IN .." and waited: 3.5 minutes with no further `XUser` call, no + sign-in window asked for and no HTTP request. Closed from its window, it ran its XSAPI and + libHttpClient cleanups through the runtime in a quarter of a second and was gone at the next + three-second sample. `GamingRepair.exe`, which Steam runs before every start, still exited + `0x80040154`, as it had with the stand-in. +- **So the silent add must not fail**: once it has, the game does not ask again. The stand-in + shows the way: in its log the silent add succeeds with a placeholder user (XUID 1), the game + goes on to `LoginWithSteam` and `XUserAddByIdWithUiAsync`, and the stand-in starts Microsoft's + device-code sign-in when the game first asks for a token — by the owner's account, with a + browser opening for it — after which the game reached character select. + +**The sign-in asked at the silent add** (2026-09-30), first from a probe and then from the game: - **The probe** loaded the runtime by its path, beside a copy of the game's `MicrosoftGame.config`, and asked the way a title may: the silent `XUserAddAsync`, then - `XTaskQueueTerminate` on the call's queue at once, then dispatching its completion port - until the termination landed. With no sake to answer, the runtime gave up after 10.1 - seconds and completed the call. With sake running, sake picked the request up after a - second and showed its panel, and Cancel there came back as `cancelled`. Both times the - call completed on a queue already terminating, nothing was refused, and the termination - finished. -- **The game, with no sign-in kept.** The silent add came 2.7 seconds after the launcher - loaded the runtime, before the game had a window. sake picked the request up 3.5 seconds - later, most of it spent walking the bottle's `Program Files` for the title, so sake now - says `waiting` before it looks. It showed the code, the browser opened at - `microsoft.com/link`, the owner signed in there, and 2 minutes 24 seconds after the pickup - sake wrote a session holding a token for each of the three relying parties. Only then did - the add return, and only then did the game open its window, on its title scene and - "SIGNING IN ..". While the add was pending the game called `XAsyncGetStatus` without - waiting and dispatched the completion port with no timeout, 4.19 million times in two - minutes, and made no other call. It terminated the add's queue after it had seen the call - complete. -- **The game again, with the sign-in kept.** sake answered 2.5 seconds after the request, 1.0 - of them to pick it up, with no panel, and the kept refresh token was replaced by the new - one Microsoft handed back. + `XTaskQueueTerminate` on the call's queue at once, then dispatching its completion port until + the termination landed. With no sake to answer, the runtime gave up after 10.1 seconds and + completed the call. With sake running, sake picked the request up after a second and showed + its panel, and Cancel there came back as `cancelled`. Both times the call completed on a queue + already terminating, nothing was refused, and the termination finished. +- **The game, with no sign-in kept.** The silent add came 2.7 seconds after the launcher loaded + the runtime, before the game had a window. sake picked the request up 3.5 seconds later, most + of it spent walking the bottle's `Program Files` for the title, so sake now says `waiting` + before it looks. It showed the code, the browser opened at `microsoft.com/link`, the owner + signed in there, and 2 minutes 24 seconds after the pickup sake wrote a session holding a token + for each of the three relying parties. Only then did the add return, and only then did the + game open its window, on its title scene and "SIGNING IN ..". While the add was pending the + game called `XAsyncGetStatus` without waiting and dispatched the completion port with no + timeout, 4.19 million times in two minutes, and made no other call. It terminated the add's + queue after it had seen the call complete. +- **The game again, with the sign-in kept.** sake answered 2.5 seconds after the request, 1.0 of + them to pick it up, with no panel, and the kept refresh token was replaced by the new one + Microsoft handed back. - **So the silent add is the wrong place to ask.** The sign-in has to start once the game is - showing its own screen, which is where the stand-in starts it: at the first token request - (above). Until 2026-09-30 this file proposed holding the silent add instead, on the belief - that the game would already be showing "SIGNING IN .." while it waited. It had not drawn + showing its own screen, which is where the stand-in starts it: at the first token request. The + trap is believing the game already shows "SIGNING IN .." while the add waits; it has not drawn anything yet. -Later on 2026-09-30 the runtime began answering `XUser` itself: the silent add at once, with -a placeholder until there is a session, and sake asked at the first token request. +**The runtime answering `XUser` itself** (2026-09-30): the silent add at once, with a placeholder +until there is a session, and sake asked at the first token request. -- **The slots, first.** Every `XUser` thunk in `libHttpClient.GDK.dll`, 44 of them, and the - one for `XUserGamertag`, reach the slot `runtime.h` gives that function with the number of - arguments its signature has. That edition asks for `XUser` under `26f3c674`, which the - runtime answers from the same table as the base ID the game uses. +- **The slots, first.** Every `XUser` thunk in `libHttpClient.GDK.dll`, 44 of them, and the one + for `XUserGamertag`, reach the slot `runtime.h` gives that function with the number of + arguments its signature has. That edition asks for `XUser` under `26f3c674`, which the runtime + answers from the same table as the base ID the game uses. - **The game sent nothing until the runtime sent the notifications the stand-in sends.** With - the add answered, it drew its window and "SIGNING IN .." and sent nothing, not even a - question about a URL's TLS. The stand-in, run from outside in a probe, showed two things - the runtime was not sending: + the add answered, it drew its window and "SIGNING IN .." and sent nothing, not even a question + about a URL's TLS. The stand-in, run from outside in a probe, showed two things the runtime was + not sending: - After the silent add, the first dispatch of the completion port of the queue the title - registered for `XUser` changes brings one `SignedInAgain` for local user 1. With that - added, the game answered it with `FindUserByLocalId` and still sent nothing. - - Registering for connectivity hint changes brings one notification, also on the - completion port, with the hint `XNetworkingGetConnectivityHint` gives. Microsoft's - reference for `XNetworkingRegisterConnectivityHintChanged` says so as well: it "sends an - initial notification callback". The game registers twice, the second time from the - module that sends its HTTP, and with this notification added as well it sent its first - request five seconds after the change event. - - Whether the game needs `SignedInAgain` too was not tried: every run that got as far had - both. + registered for `XUser` changes brings one `SignedInAgain` for local user 1. With that added, + the game answered it with `FindUserByLocalId` and still sent nothing. + - Registering for connectivity hint changes brings one notification, also on the completion + port, with the hint `XNetworkingGetConnectivityHint` gives. Microsoft's reference for + `XNetworkingRegisterConnectivityHintChanged` says so as well: it "sends an initial + notification callback". The game registers twice, the second time from the module that sends + its HTTP, and with this notification added as well it sent its first request five seconds + after the change event. + + Whether the game needs `SignedInAgain` too was not tried: every run that got as far had both. - **The game, with no sign-in kept.** Its window came up, then "SIGNING IN ..", and the game - logged in to PlayFab with Steam and added, by ID, the XUID PlayFab links to that Steam - account. Its first token request, for `https://playfabapi.com/`, came 23 seconds after - launch, and sake picked it up a second later: the panel and the browser opened while the - game was showing its sign-in, the way the stand-in's sign-in looks. The owner signed in, - and 62.6 seconds after the request sake had a session and the game its token; the game's - telemetry went on meanwhile. Tokens for `api.minecraftservices.com`, `rta.xboxlive.com`, - `peoplehub.xboxlive.com` and `userpresence.xboxlive.com` then came from the session at - once, and the game went on through `vex.minecraftservices.com` to character select. + logged in to PlayFab with Steam and added, by ID, the XUID PlayFab links to that Steam account. + Its first token request, for `https://playfabapi.com/`, came 23 seconds after launch, and sake + picked it up a second later: the panel and the browser opened while the game was showing its + sign-in, the way the stand-in's sign-in looks. The owner signed in, and 62.6 seconds after the + request sake had a session and the game its token; the game's telemetry went on meanwhile. + Tokens for `api.minecraftservices.com`, `rta.xboxlive.com`, `peoplehub.xboxlive.com` and + `userpresence.xboxlive.com` then came from the session at once, and the game went on through + `vex.minecraftservices.com` to character select. - **The game again, with the sign-in kept and the session good.** The silent add returned the - person, every token came from the session, and the game reached character select with no - panel and no sign-in. + person, every token came from the session, and the game reached character select with no panel + and no sign-in. - **Unsigned tokens were enough.** All 20 token requests were the UTF-8 kind, and every token went out with no signature: PlayFab's bound to the device, the rest bound to nothing. - `multiplayeractivity.xboxlive.com` looks to have refused its token, since the game asked - again with `ForceRefresh`, four requests at a time. Each such wave cost sake a silent - refresh of two to three seconds, and whether the calls got through afterwards the runtime - cannot see. The stand-in's failed, with an error of its own, and neither run needed them - for character select. + Microsoft's page says every call to an Xbox service carries a signature from the key its token + is bound to, and yet the stand-in's log had none of the 416 requests whose headers it recorded + carrying a `Signature`. `multiplayeractivity.xboxlive.com` looks to have refused its token, + since the game asked again with `ForceRefresh`, four requests at a time. Each such wave cost + sake a silent refresh of two to three seconds, and whether the calls got through afterwards + the runtime cannot see. The stand-in's failed, with an error of its own, and neither run needed + them for character select. + +### Setup builds and places it -Later on 2026-09-30 the app built the runtime and placed it itself: the GDK Runtime step in -setup, then Steam started from sake in the `ex` bottle. +*2026-09-30: the GDK Runtime step in setup, then Steam started from sake in the `ex` bottle.* - **The step** took under five seconds from the button to a runtime in the engine, most of it fetching libHttpClient's 3.5 MB. The archive hashed as it had on 2026-09-29, and what it unpacked to matched, file for file, the tree unpacked from it then. The DLL carries no path - from the person's home: libHttpClient is compiled by absolute path, and `__FILE__` had put - five of those paths into the hand build, which the Makefile now maps to `libHttpClient`. The - 82 `/Users/runner/…` paths still in it are where llvm-mingw built its own libraries. -- **A copy that is not sake's.** With the stand-in's copy in `system32`, starting Steam from - sake left it alone and said so in the title's log; it hashed as before, and nothing was - written beside it. -- **sake's own.** With the stand-in's three copies set aside, the next start put sake's copy - and libHttpClient's licence in `system32`, the copy hashing as the engine's does. Steam's - client loaded none of it: the runtime's log had no new line 40 seconds after Steam started. - Minecraft Dungeons II's launcher and game both loaded `C:\windows\system32\xgameruntime.dll`, - and with the sign-in kept the game reached character select, every token from the session. - The start after that found the copy already there. - -On the evening of 2026-09-30 the game's account link and its play with others were measured in -the `ex` bottle, with sake's runtime in `system32` and the game's WinHTTP traced, and against -the owner's Windows PC, where Steam runs the same game on Gaming Services. + from the person's home: libHttpClient is compiled by absolute path, and `__FILE__` had put five + of those paths into the hand build, which the Makefile now maps to `libHttpClient`. The 82 + `/Users/runner/…` paths still in it are where llvm-mingw built its own libraries. +- **A copy that is not sake's.** With the stand-in's copy in `system32`, starting Steam from sake + left it alone and said so in the title's log; it hashed as before, and nothing was written + beside it. +- **sake's own.** With the stand-in's three copies set aside, the next start put sake's copy and + libHttpClient's licence in `system32`, the copy hashing as the engine's does. Steam's client + loaded none of it: the runtime's log had no new line 40 seconds after Steam started. Minecraft + Dungeons II's launcher and game both loaded `C:\windows\system32\xgameruntime.dll`, and with the + sign-in kept the game reached character select, every token from the session. The start after + that found the copy already there. + +### Account link and playing with others + +*The evening of 2026-09-30, in the `ex` bottle with sake's runtime in `system32` and the game's +WinHTTP traced, and against the owner's Windows PC, where Steam runs the same game on Gaming +Services.* - **Account link is the game's own.** Its settings link the Steam account to a Microsoft one - through Mojang's service: `POST vex.minecraftservices.com/account/steam/link` with the - PlayFab and Minecraft tokens the runtime hands over and a Steam ticket, and `…/unlink` to - undo it. With sake's tokens, unlinking worked and linking answered 500 with no reason in its - body, nine times in three tries, which the game showed as "Error code: 0029". On Windows the - same link worked. The person's characters stayed while the accounts were unlinked. + through Mojang's service: `POST vex.minecraftservices.com/account/steam/link` with the PlayFab + and Minecraft tokens the runtime hands over and a Steam ticket, and `…/unlink` to undo it. With + sake's tokens, unlinking worked and linking answered 500 with no reason in its body, nine times + in three tries, which the game showed as "Error code: 0029". On Windows the same link worked. + The person's characters stayed while the accounts were unlinked. - **The user hash was the difference.** An XSTS token minted from a user token bound to the - device's key names a different user hash from one minted from the unbound user token, and - the runtime puts one user hash, the identity's, into every `Authorization` value. sake had - minted PlayFab's from the bound one, so the game sent PlayFab's token under another hash. - Minted instead from the unbound user token, with the device's token beside it and the - request signed by the device's key, the link answered 200 and the game said the accounts - were linked. Minecraft's token was bound to nothing throughout, so the link does not need a - title claim. + device's key names a different user hash from one minted from the unbound user token, and the + runtime puts one user hash, the identity's, into every `Authorization` value. sake had minted + PlayFab's from the bound one, so the game sent PlayFab's token under another hash. Minted + instead from the unbound user token, with the device's token beside it and the request signed + by the device's key, the link answered 200 and the game said the accounts were linked. + Minecraft's token was bound to nothing throughout, so the link does not need a title claim. - **Multiplayer activity needs a title.** `multiplayeractivity.xboxlive.com` answered 401 with `"debugMessage": "Missing title Id claim."` to sake's token, bound or unbound, signed or not. XSAPI asks for a new token with `ForceRefresh` on every 401, so each wave cost sake a silent @@ -286,219 +462,24 @@ the owner's Windows PC, where Steam runs the same game on Gaming Services. - **No title token through SISU's page either.** SISU's `/authenticate` hands out a session for this `MSAAppId` without checking the redirect. The page it returns sends the person to `oauth20_authorize.srf` with `ms-xal-00000000497c1b94://auth`, which login.live.com refuses as - not registered for the app, before anyone signs in, and `/authorize` refuses the device - code's token with the session or without. -- **Playing together.** Parties, invites and matchmaking are Mojang's, `/spicewood/…` on the - same service, and the game servers PlayFab's. Joining a party by code worked both ways with - the game on a Nintendo Switch 2, and with the Mac hosting, the two played a mission together. - -Later on 2026-09-30 the runtime took `ForceRefresh` to sake only when no ask had ended in the -last 15 minutes (The design), and the game was run again in the `ex` bottle. - -- **Before, every one went to sake.** In the day's nine runs of the game on sake's runtime, - 112 token requests went to sake: the first run's sign-in, and 111 with `ForceRefresh`, - every one for `multiplayeractivity.xboxlive.com`. Each wave of those cost a silent sign-in - that rewrote the session and the kept sign-in: in the 1.9 minutes of the last run, 26 - requests in six sign-ins, 4 to 23 seconds apart and 2.0 to 3.5 seconds each. -- **After, one did.** The owner went into the game's world and walked around, and nothing - looked different. In the 2.5 minutes of the run, eleven token requests carried - `ForceRefresh`, all for `multiplayeractivity.xboxlive.com`: the seven of the first wave went - to sake together and cost one silent sign-in of 2.5 seconds, and the four that came 21 and - 24 seconds after it were answered from the session. The session and the kept sign-in were - written once. - -## WineGDK - -`Weather-OS/WineGDK` implements `xgameruntime` inside Wine 11.14. Its author declares their -own code CC0 — "derive, redistribute and reimplement ... without any attributions" — except -code by Olivia Ryan and the Xodus interop, which stay under Wine's LGPL. At its 2026-08-22 -head: - -- task queues, `XAsync`, threading, `XSystem`, feature queries and networking are - implemented, and its IDL describes the runtime's COM-style interfaces; -- `XUser` is not: `XUserAddAsync`, `XUserGetId` and `XUserGetTokenAndSignatureAsync` return - `E_NOTIMPL`, and the sign-in in progress hands token requests to Xodus, a separate GPL-3.0 - service; -- it is C++, built by Wine's C++ support for PE modules, which the Wine 11.0 in CrossOver - 26.3.0's sources does not have — so it cannot become a patch against sake's tree. - -Not all of it is its author's to declare, which this file did not say until 2026-09-29 and -the design below took for granted. The files behind its task queues and `XAsync` open with -"From https://github.com/microsoft/libHttpClient" — Microsoft's code, MIT-licensed — beneath -an LGPL header; `InitInternalGDKC.cpp`, `UserImpl.*` and `XodusService.cpp` carry Olivia -Ryan's copyright; and its IDL has the Wine project's LGPL header rather than the CC0 -declaration. What sake takes from WineGDK is therefore facts and no text: the interfaces' -IDs and the order of their slots. - -## The design - -**A runtime sake builds.** sake's own `xgameruntime.dll`, C++, built with the engine's -toolchain outside Wine's build. It is not a Wine patch: it replaces no Wine code. It lives in -`xgameruntime/`: the task queue and `XAsync` are libHttpClient's, compiled unmodified from a -pinned commit; the IDs and slot order are WineGDK's, each checked against the title's thunks; -the rest, `XUser` included, is written there. This paragraph used to make WineGDK's -implementation the starting point, which would have taken Microsoft's code at second hand -under a header that is not its own. - -**Where it goes.** Setup builds it, in a step of its own, from the copy of `xgameruntime/` -inside Sake.app, and keeps it in the engine with libHttpClient's licence beside it. Before sake -starts a title or an installer in any bottle, it puts both in that bottle's `system32`, the one -place the launcher looks (above). Every bottle, and not only the ones a `MicrosoftGame.config` -is found in, which is what this file planned until 2026-09-30: sake starts Steam before Steam -installs the game, so at the only moment sake can put anything there, there is no config to -find; and nothing but a GDK title loads the DLL. A copy of sake's own is replaced when it -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 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 -copy is written beside the one it replaces and renamed over it, so nothing ever reads half a -DLL. On APFS a game that has the old one loaded keeps it; on exFAT, where the `ex` bottle is, -what that rename does under a running game has not been measured, and sake cannot yet see -what runs in a symlinked bottle (issue #15) to wait for it. libHttpClient is fetched by the -step itself rather than with the other sources, so that nothing else in setup waits on it, and -it is checked by what it unpacks to (below). The engine is one per Mac, so two copies of sake -that carry different runtime source each take the other's build for stale and build their own -again; only a development build run beside a release does that. - -**A sign-in in the app, started by the game.** It looks like the stand-in's (above): the -person signs in when the game does, in the browser, rather than before it starts. The runtime -asks sake at the game's first token request; sake opens `microsoft.com/link` and shows the -code to type there, in a panel of its own that floats above the browser, since there is no -sign-in without a code (above). The device-code flow uses the title's own `MSAAppId`; the -title declares that ID for exactly this, and sake signs in as no other application. XSTS -tokens are minted for `http://xboxlive.com`, for `http://playfab.xboxlive.com/`, and for -whatever a table sake keeps names for the title, which for Minecraft Dungeons II is -`rp://api.minecraftservices.com/`. Only PlayFab's is bound to a device, because the -stand-in's README says PlayFab will not link an account otherwise, and it comes from the same -user token as the rest: the runtime labels every token with one user hash, and a user token -bound to the key would give PlayFab's another, which is what made the link fail until -2026-09-30 (above). The rest are bound to nothing, and the device's key never leaves sake. - -**The user the runtime hands over.** One user, whose handle is one object's address. The -silent add returns at once: with the person the session names while its identity token lasts -five more minutes, and otherwise with a placeholder, XUID 1, the way the stand-in answers, -which takes the XUID the game then adds by ID. After it, the runtime sends the one -`SignedInAgain` and, on every registration for connectivity changes, the initial -notification, both as the stand-in does (above). A token request looks the URL's host up in -the session; a token that is missing or lasts less than five minutes sends the runtime to -sake, and a URL no relying party covers gets `E_GAMEUSER_NO_TOKEN_REQUIRED`. `ForceRefresh` -sends it to sake as well, unless an earlier ask ended in the last 15 minutes, whatever its -answer; then the request is answered as if the option were not there. XSAPI puts that option -on the person's next token request after any 401, whatever its URL, and retries the refused -call once (`Source/Shared/http_call_wrapper_internal.cpp` and `user.cpp` in Microsoft's -xbox-live-api, read 2026-09-30), so a refusal no new token cures, such as multiplayer -activity's, had cost a silent sign-in every few seconds (above); and since the option can ride -on another service's request, what it gets is the session's token rather than a failure. Every -answer is completed inside `XAsyncBegin` or from the runtime's own thread, never from the -caller's queue. - -**What sake keeps.** The refresh token and the device, in `~/Library/Sake/sign-ins`, one file -per app ID, readable by the person alone: not the Keychain, which asks for the login password -on every read under an ad hoc signature (above). A file costs this: any program the person -runs can read it, every Windows program in every bottle included, through `Z:`. With it, -someone can sign in to Xbox Live as the person, with this title's app ID, until it is -revoked; changing the account's password should do that, and has not been tried. Nothing -measured says whether it reaches anything beyond Xbox Live. With a Developer ID signature -the Keychain may stop asking, which is not measured either. - -**Files between them.** In `%LOCALAPPDATA%\Sake\<title ID>\` in the bottle, which from sake's -side is `drive_c/users/crossover/AppData/Local/Sake/`, since every sake bottle's Windows user -is `crossover`. The runtime writes `request`, holding an ID of its own; sake writes `answer` -and, once the person is signed in, `session`. Each file is `key value` lines under a first -line of `sake 1`, and each is written whole and renamed into place. `answer` says `waiting` -as soon as sake has the request, then `signed-in`, `failed` with a reason, or `cancelled`. -`session` carries the XUID, gamertag, user hash, age group and privileges, a line `token -<relying party> <expiry in Unix seconds> <token>` for each relying party, and a line -`endpoint <host> <relying party>` for each host a relying party covers: the host itself, or -one ending in it after a dot, so that the runtime keeps no table of its own. A title asks -for several tokens at once and there is one `request` per title, so a request made while -another is out joins it rather than replacing it. The runtime gives -up when nothing says `waiting` within 10 seconds, and waits 20 minutes at most for the rest, -a device code lasting 15. Files, because a Windows DLL cannot reach a socket sake listens on: -Wine's Winsock converts no `AF_UNIX` address (CrossOver 26.3.0's sources, read 2026-09-29). -The refresh token stays out of the bottle. - -## Open questions - -- ~~**Which relying party a URL needs.**~~ **Answered on 2026-09-29**: not from the title's - own table, which sake cannot read without a title token (above). The default table covers - `playfabapi.com` and `*.xboxlive.com`; anything else a title calls needs a table sake keeps - for that title, which for Minecraft Dungeons II is `api.minecraftservices.com` → - `rp://api.minecraftservices.com/`. That was what the game's own calls needed: they reached - character select on 2026-09-30 (above). -- ~~**Which call starts the sign-in.**~~ **Answered on 2026-09-30**, above: not the silent - `XUserAddAsync`, which holds the game's start until the sign-in is done, before the game - has a window; the first token request, the way the stand-in does it, with the silent add - returning at once. -- ~~**Whether a key goes into the bottle.**~~ **Answered for Minecraft Dungeons II on - 2026-09-30**, above: no. Microsoft's page says every call to an Xbox service carries a - signature from the key its token is bound to, and the stand-in's log had none of the 416 - requests whose headers it recorded carrying a `Signature`. With sake's unsigned tokens, - PlayFab's bound to the device and the rest to nothing, the game reached character select. - The multiplayer activity service wants a title token (below), not a signature. -- **Whether tokens outlive a session.** XSTS tokens last 16 hours and the user token 96. The - runtime asks sake again for a token with less than five minutes left, and a kept sign-in - makes that silent, but no session has yet run long enough to need it. -- ~~**The Keychain under ad-hoc signing.**~~ **Answered on 2026-09-29**, above: it asks for - the login password on every read, Always Allow or not, so what sake keeps is a file. -- **sake has to be running.** A request that nobody picks up fails after 10 seconds, and the - game goes on without a user. A sake quit while its game runs cannot answer; whether - quitting should be refused, or warned about, while a title runs is open. -- **Whether the game needs `SignedInAgain`.** The runtime sends it because the stand-in does, - and the game answers it; a run with the connectivity notification and no change event would - say whether it has to. -- ~~**The interface layout.**~~ **Answered for Minecraft Dungeons II on 2026-09-29**, above: - every version it names is one WineGDK lists. A title built with a later edition may name - another; the runtime refuses a version it does not know and logs it, which is where to - look. -- **The game executable's own thunks.** Its code is encrypted on disk, so only the calls it - made have been checked. -- **Security information for a URL** is answered with TLS 1.2 and no pinned certificates. - The game asks for it, in UTF-16, before every request it sends, and on 2026-09-30 its - requests went through with that answer. -- ~~**Multiplayer activity.**~~ **Answered on 2026-09-30**, above: it wants a title Id claim, - so a title token, and sake has none: XAST refuses the device code's token, so does SISU's - `/authorize`, and SISU's page wants a redirect this app has not registered. Each refusal - cost a silent sign-in through sake until later that day, when the runtime stopped taking - every `ForceRefresh` at its word (The design). The game is played without it, and with - others by party code. -- **Linking a Steam account PlayFab does not know yet.** Unlinking and linking again works - since 2026-09-30 (above), with PlayFab's token bound to the device the way the stand-in's - README asks; a PlayFab token bound to nothing was not tried, and neither was an account - PlayFab has not seen. -- ~~**How to pin libHttpClient.**~~ **Answered on 2026-09-30**: by what it unpacks to. GitHub's - page on downloading source code archives, read that day, promises that an archive of a - commit ID always has the same files, and not the same bytes: the compression may change, - with six months' notice. A hash of the archive would be a way for setup to stop one day with - nothing wrong, so SakeKit hashes the unpacked tree instead, the path and contents of every - file in it that is not hidden, and compares that with the hash it carries. The archive - hashed the same that day all the same, the third time in two days. -- **What stops the game before this matters.** The launcher's Gaming Services check is - answered by the runtime (above). The VC++ false positive is still open: the `ex` bottle gets - past it with a DLL override sake does not set. - -## Order - -1. **The runtime alone**: built, placed, and the game started with no stand-in, as far as its - sign-in. The interface layout is the largest unknown, so it goes first. **Done on - 2026-09-29**, built and placed by hand (above). -2. **The sign-in in SakeKit**, measured against the relying parties above. **Done on - 2026-09-30**: `XboxSignIn` goes from a refresh token or a code to the tokens, the runtime - asks sake through the bottle, sake shows the code and opens the browser, and the sign-in - is kept in `~/Library/Sake/sign-ins`. The runtime asked at the silent add, which the next - step moved. -3. **`XUser` over the session file**, to character select. **Done on 2026-09-30** (above): - the silent add returns at once, with the person when the session in the bottle is still - good and with a placeholder the way the stand-in does when it is not, and the sign-in - starts at the first token request. -4. **SakeKit builds and places the runtime**: `xgameruntime/` compiled with the engine's - toolchain, and libHttpClient fetched as a pinned source. Last, because the steps before it - need nothing from it. **Done on 2026-09-30** (above): a setup step builds it, and sake puts - it in every bottle before a start rather than only in a bottle whose title has a - `MicrosoftGame.config`, which is what this item said until then; The design says why. A - first install through Steam, which is the reason, has not been run, since the game was - installed already. + not registered for the app, before anyone signs in, and `/authorize` refuses the device code's + token with the session or without. +- **Playing together.** Parties, invites and matchmaking are Mojang's, `/spicewood/…` on the same + service, and the game servers PlayFab's. Joining a party by code worked both ways with the game + on a Nintendo Switch 2, and with the Mac hosting, the two played a mission together. + +### ForceRefresh + +*2026-09-30, the game run again in the `ex` bottle once the runtime took `ForceRefresh` to sake +only when no ask had ended in the last 15 minutes.* + +- **Before, every one went to sake.** In the day's nine runs of the game on sake's runtime, 112 + token requests went to sake: the first run's sign-in, and 111 with `ForceRefresh`, every one + for `multiplayeractivity.xboxlive.com`. Each wave of those cost a silent sign-in that rewrote + the session and the kept sign-in: in the 1.9 minutes of the last run, 26 requests in six + sign-ins, 4 to 23 seconds apart and 2.0 to 3.5 seconds each. +- **After, one did.** The owner went into the game's world and walked around, and nothing looked + different. In the 2.5 minutes of the run, eleven token requests carried `ForceRefresh`, all for + `multiplayeractivity.xboxlive.com`: the seven of the first wave went to sake together and cost + one silent sign-in of 2.5 seconds, and the four that came 21 and 24 seconds after it were + answered from the session. The session and the kept sign-in were written once. diff --git a/docs/getting-started.md b/docs/getting-started.md index f1b7546..13756ed 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -8,7 +8,7 @@ Diablo IV on an Apple silicon Mac, from nothing installed to a character on scre - macOS 15 or newer, and the Xcode Command Line Tools — `xcode-select --install`. - Apple's Game Porting Toolkit `.dmg` — **4.0 beta 2**, which is what the engine here is built against — from <https://developer.apple.com/download/all/>. A free Apple ID is enough. sake - cannot fetch this for you; `docs/licensing.md` says why. + cannot fetch this for you; [licensing.md](licensing.md) says why. - A Battle.net account that owns Diablo IV, and Blizzard's installer for the client. sake does not fetch that either. - About 10 GB free for sake, and about 90 GB more for the game. @@ -104,8 +104,8 @@ instead. It clones the game rather than copying it, so it costs no disk. themselves. **Add**. **Leave the arguments alone.** Without `--in-process-gpu` the login form is drawn but never -appears, and without the two ANGLE flags the client's GPU process exits. `docs/runtime.md` has -the measurements. +appears, and without the two ANGLE flags the client's GPU process exits. +[runtime.md](runtime.md#three-settings-every-run-needs) has the measurements. ## 5. Install the game @@ -129,5 +129,4 @@ Nothing to set up. The engine is built with SDL2, so a controller should work. - Every run writes a log under `~/Library/Caches/Sake/build` — `title-<id>.log` for a title, `install-<name>.log` for an installer. The window shows only the last line of it. - **Wine Tools** on the bottle opens winecfg, regedit, the uninstaller and the task manager. -- `docs/runtime.md` tells four failure states apart by thread count, memory and Metal mappings. - Read that before deciding a build is broken. +- [Troubleshooting](troubleshooting.md) has what to try next. diff --git a/docs/how-it-works.md b/docs/how-it-works.md new file mode 100644 index 0000000..4b29fb2 --- /dev/null +++ b/docs/how-it-works.md @@ -0,0 +1,177 @@ +# How sake works + +sake builds Wine from the sources CodeWeavers publish for CrossOver, adds Apple's D3DMetal from +a Game Porting Toolkit you download yourself, and runs Windows games in Wine prefixes it calls +bottles. Everything it builds lives in your home folder; nothing is installed system-wide. This +page is the map, and the files it links to have the detail and the measurements. + +## Engine, bottles and titles + +- **The engine** is one per Mac, in `~/Library/Sake/engine`: Wine, the libraries it loads, + Apple's D3DMetal and the runtime GDK titles load, about 1.1 GB. Setup builds it once. +- **A bottle** is one Wine prefix, in `~/Library/Sake/bottles/<name>`: a Windows environment of + its own, about 1 GB empty, and the place a game is installed. There can be as many as you + like, and the name you type is the directory's name. +- **A title** is a program in a bottle that the library shows with a Play button: a name, the + executable, the arguments it starts with and any environment of its own. Titles are kept in + `sake-titles.json` inside the bottle, so a bottle renamed or thrown away takes its titles + with it. + +Where everything else lives, and why, is [layout.md](layout.md). + +## Setup: seven steps + +| step | what it does | what it leaves | +|---|---|---| +| This Mac | checks for Apple silicon, Rosetta 2, the Command Line Tools, the Game Porting Toolkit and 10 GB free | nothing | +| Sources | downloads eleven archives, ten of them checked against hashes sake carries | `~/Library/Caches/Sake/dl` | +| Libraries | unpacks the toolchain and builds the nine tools and libraries Wine is configured against | the engine | +| Wine | builds CrossOver's Wine with the nine patches in `patches/` | the engine | +| D3DMetal | mounts the evaluation environment inside the toolkit's image, copies Apple's `redist/lib` out of it, unmounts what it mounted, and installs it | the engine, and a copy in `~/Library/Caches/Sake/d3dmetal` | +| GDK Runtime | fetches libHttpClient and builds `xgameruntime.dll` | `engine/lib/xgameruntime` | +| Bottle | makes the first bottle, `default` | `~/Library/Sake/bottles/default` | + +[wine-build.md](wine-build.md) says what each library is for and which configure flags must +stay; [licensing.md](licensing.md) says why D3DMetal is copied from an image you opened rather +than fetched. + +**A finished step can come back.** The Wine step counts as done only while the engine was built +from the patches this copy of sake carries, and the GDK Runtime step only while the runtime was +built from the source it carries. An update that changes either leaves that step to do again, +and the library's sidebar says **Setup needs attention**; the wizard opens by itself only until +setup is first finished. A Wine rebuild takes D3DMetal out with it, because `make install` puts +Wine's own DLLs back, so the D3DMetal step is then one press again, from sake's own copy. + +## Pressing Play + +1. **The GDK runtime goes into the bottle.** Before sake starts a title or an installer, it puts + its `xgameruntime.dll`, with libHttpClient's licence, in the bottle's `system32`. A copy of + sake's own that differs from the engine's is replaced; a copy that is not sake's, such as a + community stand-in, is left alone. Nothing but a GDK title loads it. +2. **The environment is composed.** Five variables are sake's on every run: `WINEPREFIX`, + `WINEDLLOVERRIDES=mscoree,mshtml=d`, `WINEDEBUG=-all`, `WINE_SIMULATE_WRITECOPY=1` and + `CX_APPLEGPTK_LIBD3DSHARED_PATH`. A title's own variables go in underneath them, so a title + cannot take any of the five away. +3. **The program starts from its own folder, by its bare name**, with the title's arguments. + Wine turns that into `start.exe /exec`, so in `ps` sake's own launch shows as `start.exe`. +4. **sake watches for the title's process**: the one whose `argv[0]`, cut at its first `.exe`, + ends with the title's executable. +5. **Stop takes the whole bottle down**, not only the game: `wineserver -k`, then whatever is + still running in that prefix, found through the directory wineserver keeps its socket in. + +[runtime.md](runtime.md) has why each of these is the way it is. + +A launcher, such as Battle.net or Steam, is a title like any other, and its games are started +from inside it. For Diablo IV that is not only convenient: Battle.net hands out the game's login +token only after its own Play button +([runtime.md](runtime.md#pressing-play-does-two-separable-things)). + +## What sake changes in Wine + +sake carries nine patches against Wine's own code, in `patches/`. They are LGPL-2.1-or-later, +not MIT ([licensing.md](licensing.md#wine-and-the-patches)), and each file's header says where +it came from and why. A Wine built without them configures, installs and passes every check, +and then cannot start a game, so setup refuses to build one with none. + +| patch | without it | from | +|---|---|---| +| 0001 ntdll: find `libd3dshared` without its variable | Diablo IV deadlocks when a launcher composed its environment | sake | +| 0002 ntdll: read `BOOLEAN` syscall arguments as Windows defines them | Diablo IV stalls when Battle.net's Play starts it | sake | +| 0003, 0004 winemac.drv: Metal swapchains across processes | Steam's window stays black | upstream Wine, wine-11.11 | +| 0005 winemac.drv: the same for child windows | Steam's window stays black | the patch attached to Wine bug 60263 | +| 0006 winemac.drv: a hosted swapchain for D3DMetal | Steam's GPU process crashes on every start | sake | +| 0007, 0008 winhttp: accept two options XCurl sets | a GDK title's sign-in never reaches its service | upstream Wine, wine-11.4 and wine-11.7 | +| 0009 ntdll: leave macOS's `._` files out of listings | on exFAT, a game reads them as its own files | sake | + +[runtime.md](runtime.md) has what each one fixes and how to tell that it worked, and +[patches/README.md](../patches/README.md) the rules for the directory. The four upstream +commits are carried only until CrossOver's sources contain them: once the tarball sake builds +is based on wine-11.11 or later, they are deleted rather than rebased. + +## GDK titles + +A game built on Microsoft's GDK expects Xbox Gaming Services, which Wine does not have. sake +provides the two things such a game needs from them: a runtime DLL it builds itself, and an +Xbox sign-in it performs with the game's own app ID. The first time the game wants a token, sake +shows a code and opens `microsoft.com/link`; after that it signs in without asking. The +sign-in is kept in `~/Library/Sake/sign-ins`, in a file only you can read, which any program +you run can read too, Windows programs in bottles included. Minecraft Dungeons II plays this way, +online included. One service refuses sake's token: Xbox's multiplayer activity, which needs a +title token sake cannot get, and what a player loses without it has not been tried. +[gdk.md](gdk.md) has the design, the measurements and what is still open. + +## Why CrossOver's Wine, and why you supply D3DMetal + +DirectX 12 on a Mac goes through Apple's D3DMetal, and the Wine code it plugs into, +`dlls/winemac.drv/d3dmetal.c`, is in the sources CodeWeavers publish for CrossOver and not in +upstream Wine. It is LGPL, which is why they publish it and why sake can build the same Wine. +D3DMetal itself is not redistributable: sake never ships it, downloads it for you, or takes it +out of an installed CrossOver. It walks you through downloading Apple's toolkit; from the image +you open, it mounts the evaluation environment inside, copies out the part it needs, and +unmounts what it mounted. +[wine-build.md](wine-build.md#why-crossovers-sources-and-not-upstream-wine) has the build side, +and [licensing.md](licensing.md) the line sake stays inside and why D3DMetal cannot simply be +avoided. + +## The app + +There are two windows, because setting sake up is done once and playing is done every day. + +- **The library** lists the bottles, each with its titles, beside a detail pane rather than a + row per game: a row per game means a Play button per game, and the pane is where a title's + own settings go. A row is a name and nothing else. A bottle can be selected in its own right, + not only through its titles, because a bottle just made has nothing in it and a heading alone + would be a dead end. A bottle's own actions are on the bottle's pane: Install from an + Installer, Add a Title, Import from CrossOver when there is a CrossOver bottle to take from, + Rename, Delete and Wine Tools. The sidebar's menu keeps only what is about the library as a + whole. +- **The setup wizard** is a window of its own, one step per screen with the whole list beside + it, because it runs for tens of minutes, can fail part way, and sends you to a browser. A + wizard showing only the current card would leave you not knowing where you were. Importing + finishes in under a second, so it is a sheet on the library instead. + +Choices worth knowing before changing them: + +- **Renaming a bottle with a game running is refused; deleting one stops the game.** Nobody + changing a label asked for their game to stop, whereas throwing a bottle away already means + stopping what is in it. The guard knows only what sake started itself, so `Bottle` takes the + prefix down regardless. +- **Deleting and uninstalling go to the Trash**, so both can be undone. A bottle whose game was + imported from CrossOver returns almost none of its size even once the Trash is emptied, and + the confirmation says so ([layout.md](layout.md#removing-a-bottle-returns-almost-nothing)). +- **Uninstall is in the app menu**, because it is about the app rather than either window. It + takes `~/Library/Sake` and `~/Library/Caches/Sake` to the Trash and leaves Sake.app, which is + running at the time and yours to drag away. +- **A bottle hands over Wine's own tools rather than growing settings of sake's.** The engine + ships fourteen of Wine's programs, and the bottle offers the four that mean something to a + bottle with a game in it: winecfg, regedit, the uninstaller and the task manager. The rest are + either not useful here or better done on the macOS side. What they set lives in the bottle's + registry, which wineserver writes out lazily ([runtime.md](runtime.md#a-bottle)), so a copy + in sake would be a second copy that lies. They are offered, not recommended: winecfg's + Windows-version setting can break a game, and sake reimplementing it would not make it safer, + only harder to keep true. +- **Import from CrossOver appears only when there is a CrossOver bottle to take from** + (`CrossOverBottle.available()`): a button whose sheet can say nothing but why it cannot work + is worse than no button. +- **A row carries no glyph.** Every row under a bottle is a title, so a glyph would tell them + apart from nothing, and `gamecontroller`, the widest of the symbols tried, its ink filling a + 22×14 pt box, pushed each name 27 pt right of its heading (sake, 2026-09-21). What would earn + a column back is state the pane can only show one title at a time: running, or not installed. +- **A detail pane scrolls, and keeps its `.fixedSize`.** The modifier, + `.fixedSize(horizontal: false, vertical: true)` on a `Text`, is there so a long value wraps + instead of being truncated. In a pane that does not scroll it demands more height than the + window has; the `NavigationSplitView` is then laid out taller than the window and centred in + it, so the content leaves the visible area upwards and the window draws empty while the + accessibility tree still reports every string (sake, 2026-09-21). Scrolling bounds what the + demand can do. The setup wizard had scrolled from the start. + +In the code, `SakeKit` is meant to hold every decision that is not a view, because the tests +reach only it: which setup step is current, and what blocks each one, are there rather than in +the wizard. Some judgements have drifted into the app target's `AppModel` instead +([roadmap.md](roadmap.md#open-questions)). The `sake` target is the SwiftUI app and stays thin. +Which bottles exist, and whether a typed +name can be used, are answered on `Bottle` and tested there rather than in the sheet that asks. +Renaming and deleting a bottle are methods on `Bottle` beside `stop()`, rather than anything a +caller assembles, because each has to take that prefix down first. Swift owns configuration, progress, errors +and state, and subprocesses run what only they can: configure, make and wine. Why the line is +there is in [roadmap.md](roadmap.md#the-swiftsubprocess-boundary). diff --git a/docs/layout.md b/docs/layout.md index 4ecdbee..dfe9148 100644 --- a/docs/layout.md +++ b/docs/layout.md @@ -2,9 +2,9 @@ Where sake puts things, and why the obvious alternative does not work. -Everything below was measured on 2026-09-18 against the d4-mac prototype's tree, on one -machine (Apple silicon, macOS 27.0, CLT 27.0), except where a section carries its own date — -those were measured in sake. +*Unless a section carries its own date, this was measured on 2026-09-18 against the d4-mac +prototype's tree, on one machine (Apple silicon, macOS 27.0, CLT 27.0); the dated sections were +measured in sake.* ## The layout @@ -38,23 +38,21 @@ those were measured in sake. ``` Uninstalling is an explicit action in the app, not a side effect of dragging the bundle to -the Trash. Those two directories are the whole of it, and both go to the Trash like a bottle -does. **Sake.app is not one of them** — it is running at the time, and a bundle in -`/Applications` is the user's to drag away; the sheet says so rather than leaving the user -to wonder whether the app deleted itself. - -Those two are not the whole of what sake leaves, which this section did not say until -2026-09-29. AppKit keeps the windows' frames and the open panel's last folder in -`~/Library/Preferences/dev.typester.sake.plist`, and `SourceFetcher`'s default URLSession -left `~/Library/HTTPStorages/dev.typester.sake` and `~/Library/Caches/dev.typester.sake` -on 2026-09-20. Uninstalling does not take them yet. The sign-in uses a session that keeps -nothing on disk, so it adds nothing there. - -What the sign-in keeps is inside `~/Library/Sake`, in `sign-ins/`, so uninstalling lists it -on a line of its own and takes it to the Trash with the rest, refresh tokens and all, where -emptying the Trash is what finally removes them. Added 2026-09-30. - -Run for real on 2026-09-19 against the tree described above, and put back afterwards: +the Trash. It takes those two directories, and both go to the Trash like a bottle does. +**Sake.app is not one of them** — it is running at the time, and a bundle in `/Applications` +is the user's to drag away; the sheet says so rather than leaving the user to wonder whether +the app deleted itself. What the sign-in keeps is inside `~/Library/Sake`, in `sign-ins/`, so +uninstalling lists it on a line of its own and takes it to the Trash with the rest, refresh +tokens and all, where emptying the Trash is what finally removes them (sake, 2026-09-30). + +**Three small things outside those two are left behind.** AppKit keeps the windows' frames and +the open panel's last folder in `~/Library/Preferences/dev.typester.sake.plist` (sake, +2026-09-29), and `SourceFetcher`'s default URLSession left +`~/Library/HTTPStorages/dev.typester.sake` and `~/Library/Caches/dev.typester.sake` (sake, +2026-09-20). Uninstalling does not take them yet. +The sign-in uses a session that keeps nothing on disk, so it adds nothing there. + +Run for real in sake on 2026-09-19 against the tree described above, and put back afterwards: | | | |---|---| @@ -72,10 +70,10 @@ later. ## A bottle's name is a directory name There can be as many bottles as somebody wants, and the name they type is both the directory -under `bottles/` and the value of `WINEPREFIX`. A second one, made on 2026-09-19, cost what -the first did — `runtime.md` has the figures — and `Bottle.all` finds it by its `system.reg` -rather than by it being a directory, so a creation stopped part way is not offered as a -bottle. +under `bottles/` and the value of `WINEPREFIX`. A second one cost what the first did +([runtime.md](runtime.md#a-bottle) has the figures; sake, 2026-09-19), and `Bottle.all` finds it +by its `system.reg` rather than by it being a directory, so a creation stopped part way is not +offered as a bottle. Three names are refused, and one that looks like it should be is not: @@ -115,9 +113,9 @@ guard against it is wrong. The volume is case-insensitive, so the new name alrea performs it. What makes it reachable at all is excluding the bottle being renamed from the case-insensitive collision check above. -What has to happen first, for a rename and for a delete alike, is `wineserver -k` against -this prefix — see `runtime.md`, and note that the same command without `WINEPREFIX` goes -after `~/.wine`, kills nothing of the user's and exits 0. +What has to happen first, for a rename and for a delete alike, is taking this prefix down +([runtime.md](runtime.md#taking-a-bottle-down)) — and note that `wineserver -k` without +`WINEPREFIX` goes after `~/.wine`, kills nothing of the user's and exits 0. ### Which is why the titles added by hand live in the prefix @@ -127,7 +125,7 @@ themselves — a name, the arguments, the environment, and the executable **rela in it names the prefix, so it inherits everything the section above measured: a rename stays a `moveItem`, and throwing the bottle away takes its titles with it. Keeping the list under `~/Library/Sake` instead would make both of those an operation on two places -that have to agree, which is the shape of bug that outlives the feature. Added 2026-09-20. +that have to agree, which is the shape of bug that outlives the feature. Wine ignores what it does not recognise at a prefix's root — it keeps its own `.update-timestamp` there — and a bottle nobody has added anything to has no @@ -179,7 +177,8 @@ Two things constrain it: - **The clone has to land at the same relative path.** `ProgramData/Battle.net/Agent/product.db` records the install as `C:/Program Files (x86)/Diablo IV`, so the client recognises the game only if it is there. (Prototype, 2026-09-17.) -- **`drive_c/windows` is never touched** — that is CrossOver's own Wine; see `licensing.md`. +- **`drive_c/windows` is never touched** — that is CrossOver's own Wine; see + [licensing.md](licensing.md#importing-out-of-a-crossover-bottle). `drive_c/users` is left alone as well, on weaker grounds: the prototype never carried a user profile across and Battle.net rebuilt its own. @@ -217,8 +216,9 @@ the confirmation sheet presents it as one rather than as a promise. ## Why not Application Support -Measured in sake on 2026-09-19. `~/Library/Application Support/Sake` is the obvious home and -it does not work: the engine is an autotools `--prefix`, and the space in "Application +*Measured in sake on 2026-09-19.* + +`~/Library/Application Support/Sake` is the obvious home and it does not work: the engine is an autotools `--prefix`, and the space in "Application Support" splits back out of `CPPFLAGS` and `LDFLAGS` the moment a configure script expands them. Every one of the eight library builds failed the same way: @@ -281,8 +281,8 @@ $ otool -l .../D3DMetal.framework/Versions/A/D3DMetal | grep -A4 LC_BUILD_VERSIO macOS 14 is the floor D3DMetal sets. sake targets 15 anyway, for reasons above it rather than below it: -- SwiftUI's `UtilityWindow` is `@available(macOS 15.0, *)`, and it is the window style this - app wants for the setup flow's own windows. 14 would rule it out. +- SwiftUI's `UtilityWindow` is `@available(macOS 15.0, *)`, and it is the window style an + auxiliary window of this app would want. 14 would rule it out. - The Game Porting Toolkit that supplies D3DMetal wanted Sequoia by version 3, so users who can obtain D3DMetal at all are essentially all on 15 or newer. - Nothing is gained by going higher. The UI this app needs is available at 15, so raising @@ -295,12 +295,12 @@ which cannot become the main window, cannot be minimised, and has `hidesOnDeacti The last of those decides where it may be used — a window whose job is to say "download this from Apple" must not vanish the moment the user switches to a browser. -That rules out more than this file first thought. sake has two windows as of 2026-09-19: the -library, and the setup wizard. The wizard is exactly the window that says "download this from -Apple", because that is what its D3DMetal step asks for. **So both are plain `Window`s and -nothing uses the utility style yet.** The deployment target stays at 15 on its other -grounds; when an auxiliary window does turn up — a build log is the obvious candidate — it -is the one that can carry the style, because nothing about it sends the user elsewhere. +That rules out the setup wizard, which is exactly the window that says "download this from +Apple", because that is what its D3DMetal step asks for. **So both of sake's windows, the +library and the wizard, are plain `Window`s, and nothing uses the utility style yet.** The +deployment target stays at 15 on its other grounds; when an auxiliary window does turn up — a +build log is the obvious candidate — it is the one that can carry the style, because nothing +about it sends the user elsewhere. Raise it when a macOS 26-only API earns it; raising a deployment target later is cheap. @@ -350,8 +350,7 @@ What genuinely stays pinned is `bison`, which compiles in the location of its sk So sake writes `@loader_path`-relative sonames, rewriting `include/config.h` between configure and make. -Measured against a real build on 2026-09-19, which this file previously said remained to be -done: +Measured against a real build (sake, 2026-09-19): - The four come out as `@loader_path/../../libfreetype.6.dylib` and the like — `../..` because the only thing that `dlopen`s them is `lib/wine/x86_64-unix/`, two levels under @@ -365,8 +364,7 @@ Rows five and six of the table survive as expected: `ntdll.so` and `bin/wine` st `engine/{bin,lib,lib/wine,share/wine}`, which is what Wine recomputes from `dladdr` at startup rather than trusting. -**Wine itself loading them**, which this file previously listed as the remaining unknown, -was measured on 2026-09-19 once there was a prefix to run in: +**Wine itself loads them** (sake, 2026-09-19, once there was a prefix to run in): - `DYLD_PRINT_LIBRARIES=1` over a `wine reg query` shows dyld loading `engine/lib/libfreetype.6.dylib` and `engine/lib/libSDL2-2.0.0.dylib`. Their only openers @@ -374,27 +372,25 @@ was measured on 2026-09-19 once there was a prefix to run in: hop being taken, not a lucky absolute path. - MoltenVK prints its own banner during `wineboot --init` (`MoltenVK version 1.4.2, supporting Vulkan version 1.4.357`), so `libMoltenVK.dylib` loaded as well. -- **gnutls is the one still unobserved.** `bcrypt.so` opens it when something asks for TLS, - and nothing has yet. The soname is written the same way as the other three. +- **gnutls is the one not seen loading by name.** `bcrypt.so` opens it when something asks for + TLS, and the soname is written the same way as the other three. HTTPS through Wine's own + WinHTTP works in sake's bottles (sake, 2026-09-29 and 2026-09-30), but nothing checked which + library served it, so gnutls is still the one soname not seen loading by name. -## Path length is a real constraint, and the new path is untested +## Soname length no longer depends on where the engine lives -The prototype's README records that with a long root, the absolute sonames grow long enough -that verbose `WINEDEBUG` channels overflow Wine's debug buffer and kill the process — taking -away the diagnostic tool exactly when it is needed. Its numbers: 146 characters was too long, -70 was fine. +**With `@loader_path` sonames, where the engine lives does not change how long they are**: the +longest is 38 characters (sake, 2026-09-19). It mattered for the prototype's absolute sonames. +Its README records that with a long root they grow long enough that verbose `WINEDEBUG` +channels overflow Wine's debug buffer and kill the process — taking away the diagnostic tool +exactly when it is needed. Its numbers: 146 characters was too long, 70 was fine. -| root | resulting soname length | +| root | resulting absolute soname length | |---|---| | `~/.local/share/d4-mac` (the prototype, known good) | ~76 | | `~/Library/Application Support/Sake` (rejected above) | ~92 | | `~/Library/Sake` | ~65 | -The 92 that sat in the untested gap between the two known points is no longer a question to -answer twice over. `~/Library/Sake` comes out at 65 on a fifteen-character user name, shorter -than the length already known to work — and as of 2026-09-19 sake writes `@loader_path` -sonames anyway, whose longest is 38 characters and does not depend on where the engine lives -at all. - -`Paths` still takes both roots as parameters, so a root that turns out to be wrong again does -not reach into every caller. +`~/Library/Sake` would come out at 65 on a fifteen-character user name, shorter than the length +already known to work. `Paths` still takes both roots as parameters, so a root that turns out to +be wrong again does not reach into every caller. diff --git a/docs/licensing.md b/docs/licensing.md index 72afe3b..8a26977 100644 --- a/docs/licensing.md +++ b/docs/licensing.md @@ -26,8 +26,8 @@ This is why no open-source launcher bundles D3DMetal and why they all make you s ### What sake actually does -Implemented and measured on 2026-09-19. `redist/lib` on the evaluation-environment volume is -68 MB and holds exactly this: +*Implemented and measured in sake on 2026-09-19; the unmounting was added on 2026-09-20.* +`redist/lib` on the evaluation-environment volume is 68 MB and holds exactly this: ``` external/D3DMetal.framework 67 MB, three symlinks inside it @@ -39,9 +39,9 @@ wine/x86_64-windows/*.dll the same six sake mounts the nested image with `hdiutil attach -nobrowse -readonly`, copies that tree into `~/Library/Caches/Sake/d3dmetal`, **unmounts it again**, and copies it from there into the engine. Unmounting is the point of keeping the copy: nothing afterwards needs the image, and -an image left mounted follows the app around — `runtime.md` has what that cost. An image -the user opened themselves is left alone; only what sake mounted is put back. -Added 2026-09-20. +an image left mounted follows the app around — [runtime.md](runtime.md#taking-a-bottle-down) +has what that cost. An image the user opened themselves is left alone; only what sake mounted +is put back. Three things about that worth keeping: @@ -62,10 +62,10 @@ miserable, so sake keeps its own copy of `redist/lib`. That copy is **the user's**, made on their machine from media they obtained under Apple's licence. sake does not distribute it, does not put it on a network, and deletes it with the -cache. That last one stopped being a promise on 2026-09-19: uninstalling takes +cache. That last one is a test rather than a promise (sake, 2026-09-19): uninstalling takes `~/Library/Caches/Sake` away whole, and a test asserts both that `d3dmetal` is inside it and that it is gone afterwards, so the day somebody moves the cache copy elsewhere the test says -so. The three things sake must never do are unchanged: it does not ship D3DMetal, does not +so. The three things sake must never do stand as they are: it does not ship D3DMetal, does not download it on the user's behalf, and does not take it out of an installed CrossOver. ## Importing out of a CrossOver bottle @@ -92,7 +92,7 @@ accepting the terms of whoever made it, and that is the user's act — the same D3DMetal step points at Apple's download page instead of reaching for it. What sake does is run a `.exe` or `.msi` the user already has, in the bottle they chose, -with the environment `runtime.md` describes. The file stays where it is; nothing is copied +with the environment [runtime.md](runtime.md#three-settings-every-run-needs) describes. The file stays where it is; nothing is copied into the app, and nothing about the installer is redistributed. ## Wine and the patches @@ -109,26 +109,29 @@ happy would hide the one thing about it that has to be visible. ## GDK titles For titles built on Microsoft's GDK, sake provides its own stand-in for the Gaming Runtime -(`gdk.md`). It never ships, downloads or copies Microsoft's GDK or Gaming Services, and it -takes no code or text from the community stand-in, whose repository has no licence: what the -stand-in's log and README record of its behaviour, and what its DLL does when a probe calls -it from outside, is used as a record and nothing more. Its task queue -and `XAsync` are libHttpClient's, which is Microsoft's and MIT-licensed: fetched at a pinned -commit rather than kept in this repository, and contained in any DLL built from it, so -libHttpClient's licence goes wherever that DLL goes. Setup builds the DLL on the person's own -Mac and keeps libHttpClient's `LICENSE.md` beside it in the engine, and every copy sake puts in -a bottle's `system32` has that licence put beside it. The one file is enough: of what sake -compiles out of `Source/Task` and `Include`, every file that names a copyright holder names -Microsoft, and `NOTICE.txt` and `ThirdPartyNotices.txt` are about code sake does not compile -(read 2026-09-30). From WineGDK it takes facts and no text: -which IDs the interfaces have and the order of their slots. This section used to say code -would start from the part of WineGDK its author declared CC0; the part worth taking turned out -to be libHttpClient's, under an LGPL header there (`gdk.md`). Signing in uses the title's own -`MSAAppId` from its `MicrosoftGame.config`; sake signs in as no other application, and every -request made while finding out how used that ID alone. Its requests are the shapes Microsoft's -documentation gives where it gives them and otherwise what the services accepted -(`gdk.md`); none comes from the stand-in or from Xodus. Which token is bound to a device -follows the stand-in's README, which says PlayFab needs one. +([gdk.md](gdk.md)). + +- **It never ships, downloads or copies Microsoft's GDK or Gaming Services.** +- **It takes no code or text from the community stand-in**, whose repository has no licence. + What the stand-in's log and README record of its behaviour, and what its DLL does when a probe + calls it from outside, is used as a record and nothing more. +- **Its task queue and `XAsync` are libHttpClient's**, which is Microsoft's and MIT-licensed: + fetched at a pinned commit rather than kept in this repository, and contained in any DLL built + from it, so libHttpClient's licence goes wherever that DLL goes. Setup builds the DLL on the + person's own Mac and keeps libHttpClient's `LICENSE.md` beside it in the engine, and every copy + sake puts in a bottle's `system32` has that licence put beside it. The one file is enough: of + what sake compiles out of `Source/Task` and `Include`, every file that names a copyright holder + names Microsoft, and `NOTICE.txt` and `ThirdPartyNotices.txt` are about code sake does not + compile (read 2026-09-30). +- **From WineGDK it takes facts and no text**: which IDs the interfaces have and the order of + their slots. The part of WineGDK worth taking is libHttpClient's, under an LGPL header there + ([gdk.md](gdk.md#winegdk)). +- **Signing in uses the title's own `MSAAppId`** from its `MicrosoftGame.config`; sake signs in + as no other application, and every request made while finding out how used that ID alone. Its + requests are the shapes Microsoft's documentation gives where it gives them and otherwise what + the services accepted ([gdk.md](gdk.md#signing-in-against-the-real-services)); none comes from + the stand-in or from Xodus. Which token is bound to a device follows the stand-in's README, + which says PlayFab needs one. ## Why D3DMetal cannot simply be avoided diff --git a/docs/releasing.md b/docs/releasing.md index b1798f2..c3a70db 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -62,9 +62,10 @@ tap. The tap's own documentation is deliberately thin and points back here. ## Not verified -- **Nobody has installed the cask**, here or anywhere. `brew fetch --cask sake` downloaded - v0.1.0 and the checksum matched; there was no install, no first launch, and so nothing - has seen what Gatekeeper does with an ad-hoc signature that arrived through brew. +- **The cask has been installed once, on the owner's Mac**: 0.1.1, on 2026-09-21, without + `--no-quarantine`, and it has run there since. What Gatekeeper showed on that first launch is + not recorded, and no second Mac has installed it. Before that, `brew fetch --cask sake` + downloaded v0.1.0 and the checksum matched. - `brew audit --cask --online` has not been run: Homebrew refuses to start on the machine this was written on, wanting Xcode 27 where it finds 26.4. - Neither `workflow_dispatch` recovery path has been used. diff --git a/docs/roadmap.md b/docs/roadmap.md index e10141b..a28640c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -15,234 +15,53 @@ The `d4-mac` prototype (a set of shell scripts, not in this repository) got Diab playable on 2026-09-17 by building CrossOver's Wine from CodeWeavers' published LGPL sources. It proved the approach. It is unusable by anyone who will not read shell. -None of its code is here, deliberately. What came across is the reasoning, in -`wine-build.md`, `runtime.md`, `licensing.md` and `layout.md`. Every claim in those files -says where and when it was measured. Most of it is still the prototype's; where sake has -since measured something itself, the section says so and carries its own date. - -## Phases - -### Phase 1 — the skeleton (done) - -An app that builds, launches and does nothing. Repository conventions, and the prototype's -knowledge written down. - -### Phase 2 — the build pipeline in Swift (done) - -Download, verify, configure, `make`, install — driven from Swift, reporting progress the UI -can render. This is where `wine-build.md` became code. - -The chain ran end to end on 2026-09-19: the preflight checks (Apple silicon, Rosetta 2, the -Command Line Tools, the Game Porting Toolkit, disk space), the two pieces the rest sits on -(the on-disk layout as a value type, and a subprocess runner that streams output and can be -cancelled), fetching the eleven sources against pinned hashes, building the tools and -libraries into the engine prefix, building Wine itself against them — configure, the -`@loader_path` soname rewrite, make, install, and a check that this is CrossOver's tree and -not upstream's — and guiding Apple's D3DMetal in from an image the user mounted. That leaves -a 1.1 GB engine. - -What it does not do is **run** a game. Starting Wine at all needs a prefix, and that is -Phase 3. - -### Phase 3 — bottles and titles (under way) - -Creating prefixes, importing an existing install, per-title settings. This is also where the -prototype's two ntdll patches have to land, and where the three settings in `runtime.md` -stop being something only the prototype has tried. The per-title knowledge -is pure data (executable path, arguments, environment, how to recognise its process), so it -belongs in a declarative form the GUI can read and edit — not in code. - -**Creating a prefix is done, as of 2026-09-19**, and it is the first thing here that runs -what the earlier phases built: wineboot, the wait, the checks that WoW64 came up and that -Wine found the engine's own libraries, and the crash dialog turned off before anything can -put one up. `runtime.md` has what that measured. - -**Importing is done too, the same day.** A game already installed under CrossOver is cloned -in rather than copied, which costs no disk at all, and what comes across is decided by -difference against a fresh prefix — so no title is named in the code. `layout.md` has the -measurement and `licensing.md` the line it stays inside. - -**Starting a title is done, also 2026-09-19**, and with it the first end-to-end evidence -that any of this works: the Battle.net client comes up in sake's own bottle and loads its -login page. What a title is — executable, arguments, how to recognise its process — is a -value, not code. - -**The two patches landed the same day, and with them a game runs.** Diablo IV starts behind -a live parent process and reaches 92 threads, 1982 MB and 103 Metal/AGX mappings, where the -unpatched build stalls flat at 12. `runtime.md` has both measurements and what they are -against; `licensing.md` has why `patches/` is not MIT. The Play button itself was pressed on -2026-09-20 and the game was played; the probe reproduced the check it trips over, and then -the button turned out to agree. - -**A game can be put in without CrossOver, as of 2026-09-20.** An installer the user -supplies runs in a chosen bottle, and anything already in a bottle can be added to the -library by hand — name, arguments, and the executable relative to `drive_c`, kept in -`sake-titles.json` inside the prefix so that a rename and a delete stay what `layout.md` -measured them to be. `licensing.md` has why sake runs an installer but never fetches one. -**A real installer has been through it, on 2026-09-20**, run through the app by the person -this was built for rather than by the machine that wrote it. `Battle.net-Setup.exe` is -`PE32 … Intel 80386` — a 32-bit installer, which until then was only inferred to work -from a populated `syswow64` — and it installed a client that is PE32 too. The run left a -57 KB log under `build/`. Later the same day that client signed in, Play was pressed in it, -and Diablo IV was playable with a keyboard and mouse — **the whole of it, from a Mac with -no CrossOver on it to a game somebody played.** A Switch Pro Controller over USB played it -too, and Steam went on to sign in and run Stardew Valley — both the owner's word rather than -a trace, and `runtime.md` says which is which. What nobody has written down yet is a second -machine. - -**sake ships no titles of its own, as of 2026-09-20.** It knew Battle.net once — an -executable path, its flags, and a row that appeared when that path existed. What the row -carried is now general: the flags come from looking for `libcef.dll` beside the program, -and a process is recognised by the executable the title names. The cost is that a bottle is -empty until somebody adds something to it, whether the game arrived from an installer or -out of a CrossOver bottle. - -**Steam was the second title, on 2026-09-20, and the first the engine could not show at -all.** Its installer ran through the app, the client updated itself and came up as a black -700×440 window on every start, whatever flags it was given. The cause was not in the title -and not in the flags: the client's browser process owns the window and its GPU process draws -into it, and the winemac.drv in CrossOver 26.3.0's Wine 11.0 has no way to carry rendering -across that line — D3DMetal's shim was handed a NULL window record and dereferenced it, -Vulkan drew into a view nothing hosted, and software compositing drew from the wrong -process. Upstream Wine solved the top-level half in wine-11.11; sake carries that, the -child-window half from Wine bug 60263, and its own change to the D3DMetal glue, as four -patches in `patches/`. `runtime.md` has the measurement and what to look for. - -What the second title taught about profiles: Steam needed **nothing** per-title once the -engine could host a swapchain across processes — no flags, no environment, no registry. The -three Chromium flags sake offers are Battle.net's, measured on its 32-bit CEF, and Steam's -client cannot even take them. So a title profile was name, executable and arguments until -2026-09-21, when it gained an environment of its own — `runtime.md` has what that may and -may not set — -and the argument suggestion is a heuristic for one launcher rather than a rule for Chromium. -Nothing yet knows that starting Diablo IV directly fails on the token — `runtime.md` says -so, the app does not. - -### Phase 4 — the GUI proper (under way) - -Setup flow, library, per-title configuration, uninstall. `layout.md` covers where things go -and why uninstall has to be an explicit action. - -**Started on 2026-09-19 by splitting the window in two.** Setting sake up is done once and -running a game is done every day, and one scrolling column had them interleaved. Now there -is a library window and a setup wizard, one step per screen with the whole list of steps -beside it — the list stays because a step here can take tens of minutes and fail, and a -wizard that shows only the current card leaves you with no idea where you were. - -Which step is current, and what blocks each one, is in `SakeKit` rather than the wizard: it -is the only real decision the wizard makes, and logic in a view is logic that stops being -tested. - -The library is a list with a detail pane rather than a row per game, for two reasons worth -keeping: a row per game means a Play button per game, and the detail pane is where per-title -settings go when they arrive. Importing is a sheet on that window rather than a window of -its own because it finishes in under a second. It used to be justified by belonging to one -bottle as well; that half came back on 2026-09-20, when the picker inside it went and the -way in became the bottle's own screen. The setup -wizard is a window instead precisely because it does not finish quickly: it runs for tens of -minutes and sends the user to a browser part way through. - -A row is its name and nothing else: every row under a heading is a title, so a per-row -glyph tells them apart from nothing, and `gamecontroller` — the widest symbol of the ones -tried, ink filling its 22×14pt box — pushed each name 27pt right of its own heading. -Measured in sake on 2026-09-21. If that column is ever wanted back, what would earn it is -state the detail pane can only show for one title at a time: running, or not installed. - -**More than one bottle followed on 2026-09-19.** The library is a section per bottle, and a -bottle is selectable in its own right rather than only through the games in it — a bottle -just made has nothing in it, so a heading alone would be a dead end. What the detail pane for -one offers is how a game gets in: the import, the game's own installer, and adding -something already there as a title. The import is offered only where there is a CrossOver -bottle to take from, from 2026-09-21: `CrossOverBottle.available()` already answers that, -and a button whose sheet can say nothing but why it cannot work is worse than no button. -Those are per-bottle actions and they live on the -bottle, which is where they moved on 2026-09-20 — the sidebar's menu keeps only what is -about the library rather than about one bottle in it. - -Nothing in `SakeKit` had to change to allow it: `BottleBuilder`, `BottleImporter` and -`TitleLauncher` all already took a name. What was missing was a way to enumerate what is on -disk and a way to say whether a typed name can be used, both of which are on `Bottle` and -tested there rather than in the sheet that asks. - -**Renaming and deleting one followed on 2026-09-19.** Both turned out to be directory -operations, because nothing inside a prefix names the prefix — `layout.md` has what that was -measured against. Both are methods on `Bottle` beside `stop()` rather than anything a caller -assembles, for the reason `stop()` is: each has to take this prefix's wineserver down first, -and the same command without a `WINEPREFIX` goes after `~/.wine` and exits 0. - -Deleting moves the bottle to the Trash. It can be undone, which is worth having for a bottle -with a signed-in client in it, and it is honest about the one thing it cannot do: a bottle -whose game was imported returns almost none of its size even once the Trash is emptied, -because those blocks belong to the CrossOver install as well. `layout.md` has the figures, -and the confirmation says so rather than quoting the size as a promise. - -**Taking the prefix down is why the two ask differently.** Renaming a bottle with a game -running in it is refused; deleting one is not. Nobody changing a label asked for their game -to stop, so a rename that did it anyway is a surprise with nothing gained — whereas throwing -a bottle away already means stopping what is in it, so there the confirmation says so and -goes ahead. The guard is only as good as what sake started itself, because nothing in `ps` -says which prefix a Wine process belongs to; `Bottle` taking the prefix down regardless is -what keeps the rest safe rather than merely quiet. - -**Uninstall landed on 2026-09-19 and was run here for real.** It is in the app menu rather -than in either window, because it is about the app and not about what is on screen. It takes -the two directories `layout.md` names and nothing else, and it takes them to the Trash — the -same choice bottles made, and the reason the real run could be undone afterwards rather than -costing a rebuild. - -What it deliberately does not do is delete Sake.app. `licensing.md`'s one standing promise -about the user's copy of D3DMetal — that it does not outlive the cache — is now a test rather -than a sentence. - -Still to come: adding and editing titles, and taking the build cache away on its own, which -is what somebody who wants their 4 GB back but not to lose their games is asking for. -Two rough edges found on the way: the delete confirmation names the game twice when one is -running, and a bottle just created does not offer what could be imported into it until -something else surveys — `create` sets `importTarget` after the survey that would have used -it. - -**A bottle hands over Wine's own tools rather than growing settings of its own, from -2026-09-21.** The Windows version, the DLL overrides, the drives and the audio device are -winecfg's panels, and what they set is the bottle's registry — which `runtime.md` records as -being flushed lazily by wineserver, so a copy of it in a SwiftUI form would be a second copy -that lies. The engine already ships fourteen of these programs; the bottle offers four of -them, the ones that mean something to a bottle with a game in it: winecfg, regedit, the -uninstaller and the task manager. The rest are either not useful here or better done on the -macOS side. - -Offered, not recommended. winecfg's Windows-version dropdown can break a game, which is the -same objection the CrossOver-version knob has in the open questions below: exposing it -invites a combination nobody has run. The difference is that these are Wine's own surfaces -and sake reimplementing them would not make them safer, only harder to keep true. - -The wineserver a tool starts is not something the rename guard knows about — it asks what -sake started, which is the narrowness the open question about prefixes already describes. -Running winecfg therefore does not refuse a rename the way a running game does. - -**And one layout trap, found the same day.** A `Text` with -`.fixedSize(horizontal: false, vertical: true)` in a detail pane makes the pane demand a -height the window does not have to offer; the demand reaches the `NavigationSplitView`, -which is laid out taller than the window and centred in it, so the content leaves the -visible area upwards and the window draws empty while the accessibility tree still reports -every string. The panes scroll now, which keeps the modifier — it is there so a long value -wraps instead of being truncated — and bounds what the demand can do. The setup wizard had -been doing this from the start. - -### Phase 5 — GDK titles (under way) - -A runtime DLL and a sign-in of sake's own, so that a title built on Microsoft's GDK runs -without Gaming Services or a community stand-in. `gdk.md` has what was measured, the design -and the order. The first piece is in: two WinHTTP stubs, without which the GDK's HTTP client -drops every request (2026-09-29, `runtime.md`). The second is the runtime itself, in -`xgameruntime/`: built by hand and put in a bottle's `system32` the same day, it took -Minecraft Dungeons II past its launcher's check and as far as its sign-in with no stand-in. -The third is the sign-in, in SakeKit: that night it signed a real account in to Xbox Live with -the title's own app ID. On 2026-09-30 the runtime and the sign-in together took Minecraft -Dungeons II to character select with no stand-in: the runtime hands the game its user and -tokens and asks sake through the bottle at the game's first token request, and sake shows the -code the first time and nothing after. The fourth came the same day: setup builds the runtime -in a step of its own, and sake puts it in every bottle before it starts anything there, which -took the game to character select with nothing done by hand. +None of its code is here, deliberately. What came across is the reasoning, in `docs/`. Most +of the low-level findings there are still the prototype's, and each says whose it is. + +## Where it stands + +- **Phase 1 — the skeleton (done).** An app that builds and launches, the repository's + conventions, and the prototype's knowledge written down. +- **Phase 2 — the build pipeline in Swift (done).** The preflight checks, the eleven sources, + ten of them against pinned hashes, the libraries, Wine with its patches, and D3DMetal from an image the + user mounted, driven from Swift with progress the UI can render. It first ran end to end on + 2026-09-19 and left a 1.1 GB engine. +- **Phase 3 — bottles and titles (under way).** Bottles are created, renamed and thrown away; + a game comes in through its own installer or is cloned out of a CrossOver bottle; anything in + a bottle can be added as a title, with its own arguments and environment. sake ships no titles + of its own: a bottle shows what somebody added to it. Diablo IV through Battle.net was played + on a Mac with no CrossOver on it on 2026-09-20, and Steam and Stardew Valley the same day + (the owner's report); Minecraft Dungeons II on 2026-09-30. +- **Phase 4 — the GUI proper (under way).** The library and the setup wizard, Uninstall, and + Wine's own tools on a bottle are in; [how-it-works.md](how-it-works.md#the-app) has why they + look the way they do. What is left is below. +- **Phase 5 — GDK titles (under way).** A runtime and an Xbox sign-in of sake's own, built and + placed by setup, so that a title built on Microsoft's GDK runs without Gaming Services or a + community stand-in. The four steps it was planned in are done, and Minecraft Dungeons II plays + on them; what needs a title token is out of reach ([gdk.md](gdk.md)). + +## Next + +- **Taking the build cache away on its own**, which is what somebody who wants their 4 GB back + but not to lose their games is asking for. +- **Uninstall taking the three small files it leaves**: the preferences plist and two URLSession + stores ([layout.md](layout.md#the-layout)). +- **A second Mac.** sake has run on one. + +## Known rough edges + +Found and not fixed; each was still in the code on 2026-09-30. + +- **Two buttons say "Check Again" on the This Mac step.** The bottom bar adds one when the step + is the machine step, and `primary` adds another because that step is not done, so both render + the same verb. The comment above the first says it is there for the step "worth repeating + after it has passed", which is the condition the code does not check (found 2026-09-21). +- **Editing a title moves it to the end of the sidebar.** `TitleStore.add()` filters the id out + and appends, so saving Options reorders the library. Harmless and confusing, and it cost a + measurement on 2026-09-21: a row addressed by index was no longer the row it was. +- **The delete confirmation names the game twice** when one is running. +- **A bottle just created does not offer what could be imported into it** until something else + surveys: `create` sets `importTarget` after the survey that would have used it. ## The Swift/subprocess boundary @@ -265,55 +84,31 @@ easier to read than it was interleaved with `configure` flags. ## Open questions -- **gnutls is the last `@loader_path` soname nothing has loaded.** As of 2026-09-19 a - running Wine loads freetype, SDL2 and MoltenVK by the rewritten names; `bcrypt.so` opens - 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 - 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 - each one alone, which a patch under two others cannot do; it now treats patches on one - file as a stack, reversed top-down on a copy of the files they touch. `wine-build.md` has - 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`). +- **gnutls.** freetype, SDL2 and MoltenVK have been seen loading by their rewritten names + (sake, 2026-09-19). HTTPS through Wine's own WinHTTP works in sake's bottles (sake, 2026-09-29 + and 2026-09-30), but nothing checked which library served it, so gnutls is still the one + soname not seen loading by name ([layout.md](layout.md#relocatability)). - **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 — - but launch arguments and process identification are still per-title, and one data point - is thin. -- ~~**Nothing tells sake which prefix a running Wine process belongs to.**~~ **Answered - 2026-09-20**: the other half of `/tmp/.wine-<uid>/server-<dev>-<inode>` is the prefix - directory's inode, and `lsof` against that directory names every process in the bottle — - including the ones `ps` describes only as `C:\windows\system32\services.exe`. An inode - survives a rename, so this holds across one. `runtime.md` has the measurement and what - it cost to find out. What is left of the question: the guard that refuses to rename a - bottle with a game in it still asks what sake started rather than asking the prefix, so - it is narrower than it now needs to be. -- **`AppModel` has quietly become where decisions live, and the tests cannot reach it.** - Whether a bottle may be renamed, which run a title's status belongs to, and what to forget - after an uninstall are all judgements, and all in the app target — `scripts/test.sh` only - reaches `SakeKit`. `CLAUDE.md` says logic in a view stops being tested; this is the same - thing one layer down. Either these move behind types that do not know about SwiftUI, or - the app target gets tests of its own. `typedEnvironment()`, added 2026-09-21, is another - of these: which variable names a title may not set is a judgement, and it lives in the app - target where the tests cannot reach it. -- **Two buttons say "Check Again" in the setup wizard.** The bottom bar adds one when the - step is the machine step, and `primary` adds another because that step is not done, so - both render the same verb. The comment above the first says it is there for the step - "worth repeating after it has passed" — which is the condition the code does not check. - Found 2026-09-21, not fixed. -- **Editing a title moves it to the end of the sidebar.** `TitleStore.add()` filters the id - out and appends, so saving Options reorders the library. Harmless and confusing, and it - cost a measurement on 2026-09-21: a row addressed by index was no longer the row it was. -- **Where the CrossOver version lives.** It is a knob users may need — a newer CrossOver may - fix or break a given game — but exposing it invites them to pick a combination nobody has - run. Steam gave the knob a concrete reason on 2026-09-20: some of sake's patches are - upstream Wine commits that CrossOver's next Wine rebase will contain — two then, four since - the WinHTTP stubs on 2026-09-29 — and the day the tarball sake builds is based on - wine-11.11 or later they are all to be deleted, not rebased. + places: launch arguments, process identification, which directories to import. What to import + is now a difference against a fresh prefix, and a process is recognised by the executable the + title names; the arguments come from a heuristic for one launcher, `libcef.dll` beside the + program, rather than a rule for Chromium. Nothing yet knows that starting Diablo IV directly + fails on the token ([runtime.md](runtime.md#pressing-play-does-two-separable-things)). +- **The rename guard asks what sake started, not the prefix.** A prefix's processes can be found + through its socket directory ([runtime.md](runtime.md#taking-a-bottle-down)), but the guard + that refuses to rename a bottle with a game in it still asks what sake started, so it is + narrower than it needs to be. A wineserver one of Wine's tools started is not something it + knows about, so running winecfg does not refuse a rename the way a running game does. In a + bottle that is a symlink the socket directory is not found at all + ([#15](https://github.com/typester/sake/issues/15)). +- **`AppModel` has quietly become where decisions live, and the tests cannot reach it.** Whether + a bottle may be renamed, which run a title's status belongs to, which variable names a title + may not set (`typedEnvironment()`), and what to forget after an uninstall are all judgements, + and all in the app target, where `scripts/test.sh` does not reach. `CLAUDE.md` says logic in a + view stops being tested; this is the same thing one layer down. Either these move behind types + that do not know about SwiftUI, or the app target gets tests of its own. +- **Where the CrossOver version lives.** It is a knob users may need — a newer CrossOver may fix + or break a given game, and CodeWeavers keep several versions available — but exposing it + invites them to pick a combination nobody has run. Four of sake's patches are upstream Wine + commits that a later CrossOver will contain, and the day the tarball sake builds is based on + wine-11.11 or later they are to be deleted, not rebased. diff --git a/docs/runtime.md b/docs/runtime.md index 8b72a22..35766da 100644 --- a/docs/runtime.md +++ b/docs/runtime.md @@ -1,38 +1,43 @@ -# Running games: the settings, the patches, and how to tell failures apart +# Running games: the settings every run needs, and what each patch fixes -A built Wine is not a working one. This is what the d4-mac prototype needed on top of the -build to get Diablo IV from "starts" to "plays", verified 2026-09-17 and 2026-09-18 on one -machine. **sake now creates prefixes and starts the Battle.net client in one**, dated in -the sections below, and it builds the file layout D3DMetal needs. No game has been started, -so everything from the Play button onwards is still the prototype's. +A built Wine is not a working one. This file is what a game needs on top of the build — the +bottle, three settings, how a title is started and stopped — and the nine patches sake carries, +each with what it fixes and how to tell that it is working. Without the patches Wine still +configures, installs and passes every check, and fails only once a game starts, which is why +setup refuses to build it with none. [how-it-works.md](how-it-works.md#what-sake-changes-in-wine) +has the patches in one table, and [debugging.md](debugging.md) the instruments. -## Creating a prefix +*A measurement without a tag is the d4-mac prototype's, made on one machine on 2026-09-17 and +2026-09-18; sake's own measurements and the owner's reports say so.* -`wine wineboot --init` makes it, and `wineserver -w` is where it finishes — Wine's processes -outlive the command that started them, so returning from wineboot is not the end. +## A bottle -**`WINEDLLOVERRIDES="mscoree,mshtml=d"`, or wineboot never returns.** Without it wineboot -puts up the Wine Mono installer's dialog and waits for a click that never comes: 0% CPU -inside `CFRunLoopRun` → `mach_msg` forever, and `syswow64` is never populated. (Prototype, -2026-09-17.) +**`wine wineboot --init` makes a prefix, and `wineserver -w` is where it finishes.** Wine's +processes outlive the command that started them, so returning from wineboot is not the end. -**An empty `syswow64` is the one check worth making.** It means WoW64 did not initialise, so -no 32-bit application will run — and Battle.net's launcher is 32-bit. Everything else about -the prefix looks finished when this is what happened. +**`WINEDLLOVERRIDES="mscoree,mshtml=d"`, or wineboot never returns.** Without it wineboot puts +up the Wine Mono installer's dialog and waits for a click that never comes: 0% CPU inside +`CFRunLoopRun` → `mach_msg` forever, and `syswow64` is never populated (prototype, 2026-09-17). + +**An empty `syswow64` is the one check worth making.** It means WoW64 did not initialise, so no +32-bit application will run, and Battle.net's launcher is 32-bit. Everything else about the +prefix looks finished when this is what happened. A populated one ran them: Battle.net's +installer is `PE32 … Intel 80386`, and run through the app it installed a client that is PE32 +too and left a 57 KB log under `build/` (sake, 2026-09-20). **Turn the crash dialog off before anything can crash**: `ShowCrashDialog=0` under -`HKCU\Software\Wine\WineDbg`. Otherwise a crash spawns `winedbg --auto`, which puts up a -dialog and holds the process until somebody clicks Close — an unattended command just blocks -until it times out — and resets `WINEDEBUG` on the way, so suppressed logging comes roaring -back into whatever was being debugged. (Prototype, 2026-09-18.) +`HKCU\Software\Wine\WineDbg`. Otherwise a crash spawns `winedbg --auto`, which puts up a dialog +and holds the process until somebody clicks Close — an unattended command just blocks until it +times out — and resets `WINEDEBUG` on the way, so suppressed logging comes roaring back into +whatever was being debugged (prototype, 2026-09-18). Both halves showed up in sake: with the +value set, a run whose render process kept hitting a breakpoint carried on unattended instead +of stopping on a dialog, and `winedbg --auto` still ran and symbolised, spilling 880 lines of +`dbghelp_dwarf` fixmes into a run made with `WINEDEBUG=-all` (sake, 2026-09-19). -Both halves of that showed up in sake on 2026-09-19. With the value set, a run whose render -process kept hitting a breakpoint carried on unattended instead of stopping on a dialog — -and `winedbg --auto` still ran and symbolised, spilling 880 lines of `dbghelp_dwarf` fixmes -into a run made with `WINEDEBUG=-all`. +**wineserver keeps the registry in memory and writes it out lazily**, so `user.reg` read from +disk can be stale; take the server down first, then read the file (prototype, 2026-09-18). -sake created its first bottle on 2026-09-19 (Apple M5, macOS 27.0, against the engine built -the same day). What that cost and what came out: +*sake's first bottle, 2026-09-19: Apple M5, macOS 27.0, the engine built the same day.* | | | |---|---| @@ -41,51 +46,50 @@ the same day). What that cost and what came out: | the bottle | 997 MB, 801 files in `system32` and 841 in `syswow64` | | everything Wine printed | MoltenVK's three-line banner. No `err:`, no `fixme:`, nothing else | -Two things in a fresh bottle that a path in this document may not lead you to expect: +Two things in a fresh bottle that a path in these files may not lead you to expect: -- **The Windows user is `crossover`,** not the account's short name — `drive_c/users/crossover`. +- **The Windows user is `crossover`,** not the account's short name: `drive_c/users/crossover`. CrossOver's tree does this, and the prototype's bottle has the same directory, so every - `drive_c/users/<user>/…` path below means that one. -- **`dosdevices` maps whatever was mounted at the time.** A bottle created while Apple's - Game Porting Toolkit is still mounted gets a `d:` pointing into `/Volumes`, which dangles - as soon as it is ejected. Harmless, and worth recognising rather than debugging. + `drive_c/users/…` path in these files means that one. +- **`dosdevices` maps whatever was mounted at the time.** A bottle created while Apple's Game + Porting Toolkit is still mounted gets a `d:` pointing into `/Volumes`, which dangles as soon + as it is ejected. Harmless, and worth recognising rather than debugging. -## Three settings carry the whole thing +## Three settings every run needs None is on by default, and no symptom resembles its cause. -**Two of the three are sake's to apply and one is not.** `WINE_SIMULATE_WRITECOPY` and -`CX_APPLEGPTK_LIBD3DSHARED_PATH` are environment, so `Bottle.environment` puts them on -everything the engine runs. `--in-process-gpu` and the two ANGLE flags beside it are -**Chromium's**, and mean something only to a program built on CEF — putting them on a game -that reads its own `argv` is not free. sake therefore offers them when it can see it is -dealing with a Chromium app, and otherwise leaves the arguments empty. +**Two are environment and sake's to apply; one is Chromium's and is not.** +`WINE_SIMULATE_WRITECOPY` and `CX_APPLEGPTK_LIBD3DSHARED_PATH` are environment, so +`Bottle.environment` puts them on everything the engine runs. `--in-process-gpu` and the two +ANGLE flags beside it are **Chromium's**, and mean something only to a program built on CEF — +putting them on a game that reads its own `argv` is not free. sake therefore offers them when it +can see it is dealing with a Chromium app, and otherwise leaves the arguments empty. -What it looks for is `libcef.dll`, **beside the program or one directory below it**. -Measured against a real Battle.net install on 2026-09-20: the exe is -`Battle.net/Battle.net.exe` and its CEF build is `Battle.net/Battle.net.17821/libcef.dll`, -so looking only beside the program finds nothing and the flags would never be offered for -the one title that is known to need them. +What it looks for is `libcef.dll`, **beside the program or one directory below it**. A real +Battle.net install has its exe at `Battle.net/Battle.net.exe` and its CEF build at +`Battle.net/Battle.net.17821/libcef.dll`, so looking only beside the program would never offer +the flags to the one title known to need them (sake, 2026-09-20). -**Those three flags are Battle.net's, not Chromium's in general.** Steam's client, the second -Chromium app through sake (2026-09-20), takes none of them: `steam.exe` consumes whatever it -is given and passes nothing through to `steamwebhelper.exe`, and Steam's own switch list has -no in-process-GPU option left. Its `libcef.dll` also sits three directories down, so the -heuristic never offers them for it — correctly, as it turns out. What Steam needed was in -the driver, not in the arguments; the section on Steam below has the measurement. +**The flags are Battle.net's, not Chromium's in general.** Steam's client takes none of them: +`steam.exe` consumes whatever it is given and passes nothing through to `steamwebhelper.exe`, +and Steam's own switch list has no in-process-GPU option left. Its `libcef.dll` sits three +directories down, so the heuristic never offers them for it — correctly, as it turns out. What +Steam needed was in the driver ([below](#steam-one-process-draws-another-owns-the-window)) +(sake, 2026-09-20). -### `WINE_SIMULATE_WRITECOPY=1` — or Battle.net never fetches the login page +### `WINE_SIMULATE_WRITECOPY=1` — or Battle.net never fetches its login page CodeWeavers' `CW Hack 22996`. With it, a page that has been `VirtualProtect`ed away from `PAGE_WRITECOPY` is reported as already copied, which is what Windows does. -Without it, every CEF render process executes an `int3` within five seconds of starting, -always at the same address; `UAuth: begin loading` never appears and the login page is never -even requested. CodeWeavers told Battle.net users to set this by hand in 2023 and it is +Without it, every CEF render process executes an `int3` within five seconds of starting, always +at the same address; `UAuth: begin loading` never appears and the login page is never even +requested (prototype). CodeWeavers told Battle.net users to set this by hand in 2023, and it is still not automatic. -sake measured that on 2026-09-19 by starting the client twice with nothing different but -this variable: +*Measured in sake on 2026-09-19, by starting the client twice with nothing different but this +variable:* | | with it | without it | |---|---|---| @@ -93,84 +97,82 @@ this variable: | `UAuth: begin loading` | present, then `finished loading. statusCode=200 state=Login` | never appears | | `Battle.net.exe` processes | 4 | 3 | -`0x80000003` is `STATUS_BREAKPOINT`, which is the `int3`, and the first arrives nine seconds -in and then one every five seconds after it. Same bottle, same arguments, same other three -variables: this one line is the difference between a login page and a breakpoint. +`0x80000003` is `STATUS_BREAKPOINT`, which is the `int3`; the first arrives nine seconds in, and +then one every five seconds. Same bottle, same arguments, same other three variables: this one +line is the difference between a login page and a breakpoint. -### `--in-process-gpu` — or the login form is drawn but never shown +### `--in-process-gpu` and ANGLE — or Battle.net's login form is drawn but never shown With a separate GPU process the login web view never gets a compositor surface of its own. -MoltenVK reports swapchains for the window (348x646) and the chrome strip (348x50) but never -one the size of the page content (348x558); the view stays black while the renderer paints -the form perfectly. Folding the GPU into the browser process makes the content surface -appear. +MoltenVK reports swapchains for the window (348x646) and the chrome strip (348x50) but never one +the size of the page content (348x558); the view stays black while the renderer paints the form +perfectly. Folding the GPU into the browser process makes the content surface appear +(prototype). This is not a graphics setting in disguise. Turning off Battle.net's own browser hardware -acceleration changes nothing, `--disable-direct-composition` changes nothing, and fonts are -not involved. +acceleration changes nothing, `--disable-direct-composition` changes nothing, and fonts are not +involved. Battle.net also needs `--use-gl=angle --use-angle=vulkan`. Left alone, ANGLE tries its D3D11 -backend (which gets nothing — D3DMetal has no 32-bit half), then SwANGLE, then gives up with -"GL is disabled" and the GPU process exits with `ACCESS_VIOLATION`. +backend, which gets nothing because D3DMetal has no 32-bit half, then SwANGLE, then gives up +with "GL is disabled", and the GPU process exits with `ACCESS_VIOLATION` (prototype). ### `CX_APPLEGPTK_LIBD3DSHARED_PATH` — or Diablo IV does not start at all -Apple's `libd3dshared.dylib` exports `register_non_native_code_region`, which is how Rosetta -is told a region of memory holds dynamically generated x86_64 code. Wine only looks that -symbol up when this variable points at the library (`init_non_native_support()` in +Apple's `libd3dshared.dylib` exports `register_non_native_code_region`, which is how Rosetta is +told a region of memory holds dynamically generated x86_64 code. Wine looks that symbol up only +when this variable points at the library (`init_non_native_support()` in `dlls/ntdll/unix/loader.c`). CrossOver's launcher sets it on every run; nothing else does. -Blizzard's protected loader `diablo_iv_loader.dll` generates code at runtime and drives it -with fibers plus `SetThreadContext` on other threads. Without the registration the fiber -switch does not return where the loader expects, its scheduler loop re-enters, and it -deadlocks re-acquiring its own non-recursive SRW lock. What you see is 0.0% CPU, 122 MB -resident, nine threads, no window, and **not one byte** in the game's own -`_FenrisDebug-*.txt`. Nothing in that picture points at Rosetta. +Blizzard's protected loader `diablo_iv_loader.dll` generates code at runtime and drives it with +fibers plus `SetThreadContext` on other threads. Without the registration the fiber switch does +not return where the loader expects, its scheduler loop re-enters, and it deadlocks +re-acquiring its own non-recursive SRW lock. What you see is 0.0% CPU, 122 MB resident, nine +threads, no window, and **not one byte** in the game's own `_FenrisDebug-*.txt`. Nothing in that +picture points at Rosetta (prototype). The 32-bit client is structurally unaffected: `pe_module_loaded()` reaches `init_non_native_support()` only on the 64-bit side, because the WoW64 entry point `wow64_pe_module_loaded()` is a stub returning `STATUS_NOT_IMPLEMENTED`. -**Also place `libd3dshared.dylib` in the same directory as `D3DMetal.framework`.** -`d3d12.so` declares `LC_RPATH = @loader_path` and looks for `libd3dshared.dylib` beside -itself; `libd3dshared` then `dlopen`s `@rpath/D3DMetal.framework/D3DMetal` relative to *its* -own location. Copying only `libd3dshared` next to the `.so` files breaks it. +**`libd3dshared.dylib` goes in the same directory as `D3DMetal.framework`.** `d3d12.so` declares +`LC_RPATH = @loader_path` and looks for `libd3dshared.dylib` beside itself; `libd3dshared` then +`dlopen`s `@rpath/D3DMetal.framework/D3DMetal` relative to *its* own location. Copying only +`libd3dshared` next to the `.so` files breaks it. -sake does this on 2026-09-19: it copies `libd3dshared.dylib` into -`lib/wine/x86_64-unix/` and puts a `D3DMetal.framework` symlink beside it pointing at -`../../external/D3DMetal.framework`, then refuses to call the install done unless -`lib/wine/x86_64-unix/D3DMetal.framework/D3DMetal` resolves. The engine that comes out has -`d3d12.so` linking `@rpath/libd3dshared.dylib` and that symlink landing on a real x86_64 -Mach-O. **Nothing has been run against it.** +So sake copies `libd3dshared.dylib` into `lib/wine/x86_64-unix/`, puts a `D3DMetal.framework` +symlink beside it pointing at `../../external/D3DMetal.framework`, and refuses to call the +install done unless `lib/wine/x86_64-unix/D3DMetal.framework/D3DMetal` resolves. The engine that +comes out has `d3d12.so` linking `@rpath/libd3dshared.dylib` and that symlink landing on a real +x86_64 Mach-O (sake, 2026-09-19). In Diablo IV on this engine, Metal's HUD names +`Game Porting Toolkit 4.0b2` (sake, 2026-09-21). ### Everything else belongs to one title -The three above are sake's, and `Bottle.environment` puts them on everything the engine -runs. Anything else is one title's business, and since 2026-09-21 a title carries its own -`KEY=VALUE` pairs. They go in underneath the bottle's, which is composed afterwards, so the -five names `Bottle.environment` writes — `WINEPREFIX`, `WINEDLLOVERRIDES`, `WINEDEBUG`, -`WINE_SIMULATE_WRITECOPY` and `CX_APPLEGPTK_LIBD3DSHARED_PATH` — win. The sheet refuses -those by name rather than accepting a value it would then quietly ignore, because a run that -behaves as though a variable had been set is the worse of the two failures. - -**`MTL_HUD_ENABLED=1` draws Metal's performance HUD, and D3DMetal adds a section of its -own to it.** The HUD belongs to the OS, so anything rendering through Metal can show it; -what makes it worth knowing here is that block. Measured in Diablo IV on sake's own -engine, 2026-09-21: above an FPS and GPU-time graph the HUD names the translation -`D3D12 (Metal 4)` and the process `x86_64`, and below it lists -`Game Porting Toolkit 4.0b2` with Dispatch, Draw, Clear Resource, Copy Resource and -ExecuteIndirect counts. It is the cheapest look at what D3DMetal is doing per frame, and -it costs no trace. - -The prototype lists this variable among the ones that made no difference. That is about -the hang it was tested against, not about the HUD: it did not fix the hang, and it does -draw. - -## Starting a title - -From the game's own directory, by its bare leaf name, with the title's arguments. sake did -this for the first time on 2026-09-19; a healthy Battle.net run looks like this in -`ps -Ao pid=,args=`: +The two variables above are sake's, and `Bottle.environment` puts them on everything the engine +runs. Anything else is one title's business, and a title carries its own `KEY=VALUE` pairs. +They go in underneath the bottle's, which is composed afterwards, so the five names +`Bottle.environment` writes — `WINEPREFIX`, `WINEDLLOVERRIDES`, `WINEDEBUG`, +`WINE_SIMULATE_WRITECOPY` and `CX_APPLEGPTK_LIBD3DSHARED_PATH` — win. The sheet refuses those by +name rather than accepting a value it would then quietly ignore, because a run that behaves as +though a variable had been set is the worse of the two failures. + +**`MTL_HUD_ENABLED=1` draws Metal's performance HUD, and D3DMetal adds a section of its own to +it.** The HUD belongs to the OS, so anything rendering through Metal can show it; what makes it +worth knowing here is that block. In Diablo IV on sake's own engine, above an FPS and GPU-time +graph the HUD names the translation `D3D12 (Metal 4)` and the process `x86_64`, and below it +lists `Game Porting Toolkit 4.0b2` with Dispatch, Draw, Clear Resource, Copy Resource and +ExecuteIndirect counts (sake, 2026-09-21). It is the cheapest look at what D3DMetal is doing per +frame, and it costs no trace. + +The prototype lists this variable among the ones that made no difference. That is about the +hang it was tested against, not about the HUD: it did not fix the hang, and it does draw. + +## Starting and recognising a title + +**A title starts from the game's own directory, by its bare leaf name, with the title's +arguments.** A healthy Battle.net run looks like this in `ps -Ao pid=,args=` (sake, +2026-09-19): ``` start.exe /exec Battle.net.exe --use-gl=angle --use-angle=vulkan @@ -185,68 +187,63 @@ C:/ProgramData/Battle.net/Agent/Agent.9775/Agent.exe --session=… Three things in that list defeat a naive process check: -- **`wine` turns a relative name into `start.exe /exec`.** sake's own launch therefore - 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. +- **`wine` turns a relative name into `start.exe /exec`.** sake's own launch therefore appears + as `start.exe`, never as the game, which is the case the rule below exists for. - **wineserver does not spell itself `<engine>/bin/wineserver`.** `ps` shows `<engine>/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. -- **`--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. - -What the client's own logs said on that run is the evidence the settings above did their -job: `libcef-*.log` held two `WSALookupServiceBegin failed` lines and nothing else — no GPU -errors, no "GL is disabled" — and `battle.net-*.log` ended -`UAuth: finished loading. statusCode=200 state=Login`. - -**Nobody looked at the screen.** This was measured without screen access, so "the login form -is visible" is not claimed. What is claimed is that the page was requested, came back 200, -and the renderer that draws it was still alive seventy-five seconds later. - -## Two patches to ntdll - -sake carries nine patches in `patches/`, all LGPL-2.1-or-later because all are derivatives -of Wine. Two of the three 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 two in winhttp arrived with Minecraft Dungeons II on -2026-09-29 and are in the GDK section after it, and the third in ntdll came with the same -game a day later and has the exFAT section after that. 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 -before-the-patch half, and sake has not reproduced that half — its engine has never been -built without them. - -**Resolve `libd3dshared` from `dll_dir` when the variable is unset.** A process whose -environment was composed by an application never inherits the variable. The prototype -measured, with the variable removed: before the patch 122 MB at 0.0% CPU with nine threads -(deadlocked), after it 2561 MB at 38.6% CPU with 84 threads (running). The variable still -wins when set. - -sake's own measurement looks at what is mapped rather than at CPU. With the variable removed -from the environment, Diablo IV came up at 83 threads and 2534 MB with -`<engine>/lib/external/libd3dshared.dylib` mapped into it, seven regions. An unpatched ntdll -returns before that `dlopen` when the variable is unset, so the library being in the process -at all is the patch and nothing else. - -**`vmmap` is how to check this, not `WINEDEBUG=+module`.** The two `TRACE`s in -`init_non_native_support` only run once something calls `pe_module_loaded`, which a -`wine cmd /c exit` never does — and during a real game start they did not reach a filter on -wine's own stderr either. Two attempts went that way before the mapping was looked at -instead, which took one command. - -**Read `BOOLEAN` syscall arguments as the Windows ABI defines them.** This is the one that -made the Play button work, and it is worth understanding before touching ntdll. - -Since Diablo IV 3.1.0 (2026-06-30) the loader inspects the process that started it, when -that process is still alive — and `Agent.exe` always is. It opens the parent, reads its -image path, and walks `\DosDevices` one entry at a time with `NtQueryDirectoryObject` to -build a drive-letter-to-device map. CrossOver's build asks for index 0, 1, 2 … 48 and -finishes. The prototype's asked for **index 0 on every call, ~55,000 times a second, -forever** — its `+server` log grew at 17 MB/s, which is how the loop was found. + nothing and so reports success every time — sake's first version did exactly that, and only a + real run showed it. +- **`--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. + +**The game's process is the one whose `argv[0]` ends with the executable's name**, not one that +contains it: a loose match also catches `cmd.exe`, `start.exe` or any launcher carrying the name +in its own arguments, which handed the prototype the wrong process twice. Matching the bare name +at the start is not enough either, because `argv[0]` is spelled differently depending on how the +program was started. So sake cuts `argv[0]` at its first `.exe` and checks what that ends with. + +What the client's own logs said on that run is the evidence the settings did their job: +`libcef-*.log` held two `WSALookupServiceBegin failed` lines and nothing else — no GPU errors, no +"GL is disabled" — and `battle.net-*.log` ended +`UAuth: finished loading. statusCode=200 state=Login`, and the renderer that draws the page was +still alive seventy-five seconds later (sake, 2026-09-19). The login form was +seen, the client signed in, and Play started Diablo IV, which was played (the owner's report, +2026-09-20). + +## Diablo IV: two ntdll patches and the Play button + +*Both patches came from the prototype unchanged and go in before configure. sake measured both +against its own engine and bottle on 2026-09-19, the day it started carrying them; the +prototype's numbers are kept as the before-the-patch half, which sake has not reproduced, +because its engine has never been built without them.* + +### 0001: find libd3dshared without the variable + +**`patches/0001` resolves `libd3dshared` from `dll_dir` when `CX_APPLEGPTK_LIBD3DSHARED_PATH` is +unset**, because a process whose environment an application composed never inherits the +variable. The variable still wins when set. + +With the variable removed, before the patch: 122 MB at 0.0% CPU with nine threads, deadlocked; +after it, 2561 MB at 38.6% CPU with 84 threads, running (prototype). sake's measurement looks at +what is mapped rather than at CPU: with the variable removed from the environment, Diablo IV +came up at 83 threads and 2534 MB with `<engine>/lib/external/libd3dshared.dylib` mapped into +it, seven regions. An unpatched ntdll returns before that `dlopen` when the variable is unset, +so the library being in the process at all is the patch and nothing else (sake, 2026-09-19). +`vmmap` is how to see it, not `WINEDEBUG=+module` +([debugging.md](debugging.md#looking-from-the-mac-side)). + +### 0002: read BOOLEAN syscall arguments as Windows defines them + +**`patches/0002` is the one that made the Play button work**, and it is worth understanding +before touching ntdll. + +Since Diablo IV 3.1.0 (2026-06-30) the loader inspects the process that started it, when that +process is still alive — and `Agent.exe` always is. It opens the parent, reads its image path, +and walks `\DosDevices` one entry at a time with `NtQueryDirectoryObject` to build a +drive-letter-to-device map. CrossOver's build asks for index 0, 1, 2 … 48 and finishes. The +prototype's asked for **index 0 on every call, ~55,000 times a second, forever** — its `+server` +log grew at 17 MB/s, which is how the loop was found. `WINEDEBUG=+syscall` showed the fifth argument, `RestartScan`, a stack-passed `BOOLEAN`: @@ -255,135 +252,131 @@ forever** — its `+server` log grew at 17 MB/s, which is how the loop was found | the prototype | `6c006200610000` — UTF-16 `abl`, leftover from a path string | | CrossOver | `00000000` | -The low byte is FALSE in both. The Windows x64 ABI leaves the upper bits of a narrow -argument undefined and MSVC stores exactly one byte, so the caller is within its rights. +The low byte is FALSE in both. The Windows x64 ABI leaves the upper bits of a narrow argument +undefined and MSVC stores exactly one byte, so the caller is within its rights. `__wine_syscall_dispatcher` copies the whole 8-byte word into the SysV register, and the clang-built unix side assumes — as the SysV ABI permits — that a narrow parameter arrives zero-extended, compiling the test to `testl %r8d, %r8d`. Non-zero garbage above the low byte therefore reads as TRUE and the enumeration restarts forever. -The fix adds an empty asm barrier that makes the compiler forget the zero-extension -assumption, and applies it to both `BOOLEAN` parameters of `NtQueryDirectoryObject` only, -because that is the call that was measured. **The same exposure exists in -`NtQueryDirectoryFile`, `NtQueryEaFile`, `NtSetTimer`, `NtLockFile`, -`NtNotifyChangeDirectoryFile`, `NtNotifyChangeKey` and `NtCreateEvent`** — Wine's own PE DLLs -are their usual callers and keep the slot clean, so nothing has been seen to need it. +The fix adds an empty asm barrier that makes the compiler forget the zero-extension assumption, +and applies it to both `BOOLEAN` parameters of `NtQueryDirectoryObject` only, because that is +the call that was measured. **The same exposure exists in `NtQueryDirectoryFile`, +`NtQueryEaFile`, `NtSetTimer`, `NtLockFile`, `NtNotifyChangeDirectoryFile`, `NtNotifyChangeKey` +and `NtCreateEvent`** — Wine's own PE DLLs are their usual callers and keep the slot clean, so +nothing has been seen to need it. Two things this is *not*: - **Not a CrossOver-only correctness win.** CrossOver's `ntdll.so` has the identical - `testl %r8d, %r8d`. It passes because its GCC/binutils-built PE DLLs leave zeros in that - slot. That reading is inference, not measurement; what was measured is the zero in their - trace and the string in ours. Either way it is a latent bug in every clang-built Wine. -- **Not Valve's Proton Hotfix.** ValveSoftware/Proton #9926 is a different failure on Linux - (an exit on a breakpoint before any renderer init). A GCC-built unix side cannot hit this - bug. - -sake measured this one with the prototype's probe shape — `start.exe /exec` keeps a Windows -parent alive exactly as `Agent.exe` does, which reproduces the check in half a minute with -no client and no mouse. Against sake's own engine and bottle: peak 92 threads, 1982 MB, 103 -Metal/AGX mappings, still alive when the sampling ended. The stall this replaces sits flat -at 12-13 threads and 235-245 MB for as long as anyone cares to watch, so there is no reading -of those numbers that confuses the two. - -**That is the check cleared, not the button pressed.** Nobody has pressed Play on sake's -build; what has been shown is that the thing the button trips over no longer stalls. - -## SSO: pressing Play does two separable things + `testl %r8d, %r8d`. It passes because its GCC/binutils-built PE DLLs leave zeros in that slot. + That reading is inference, not measurement; what was measured is the zero in their trace and + the string in ours. Either way it is a latent bug in every clang-built Wine. +- **Not Valve's Proton Hotfix.** ValveSoftware/Proton #9926 is a different failure on Linux (an + exit on a breakpoint before any renderer init). A GCC-built unix side cannot hit this bug. + +sake measured it with the prototype's probe shape: `start.exe /exec` keeps a Windows parent +alive exactly as `Agent.exe` does, which reproduces the check in half a minute with no client +and no mouse. Against sake's own engine and bottle: peak 92 threads, 1982 MB, 103 Metal/AGX +mappings, still alive when the sampling ended (sake, 2026-09-19). The stall this replaces sits +flat at 12-13 threads and 235-245 MB for as long as anyone cares to watch, so there is no reading +of those numbers that confuses the two. Play itself was pressed the next day, and the game was +played (the owner's report, 2026-09-20). + +### Pressing Play does two separable things 1. **The client becomes willing to hand out a token.** Launching the game directly without a - press earlier in the same client session gets it all the way up — rendering, intro - playing — and then `Aurora has rejected the token`, *"There was a problem logging in. - (Code 7)"*. Measured in one session: manual launch at 02:13 got Code 7, Play pressed at - 02:48, manual launch at 02:52 logged in and reached character select. -2. **`Agent.exe` starts the game.** This is the part the BOOLEAN bug broke. - -The client logs `Pre-existing game session detected without a pending launch` for every -manual start **including the ones that log in perfectly**, so that message says nothing -about the token. - -Implication for sake: a "launch the game directly" button cannot work for this title on its -own. The launcher's own flow has to be driven at least once per session. - -**There is a third way in that nobody here has tried.** Blizzard installs its own -`Diablo IV Launcher.exe` beside the game, and the desktop shortcut the installer leaves -points at that with no arguments at all — read out of -`drive_c/users/Public/Desktop/Diablo IV.lnk` on 2026-09-21, 250 bytes, target and working -directory and nothing else. So "start Diablo IV" as a title in sake need not mean starting -`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. + press earlier in the same client session gets it all the way up — rendering, intro playing — + and then `Aurora has rejected the token`, *"There was a problem logging in. (Code 7)"*. In one + session: a manual launch at 02:13 got Code 7, Play was pressed at 02:48, and a manual launch + at 02:52 logged in and reached character select (prototype). +2. **`Agent.exe` starts the game.** This is the part the `BOOLEAN` bug broke. + +The client logs `Pre-existing game session detected without a pending launch` for every manual +start, **including the ones that log in perfectly**, so that message says nothing about the +token. + +So a "launch the game directly" button cannot work for this title on its own: the launcher's own +flow has to be driven at least once per session. sake offers no such button, and nothing in it +knows that a title naming the game's own executable will fail this way. + +**A third way in has not been tried.** Blizzard installs its own `Diablo IV Launcher.exe` beside +the game, and the desktop shortcut the installer leaves points at it with no arguments at all: +`drive_c/users/Public/Desktop/Diablo IV.lnk`, 250 bytes, target and working directory and +nothing else (read, 2026-09-21). So a Diablo IV title need not mean starting +`Diablo IV.exe`: it can start the launcher Blizzard ships, which talks to the client the way the +Play button does. **Untested**: the shortcut was read, not run. ## Controllers need SDL2 -`winebus.sys` has two backends. **IOHID** is built either way and handles anything behaving -as a plain HID gamepad. **SDL** is compiled in only if configure found SDL2, and it is the -one that knows device-specific protocols. +**Wine has to be built with SDL2, or a Switch-style pad is dead in the game.** `winebus.sys` +has two backends. IOHID is always built and handles pads that behave as plain HID gamepads. +SDL is built only when configure finds SDL2, and it knows device-specific protocols. A pad +that presents itself as a Switch Pro Controller enumerates as a HID device with a reasonable +descriptor and then sends no input at all until it has been through Nintendo's handshake, +which lives in SDL's HIDAPI driver — so an IOHID-only build gives a pad that macOS sees and the +game does not. + +Use SDL2 newer than CrossOver's 2.30.12: 2.32.2 fixed a crash initialising with controllers +already connected on macOS, 2.32.6 made Switch controllers initialise reliably on macOS, and +2.32.10 fixed thumbstick range and calibration for Switch Pro Controllers. If a pad +misbehaves, 2.30.12 is the version known to work under CrossOver and the one to bisect +against. Not SDL3: Wine looks for pkg-config's `sdl2` and `SDL_Init` in `libSDL2-2.0*`. -A Nintendo Switch Pro Controller needs the second: it enumerates as a HID device with a -reasonable descriptor and then sends **no input reports at all** until it has been through -Nintendo's handshake, which lives in SDL's HIDAPI driver. An IOHID-only build gives a -controller that is plugged in, visible to macOS, and completely dead in the game. +One pad has been played with: an 8BitDo Ultimate 2, which presents itself as Nintendo's +`057E:2009` and which macOS lists as `Pro Controller` (read with +`system_profiler SPBluetoothDataType`, 2026-09-21). That identity is what matters: the ids are +what send it down SDL's Switch driver and Nintendo's handshake. It played Diablo IV: -Verified by playing the game with a Switch Pro Controller over USB, 2026-09-17. +| where | connection | how it is known | +|---|---|---| +| the prototype | USB | played, 2026-09-17 | +| sake | USB | the owner's report, 2026-09-20 | +| sake | Bluetooth | the owner's report, 2026-09-21 | -**The same controller and cable played Diablo IV through sake on 2026-09-20.** Reported by -the owner, not instrumented — there is no log of that run. What it settles is that the SDL2 -requirement above carries over to sake's own engine and bottle; it is not new evidence about -the driver. +The sake rows are reports rather than traces: they show the SDL2 requirement carries over to +sake's own engine and bottle, and are not new evidence about the driver. Other pads have not +been tried. -**And over Bluetooth on 2026-09-21** — the same pad, no cable, played in the game. The owner's -report again, which is what takes "Bluetooth is untested" off this section. Other pads are -still untested. +## Steam: one process draws, another owns the window -**That pad is an 8BitDo Ultimate 2 Bluetooth Controller**, not Nintendo hardware: it claims -Nintendo's own ids, `Vendor 0x057E / Product 0x2009`, and macOS lists it as `Pro Controller`. -Read here from `system_profiler SPBluetoothDataType`, 2026-09-21. So "Switch Pro Controller" -above is the identity the pad presents rather than who built it — and that identity is the -thing that matters, because the ids are what send it down SDL's Switch driver and Nintendo's -handshake. +**Steam's client needs `patches/0003` to `0006`: without them its window is black on every +start, whatever flags it is given.** The client's browser process owns the window and its GPU +process draws into it, and the winemac.drv in CrossOver 26.3.0's Wine 11.0 has no way to carry +rendering across that line. With the patches a start with no arguments works, and Steam needs +nothing per-title: no flags, no environment, no registry. -Use SDL2 newer than CrossOver's 2.30.12: 2.32.2 fixed a crash initialising with controllers -already connected on macOS, 2.32.6 fixed reliability of initializing Switch controllers on -macOS, and 2.32.10 fixed thumbstick range and calibration for Switch Pro Controllers by -name. If a pad misbehaves, 2.30.12 is the version known-good under CrossOver and the right -thing to bisect against. Not SDL3 — Wine looks for pkg-config's `sdl2` and `SDL_Init` in -`libSDL2-2.0*`. - -## Steam: the client draws in one process and owns its window in another - -Measured in sake on 2026-09-20 against the default bottle, with Steam's 64-bit client (build -1788652215) installed through the app and the engine built from CrossOver 26.3.0's sources -with D3DMetal 4.0b2: thirteen starts, seven of them traced, six windows photographed. -Everything in this section is sake's own measurement. - -**What a start looked like.** The client comes up as a 700×440 window called "Sign in to -Steam" that is black to the last pixel — captured by window id on three starts, 1400×880 -pixels at 2×, 100.00% black, one colour. Steam's `cef_log.txt` says why: `GPU process exited -unexpectedly: exit_code=-1073741819` three times per webhelper, Steam restarting the webhelper -once, then `Disabling GPU acceleration: Disabled/CrashCount` and a SwiftShader GPU process -compositing in software, into the same black. +*Measured in sake on 2026-09-20 against the default bottle, with Steam's 64-bit client (build +1788652215) installed through the app and the engine built from CrossOver 26.3.0's sources with +D3DMetal 4.0b2: thirteen starts, seven of them traced, six windows photographed.* + +**What a start looked like.** The client comes up as a 700×440 window called "Sign in to Steam" +that is black to the last pixel — captured by window id on three starts, 1400×880 pixels at 2×, +100.00% black, one colour. Steam's `cef_log.txt` says why: `GPU process exited unexpectedly: +exit_code=-1073741819` three times per webhelper, Steam restarting the webhelper once, then +`Disabling GPU acceleration: Disabled/CrashCount` and a SwiftShader GPU process compositing in +software, into the same black. **Who owns what.** `WINEDEBUG=+pid,+win` shows the browser process of steamwebhelper creating every window the client shows: an `SDL_app` top-level, a `CefBrowserWindow` inside it, a `Chrome_WidgetWin_1` inside that (700×440, the compositor's target) and a -`Chrome_RenderWidgetHostHWND`. The GPU process, `steamwebhelper.exe --type=gpu-process`, -creates no window at all, and `steam.exe` holds only its bootstrap and tray helpers. The -swapchain is therefore asked for by one process on a window another process owns. +`Chrome_RenderWidgetHostHWND`. The GPU process, `steamwebhelper.exe --type=gpu-process`, creates +no window at all, and `steam.exe` holds only its bootstrap and tray helpers. The swapchain is +therefore asked for by one process on a window another process owns. **Where it died.** On the `macdrv_d3dmtl` channel the GPU process makes exactly one hook call, -`get_win_data 0x…`, and the next line is `c0000005` reading address `0x18`, repeated 255 -times as the crash handler re-faulted. winemac.drv keeps its window records per process, so +`get_win_data 0x…`, and the next line is `c0000005` reading address `0x18`, repeated 255 times as +the crash handler re-faulted. winemac.drv keeps its window records per process, so `get_win_data` returned NULL for the browser's window; `0x18` is `client_cocoa_view` in the record D3DMetal expects; and `vmmap` on a live GPU process put the faulting `rip` inside `libd3dshared.dylib`, whose `WineSwapchainCallbacks::InitializeForHWND` reads that field -straight after the call — `movq 0x18(%rcx), %rcx`, with no check for NULL. The 254 faults -after the first are `RtlVirtualUnwind2` writing to a NULL out-parameter while unwinding -through the shim's unix-side frame: Wine cannot dispatch an exception raised inside a dylib -the PE side called into, so crashpad never writes its dump and the process exits with the -exception code. - -**No flag reaches it.** The `--use-gl=angle --use-angle=vulkan --in-process-gpu` the title -had been given are consumed by `steam.exe` and never appear on the webhelper's command line; +straight after the call — `movq 0x18(%rcx), %rcx`, with no check for NULL. The 254 faults after +the first are `RtlVirtualUnwind2` writing to a NULL out-parameter while unwinding through the +shim's unix-side frame: Wine cannot dispatch an exception raised inside a dylib the PE side +called into, so crashpad never writes its dump and the process exits with the exception code. + +**No flag reaches it.** The `--use-gl=angle --use-angle=vulkan --in-process-gpu` the title had +been given are consumed by `steam.exe` and never appear on the webhelper's command line; `webhelper.txt` prints that line. Steam's own switches live in `steamclient64.dll`, not `steam.exe` — `strings` on the exe finds seven and misleads — and this build has 45 `-cef-*` options. What each relevant one did, 45 seconds per start, window captured by id: @@ -396,74 +389,72 @@ options. What each relevant one did, 45 seconds per start, window captured by id | `-cef-disable-gpu` | lives; SwiftShader | black, the same path | | `-cef-disable-browser-underlays`, `D3DM_NO_WINDOW=1` | no change | no change | -`-cef-in-process-gpu` and `-cef-single-process`, which would have made this one process the -way `--in-process-gpu` does for Battle.net, are no longer in the binary. +`-cef-in-process-gpu` and `-cef-single-process`, which would have made this one process the way +`--in-process-gpu` does for Battle.net, are no longer in the binary. **The fix is in the driver**, as four patches in `patches/`, each with its history in its -header. Upstream Wine's `52e03c61` and `1a63b0d7` (both by CodeWeavers, merged for -wine-11.11) give a process a Metal swapchain for a top-level window another process owns: -the layer is exported through a `CAContext` and the owner hosts it in its window with a -`CALayerHost`. The reference implementation attached to Wine bug 60263 takes that to child -windows, posting the context to the child's root and keeping the hosted layer at the child's -rectangle. sake's own change is to `d3dmetal.c`: D3DMetal's `get_win_data` for a window this -process does not own now gets a record whose view leads to that hosted swapchain, where it -used to get NULL. The view has to be a real `NSView`: the first attempt handed D3DMetal the -client surface itself and it died in `objc_msgSend_stret`, asking that pointer for its -bounds — the patch header has the register dump. - -**On the rebuilt engine, the same day, a start with no arguments works.** No `c0000005` in -any process. The GPU process makes all six glue calls and they read, on `+macdrv_d3dmtl`, -`get_win_data 0x20112` → `remote_win_data window 0x20112 of another process: view … rect -(0,0)-(700,440)` → `create_metal_device` → `view_create_metal_view … hosted swapchain …, -view …` → `view_get_metal_layer` → `release_win_data`; `+msg` shows it posting message -`80001002` to the browser's root, and the browser logs `WM_MACDRV_CREATE_REMOTE_LAYER child -0x20112 context_id 706998962` on receipt. Steam's GPU report stays `ANGLE_D3D11` with -`gpu_compositing: enabled`, one GPU process for the whole run. The window, 45 seconds in: -0.00% black over 1400×880 pixels, 142 colours in a sample, and the sign-in form — logo, -account name, password, Sign in, the QR code — legible in the capture. Thirty seconds in it -was 630×397 with the desktop showing through, so the first frame arrives some seconds after -the layer host does. Nobody had signed in yet when this was written. - -One instrument changed with the fix: `screencapture -l <id>` on a window that hosts another -process's layer fails with "could not create image from window", where the black windows -captured fine. The capture above is a full-screen shot taken with the window raised for a -second and cropped to its bounds. The Vulkan route (`-cef-use-vulkan`) is still black on the -rebuilt engine, for the reason in the table: its swapchain is on a window the GPU process -owns under a root it does not, and nothing hosts that shape yet. - -**Signed in, and a game ran, later the same day.** The client took an account, the library -came up, and Stardew Valley installed through it and played. Reported by the owner, not -instrumented: nothing traced that run, and the measurements above all stop at the sign-in -form. 2026-09-20. - -## GDK titles: XCurl drops a request when WinHTTP refuses an option - -Measured in sake on 2026-09-29 in the `ex` bottle, with Minecraft Dungeons II started through -Steam. The title is built on Microsoft's GDK and needs Xbox Gaming Services, which Wine does -not have, so a community stand-in DLL was in its place throughout. The GDK's HTTP client, -XCurl, runs over WinHTTP. +header. Upstream Wine's `52e03c61` and `1a63b0d7` (both by CodeWeavers, merged for wine-11.11) +give a process a Metal swapchain for a top-level window another process owns: the layer is +exported through a `CAContext` and the owner hosts it in its window with a `CALayerHost`. The +reference implementation attached to Wine bug 60263 takes that to child windows, posting the +context to the child's root and keeping the hosted layer at the child's rectangle. sake's own +change is to `d3dmetal.c`: D3DMetal's `get_win_data` for a window this process does not own now +gets a record whose view leads to that hosted swapchain, where it used to get NULL. The view has +to be a real `NSView`: the first attempt handed D3DMetal the client surface itself and it died +in `objc_msgSend_stret`, asking that pointer for its bounds — the patch header has the register +dump. + +**On the rebuilt engine a start with no arguments works.** No `c0000005` in any process. The GPU +process makes all six glue calls and they read, on `+macdrv_d3dmtl`, `get_win_data 0x20112` → +`remote_win_data window 0x20112 of another process: view … rect (0,0)-(700,440)` → +`create_metal_device` → `view_create_metal_view … hosted swapchain …, view …` → +`view_get_metal_layer` → `release_win_data`; `+msg` shows it posting message `80001002` to the +browser's root, and the browser logs `WM_MACDRV_CREATE_REMOTE_LAYER child 0x20112 context_id +706998962` on receipt. Steam's GPU report stays `ANGLE_D3D11` with `gpu_compositing: enabled`, +one GPU process for the whole run. The window, 45 seconds in: 0.00% black over 1400×880 pixels, +142 colours in a sample, and the sign-in form — logo, account name, password, Sign in, the QR +code — legible in the capture. Thirty seconds in it was 630×397 with the desktop showing +through, so the first frame arrives some seconds after the layer host does. A window hosting +another process's layer cannot be captured by id, so this one was photographed another way +([debugging.md](debugging.md#looking-from-the-mac-side)). + +The Vulkan route (`-cef-use-vulkan`) is still black on the rebuilt engine, for the reason in the +table: its swapchain is on a window the GPU process owns under a root it does not, and nothing +hosts that shape yet. + +**The client then signed in, and a game ran**: an account was taken, the library came up, and +Stardew Valley installed through it and played. Nothing traced that run, and the measurements +above stop at the sign-in form (the owner's report, 2026-09-20). + +## GDK titles: WinHTTP options XCurl cannot do without + +**`patches/0007` and `0008` accept two WinHTTP options Wine 11.0 does not know; without them a +GDK title's HTTP client drops a request before sending it.** XCurl, the GDK's HTTP client, runs +over WinHTTP and abandons a request when an option it sets is refused, so `LoginWithSteam` never +reaches PlayFab and Minecraft Dungeons II shows LOG IN FAILED, error 0063. + +*Measured in sake on 2026-09-29 in the `ex` bottle, with Minecraft Dungeons II started through +Steam. The title needs Xbox Gaming Services, which Wine does not have, so a community stand-in +DLL was in its place throughout.* **Two of the options XCurl sets do not exist in Wine 11.0.** The options at the eleven -`WinHttpSetOption` call sites in `XCurl.dll`, read with the engine toolchain's -`llvm-objdump`, were set one at a time from a small exe against CrossOver 26.3.0's WinHTTP, -with no request sent. Two fail, both with `ERROR_WINHTTP_INVALID_OPTION` (12009), because -`session.c` has no case for either: `WINHTTP_OPTION_IPV6_FAST_FALLBACK` (140), set on the -session, and `WINHTTP_OPTION_DECOMPRESSION` (118), set on each request as soon as it is -opened. - -**A refusal ends the request before it is sent.** For 118, XCurl reads the error and -abandons the request, so `LoginWithSteam` never reaches PlayFab and the game shows LOG IN -FAILED, error 0063. Refusing 140 alone does the same: with the stand-in's own answer to it -removed and 118 still answered, the game showed the same error, and the stand-in logged 140 -refused 25 times and not one connection made. +`WinHttpSetOption` call sites in `XCurl.dll`, read with the engine toolchain's `llvm-objdump`, +were set one at a time from a small exe against CrossOver 26.3.0's WinHTTP, with no request +sent. Two fail, both with `ERROR_WINHTTP_INVALID_OPTION` (12009), because `session.c` has no case +for either: `WINHTTP_OPTION_IPV6_FAST_FALLBACK` (140), set on the session, and +`WINHTTP_OPTION_DECOMPRESSION` (118), set on each request as soon as it is opened. + +**A refusal ends the request before it is sent.** For 118, XCurl reads the error and abandons +the request. Refusing 140 alone does the same: with the stand-in's own answer to it removed and +118 still answered, the game showed the same error, and the stand-in logged 140 refused 25 times +and not one connection made. **Upstream stubbed both, and sake carries the two commits** until CrossOver's sources contain them: `patches/0007` is Paul Gofman's for 118, from wine-11.4, and `patches/0008` is Hans -Leidekker's for 140, from wine-11.7. Each accepts the option, prints a `FIXME` and does -nothing else. For 118 that is enough, because the `Accept-Encoding` header is WinHTTP's to -add -- `XCurl.dll` holds no such string, in ASCII or UTF-16 -- so nothing asks the server to -compress. wine-11.5 replaced the 118 stub with real gzip and deflate support, which sake -does not carry. +Leidekker's for 140, from wine-11.7. Each accepts the option, prints a `FIXME` and does nothing +else. For 118 that is enough, because the `Accept-Encoding` header is WinHTTP's to add — +`XCurl.dll` holds no such string, in ASCII or UTF-16 — so nothing asks the server to compress. +wine-11.5 replaced the 118 stub with real gzip and deflate support, which sake does not carry. **How to tell it worked.** The same exe on the patched engine gets `TRUE` for both, and with `WINEDEBUG` at its default prints the two lines below, where the unpatched engine printed @@ -475,91 +466,92 @@ fixme:winhttp:set_option WINHTTP_OPTION_DECOMPRESSION, 0x3 stub. ``` sake starts titles with `WINEDEBUG=-all`, so a title's log never shows them; the tell in the -game is the sign-in going through with both of the stand-in's hooks removed, which it did on -the rebuilt engine: Microsoft's sign-in, then `LoginWithSteam`, then character select, with -every reply the stand-in logged, 20 of them, a 200 and no option refused. +game is the sign-in going through with both of the stand-in's hooks removed, which it did on the +rebuilt engine: Microsoft's sign-in, then `LoginWithSteam`, then character select, with every +reply the stand-in logged, 20 of them, a 200 and no option refused. ## A bottle on exFAT: the `._` files macOS writes are not the game's -Measured in sake on 2026-09-30 in the `ex` bottle, a symlink into a directory on an exFAT -disk, with Minecraft Dungeons II started through Steam. The game had reset its settings on -every launch since 2026-09-29, with the community stand-in and with sake's own runtime alike. +**`patches/0009` leaves macOS's AppleDouble files out of a directory listing** when the file each +belongs to is beside it and the volume has no native extended attributes. Without it a game on +such a disk can read one as its own file: Minecraft Dungeons II read one as its settings and +reset them on every launch, with the community stand-in and with sake's own runtime alike. A +`._` file on APFS, or one with nothing beside it, is still listed, and a name asked for exactly +is still found. + +*Measured in sake on 2026-09-30 in the `ex` bottle, a symlink into a directory on an exFAT disk, +with Minecraft Dungeons II started through Steam.* **macOS writes a second file beside nearly every file there.** exFAT cannot store extended attributes itself — `getattrlist` reports `VOL_CAP_INT_EXTENDED_ATTR` unset for that disk and -set for the internal APFS volume — so macOS keeps a file's attributes in a 4096-byte -AppleDouble file named `._` and the file's own name. On this Mac a file is given one as soon as -it is written, because it is given `com.apple.provenance`: the game's saves were, and so was a -file written from a shell. The bottle held 6,259 of them. Wine lists these as ordinary files, -marked hidden because their names start with a dot, and its sorted listing puts each before -the file it belongs to. +set for the internal APFS volume — so macOS keeps a file's attributes in a 4096-byte AppleDouble +file named `._` and the file's own name. On this Mac a file is given one as soon as it is +written, because it is given `com.apple.provenance`: the game's saves were, and so was a file +written from a shell. The bottle held 6,259 of them. Wine lists these as ordinary files, marked +hidden because their names start with a dot, and its sorted listing puts each before the file it +belongs to. **The game read one as its settings.** It lists `Saved\SaveGames\*.*`, opened -`._GlobalSaveDataDefault.sav`, read its 4096 bytes and never opened `GlobalSaveDataDefault.sav` -at all. It then showed SETTINGS FILE DAMAGED and sent the person through the initial setup -again, although the file it had written is sound: JSON with every byte one lower. With the -five companions in `SaveGames` removed by hand, the same file loaded and the game went -straight to play; its next save brought all five back. Steam's client in that bottle had been -syncing `._sharedconfig.vdf` to Steam Cloud as one of its configuration files: its -`logs/cloud_log.txt` reports it in sync nine times before the patch. - -**`patches/0009` leaves such a file out of a directory listing** when the file it belongs to is -beside it and the volume has no native extended attributes. A `._` file on APFS, or one with -nothing beside it, is still listed, and a name asked for exactly is still found. - -**How to tell it worked.** `WINEDEBUG=+file` prints `leaving out` and the name for each file -left out, and the listing after it holds none of them. On the rebuilt engine, the same day and -with the five companions back on disk, the game's listing of `SaveGames` left them out and -returned the five saves, it read `GlobalSaveDataDefault.sav`, and it started with no dialog. -Steam's next sync named `sharedconfig.vdf` alone and found nothing to download. In -`wine cmd /c dir`, a `._` file with nothing beside it on the exFAT disk and a `._` file on APFS -were both listed, and `rmdir /s /q` removed an exFAT directory holding two companions it had -not been shown, since macOS removes a companion with its file. Starting `cmd` in that bottle -left out 854 names. +`._GlobalSaveDataDefault.sav`, read its 4096 bytes and never opened `GlobalSaveDataDefault.sav` at +all. It then showed SETTINGS FILE DAMAGED and sent the person through the initial setup again, +although the file it had written is sound: JSON with every byte one lower. With the five +companions in `SaveGames` removed by hand, the same file loaded and the game went straight to +play; its next save brought all five back. Steam's client in that bottle had been syncing +`._sharedconfig.vdf` to Steam Cloud as one of its configuration files: its `logs/cloud_log.txt` +reports it in sync nine times before the patch. + +**How to tell it worked.** `WINEDEBUG=+file` prints `leaving out` and the name for each file left +out, and the listing after it holds none of them. On the rebuilt engine, with the five +companions back on disk, the game's listing of `SaveGames` left them out and returned the five +saves, it read `GlobalSaveDataDefault.sav`, and it started with no dialog. Steam's next sync +named `sharedconfig.vdf` alone and found nothing to download. In `wine cmd /c dir`, a `._` file +with nothing beside it on the exFAT disk and a `._` file on APFS were both listed, and +`rmdir /s /q` removed an exFAT directory holding two companions it had not been shown, since +macOS removes a companion with its file. Starting `cmd` in that bottle left out 854 names. Not measured: FAT and SMB volumes, which macOS treats the same way when they lack native -extended attributes, and whether Steam ever removes the copy of `._sharedconfig.vdf` its -cloud still holds. +extended attributes, and whether Steam ever removes the copy of `._sharedconfig.vdf` its cloud +still holds. + +## Taking a bottle down -## Killing wineserver leaves the prefix's own services running +**`Bottle.takeDown` is the whole sequence: `wineserver -k`, then `SIGTERM` to whatever is still +in the prefix, then `SIGKILL`, then a count once more, and what is left is what gets reported** +rather than an assumption of success. Stopping the `wine` sake started is not enough: +`Agent.exe` runs with ppid 1, wineserver is its own daemon, and Battle.net keeps a fistful of +CEF helpers (prototype). `wineserver -k` alone leaves the prefix's own services running, `ps` +cannot say which prefix a process belongs to, and the directory wineserver keeps its socket in +can. It finds nothing in a bottle that is a symlink: `Bottle.serverDirectory` +reads the link's own inode rather than the prefix's +([#15](https://github.com/typester/sake/issues/15)). -**Measured in sake on 2026-09-20.** A title was started from the library and stopped again. -`wineserver -k` took down the game and the server, and then seven processes were still -there, all reparented to ppid 1: +**`wineserver -k` leaves the prefix's own services behind.** A title was started from the +library and stopped again: `wineserver -k` took down the game and the server, and then these +seven were still there, all reparented to ppid 1 (sake, 2026-09-20): ``` services.exe winedevice.exe ×2 plugplay.exe svchost.exe -k LocalServiceNetworkRestricted explorer.exe /desktop rpcss.exe ``` -They stayed for the rest of the session. Because they had been started by the app, macOS -kept the app's LaunchServices record alive as `exited-with-subordinates` — so **the Dock -went on showing a running sake for an app that had already quit**, which is how this was -noticed at all. - -Six of the seven took `SIGTERM`; one `winedevice.exe` needed `SIGKILL`. - -### A mounted disk image does the same thing, and lasts longer - -The Wine processes above were found while chasing a Dock tile that would not go away, and -they turned out not to be the whole answer. **An image sake mounted keeps its -`diskimages-helper` running with ppid 1**, macOS counts that helper as a subordinate of the -app that mounted it, and the app's LaunchServices record therefore stays at -`exited-with-subordinates` — so the Dock shows a running sake for an app that quit hours -ago, across every launch since. - -Measured 2026-09-20: the Game Porting Toolkit's evaluation-environment image had been -mounted by the D3DMetal step at 12:27 and was still mounted at 13:30. Ejecting it took the -helper with it and the tile disappeared from the Dock in the same second. `D3DMetalInstaller` -now unmounts what it mounts; `licensing.md` says why that was always the intention. - -### Which prefix a process belongs to: the socket directory - -`ps` is no help — these spell themselves `C:\windows\system32\services.exe` and carry -neither the engine's path nor the game's name, so a sweep looking for those two reports -success with seven processes up. That was sake's bug, not just a gap in diagnosis. - -What does answer it is where wineserver keeps its socket: +They stayed for the rest of the session. Because the app had started them, macOS kept its +LaunchServices record alive as `exited-with-subordinates`, so **the Dock went on showing a +running sake for an app that had already quit**, which is how this was noticed at all. Six of +the seven took `SIGTERM`; one `winedevice.exe` needed `SIGKILL`. + +**A disk image sake mounted does the same, and lasts longer.** Its `diskimages-helper` keeps +running with ppid 1, macOS counts that helper as a subordinate of the app that mounted it, and +the app's LaunchServices record therefore stays at `exited-with-subordinates`, so the Dock shows +a running sake for an app that quit hours ago, across every launch since. The Game Porting +Toolkit's evaluation-environment image, mounted by the D3DMetal step at 12:27, was still mounted +at 13:30; ejecting it took the helper with it, and the tile left the Dock in the same second +(sake, 2026-09-20). `D3DMetalInstaller` unmounts what it mounts +([licensing.md](licensing.md#what-sake-actually-does)). + +**Which prefix a process belongs to is in the socket directory.** `ps` is no help: these spell +themselves `C:\windows\system32\services.exe` and carry neither the engine's path nor the game's +name, so a sweep looking for those two reports success with seven processes up. That was sake's +bug, not just a gap in diagnosis. What does answer it is where wineserver keeps its socket: ``` /tmp/.wine-<uid>/server-<dev>-<inode> both halves in hex @@ -568,126 +560,8 @@ actual /tmp/.wine-502/server-1000012-8763ecd ``` The two halves are the **prefix directory's own `st_dev` and `st_ino`**, confirmed against a -live bottle on 2026-09-20. `lsof -t +D <that directory>` returned exactly those seven pids -and nothing else — the clients hold the server's `tmpmap-*` shared memory open, so they are -still found **after the server itself is gone**. - -Two things follow. An inode does not change when a directory is renamed, so this identifies -a bottle's processes across a rename. And it is per prefix, so nothing here can reach a -CrossOver bottle or another of sake's. - -`Bottle.takeDown` is the whole sequence: `wineserver -k`, then whatever is still in the -prefix gets `SIGTERM`, then `SIGKILL`, then it is counted once more and **what is left is -what gets reported** rather than an assumption of success. - -## Telling failure states apart - -RSS alone misleads, and a hang and a slow start look nothing alike. Thread count separates -the top three states; `vmmap $pid | grep -icE 'Metal|AGX'` says whether graphics was ever -reached (~50 mappings means never, 100+ means rendering). - -| state | threads | RSS | Metal/AGX maps | -|---|---|---|---| -| no Rosetta registration, or a mismatched-ABI module | 9-11 | 125-155 MB | ~50 | -| the `\DosDevices` loop (before the BOOLEAN fix) | 12 | 235-245 MB | ~50 | -| graphics up, waiting on the client | 17-19 | 390-410 MB | 74-81 | -| running and rendering | 83-98 | 1.7-4.6 GB | 100+ | - -Four traps in that table, each of which produced a wrong conclusion in the prototype: - -- **The running row kept being too narrow.** It read 83-90, then 83-96, then 83-98, widened - each time someone measured again. Read it as "well past 40", not as a window to match. -- **The counts include the process row** (`ps -M -p $pid | tail -n +2 | grep -c .`). Count - thread rows alone and everything reads one low — the stall comes out at 11, lands in the - row above, and a reproducing hang gets reported as a dead build. -- **Threads do not separate the bottom two states.** 9-11 against 12 is one thread. RSS is - what tells those apart: 125-155 MB against 235-245 MB. -- **Sample, do not read once at the end.** A build that clears the check and then dies of - something unrelated looks identical to one that never cleared it, if you only look at the - end. Keep the peak. - -Identify the game's process by the `argv[0]` that *ends with* the executable name, not one -that contains it: a loose match also catches `cmd.exe`, `start.exe` or any launcher carrying -the name in its own arguments. That happened twice. Matching the bare name at the start is -not enough either, because `argv[0]` is spelled differently depending on how the game was -started. Cut `argv[0]` at its first `.exe` and check what that ends with. - -## Diagnostic technique that paid off - -- **Search before analysing.** The `WINE_SIMULATE_WRITECOPY` fix is documented across - Lutris, GamingOnLinux and CodeWeavers' own forum. Hours of first-principles crash analysis - went in before anyone searched. The lesson was then ignored on the Play button and the - same bill arrived. Caveat learned the second time: the search found the *game update*, not - the *cause*. **Search first, then measure.** -- **Diff against a working implementation on the same machine.** CrossOver is installed and - this tree is built from *its* sources, so anything that differs is configuration or build - flags. Running CrossOver's binaries against the prototype's bottle answered "build or - bottle?" in one command. Its Perl `bin/wine` is readable and - `--bottle NAME --ux-app /usr/bin/env` dumps the environment its launcher builds — bisect - the environment from the side that works, rather than guessing single variables against a - failing run. -- **Make the two candidates produce different observable output before believing either.** - The Play button was blamed on `Agent.exe` not passing an environment variable. The - variable arrives. The diagnosis stood because the symptom it predicted was the symptom - present. -- **Read the application's own logs first.** Battle.net writes - `drive_c/users/<user>/AppData/Local/Battle.net/Logs/{battle.net,libcef}-*.log`, and the - libcef log named the real problem after a lot of guessing had not. -- **`--remote-debugging-port=9222`** distinguishes "not painting" from "painting but not - shown". Battle.net forwards unrecognised arguments to CEF. `Page.captureScreenshot` proved - the renderer was drawing the login form perfectly while the window was black. -- **MoltenVK's `Created N swapchain images with size (W, H)` lines are a free instrument.** - Comparing which surface sizes appear between runs exposed the missing content-sized - surface and then confirmed the fix. -- **`WINEDEBUG=+loaddll` names the last DLL before a hang** and is safe on its own. - `+server`, `+syscall`, `+module`, `+seh` and `+file` are light enough to keep a failure - reproducing. -- **Wine keeps its sockets in `wineserver`**, not the Windows-side process. `lsof` against - the Battle.net pid shows zero connections while it is talking to Blizzard happily. This - produced three consecutive wrong network conclusions. -- **`wineserver -k` silently targets `~/.wine` unless `WINEPREFIX` is set**, exits 0, and - reports success having killed nothing. It is the first half of taking a bottle down — - `Agent.exe` runs with ppid 1, wineserver is its own daemon, and Battle.net keeps a - fistful of CEF helpers — but **it is not the whole of it**, which is the next section. -- **`WINEDEBUG=err+all` causes crashes rather than revealing them.** A failed `dlopen` of a - missing dylib produces a `dlerror()` string long enough to overflow Wine's debug buffer; - the exception cannot be dispatched and the process dies. Raising the log level turns a - cleanly handled failure into a crash. -- **A whole-module relay trace can hide the bug.** `RelayFromInclude` on the loader logs ~7M - calls and the game then starts fine. That was read as timing sensitivity and it was not - one — the overhead changes what callees leave on the stack. A Heisenbug is a limit on - which instrument you may use, not evidence about the cause. -- **Attaching a debugger to Diablo IV is destructive.** The protected loader answers with an - unhandled `0xc00000e5` and obfuscated registers, and the process drops from 58% CPU to - 1.8% — the state you came to read is gone. `sample(1)` is safe but cannot unwind through - `__wine_syscall_dispatcher`. -- **Check that the control actually ran.** A control run whose log is zero bytes did not - reproduce anything; it failed to start. -- **`WINEDEBUG=+pid` before anything else with more than one process.** Without it every - trace prefix is a thread id, and four Chromium processes cannot be told apart. Learned on - Steam, 2026-09-20. -- **`WINEDEBUG=<program>:+<channel>` traces one process.** An option with a name and a colon in - front applies only where the executable has that name (`parse_options` in - `dlls/ntdll/unix/debug.c`), so a game started by Steam can be traced without Steam's own - processes. `-all,Dungeons-Win64-Shipping.exe:+pid,Dungeons-Win64-Shipping.exe:+file` in - Steam's environment gave 24 MB by the time the game showed its first dialog, 2026-09-30. -- **`+macdrv_d3dmtl` is D3DMetal's half of the conversation.** It is the channel of the glue - in `dlls/winemac.drv/d3dmetal.c`, the only code D3DMetal calls in Wine. A `get_win_data` - with no `create_metal_device` after it means winemac returned NULL, and the six calls of a - swapchain's creation read like a checklist. -- **`+win` names the owner of an HWND**, class and parent included, which is how "whose window - is the GPU process drawing into" got answered. -- **A fault inside a dylib has no module name in `+seh`.** `vmmap` a live process for the - `__TEXT` ranges of `libd3dshared`, `D3DMetal` and `winemac.so`, then `objdump -d` the dylib - at `rip` minus its start; `+loaddll` only knows PE modules. -- **Crashpad eats the crash.** A CEF process never reaches `winedbg --auto`, so there is no - backtrace to wait for; `+seh` is the only view of where it died. -- **A Wine window can be photographed even behind the terminal.** Once Screen Recording is - granted to the terminal, `screencapture -x -o -l <CGWindowID>` captures an occluded window, - and `CGWindowListCopyWindowInfo` gives the id (owner `wine`). A full-screen capture shows - whatever is in front, which here is always the terminal. This is what turned "black" from a - report into 100.00% of 1,232,000 pixels. **Not once the window hosts another process's - layer**: then `-l` fails with "could not create image from window" and `-R` never worked - here at all, so raise the window (`set frontmost of (first process whose unix id is …)` - through System Events), take the full screen, crop to the window's bounds, and hand focus - back to the terminal. +live bottle, and `lsof -t +D <that directory>` returned exactly those seven pids and nothing +else: the clients hold the server's `tmpmap-*` shared memory open, so they are still found +**after the server itself is gone** (sake, 2026-09-20). An inode does not change when a +directory is renamed, so this identifies a bottle's processes across a rename; and it is per +prefix, so nothing here can reach a CrossOver bottle or another of sake's. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..82e2e4c --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,105 @@ +# Troubleshooting + +What to do when something goes wrong. Whether a game is known to run at all is in the +[README](../README.md#what-runs); if nothing here helps, [report it](#reporting-a-problem). + +## First, read the log + +Every run writes a log in `~/Library/Caches/Sake/build`, and sake's window shows only its last +line: + +- `title-<id>.log` for a title +- `install-<name>.log` for an installer +- `tool-<name>.log` for one of Wine's tools + +The names do not include the bottle yet, so a run in one bottle overwrites the log of the same +title or installer in another ([#14](https://github.com/typester/sake/issues/14)). + +**Wine Tools**, on a bottle, opens Wine's own winecfg, regedit, uninstaller and task manager in +that bottle. + +## Setting up + +- **A step shows a lock.** Something it needs is not done yet, and the step says which: This + Mac's checks, or a step before it. This Mac needs all five to pass — Apple silicon, Rosetta 2 + (`softwareupdate --install-rosetta`), the Command Line Tools (`xcode-select --install`), the + Game Porting Toolkit, and 10 GB free — and until it does, every step not yet done is locked. + Install what is missing or make room, then press **Check Again**. +- **The sidebar says Setup needs attention.** A step is not done any more: an update to sake + changed what it builds, or This Mac no longer passes its checks, such as with less than 10 GB + free. Click it: the wizard opens on the first step not done and says why. Building Wine again + takes minutes and the GDK Runtime seconds; D3DMetal is then one press, from the copy sake + kept, with no need for the toolkit's image. +- **Gatekeeper stops sake the first time.** The app is not notarised. Allow it in System + Settings > Privacy & Security, or install it with + `brew install --cask --no-quarantine typester/sake/sake`. +- **The D3DMetal step waits for the toolkit.** Download Game Porting Toolkit 4.0 beta 2 from + <https://developer.apple.com/download/all/>, open the `.dmg`, then press **Install**. sake + copies what it needs and unmounts the image it mounted; you need the image only once. + +## Starting a game + +- **Battle.net's login form never appears, or its GPU process keeps exiting.** The title needs + its arguments: `--use-gl=angle --use-angle=vulkan --in-process-gpu`. sake fills them in when + it finds `libcef.dll` beside the program or one folder below it; if they are gone, put them + back in the title's Options. +- **Diablo IV says "There was a problem logging in. (Code 7)"** It was started directly. Start + the Battle.net launcher and press Play inside it: the client hands out the game's login token + only after that press. +- **A game built on Microsoft's GDK, such as Minecraft Dungeons II.** The first time it signs + in, sake shows a code and opens `microsoft.com/link` in your browser; type the code there and + sign in. Later starts sign in without asking. + - **Keep sake running while you play.** The game asks sake for its tokens. With sake not + running, it waits ten seconds and goes on without a user. + - **Play with friends by party code.** One Xbox service the game uses, multiplayer activity, + refuses sake's sign-in, so anything in the game that depends on it does not work. +- **A game on an external disk resets its settings every time it starts.** On a disk formatted + exFAT, macOS writes a `._` file beside nearly every file, and a game can read one as its own + settings. sake's Wine leaves those files out; if the sidebar says Setup needs attention, build + Wine again. +- **A game seems to hang, or never gets going.** Before deciding a build is broken, read + [Debugging](debugging.md#telling-failure-states-apart): it tells four failure states apart by + thread count, memory and Metal mappings. + +## Bottles + +- **A bottle has a `D:` drive that leads nowhere.** The bottle was made while the Game Porting + Toolkit's image was mounted, and Wine mapped it. It is harmless. +- **Import from CrossOver refuses.** Importing clones the game instead of copying it, which + works only when the CrossOver bottle is on the same disk as `~/Library/Sake`. +- **Deleting a bottle with an imported game gives back almost no space**, even once the Trash + is emptied: the game's data is shared with the CrossOver install it came from, which still + has it. +- **A bottle cannot be renamed while a game runs in it.** Stop the game first. Deleting a + bottle stops what runs in it, and the confirmation says so. +- **Wine's own settings**, such as the Windows version, DLL overrides, drives and the audio + device, are in winecfg, under Wine Tools. Changing the Windows version can break a game. + +## Controllers + +Nothing to set up. The engine is built with SDL2, which knows device-specific protocols such as +the Switch Pro Controller's, so a controller should work. If yours is not seen, report it. + +## After you quit + +- **The Dock still shows sake after it has quit.** Something sake started is still running. + **Stop** on a title takes down everything in its bottle, not only the game. In a bottle that + is a symlink to another disk, Stop does not find what runs there + ([#15](https://github.com/typester/sake/issues/15)). + +## Removing sake + +**Uninstall sake…** in the app menu moves `~/Library/Sake` and `~/Library/Caches/Sake` to the +Trash: the engine, the bottles and the games in them, your Xbox sign-in, and the build cache +with sake's copy of D3DMetal. Until you empty the Trash, it can all be put back. Sake.app +itself stays; drag it to the Trash, or run `brew uninstall --cask sake`. Three small files are +left behind: `~/Library/Preferences/dev.typester.sake.plist`, +`~/Library/HTTPStorages/dev.typester.sake` and `~/Library/Caches/dev.typester.sake`. + +## Reporting a problem + +[Open an issue](https://github.com/typester/sake/issues/new) with your Mac, your macOS version +and sake's version (About Sake, in the app menu), and attach the log from +`~/Library/Caches/Sake/build`; paths in it show your macOS user name. For whether a game runs, +working or not, use the +[compatibility form](https://github.com/typester/sake/issues/new?template=compatibility.yml). diff --git a/docs/wine-build.md b/docs/wine-build.md index aaede00..a418ef4 100644 --- a/docs/wine-build.md +++ b/docs/wine-build.md @@ -2,13 +2,12 @@ What the build has to do, and which parts of it are not negotiable. -Everything here was learned in the d4-mac prototype between 2026-08 and 2026-09-17, on one -machine (Apple silicon, macOS 27.0), unless a section says otherwise. **sake produced the -nine components below and then built Wine itself on 2026-09-19** — configure, the soname -rewrite, make and install, 4m40s for Wine on ten cores, 1.1 GB of engine. Later the same day -it created a prefix with that engine and Wine came up clean, and later still a game started -in it; `runtime.md` has both. Treat anything not marked as sake's own measurement as the -specification the implementation has to satisfy rather than a report on its behaviour. +*Unless a section says otherwise, this was learned in the d4-mac prototype between 2026-08 and +2026-09-17, on one machine (Apple silicon, macOS 27.0), and is the specification sake's build +has to satisfy rather than a report on its behaviour.* sake built the nine components below and +then Wine itself, configure, the soname rewrite, make and install: 4m40s for Wine on ten cores, +and 1.1 GB of engine (sake, 2026-09-19). What a game then needs from that engine is in +[runtime.md](runtime.md). ## Why CrossOver's sources and not upstream Wine @@ -37,7 +36,7 @@ produced: | gmp, nettle, libtasn1 | static, underneath gnutls | | gnutls | without TLS the Battle.net client cannot log in | | freetype | no fonts at all without it | -| SDL2 | no game controller works without it — see `runtime.md` | +| SDL2 | no game controller works without it — see [runtime.md](runtime.md#controllers-need-sdl2) | | MoltenVK | Chromium's GPU process dies with no Vulkan driver | Notes that cost time to find: @@ -64,8 +63,8 @@ repository's MIT. Four are upstream Wine commits carried only until the CrossOve sake builds catch up — two of the winemac.drv ones with wine-11.11, the winhttp ones with wine-11.4 and wine-11.7 — 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 from. -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`. +What each one is for, and how to tell that it worked, is in [runtime.md](runtime.md); why they +are a separate directory is in [licensing.md](licensing.md#wine-and-the-patches). 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 @@ -102,26 +101,23 @@ game, and that is a long way downstream of here. **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. +Sake.app carries, as the GDK runtime's step does with its source ([gdk.md](gdk.md#the-runtime)). +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, no cheaper than the first: 4m24s (sake, 2026-09-19). 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. + +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 (sake, 2026-09-30). ## configure flags that must not be removed @@ -158,26 +154,20 @@ clang is *not* required; the Mach-O side builds with stock Apple clang. - **`make install` overwrites D3DMetal.** It puts Wine's own `d3d10`/`d3d11`/`d3d12`/`dxgi.dll` back, so installing D3DMetal has to happen *after* every `make install`, not once. sake keeps its own copy of Apple's `redist/lib`, so putting it back is one press and does not need the - toolkit mounted again. Nothing re-runs it on its own; what the code does is stop claiming - the step is finished, so the wizard sends the user there. - - This section used to say that deleting the whole engine was the only way to re-run `make - install`, so D3DMetal went with it and the next install put it back. That is wrong. - Measured in sake on 2026-09-19: deleting `engine/bin/wine` alone is enough to make the - wizard rebuild, and afterwards all four DLLs were Wine's — while the D3DMetal step still - read **"already installed"**, because it asked whether the framework was there and the - framework is what `make install` does not touch. Silent, and the engine it left could not - run a DX12 game. - - **`isInstalled` now asks whether the four DLLs are Apple's**, which is the question - `verify()` had been asking all along, so a Wine rebuild drops the D3DMetal step back to - unfinished and the wizard opens on it. Measured the same day, on the real engine and - through the app: with one DLL swapped for Wine's own the wizard opened on D3DMetal and - its row read "waiting", and one press put the engine back. + toolkit mounted again. Nothing re-runs it on its own; the D3DMetal step stops claiming to be + finished, so setup sends the user there. + + **The step asks whether the four DLLs are Apple's, not whether the framework is there**, + because the framework is what `make install` does not touch. Asking about the framework is + silent and wrong: after a rebuild, which deleting `engine/bin/wine` alone is enough to cause, + all four DLLs were Wine's while the step still read **"already installed"**, and the engine it + left could not run a DX12 game (sake, 2026-09-19). With `isInstalled` asking what `verify()` + asks, one DLL swapped for Wine's own made the wizard open on D3DMetal with its row at + "waiting", and one press put the engine back (sake, 2026-09-19). - **Sonames must not be leaf names.** A leaf name resolves only through `DYLD_LIBRARY_PATH`, and that does not reach Wine's child processes. sake rewrites the four in `include/config.h` to `@loader_path`-relative paths between configure and make; - see `layout.md`. + see [layout.md](layout.md#relocatability). - **`include/` is generated by a make of its own before anything else is compiled.** makedep works out what a widl-generated header includes from its IDL's own `import` and `cpp_quote` lines, and drops an `#include "x.idl"` there (`parse_file` in @@ -208,7 +198,7 @@ clang is *not* required; the Mach-O side builds with stock Apple clang. ### The wrapping no longer works with the Command Line Tools alone -Measured in sake on 2026-09-19 (CLT 27.0, macOS 27.0). This one is not the prototype's. +*Measured in sake on 2026-09-19 (CLT 27.0, macOS 27.0).* `/usr/bin/make` and `/usr/bin/clang` are universal, but they are xcode-select shims that `dlopen` `libxcrun.dylib` — and that library ships arm64 and arm64e only. The real binaries @@ -239,9 +229,7 @@ compiler goes on PATH. ### Only what ends up inside Wine is x86_64 -Measured in sake on 2026-09-19. This corrects an earlier claim here that all nine landed as -x86_64 and that "the two that produce executables run". They do run — just not inside a Wine -build. +*Measured in sake on 2026-09-19.* `bison` and `pkgconf` are build tools. Wine neither links nor `dlopen`s what they produce, so nothing requires them to match Wine's architecture; the prototype had them x86_64 only @@ -262,7 +250,9 @@ them. bison and pkgconf are native. ### The PE compiler leads the system, and `CC` is absolute -Measured in sake on 2026-09-19. llvm-mingw ships a bare `clang` and `clang++` beside its +*Measured in sake on 2026-09-19.* + +llvm-mingw ships a bare `clang` and `clang++` beside its `x86_64-w64-mingw32-*` ones, and the two halves of a Wine build disagree about which clang the name `clang` should mean.