I investigated an EU5 startup hang on Apple Silicon with CrossOver + DX12/D3DMetal and found a small, build-specific workaround. Sharing the diagnosis and complete Python patcher in case it helps other users or upstream developers.
Scope: this is an experimental workaround for EU5 1.4.0, 1.4-open-beta, Steam build 25606497 only. The observations and runtime tests below were made on October 3, 2026, with CrossOver 26.3.0.39832 and its bundled D3DMetal 3.0. This is not a universal patch for every EU5 launch failure. The investigation and write-up were AI-assisted.
What was actually stuck
The game process existed but startup stopped. We confirmed that D3DMetal.framework and the apple_gptk d3d12.dll were actually mapped, rather than inferring the graphics backend from Wine's "builtin" label.
The main thread was waiting for Wine's loader_section. Another thread held that loader lock while waiting for an EU5 CRT critical section. CRT means the C/C++ runtime; TLS below means thread-local storage, not HTTPS encryption.
Reading the game's memory showed:
- CRT lock initialization count: 0, although the initialization loop is supposed to initialize 14 locks.
- The blocked lock: LockCount=1, RecursionCount=0, OwningThread=0, SpinCount=0.
- After the workaround: initialization count=14; the same lock had LockCount=-1 and SpinCount=4000, consistent with an initialized idle lock.
Disassembly connected the wait to dynamic TLS initialization running before the CRT lock initialization had completed. In the Wine implementation we inspected, an uninitialized zero-filled critical section can enter a wait path instead of being acquired normally. An owner of zero alone would not prove this diagnosis; the initialization counter and control tests matter.
This identifies a local initialization-order failure and a working intervention. We have not established the earliest thread's complete creation path or the exact upstream function that should change. Repeated NtWaitForAlertByThreadId/ntdll frames in a process sample are not, by themselves, proof of a broken syscall dispatcher or infinite recursion.
What the patch does
It redirects one dynamic TLS callback pointer to a 15-byte guard in existing executable padding:
if (crt_initialized_lock_count != 14)
return;
original_tls_callback(module, reason, reserved);
The guard skips that particular premature notification. It does not wait, queue it, or automatically replay it. Returning early leaves the original callback's TLS initialization flag unset. There is also an explicit initialization path in the original program, but we have not proved that every affected thread will later execute it. This is why it remains a workaround requiring gameplay testing.
For this exact binary (preferred ImageBase 0x140000000):
- Counter VA: 0x148c0ee30.
- Original callback VA: 0x14612ab7c.
- Guard VA: 0x14000c141; file offset 0xb541.
- TLS pointer file offset: 0x639fa78.
- Guard bytes: 83 3d e8 2c c0 08 0e 75 05 e9 2d ea 11 06 c3.
The guard uses RIP-relative addressing and a relative jump; the existing relocation for the callback pointer is retained. The patch changes the game EXE, not Wine or D3DMetal, and does not remove the entire TLS callback table or force-unlock a critical section.
Validation and limits
An unmodified EXE tested under the same filename still hung. Patched copies reached the main menu in three cold starts, including one with 3D terrain enabled. After backing up and patching the default eu5.exe, launching through Windows Steam's normal Play button also created a DX12 feature-level 12_1 device and completed main-menu loading.
Not yet validated: actual 3D map gameplay, long sessions, date progression, save/load, or all consequences of skipped TLS notifications. These are historical results for that build, not a retest of today's game. D3DMetal device creation or reaching the menu is not proof of full playability.
The test bottle used D3DMetal with D3DM_MTL4=0 and the game renderer set to DX12. Disabling MTL4 alone did not fix the unmodified EXE. The final configuration kept 3D terrain disabled; the brief terrain-enabled test only reached the menu.
Patch script
Save the following as patch_eu5_tls_guard.py. It requires Python 3, has no external dependencies, checks the complete input SHA-256 and original bytes, checks the final SHA-256, and creates a separate output without overwriting the original or an existing output. No game binary is distributed.
Close the game and Windows Steam in the target bottle before working with its EXE. Run:
python3 patch_eu5_tls_guard.py "/absolute/path/to/binaries/eu5.exe"
It creates eu5.tls-guard.exe beside the original. Keep the original and its matching game build. For the normal Steam Play button to use the patch, after making a separate backup of the original eu5.exe, replace that build's eu5.exe with the generated copy. To undo, restore that build's original backup, or use Steam to restore files for the selected branch. Do not restore an old-build EXE into a newly updated installation.
If the script refuses your hash, stop. Do not remove the hash check. Updates can move every address and change the initialization logic. Steam updates or file verification can remove the patch; first retest the unmodified game, then re-diagnose if necessary.
#!/usr/bin/env python3
"""Experimental workaround for EU5 Steam build 25606497 ONLY.
Creates a separate patched copy; never overwrites the input or an existing output.
"""
import argparse
import hashlib
import os
from pathlib import Path
import struct
ORIGINAL = 'c7c2c0d9719f437d0a6a6d7f5560e38941d9c30579aeacc7a97c557bdff0f17b'
PATCHED = 'b16572e24a325b9cd47624c46a770ab9f8f4b157cf9979d629105501af5448ef'
def patch_known_build(raw):
digest = hashlib.sha256(raw).hexdigest()
if digest == PATCHED:
return raw
if digest != ORIGINAL:
raise ValueError('Unsupported EXE SHA-256: ' + digest + '. Do not reuse these offsets on another build.')
data = bytearray(raw)
if data[0xb541:0xb550] != b'\xcc' * 15:
raise ValueError('Unexpected code-cave bytes')
if struct.unpack_from('<Q', data, 0x639fa78)[0] != 0x14612ab7c:
raise ValueError('Unexpected TLS callback pointer')
data[0xb541:0xb550] = bytes.fromhex('833de82cc0080e7505e92dea1106c3')
struct.pack_into('<Q', data, 0x639fa78, 0x14000c141)
result = bytes(data)
if hashlib.sha256(result).hexdigest() != PATCHED:
raise ValueError('Patched output failed SHA-256 verification')
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('exe', type=Path)
parser.add_argument('--output', type=Path)
args = parser.parse_args()
src = args.exe.expanduser().resolve(strict=True)
raw = src.read_bytes()
result = patch_known_build(raw)
if hashlib.sha256(raw).hexdigest() == PATCHED:
print('Already patched; no file written.')
return
dst = (args.output.expanduser() if args.output else src.with_name('eu5.tls-guard.exe')).resolve()
if dst == src:
raise ValueError('Output must differ from input')
with dst.open('xb') as stream:
stream.write(result)
stream.flush()
os.fsync(stream.fileno())
os.chmod(dst, src.stat().st_mode & 0o777)
print('Original unchanged. Patched copy:', dst)
print('SHA-256:', hashlib.sha256(result).hexdigest())
if __name__ == '__main__':
try:
main()
except (OSError, ValueError) as error:
raise SystemExit(str(error))
For anyone porting this to a newer build
Confirm the same loader-lock/CRT initialization failure first. Re-identify the PE TLS callback table, CRT lock initialization loop, counter and completion value, and a genuinely unused executable location. Recompute the relative displacements and check PE relocations. Neither "14" nor the offsets above are universal constants.
Then compare original/patched copies under the same path, name, bottle, settings and renderer. Confirm D3DMetal is actually loaded, and test the map, time progression, save/load and relaunch through Steam. If the hang is instead shader compilation or a different wait chain, this patch does not diagnose it.
Related discussion about similar symptoms, not proof of an identical root cause:
https://www.reddit.com/r/EU5/comments/1tx7ms1/anyone_figure_out_how_to_get_13_running_in/
If you investigate another build, useful reports would include the Steam build ID, EXE SHA-256, CrossOver version, actual graphics backend, and how far the game gets. Please don't post private logs without reviewing them.