Highball › Windows and macOS both use thread slot 0x20, so fiber-based games crash under Wine on Macs
Windows and macOS both use thread slot 0x20, so fiber-based games crash under Wine on Macs
Two games with nothing in common, Marvel's Guardians of the Galaxy and Metaphor: ReFantazio, fail under Wine on a Mac in two different ways. One faults a few seconds after launch reading a small number like 0x20FF as if it were a pointer. The other opens a black window and pins a core at 100 percent forever. The cause is one byte offset that two operating systems both consider theirs. Upstream Wine fixed this class in 10.5 in a different way, described at the end, but the builds most Mac gamers run predate it.
The slot
On x86-64 Windows, every thread has a Thread Environment Block, and the gs segment register points at it. Windows code reads the block through gs: with fixed offsets: gs:0x30 is the block's own address, gs:0x60 is the process block, and gs:0x20 is the current fiber. Fibers are Windows' cooperative threads, and game job systems love them. GetCurrentFiber() is not a function call, it is a compiler intrinsic that emits mov rax, gs:[0x20].
On macOS, gs points at the pthread's thread-specific data. Its slots are fixed too: slot 0 is the pthread itself and slot 4, at gs:0x20, is the thread's Quality of Service priority word. libdispatch reads it when it queues a block and when it changes a priority. Measured in a plain x86-64 process on this Mac: the three slots Wine writes its own fields into (0x30, 0x58, 0x60) read zero, and 0x20 reads 0x20FF on a user-interactive thread, 0x4FF on a utility thread, 0x2FF on a background one.
Wine on macOS keeps the Windows block separate from the pthread data and copies the three fields Windows code reads constantly, the self pointer, the thread-local storage pointer and the process block, into the matching pthread slots. It cannot copy the fiber pointer, because that slot is not free.
Failure one: a QoS class handed to SwitchToFiber
Guardians of the Galaxy, on the CrossOver-tree build before any fix: gs:0x20 reads a constant 0x8FF on every thread, and the game faults on Play Game reading 0x907, which is that value plus the eight bytes SwitchToFiber reads from a fiber. Metaphor's demo on the Wine 10 engine: a page fault reading 0x20FF, at METAPHOR.exe+0xD6C69, before a window exists. Wine's relay trace shows the faulting thread's last calls: SetThreadPriority and ResumeThread on its workers, CreateFiber twice, SwitchToFiber(03f29ba0), then the fault. 0x20FF is the QoS priority word of a user-interactive thread. The game asked for its current fiber, got the thread's scheduling priority, and dereferenced it.
Same fault with AVX advertised to the game, same on a different graphics layer. Neither the CPU features nor the renderer was involved, only the slot.
Failure two: a fiber pointer handed to the scheduler
Wine 11, with an earlier patch of ours that mirrors the fiber pointer into gs:0x20 on every Wine thread so that GetCurrentFiber() returns the truth. Guardians plays. Metaphor opens its window, goes black and spins at 100 percent CPU.
A sample of the process shows why. The Cocoa main thread is inside Wine's signal handler after a fault in libdispatch, in _dispatch_set_priority_and_voucher_slow. Three worker threads are more than a thousand frames deep in nested exception dispatch. Metaphor's job threads switch fibers and also drive the window. When one of them queues a block onto the main queue, libdispatch reads gs:0x20 on that thread to record the priority, gets a fiber pointer, and later the main queue tries to apply it as a QoS class. Guardians never took that path in any run here.
So one side or the other loses. Give the slot to Windows and macOS's scheduler faults. Give it to macOS and the game faults.
The fix: the slot takes turns
The values cannot share the slot, but they can take turns, because at any moment a thread is running either Windows code or Wine's Unix side, and the boundary between the two is a small number of well-defined places in Wine's ntdll: the system call dispatcher, the Unix-call dispatcher, and the callbacks from Wine back into Windows code.
The patch keeps the host's QoS value in a spare field of Wine's per-thread data (host_qos, TEB+0x340), saved once at thread start. Every entry into Unix code writes it back into gs:0x20. Every return to Windows code writes the fiber pointer there instead. Two moves per crossing, none on other platforms.
/* entering Unix code */
movq 0x340(%r13),%rax /* host_qos */
movq %rax,%gs:0x20
/* returning to Windows code */
movq 0x20(%r13),%rdx /* teb->Tib.FiberData */
movq %rdx,%gs:0x20
The full patch is 0009-macos-swap-fiberdata-and-qos-slot.patch in the engine repository: about 30 lines across eight places in signal_x86_64.c, with the measurements in its header.
Threads that never run Windows code, the Cocoa main thread and libdispatch's workers, keep their QoS value untouched, because nothing writes the slot on them any more.
The mistake in the first build
The first build of this patch crashed every Wine process at startup, wineboot included. The fault was in build_module, on the instruction after a call into the Unix-call dispatcher, reading through r15. That dispatcher has a fast return path that restores only three registers and trusts the System V callee-saved ones for the rest. The patch had borrowed r15 to carry the QoS value on the way in. Nothing restored it, and Windows code came back to a register full of scheduling class. Two things fixed it: use a register that is dead until the call (rax), and write the fiber pointer back on that fast path too, which the first build had also missed. Worth saying because "verify each exit path's register list before borrowing a register" is the whole lesson of dispatcher code.
Result
Engine r8, Wine 11 tree, macOS 26.6.2, M1 Pro:
- Metaphor: ReFantazio, Prologue Demo: title, cutscenes, dialogue and the run through the opening desert at 55 to 59 fps, 35 minutes, no fault. On the engine before it: black window and a hang. On the engine before that: a fault before the window.
- Marvel's Guardians of the Galaxy: launcher, Play Game, then the opening bedroom under keyboard control at 41 to 42 fps at 1280x720 on D3DMetal, no fault. Play Game is where it faulted on every engine before the mirror, and the swap keeps it playing.
The same method found the other fix shipped this week: Red Dead Redemption 2 would not load on any MoltenVK build because the game creates a few images in a linear layout Metal cannot back, and MoltenVK refuses them. Eleven lines that fall back to optimal tiling with a warning, now a pull request on MoltenVK (#2825). Different layer, same habit: reproduce, read the trace, find the one assumption two systems disagree on.
What upstream Wine does, and why our engine did not have it
Upstream Wine solved this class a different way in Wine 10.5. Brendan Shanks's change ("ntdll: On macOS x86_64, swap GSBASE between the TEB and macOS TSD when entering/leaving PE code", 3a16aabbf5) switches the entire gs base at each crossing with a private macOS syscall, _thread_set_tsd_base: Windows code runs with gs pointing at the real TEB, so every offset is right, and Wine's Unix side runs with gs pointing at the pthread block, so libdispatch sees the real QoS class. One syscall per crossing instead of two moves, and no field is shared at all. I did not know that change existed when I wrote the patch, and I found it while preparing to send mine upstream, which is the right order to find it in.
Two Wine trees that Mac gamers actually run do not carry it. Wine 10.0, which the Sikarugir builds are based on, predates it by five releases, and the older Game Porting Toolkit builds Whisky used are further back still: that is the engine where Metaphor faults reading 0x20FF. And the CrossOver 26.3 source tree, which Highball's Wine 11 engine is built from, does not contain it either, which is why our build of that tree hit the second failure. CrossOver's shipped product may handle it another way, users report the game running there, so no claim about CrossOver from me beyond what its source tree contains.
So the one-slot swap stays in Highball's engine as the cheap fix for trees without the upstream change, and the long-term fix is to carry the upstream gs base switch into the CrossOver tree. The patch, the build workflow and every engine manifest are public at github.com/gauthierpiarrette/highball-engine, and the game rows with the exact error text are at gethighball.com/database.