Forza Horizon 6 on Linux, from a broken installer to working saves
I’ve spent the last few weeks getting Forza Horizon 6 to run on Linux, and it turned out to be the
usual thing with recent Windows games: never one problem, a queue of them, each
one hiding the next. Four rounds. The installer wouldn’t extract. Then the game
wouldn’t start. Then it started and immediately quit. And then it ran
beautifully and refused to save, LOAD FAILED, E:0-0, every single time. This
post is the whole queue, in order, with the settings that actually worked. If
you’re only interested in the save fix, click
here.
Before going further: I don’t endorse piracy. This is an experiment to understand compatibility problems and help make Windows game support on Linux better. Use software you are entitled to use. Buy the games you like! There are no download links here, and no game files or proprietary binaries are distributed.
The machine#
Everything below was done on:
- CachyOS, KDE Plasma 6 on Wayland (XWayland for the game)
- Ryzen 7 5800X, 31 GiB RAM, Radeon RX 6700 XT on RADV / Mesa 26.2
- Lutris 0.5.22 with UMU,
proton-cachyosas the runner
Your mileage will vary, particularly on NVIDIA: two of the fixes below are AMD-specific.
Part 1: getting the files out#
A disappearing error is not success#
The installer reported Unarc -11, with a disk-write or decompression error, on
a drive that had 589 GiB free.
The first workaround was worse than the failure. Switching Wine version and
setting the installer’s LARGE_ADDRESS_AWARE bit made the error message
disappear. Great? Not really: one CPU core was pinned, the archive read position
stayed at 31 bytes, and the output directory stayed empty. A moving progress
counter and a log line saying “installation succeeded” are not proof that
anything was extracted.
A newer Wine avoided that hang, but both the original and modified installer
still returned -11.
That’s the first lesson of the whole project, and it kept being relevant: a disappearing error isn’t success, and a busy thread isn’t progress.
The game isn’t inside setup.exe#
So let’s stop arguing with the wizard and separate the installer’s interface from the thing doing the actual work.
innoextract unpacks the files embedded in an Inno Setup installer without running it:
innoextract --extract --output-dir unpacked /path/to/setup.exe
That yields a small collection of tools (unarc.dll, arc.ini, CLS.ini,
CLS-srep.dll and decoder helpers). The actual game data lives in external
.bin archives alongside.
So the plan became: write a small Windows program that loads the supplied
unarc.dll and calls FreeArcExtract directly, under Wine. Keeping the
original decoders together matters, an unrelated DLL off the internet is not a
substitute for the archive’s matching tools and configuration.
The first standalone attempt failed too:
Can't open read file mapping!
Which was progress, because it was finally something specific.
Eight bytes that were missing#
Instrumenting the mapping calls in CLS-srep.dll gave the useful part:
OpenFileMappingA("Init_MapFile_") -> error 2 (not found)
OpenFileMappingA("Read_MapFile_CLS-srep02") -> error 2
MapViewOfFile(NULL, ...) -> error 6 (invalid handle)
The decoder expected initialization from its host that our host never did. It
reads a prefix out of a 256-byte named shared-memory mapping called
Init_MapFile_, then uses that prefix for every mapping it shares with its
helper processes. No prefix, no communication.
Creating that mapping and writing the null-terminated string Global\ into it
(eight bytes including the terminator) fixed it:
HANDLE mapping = CreateFileMappingA(
INVALID_HANDLE_VALUE, NULL, PAGE_READWRITE, 0, 256, "Init_MapFile_");
/* Check errors and existing mappings before writing. */
char *prefix = MapViewOfFile(mapping, FILE_MAP_ALL_ACCESS, 0, 0, 256);
memcpy(prefix, "Global\\", 8);
/* Keep the mapping and view alive until extraction and DLL cleanup finish. */
The next run:
OpenFileMappingA("Global\Read_MapFile_CLS-srep03") -> success
OpenFileMappingA("Global\Write_MapFile_CLS-srep03") -> success
FreeArcExtract returned 0
An important distinction, and one I’d rather state than let a reader assume:
this explains our standalone host’s mapping error. It does not prove the
original installer’s -11 had the same cause. This isn’t an “all Unarc errors
fixed” tutorial.
Making it repeatable#
I wrapped the extraction in a small C host plus a Bash driver, with no home directory or drive mapping baked in. Both are published here: unarc-extract.c and extract.sh, source only, no binaries, and no archives or decoder DLLs, which you have to supply yourself.
bash extract.sh \
--prefix /absolute/path/to/dedicated-prefix \
--tools /absolute/path/to/prepared-decoder-tools \
--work /absolute/path/to/new-extraction \
/absolute/path/to/archives/fg-06.bin
It builds the 32-bit host with MinGW, converts paths with winepath, and gives
each archive its own output directory and log. It refuses to reuse an existing
work directory: silently skipping a partial file from an earlier attempt is
exactly how a retry ends up looking successful.
It also blocks system sleep for the duration and releases that block on exit, without changing any permanent power settings.
Two prerequisites that are specific to this recipe, not universal: the
placeholder paths in CLS.ini have to point at your temporary directory, and
this particular installer replaced fgrplc with -1 in arc.ini.
Note that an isolated Wine prefix is not a security sandbox. These decoders run code as your user. Don’t run them as root, and use a real VM for anything untrusted.
Extracted still isn’t installed#
All eight archives extracted, producing about 142 GiB of intermediate files. But
a lot of those were ingredients rather than game assets: video reconstruction
commands, audio conversion steps, and two different patches both named
new.x5n. Dumping everything into one directory would have destroyed that
distinction.
innounp recovered the installation recipe so the reconstruction steps could be replayed in the right order and the right working directories (36 audio banks and 26 video files rebuilt), while skipping the bundled URL launcher and unrelated installer actions.
Final state: roughly 150 GB, 14,531 files, every hash matching the bundled manifest. That’s the point where you actually have a game to debug.
Part 2: making it start#
It won’t launch, AMD AGS#
The game launched and died on a null pointer dereference deep in the executable. The cause was upstream of the game entirely.
AMD AGS is AMD’s GPU information library. The game ships its own copy; that copy
runs under Wine, asks the driver to identify itself, and gets back error 6,
AGS_NO_AMD_DRIVER_INSTALLED. There’s no real AMD Windows driver to talk to,
so it hands back a null context. The game then reads GPU information out of that
null context and falls over.
The fix is to not use the shipped copy. Proton has a builtin AMD AGS that answers correctly:
amd_ags_x64 = b # builtin, not native
That’s the whole fix. No GPU spoofing is necessary, which matters, because the obvious-looking workaround is a trap.
Do not set
DXVK_CONFIG=dxgi.hideAmdGpu=True. An early attempt at that was followed by a whole-machine crash. It isn’t needed, the builtin AGS gives the game valid GPU information. I now explicitly blankDXVK_CONFIGin the launch environment so an inherited value can never bring it back.
It starts, shows a black window, then quits#
With GPU information fixed, the game got further and then exited cleanly. No crash, no error. It just decided to stop.
It wasn’t a crash because nothing had crashed. The bundled amd_ags_x64.dll
isn’t really AMD’s library, it’s a wrapper that forwards to the real one while
doing its own bookkeeping. Part of that bookkeeping hooks CloseHandle, and on
one particular handle, associated with the game’s language preference key, it
decides something is wrong and calls NtTerminateProcess(-1, 0) on itself.
Two ways past it, both of which work:
- Use the community compatibility wrapper (the FH6-Cachy
version.dll) with builtin AMD AGS, keeping the original Xbox runtime. This is the route I ended up playing on. - Keep the original wrapper and change one byte, flipping a
JNEinto aJMPat file offset0x74f, which skips the false-shutdown branch, with Proton’s AMD AGS as the forwarding target.
Option 2 is a satisfying confirmation that the diagnosis is right: patch out that one branch and the shutdown stops happening. Option 1 needs no patched binaries, so that’s what the settings below use.
Part 3: making it save#
LOAD FAILED, E:0-0#
This is the one that nearly ended the project. The game ran beautifully. Menus worked, the world loaded. And every save attempt produced:
LOAD FAILED, E:0-0
It reproduced with original bundled files, with a completely fresh Wine prefix, with a different Proton build, and with a startup path that loaded no community DLLs at all. So: not prefix rot, not Proton, not mixing files.
The fix is a single registry value inside the prefix:
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\GamingServices]
"ForceUseInprocGameSaves"=dword:00000001
No patched binaries, no replacing the Xbox runtime, no Microsoft account, fully reversible. If you take one thing from this post, take that.
Why that works#
Briefly, because the detail is genuinely interesting.
PlayFabGameSave.dll, the save library, ships two save backends. You can
read their names straight out of the binary’s RTTI, because MSVC leaves every
C++ class name lying around in .rdata:
.?AVGameSaveAPIProviderGRTS@GameSave@PlayFab@@
.?AVGameSaveAPIProviderWin32@GameSave@PlayFab@@
GRTS is the Xbox path, it talks to xgameruntime.dll. Win32 is a plain local
one. The game was on the GRTS path, which asks the Xbox runtime for a COM class,
{704C3F58-E629-4CC2-B197-30511B996FE2}, then calls a method on the object it
gets back. That call returned E_NOTIMPL (“not implemented”) and the game
treats that as fatal.
The proof took one grep. Search every shipped DLL for those sixteen GUID bytes
and you get exactly two hits: PlayFabGameSave.dll, the caller, and the vendor
Xbox runtime shipped alongside. It appears nowhere in the xgameruntime.dll
that actually loads. That file is a stand-in for Microsoft’s real runtime and it
never implemented this interface: its single mov eax, E_NOTIMPL; ret stub is a
shared filler wired into 142 different vtable slots.
So the save failure was never a Linux problem at all. It’s a missing interface in a substitute runtime, and it would presumably behave the same on Windows.
The good news: the provider choice is already configurable. The factory that picks a backend logs its own decision.
PlatformGetAPIProvider: ForceUseInprocGameSaves registry set
-> forcing in-proc (Win32 provider)
It reads HKLM\SOFTWARE\Microsoft\GamingServices\ForceUseInprocGameSaves, and if
it’s 1 it uses the Win32 backend instead. Before trusting that, I checked all
40 vtable slots on both: GRTS has four unimplemented stubs, Win32 has none. Every
method is real.
Set the key and the Xbox runtime is simply never asked.
Your saves are not where you expect#
This is the part that will make you think it didn’t work.
The in-proc backend does not use the Documents/MicrosoftStore/.../SaveGames
folder. That folder stays empty forever. Real saves live here:
$WINEPREFIX/drive_c/users/steamuser/AppData/Local/<Game>/
PlayFabSaveStorage_<id>/ContainersRoot/
SaveVersion/SaveVersion
User_<id>/VersionFlags
User_<id>/C_ProfileData
Estate_<guid>/header
Estate_<guid>/EstateData
Back up that directory. After playing the intro and quitting, C_ProfileData
had grown from 23,796 to 174,804 bytes and a cloudsync/localstate.json
appeared with a readable summary (cars, credits, progress). Saves survive a full
quit and relaunch.
The settings, all together#
This is the Lutris configuration that works:
system:
disable_screen_saver: true
env:
PROTON_LOG: '0'
DXVK_CONFIG: ''
wine:
version: proton-cachyos-x86_64
dxvk: true
vkd3d: true
esync: true
fsync: true
show_debug: '-all'
overrides:
amd_ags_x64: b # builtin, fixes the null GPU info crash
version: n,b # community compatibility wrapper
winmm: b # builtin
steam_api64: n,b
Plus, once, inside the prefix:
[HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\GamingServices]
"ForceUseInprocGameSaves"=dword:00000001
Note that winmm is builtin here, not native, which differs from what a lot of
guides suggest.
What actually took the time#
Four rounds, and the last one comes down to a single registry value. That’s a satisfying place to land, but it’s worth being honest about the shape of the work: the save bug ate days of live debugging, GDB, hardware breakpoints, trace scripts, careful notes about which address belonged to which process. All of that correctly identified which call was failing and never explained why.
The answer came from reading the DLL on disk instead of running it. Parsing RTTI gave the two provider names, which reframed the question from “what returns this error” into “which backend are we on, and can we pick the other one”. Comparing the two vtables showed one was fully implemented. Grepping a GUID across shipped files proved which binary was missing the interface. None of it needed the game running.
The same shape shows up in Part 1. The extraction didn’t get solved by trying more Wine versions, it got solved by making one specific failure observable, then supplying the initialization the decoder had always expected.
So: make the failure observable, then read before you run. Use the debugger to confirm a theory, not to find one.
Credit where it’s due#
Most of the work here was agentic. I set the constraints (don’t replace the original runtime, no Microsoft sign-in, don’t touch the main install, keep every experiment reversible) and steered between rounds. The RTTI parsing, the vtable comparison, the GUID grep and the call-chain reconstruction were all done by agents working through the binaries.
The interesting part is which agent did what. The days of live debugging happened in one long running Codex Astra session on medium effort, and that session kept digging into the same hole: more breakpoints, more traces, a bit more of the call chain each round. We were running circles, and I didn’t see it, because every step genuinely looked like it was getting closer.
What broke the loop was starting over. I pointed a fresh Claude Opus on high effort at the analysis directory. It read the notes, dropped the live debugging entirely and went to parse the DLL on disk instead. The two provider names came out of the RTTI shortly after, and from there the registry key was a short walk.
If you hit E:0-0 on another title that ships the same Xbox runtime, the
registry key is the first thing I’d try. Let me know in the comments if it helps!