Platform ports#
The same generated C++ and the same Vulkan renderer run on every platform. What differs is the toolchain, how the host maps memory, waits and schedules threads, and which GPU work pays off.
| Windows | Linux, Steam Deck | macOS | Android | |
|---|---|---|---|---|
| CPU | x86-64, SSE4.1 (FMA3 variant) | x86-64 | arm64 | arm64 |
| Compiler | Clang 20.1.8 (clang-cl) |
Clang 20.1.8, Steam Runtime 3 sysroot | Command Line Tools clang | NDK r29 |
| Vulkan | system driver | RADV or NVIDIA | MoltenVK, bundled | Turnip on Adreno 7xx, else the system driver |
| Host page size | 4 KB, 64 KB granularity | 4 KB | 16 KB | 4 or 16 KB |
| Audio, input | SDL3, XInput | SDL3 | SDL3 | AAudio, SDL3, touch |
Windows#
The reference PC is a Ryzen 7 5800X (8 cores, 16 threads) with an RTX 4080. Against the console's 30 fps and Xenia's 30 fps cap, the native renderer runs the opening drive at 120 fps, the display's limit. The heavy race holds 119 fps at 1x and 2x and 102 fps at 3x (Performance). Most desktop work was on the CPU side of the renderer; see GPU optimizations.
Smaller PCs. The game's frame rate sets every per-second CPU cost, since the simulation steps and the recorder's work scale with it. 60 fps is half the CPU work of 120, and 40 a third. That is the idea behind the presets:
| Preset | Settings | For |
|---|---|---|
| Ultra Low 30 | Low-spec 60 without sun shadows, reflections at a quarter rate, the scene in one pass, 30 limit | integrated graphics on 2 physical cores |
| Low-spec 60 | 1x, no MSAA, bilinear, the game's anisotropy, no FXAA, 60 limit | new installs |
| Balanced 40 | the same at 40, even pacing on 120 Hz displays and the Deck's 40 Hz mode | under 4 physical cores |
| Performance 120 | discrete GPU with 6 GB or more, 6 or more threads, a 120 Hz display |
The core-count limits were tested on the same PC with the game restricted to fewer cores (--host-cpus), a load on the sibling threads, and a VRAM balloon. These show sensitivity, not real older hardware:
| Low-spec 60, heavy race start | Presents a second |
|---|---|
| 16 threads | 59.7 |
| 4 cores, 8 threads | 59.7 |
| 4 cores, 4 threads (the recorder was faster without SMT: 7.4 against 8.9 ms) | 59.7 |
| 4 slow cores | 58.9–59.3 |
| 2 cores, 4 threads | 59.0–59.3, 0.8–1.0 % long frames |
| 4 slow cores, Balanced 40 | 39.9 |
| about 1 GB of VRAM free | unchanged |
Toolchain. The FMA3 variant is chosen at build time when Windows reports AVX2, with a guard that stops it on a CPU without FMA3; it cut the guest main thread's CPU by 6–7 %. Windows rounds a sub-millisecond Sleep to a yield, so short waits there use high-resolution waitable timers.
Linux and the Steam Deck#
Build. Clang 20.1.8 targets x86_64-linux-gnu against the Steam Runtime 3 ("sniper") SDK sysroot and links with lld. libstdc++ is linked statically, so the 195 MB binary needs only glibc 2.30 and no system C++ runtime. The launcher reads the disc image directly, without mounting it, and sizes the build's job count to memory. The first build takes about 35 minutes on the Deck.
What changed from Windows.
- Real-time thread priorities fail with
EPERMfor an unprivileged process, and a guest priority of 0 or less was invalid and failed thread creation. Priorities map to nice values instead. - Multi-object and alertable waits polled every millisecond; they now wake on signal.
- Timers use
CLOCK_MONOTONIC_RAW; the display refresh rate comes from SDL. - Audio goes through SDL3, which loads PipeWire, PulseAudio or ALSA at run time.
- SteamOS ships
librenderdoc.so, which hooked every run; RenderDoc is now used only when it is already loaded. - Input comes from SDL3, including SDL's Deck driver and Steam Input's virtual pad when Steam starts the game.
- LLVM's Linux
ld.lldand the sysroot'swayland-scannerlinklibxml2.so.2, which SteamOS, Arch and other distributions with libxml2 2.14 or newer no longer have. Setup runs both through small wrappers that load only that library and its ICU from the sysroot, so nothing has to be installed on the host.
The Deck. The Steam Deck LCD (Zen 2, 4 cores and 8 threads, RDNA 2 on Mesa RADV) runs the race and free roam at 60 fps at 1x under its default 15 W limit. A replayed frame at 1x:
| Change | Frame |
|---|---|
| Default | 11.19 ms |
| Partial resolves (on for every Linux build, identical image) | 10.87 ms |
| Coarse 2 × 2 shading (SCENE SHADING > COARSE) | 10.42 ms |
| New defaults plus coarse shading | 9.9 ms |
One trap: with Manual GPU Clock on in the Quick Access menu, Steam can pin the GPU at 200 MHz. The game drops to about 10 fps while the GPU reports full load at under 1 W, and starting over SSH leaves the clock there too. The launcher's deck doctor reports the clock.
macOS on Apple silicon#
Build. Command Line Tools clang for arm64 with a macOS 13 target, and a MoltenVK that ShiftGlue builds and bundles. The generated code is the same C++: VMX operations reach NEON through SIMDe (byte shuffles become tbl), FPCR replaces MXCSR, and std::fma is one instruction. Guest functions are weak thunks rather than aliases, because Mach-O has no overridable alias. Setup from the disc image to a built game takes about 18–20 minutes on an M4 Pro.
16 KB pages. One host page holds four guest pages. The write-watch code took a host page's protection from its first guest page, so a committed page next to an uncommitted one faulted (SIGBUS in free roam, runs 2–4). Using the union of the four guest pages' access fixed it.
MoltenVK limits. No geometry shaders and no wireframe fill, so the vertex-shader expansion paths are used. No fragment shading rate, so SCENE SHADING does nothing. The portability subset lacks separate stencil reference masks, and SPIRV-Cross rejects some decorations on function-local arrays, which the translator avoids.
Results. Free roam at 1x runs at a median of about 105 fps on an M4 Pro Mac mini. The M4 Pro is shading-bound, so two switches pay off there that don't on the RTX 4080: texture gamma through sRGB views (−8 % at 1x, offered as TEXTURE GAMMA FAST) and quads as triangle lists (−5 % at 2x, now the default everywhere).
Distribution. The launcher bundle is ad-hoc signed and not notarized, so players approve its first start in System Settings.
Android#
Build. NDK r29, arm64-v8a, API 33, c++_shared, 16 KB ELF alignment (-Wl,-z,max-page-size=16384). The game is libmain.so, started by SDL. The Windows launcher builds the APK from the code it already translated and shares it over Wi-Fi.
Bring-up was done first on an Android emulator on the M4 Pro. Guest memory needed memfd shared memory and a reserve-then-fix layout; APCs use bionic's sigqueue; a double pthread_join crashed. The page-fault path parsed /proc/self/maps, 12,000 lines per fault, which cost the 8 Elite heavily until it was removed.
Memory ordering. x86 is strongly ordered, so the recompiler had dropped the game's PowerPC barriers. On AArch64 they had to come back: sync and eieio became full fences, lwsync acquire-release and isync acquire, about 320 sites in all. The x86 output is unchanged, byte for byte.
Drivers. Turnip (Mesa) is bundled and chosen automatically on Adreno 7xx. On the Odin's busy drive, frame time at 4x / 1x:
| Driver | 4x | 1x |
|---|---|---|
| Stock Qualcomm | 49 ms | 41 ms |
| Older Turnip | 39 ms | 34 ms |
| Turnip Gen8 V37 (bundled) | 32 ms | 28 ms |
Qualcomm drivers needed three workarounds: infinite fence waits return at once, push descriptors read as null, and the recorder thread is off there. Sparse buffers read back zeros on Turnip, so shared memory uses a plain buffer. RedMagic phones cap an unknown app's clocks until the game is added to their Game Space.
Heat. The RedMagic Astra (8 Elite) holds 60 fps for about the first minute, then 37–39 fps at 40–44 °C skin temperature and 26–28 fps above 44.5 °C, with the cores at 2.0–2.2 GHz and the GPU frame at 35.6 ms against 20 ms cold. An ADPF performance hint changed nothing. One build measured 21.5–32 ms in the same scene across a batch, which is why live settings are compared inside one run, flipping every 300 frames.
Presets. Measured on the Odin 2 Portal (Adreno 740 at its 680 MHz maximum, 90–99 % busy):
| Preset | MSAA | Sun shadows | Rate |
|---|---|---|---|
| SMOOTH 60 | 2x for the game's 4x | off | 60 |
| QUALITY 30 | the game's 4x | on | 30 |
Both draw the scene's three bands in one pass (25.4 against 28.3 ms at 1x), update the reflection map at a quarter rate (about 2.1 ms), use bilinear output (FSR 1 costs 1.4 ms) and the game's own anisotropy. Shadows cost about 6.4 ms. The Odin and the Thor hold about 60 fps with SMOOTH 60; the Astra does while cool.
Tiled GPUs. Adreno renders in tiles, so every rendering, clear and store has a cost a desktop GPU doesn't show. What paid off was fewer renderings (clears folded in, the scene drawn once) and specialized resolve and transfer passes; see GPU optimizations. What didn't: forcing on-chip (GMEM) rendering for MSAA, 49.7 ms against 18.9 ms at 4x, so on-chip MSAA is no route to 4x at 60; store-op tuning; and trimming render areas, which the driver already does.
Stutters. Hitches on Android came from the runtime's global lock, not the GPU; see the global critical region.
What carried across#
- Don't assume 4 KB pages: the guest's
0xE0000000view and write-watches both broke on larger pages. - Put back the guest's memory barriers on ARM, and don't rely on
volatileto order anything there. - Waits differ by OS: Windows rounds short sleeps to yields, Linux denies real-time priorities, and polling multi-waits burn CPU everywhere.
- Keep I/O, audio device opens and
mprotectoff any process-wide lock. - Measure each GPU separately. Texture gamma saved 8 % on the M4 Pro and nothing on the RTX 4080; partial resolves saved 3 % on the Deck and nothing on the desktop.