Chapter 66: Running Windows Games on Android¶
A modern Android phone carries an ARM64 GPU powerful enough to run desktop PC games. But the games themselves are x86-64 Windows binaries. They expect the Win32 API, a glibc-flavored Linux kernel ABI underneath Wine, and a desktop graphics driver. None of that exists on a stock, unrooted Android device.
Projects like GameNative, and the Winlator runtime it builds on, bridge that gap entirely in userspace: no root, no kernel patches, no custom ROM. They stack a chain of translation layers on top of the same AOSP internals that the rest of this book dissects. Each layer solves one slice of the impedance mismatch.
This chapter takes GameNative as a worked example and walks the whole stack from
the top down. It starts with the Android app and the glibc root filesystem that
it ships inside an APK. Then it covers the bionic/glibc boundary and the
redirections that cross it. Next come the x86-64 to AArch64 translators (FEX
and Box64) and Wine as the Windows API layer. The last parts cover the
Direct3D to Vulkan graphics chain that reaches the real Adreno GPU, and the
audio path that ends at AAudio.
Every layer is a self-contained piece of technology. So each layer gets its own architecture, its own rationale, and its own map of where it plugs into the Android internals covered earlier.
A note on source references. The Android touchpoints are cited against the AOSP tree with file path and line number, exactly as elsewhere in this book. The translation-stack projects (GameNative, Winlator, Wine, FEX, Box64) are external repositories that move quickly. So they are cited by repository-relative file path only, without line numbers. Line numbers would be stale before the ink dried.
66.1 The Problem and the Layer Cake¶
66.1.1 Two Orthogonal Translations¶
Running a Windows game on an ARM phone is not one problem; it is two unrelated problems that are easy to confuse:
-
Instruction-set translation. The game is a stream of x86-64 machine instructions. An ARM64 CPU cannot execute them. Something must translate x86-64 to AArch64, either ahead of time or just-in-time. This is the job of FEX or Box64. It is pure CPU emulation: it knows nothing about windows, files, or sockets, only about opcodes, registers, and flags.
-
API and ABI translation. The game calls the Windows API:
CreateFileW,NtUserCreateWindowEx,D3D11CreateDevice. Those functions do not exist on Linux. Something must implement the Windows API on top of the POSIX/Linux kernel. This is the job of Wine. It is not an emulator: it contains no instruction translation at all, only reimplementations of Windows behavior in native code.
Keeping these two jobs separate is the single most important architectural idea in the whole stack. The performance of the entire system depends on emulating as little as possible, ideally only the game's own code. Wine, the graphics driver, and the audio server can then run as native ARM64. We will return to this theme repeatedly. This is because the newest designs (ARM64EC, covered in 66.5.8) exist precisely to shrink the "emulated" box to just the game.
66.1.2 The Full Stack at a Glance¶
The layers, from the Windows game at the top to the Android kernel at the bottom, look like this.
Diagram: the Windows-game translation stack on Android¶
graph TD
GAME["Windows game<br/>x86-64 PE binary"]
subgraph Guest["Guest world: rootfs in app storage"]
DXVK["DXVK / VKD3D-Proton<br/>(Direct3D to Vulkan)"]
WINE["Wine / Proton<br/>(Win32 + NT API)"]
EMU["FEX / Box64<br/>(x86-64 to AArch64 JIT)"]
end
subgraph App["GameNative app process (Android bionic)"]
XSERVER["In-app X server<br/>+ Vortek renderer"]
PULSE["PulseAudio server"]
STORE["Steam / Epic / GOG client<br/>(Pluvia lineage)"]
end
subgraph AOSP["Android platform"]
VKLOADER["Vulkan loader<br/>libvulkan"]
AAUDIO["AAudio / AudioTrack"]
SURFACE["ANativeWindow / SurfaceFlinger"]
DRIVER["Adreno Vulkan driver"]
end
GAME --> WINE
GAME -.->|"D3D calls"| DXVK
DXVK -->|"Vulkan"| XSERVER
WINE --> EMU
WINE -.->|"WASAPI audio"| PULSE
XSERVER --> VKLOADER
VKLOADER --> DRIVER
XSERVER --> SURFACE
PULSE --> AAUDIO
STORE -->|"installs game"| GAME
Read it as three bands. The guest band is a Linux x86-64 world running
inside a normal Android app's private storage. The app band is native ARM64
Android code. It holds the in-app X server for the game's frames, the PulseAudio
server for its audio, and the storefront client that downloaded the game. The
platform band is plain AOSP: the Vulkan loader and
ANativeWindow from Chapter 13, AAudio from Chapter 15, and the SurfaceFlinger
compositor from Chapter 24.
Crucially, the dotted Direct3D and audio arrows leave the emulator early. The game's D3D calls go to DXVK. In the fast configurations, DXVK runs as native ARM64 code and emits Vulkan. So the heavy graphics translation is not itself emulated. The same is true of audio. Only the solid arrow from the game into Wine, and from Wine into the emulator, represents x86-64 code that the JIT must actually translate.
66.1.3 No Root, No Kernel Changes¶
Everything in the guest band runs with the privileges of an ordinary
installed app. There is no su, no insmod, no SELinux policy change, no
modification to the system image. That constraint makes these apps installable
from a store on a locked bootloader, and it forces most of the engineering
cleverness in this chapter. A desktop Wine setup can chroot
into a rootfs, mount filesystems, and dlopen the system's glibc GPU driver
directly. An Android app can do none of those things. Each missing capability is
replaced by a userspace stand-in:
| Desktop capability | Forbidden on unrooted Android | Userspace replacement |
|---|---|---|
chroot into a rootfs |
needs CAP_SYS_ADMIN |
PRoot (ptrace path rewriting) |
| Mount the rootfs image | needs root | extract into app-private storage |
| System V shared memory | restricted by the kernel | ashmem/memfd-backed shim |
dlopen the glibc GPU driver |
driver is a bionic library |
IPC to a native renderer, or thunks |
| Run a system PulseAudio | no system daemon access | bundle a PulseAudio server in the app |
66.1.4 The Unrooted-App Constraints in AOSP Terms¶
Three AOSP mechanisms shape what the stack can and cannot do, and all three were introduced in earlier chapters.
Linker namespaces (Chapter 7). An app's native libraries are loaded in an
isolated linker namespace so they cannot see arbitrary system libraries. The
android_dlopen_ext flags that govern this live in
bionic/libc/include/android/dlext.h. ANDROID_DLEXT_USE_NAMESPACE
(bionic/libc/include/android/dlext.h:115) lets a caller choose the target
namespace for a library, but the header marks it internal-use-only. This is because there is no
NDK API for namespaces.
The public ANDROID_DLEXT_USE_LIBRARY_FD
(bionic/libc/include/android/dlext.h:80) lets a caller load a library straight
from a file descriptor, for example a .so stored uncompressed inside the APK.
The
Vulkan loader itself uses the namespace flag when it opens the device driver, as
we will see in 66.7.4.
W^X enforcement (Chapter 7). A JIT must write machine code and then execute
it. For apps that target API 26 and later, the dynamic linker refuses to load
any ELF load segment that is both writable and executable.
bionic/linker/linker_phdr.cpp:1057 emits the
"has load segments that are both writable and executable" diagnostic, and
bionic/linker/linker_phdr.cpp:1061 records the W+E load segments warning. A
translator like FEX or Box64 therefore cannot ship prebuilt .so files with RWX
segments.
That check only covers ELF files that the linker maps. Anonymous JIT mappings
are untouched by it. SELinux even grants app processes execmem
(system/sepolicy/private/app.te:213), so an app may map anonymous PROT_WRITE | PROT_EXEC memory. The translators nevertheless follow the
hardening convention. They map JIT pages writable, fill them, then flip them to
executable with mprotect. So every page is write-xor-execute at any instant.
App-data execution limits. On recent Android, executing binaries from an
app's writable data directory is increasingly restricted. The translation stack
must run the guest's executables through a loader it controls (PRoot, or a
redirection library). It must not hand the kernel a path under the app's data
dir and call execve directly. This is one of the jobs of GameNative's
proprietary libredirect.so, discussed in 66.4.4.
66.2 GameNative: the Integrated Application¶
GameNative is an Android app that runs Windows PC games locally, with built-in
storefront integration for Steam, Epic Games Store, GOG, and Amazon Games. It is
the most complete public example of the full stack, so it anchors this chapter.
It is licensed GPL-3.0, with the package id app.gamenative.
66.2.1 Lineage: a Pluvia and Winlator Graft¶
GameNative is best understood as a graft of two upstream codebases rather than a single fork:
-
The application and Steam-client layer comes from Pluvia, a lightweight unofficial Steam client for Android. Its fingerprints are in the source: the
Applicationsubclass isPluviaApp.kt, the Room database isPluviaDatabase.kt, and Steam connectivity uses the JavaSteam library. Pluvia provides account login, the library view, and the SteamPipe depot downloader. -
The runtime and compatibility layer comes from Winlator. The entire
com.winlator.*Java tree is vendored underapp/src/main/java/com/winlator/, with the subpackagescontainer,core,xserver,xenvironment,winhandler,box86_64,fexcore,inputcontrols, andrenderer. The native side underapp/src/main/cpp/winlator/is Winlator's too.
So in code-provenance terms GameNative is Pluvia (the storefront app) plus Winlator (the Wine and emulation runtime), unified under GameNative's GPL-3.0 Kotlin layer. Popular write-ups that call it simply "a fork of Winlator" collapse this two-source structure. The accurate statement is the one above. The rest of the chapter relies on the Winlator half, which is the part that actually runs the game.
66.2.2 The App Architecture¶
The Android-facing half is Kotlin with Jetpack Compose UI and Dagger Hilt dependency injection. Three pieces matter for our purposes:
SteamService(a foregroundService) owns the Steam session; per-store services sit alongside it for Epic, GOG, and Amazon.XServerScreenis the full-screen Compose surface a game renders into, driven byXServerViewModel. This is where the in-app X server, the audio server, and the graphics renderer are brought up.- The vendored Winlator runtime does the actual container, rootfs, Wine,
and emulator work, orchestrated from the
com.winlator.xenvironmentpackage.
66.2.3 The Container Model¶
A container is one game's isolated Windows environment: a Wine prefix plus
its configuration (graphics driver, emulator, audio driver, DLL overrides,
environment variables). The classes com.winlator.container.Container and
ContainerManager define and manage them, and the defaults are the clearest
single statement of what "the GameNative stack" actually is. From
app/src/main/java/com/winlator/container/Container.java:
// GameNative: app/src/main/java/com/winlator/container/Container.java
public static final String DEFAULT_EMULATOR = "FEXCore";
public static final String DEFAULT_AUDIO_DRIVER = "pulseaudio";
public static final String DEFAULT_DXWRAPPER = "dxvk";
// graphics driver default resolves to "vortek"
The pinned component versions live alongside, in
app/src/main/java/com/winlator/core/DefaultVersion.java: Box64, Box86, the
FEXCore build number, Turnip (the Mesa Adreno Vulkan driver), DXVK, VKD3D, and
the rest. The default Wine is recorded in WineInfo.java. These two files show
the entire default pipeline at a glance. This is why a "Try It" exercise at the
end of the chapter is simply to read them.
66.2.4 The Default Stack¶
Putting the container defaults together, a freshly created GameNative container runs:
- Wine / Proton in an ARM64EC configuration as the Windows API layer (66.6). FEXCore translates the game's x86-64 code. A WoW64 helper translates any 32-bit code (66.5.8).
- DXVK translating Direct3D 9/10/11 to Vulkan, with VKD3D-Proton for Direct3D 12 (66.7.2).
- Vortek as the Vulkan path that reaches the device's Adreno driver (66.7.3), with Turnip (Mesa) available as an alternative.
- PulseAudio as the audio server (66.8).
Box64 with a classic glibc launch path is also bundled as the alternative route for Wine builds that are not ARM64EC. A different launcher component selects that path (66.5.6).
66.2.5 Packaging: How the Pieces Reach the Device¶
GameNative ships a remarkable amount of prebuilt native software inside one app, through three distinct delivery mechanisms.
-
Android-process native libraries ship in
jniLibs/arm64-v8a/and are unpacked by the platform's package manager into the app'snativeLibraryDir. These libraries load into the Android (bionic) process. They are the PulseAudio server libraries (libpulse.soand friends), the native graphics renderers (libvortekrenderer.so,libvirglrenderer.so), the in-app X server (libwinlator.so), and the proprietarylibsteambootstrap.so. -
Guest components ship as compressed tar archives in
assets/(Zstandard.tzstand xz.txz) and are extracted into the rootfs at runtime bycom.winlator.core.TarCompressorUtils. Wine, FEX, Box64, DXVK, VKD3D, the Vulkan drivers, and the Windows DLL components all arrive this way. After extraction their ELF interpreters and RPATHs are patched with a vendoredpatchelf. -
Large or optional payloads are downloaded on demand. The Ubuntu rootfs is delivered as a separate Android dynamic feature module (
ubuntufs/,dist:on-demand). The graphics drivers, extra DXVK builds, and Proton container patterns are fetched from GameNative's download server per theassets/*_download.jsonmanifests. This keeps the base APK installable while the multi-gigabyte Linux userland arrives only when a game is first run.
66.2.6 End-to-End Launch Sequence¶
Diagram: launching a game in GameNative¶
sequenceDiagram
participant U as User
participant SS as SteamService
participant CM as ContainerManager
participant IFS as ImageFsInstaller
participant XE as XEnvironment
participant PL as ProgramLauncher
participant W as Wine + FEX
participant GPU as Vortek + Adreno
U->>SS: Pick game, Play
SS->>SS: Authenticate, download depot
SS->>CM: Open or create container
CM->>IFS: Ensure rootfs (imagefs) installed
IFS-->>CM: Wine + components extracted
CM->>XE: Bring up environment components
XE->>XE: Start X server, PulseAudio, Vortek
XE->>PL: Launch guest program
PL->>W: PRoot or bionic exec into Wine
W->>W: Load game PE, start emulating x86-64
W->>GPU: D3D to Vulkan frames
GPU-->>U: Composited to the app Surface
The XEnvironment object holds an ordered list of components. Each
component is a small class in com.winlator.xenvironment.components that starts
one subsystem. The first components start the X server and
PulseAudioComponent. The next ones start the graphics renderer
(VortekRendererComponent or a VirGL renderer) and a System V shared-memory
component. The last component is a program launcher that spawns Wine under the
emulator. The launcher is
either BionicProgramLauncherComponent (the default ARM64EC/FEX path) or
GuestProgramLauncherComponent (the classic glibc/Box64 path).
66.2.7 Licensing and the GPL Aggregation Question¶
GameNative is GPL-3.0, as is the vendored Winlator code. Most of the bundled
runtime carries its own upstream licenses, mostly permissive or LGPL. Wine and
PulseAudio are LGPL-2.1, Mesa (Turnip, Zink) is largely MIT, DXVK is
zlib-licensed, and Box64 and FEX are MIT/BSD-style. The project's
THIRD_PARTY_NOTICES file makes a blanket written offer of source for the
copyleft components.
Two components are deliberately closed-source, and they are the legally interesting part:
libredirect.so(shipped inassets/redirect.tzst) is loaded only into the Wine/Proton subprocesses throughLD_PRELOAD, never into the Android app's own address space. It rewrites a legacy package identifier and adapts process launches to recent-Android exec-from-data restrictions.libsteambootstrap.sois a JNI shim that drives a real in-process Steam client for achievements, cloud saves, and the overlay.
The maintainer's stated position is that these run as separate
programs/subprocesses that communicate only through OS interfaces. So they are
mere aggregation under GPL-3.0 section 5, not derivative works of the GPL app.
That is the project's argument, not an adjudicated fact. Whether closed
LD_PRELOAD and JNI shims withstand scrutiny when they ship alongside a GPL-3.0
app is a genuine, unsettled GPL question. The app builds and runs without either
library,
with reduced compatibility, which is part of the maintainer's separability
argument.
66.3 The rootfs: a glibc Linux Userland Inside an App¶
66.3.1 What Is in the rootfs¶
Wine, the emulator's guest libraries, and the game all expect a Linux
filesystem laid out the normal way. It has /usr/lib, /bin, an ld-linux
dynamic loader, and above all a glibc C library. Android provides none of
that to an app; it provides bionic and an app-private data directory. So the
stack ships an entire small Linux distribution, historically called the
imagefs in Winlator, and extracts it into the app's private storage.
The rootfs is based on Ubuntu (the original Winlator uses Focal Fossa,
20.04). Inside it sit a glibc userland, the Wine installation under /opt, and
the per-container Wine prefix at /home/xuser/.wine. The path constants are
defined in com.winlator.xenvironment.ImageFs.
66.3.2 Shipping and Extraction¶
The userland is shipped compressed and unpacked on first use, never mounted (an
app cannot mount). The relevant code is in two classes. ImageFsInstaller
extracts the Wine tarball into <rootfs>/opt/<version>. TarCompressorUtils
handles both the Zstandard .tzst and xz .txz formats that GameNative uses.
Once extracted, ELF binaries are fixed up with patchelf. This makes their
interpreter and library search paths point inside the rootfs rather than at a
system that does not exist.
The size is the reason the rootfs is delivered as an on-demand dynamic-feature module (66.2.5). A full Ubuntu-plus-Wine userland is hundreds of megabytes to gigabytes, far too large to sit in a base APK.
66.3.3 Entering the rootfs: PRoot¶
A desktop setup would chroot into the rootfs. An unrooted app cannot. The
classic Winlator answer is PRoot, a userspace re-implementation of chroot
and bind-mounts built entirely on the Linux ptrace system call.
PRoot launches the guest program as a traced child. Every time the child makes a
system call that names a path, PRoot intercepts it at the kernel boundary. PRoot
rewrites the path string in the tracee's memory so that, say, /usr/lib/...
becomes <app data>/imagefs/usr/lib/..., and then lets the kernel proceed. The
guest believes it lives at /, but PRoot remaps every path on the fly. The same
trick is reapplied across execve so that child processes stay
confined. Bind-mounts like --bind=/dev and --bind=/proc are implemented the
same way, by remapping the relevant prefixes.
Diagram: how PRoot remaps a guest path with ptrace¶
sequenceDiagram
participant G as Guest (Wine/box64)
participant K as Linux kernel
participant P as PRoot tracer
G->>K: openat(AT_FDCWD, "/usr/lib/libc.so")
K-->>P: ptrace stop (syscall-enter)
P->>P: Rewrite path to<br/>/data/.../imagefs/usr/lib/libc.so
P->>K: ptrace continue
K-->>G: fd for the real file in app storage
The cost is the price of admission for going rootless: every path-bearing
syscall takes a ptrace stop-and-continue round trip into the PRoot process.
That overhead is the main reason the newer bionic designs (66.3.6) try to
eliminate PRoot entirely.
66.3.4 glibc Versus bionic: the Central Mismatch¶
The rootfs gives Wine a glibc world, which is exactly what Wine wants. But it
creates the defining problem of the whole stack. The process now contains
glibc, while every native Android library, including the GPU driver, is built
against bionic. You cannot simply dlopen a bionic .so into a glibc
process. The two C libraries disagree about thread-local storage layout, about
the dynamic loader's internal structures, about errno, about pthread_t,
about stack canaries. Loading one libc's shared object into the other's process
corrupts state and crashes.
This is the problem that "bionic redirections" exist to solve, and it is
important enough to get its own section (66.4). For now, note the shape of the
two answers. The first answer is to keep the glibc world and reach Android
services through inter-process communication rather than linking. The
PulseAudio server, the Vortek renderer, and the SysV-SHM server are all separate
endpoints reached over sockets. The second answer is to abandon glibc and run
Wine directly on bionic (the newer forks).
66.3.5 System V Shared Memory over ashmem¶
The cleanest concrete example of a bionic redirection is System V shared memory.
X11, DXVK, and other components use shmget/shmat to share large buffers
between processes. Android's kernel restricts the System V SHM API. Winlator's
glibc rootfs therefore ships a small shim library, libandroid-sysvshm.so,
which the launcher components inject into guest processes with LD_PRELOAD. It
interposes the shmget/shmat/shmdt family and reimplements it on top of
Android's anonymous shared memory. A small server on the Android side
(com.winlator.sysvshm) brokers this.
On the Android side, the natural primitive is ASharedMemory_create
(frameworks/native/include/android/sharedmem.h:78), the NDK entry point that
returns a file descriptor for an anonymous, refcounted shared-memory region.
Underneath, it is backed by memfd_create
(bionic/libc/include/sys/mman.h:183, available from API 30) or the older
ashmem driver. The redirection turns a System V shmget call inside the guest into
an ASharedMemory-style fd on the host, passed back across the socket. The guest
keeps using the System V API it was written for; the host satisfies it with an
Android primitive the kernel actually allows.
66.3.6 The bionic-Wine Alternative¶
The newest Winlator forks, and GameNative's default path, take the other route:
build Wine for bionic and drop PRoot. If Wine itself runs on bionic,
there is no libc mismatch with the Android GPU driver, and there is no per-syscall
ptrace tax. The price is that everything Wine relied on glibc for must now be
provided on bionic. This is why this path relies on ARM64EC Wine builds
(66.5.8) and a set of redirection libraries. These libraries fix up path and
behavior differences in place of PRoot. We turn to those next.
66.4 Bionic Redirections and the libc Boundary¶
"Bionic redirections" is the umbrella term for the techniques. They let a stack
built around glibc-flavored Linux software cooperate with an Android system
built around bionic. There are several distinct problems hiding under that
phrase, and they have different solutions.
66.4.1 Why Two libcs Cannot Share a Process¶
Here is the core constraint, stated precisely, because everything else follows
from it. A single Linux process has exactly one dynamic loader and one C
library. That library provides the loader's internal state, the TLS block,
malloc, and the syscall wrappers. glibc and bionic are different
implementations with incompatible internal layouts.
A glibc process's threads have glibc pthread structures and a glibc TLS model.
A bionic .so compiled to find its thread-control-block at a bionic offset
will read garbage. There is no flag that makes the two coexist.
The boundary between them must be crossed by some mechanism that does not
require linking. There are exactly three such mechanisms in this stack:
inter-process communication, ABI thunking, or the choice of one libc for the
whole process.
66.4.2 Linker Namespaces and dlext¶
Even when the stack does run native bionic code, it is subject to the linker
namespace isolation from Chapter 7. App native libraries load into a restricted
namespace that cannot see most system libraries. The driver-loading path uses
android_dlopen_ext with ANDROID_DLEXT_USE_NAMESPACE
(bionic/libc/include/android/dlext.h:115) to target a specific namespace.
Custom-driver loaders such as adrenotools (used to side-load a newer Turnip
build than the device ships) rely on exactly this machinery. They use it to get
a chosen .so loaded into a namespace that can reach the GPU kernel interfaces.
The
ANDROID_DLEXT_USE_LIBRARY_FD flag
(bionic/libc/include/android/dlext.h:80) is the companion that loads a driver
straight from a file descriptor.
66.4.3 W^X and the JIT¶
The translators are JITs, and the JIT discipline from 66.1.4 applies. Every
translator on Android follows the same pattern. It maps a code buffer
PROT_READ | PROT_WRITE and emits AArch64 instructions into it. It then clears
the instruction cache for that range. Last, it calls mprotect on the buffer to
make it PROT_READ | PROT_EXEC before it jumps in. A page is never both
writable and executable at once.
That is the translator's own hardening pattern, not compliance with the linker's
W+E rejection (bionic/linker/linker_phdr.cpp:1057). That rejection applies to
ELF load segments and never sees these anonymous code buffers. Some devices and
Android versions further restrict execution of memory from app data. There, the
translator must allocate its code pages as anonymous memory that it owns. It
must not map a file from the data directory.
66.4.4 Path Redirection: PRoot Versus libredirect¶
PRoot (66.3.3) is one form of path redirection: rewrite paths in the kernel ABI
using ptrace. It is general but slow. The bionic path replaces it with a
preloaded library, GameNative's libredirect.so, injected into Wine
subprocesses with LD_PRELOAD. The library does not trap every syscall through
ptrace. It interposes the libc functions that take paths and rewrites their
arguments in-process, with no tracer round trip.
The library also adapts process launches to recent-Android restrictions on
executing from the app data directory. This stands in for the kernel mechanism
that an unrooted app is not allowed to use. The trade-off is generality for
speed. A preload library only catches calls that go through the functions it
interposes, whereas PRoot catches everything at the kernel boundary. But the
preload approach avoids the per-syscall ptrace cost that dominates PRoot's
overhead.
66.4.5 The IPC Escape Hatch¶
The most robust redirection is to not cross the libc boundary in-process at all.
Three of the stack's services live on the Android (bionic) side as separate
endpoints and are reached from the guest over Unix-domain sockets:
- the PulseAudio server, which the guest's Wine audio backend connects to as an ordinary PulseAudio client (66.8);
- the Vortek Vulkan renderer, where the guest holds only a thin Vulkan ICD. The ICD serializes commands over a socket to the native renderer (66.7.3);
- the System V SHM server (66.3.5).
Because the boundary is a socket, the two sides can use different C libraries, different instruction sets, and different memory models. None of that matters: the only contract is the wire protocol. This is the same reason Binder works across processes with different runtimes (Chapter 9). It costs a serialization step and a copy, but it sidesteps the libc problem entirely. This is why the performance-critical-but-not-hottest paths (audio, command submission) are built this way, while the truly hot path (CPU translation) is not.
66.5 FEX and Box64: Translating x86-64 to AArch64¶
This is the layer that does the actual instruction-set emulation from 66.1.1. Two projects dominate, and GameNative ships both: FEX (the default, via its FEXCore engine) and Box64. They solve the same problem with opposite philosophies, and understanding the difference explains most of the compatibility-versus-speed trade-offs users encounter.
This chapter complements Chapter 19. That chapter covered Android's own
in-process binary translators that plug into the Native Bridge. One is
Berberis, the AOSP translator that runs riscv64 app code on x86_64 devices
(its CPU-emulation core was consolidated under
frameworks/libs/binary_translation/cpu_emulation/ in Android 17). The other is
the historical, closed-source Houdini, which ran ARM app code on Intel x86.
Both
run guest Android APK native code under ART, in the opposite direction to the
x86-64-on-AArch64 problem here, and neither is involved in this stack.
FEX and Box64 are the same dynarec idea applied to whole Linux/Windows x86-64 programs. They run inside an ordinary app process, not as Native Bridge plugins. They translate x86-64 to AArch64, which is a direction no AOSP-shipped translator covers.
66.5.1 What a Dynamic Recompiler Does¶
A dynamic recompiler, or dynarec, is a JIT for foreign machine code. It reads a block of x86-64 instructions and translates them once into AArch64 instructions. It caches the result and jumps to the cached translation every time the guest reaches that address again. The translated code keeps the guest's registers in host registers and keeps the guest's memory as ordinary host memory. and falls back to a dispatcher only at block boundaries, to decide what to run next.
A dynarec is typically five to ten times faster than an interpreter. This is because the per-instruction decode cost is paid once, at translation time, instead of on every execution.
Three things make x86-to-ARM specifically hard, and each one is a place where FEX and Box64 spend their engineering effort:
- Flags. Almost every x86 arithmetic instruction updates the EFLAGS register. It is ruinously expensive to compute those flag bits on ARM after every operation. So a good dynarec defers and elides flag computation. It materializes flags only when something actually reads them.
- Vector instructions. SSE, AVX, and x87 floating point have to map onto ARM's NEON vector registers (and SVE where the host supports it). This needs careful attention to NaN handling and rounding.
- The memory model. x86 has a strong memory model (Total Store Ordering); ARM has a weaker one. Preserving correctness here is subtle and costly enough to warrant its own subsection (66.5.3).
66.5.2 The FEXCore Pipeline¶
FEX is a userspace x86 and x86-64 emulator for AArch64 hosts. Its emulation engine is a library called FEXCore, and its translation pipeline is a clean four-stage design:
Diagram: the FEXCore translation pipeline¶
graph LR
X86["x86-64 instruction<br/>stream"] --> DEC["Frontend decoder<br/>prefixes, REX, VEX/EVEX"]
DEC --> DISP["OpcodeDispatcher<br/>x86 semantics to IR"]
DISP --> IR["SSA IR"]
IR --> PASS["PassManager<br/>optimization passes"]
PASS --> JIT["Arm64 JIT emitter"]
JIT --> HOST["Native AArch64<br/>code in cache"]
Walking the stages against the FEX source tree:
- The frontend decoder reads the raw byte stream and finds instruction boundaries. It handles x86's legacy prefixes, REX prefixes, and the VEX/EVEX encodings used by AVX.
- The
OpcodeDispatcher(FEXCore/Source/Interface/Core/OpcodeDispatcher.cpp, with vector and x87 handling split intoOpcodeDispatcher/Vector.cppandOpcodeDispatcher/X87.cpp) translates each decoded instruction into FEX's own SSA-form intermediate representation. The IR opcodes themselves are defined inFEXCore/Source/Interface/IR/IR.json. - The
PassManager(FEXCore/Source/Interface/IR/PassManager.cpp) runs optimization passes over the IR. The most important for performance are register allocation, redundant-flag elimination, and an x87 stack optimization pass. Register allocation maps IR values to ARM registers. Redundant-flag elimination drops EFLAGS updates that are never read. - The JIT backend (
FEXCore/Source/Interface/Core/JIT/JIT.cpp) emits native AArch64 instructions from the optimized IR into the code cache. FEX uses a custom ARM64 emitter rather than an off-the-shelf assembler to keep code generation fast and compact.
FEX supports MMX, SSE through SSE4, x87, and AVX/AVX2. It translates multi-block regions rather than single basic blocks, and it dispatches between cached translations through a lookup cache. FEX handles self-modifying code. It detects guest writes to pages that contain translated code and invalidates the affected cached blocks.
66.5.3 The TSO Memory-Model Problem¶
This is the most important performance subtlety in the entire emulation layer. x86 guarantees Total Store Ordering: stores from one core become visible to other cores in program order. ARM does not; it allows stores to be reordered unless you insert barriers or use acquire/release instructions. A multithreaded game written for x86's strong ordering will race and crash if its memory operations are naively translated to plain ARM loads and stores.
FEX's correct-by-default answer is to emulate TSO. It uses ARM's acquire/release
load and store instructions (the FEAT_LRCPC extensions) and atomics
(FEAT_LSE2 for the unaligned atomics x86 permits but ARM normally forbids).
This emulation is expensive. Faithful reproduction of x86 ordering on a weak
model can cost on the order of a 10x slowdown on the affected memory traffic.
The cost is felt most in vector-heavy game code.
The escape valve is hardware TSO. Some ARM cores can be switched into a hardware total-store-ordering mode. Apple Silicon does this (it is how Rosetta 2 is fast). Where the host CPU exposes this mode, FEX requests it and relaxes its software emulation for a large win. FEX also offers tunables to disable TSO emulation for vector accesses or for stack memory (which is thread-private and safe). These tunables trade a little stability for speed.
The practical upshot for a phone is that a Snapdragon's specific memory-ordering capabilities materially affect how fast emulation runs, independent of raw clock speed.
66.5.4 FEX Thunks: Calling Native ARM64 Graphics¶
If FEX translated every instruction the game's process executed, including the entire Vulkan driver, performance would be hopeless. The lever that avoids this is thunking, also called library forwarding. A thunk lets emulated x86 guest code call a native AArch64 host library directly. So heavy libraries like the GPU driver run as native code, not as translated code.
The design is two-sided. On the guest side, FEX provides a small x86 stub
library that presents the normal guest ABI (for example a guest libvulkan.so).
It does not contain the driver but forwards each call across the boundary. On
the host side, a native ARM64 thunk library receives the forwarded call and
invokes the real host library. The FEX source carries both halves for the key
graphics libraries: ThunkLibs/libvulkan/Guest.cpp and
ThunkLibs/libvulkan/Host.cpp for Vulkan, and ThunkLibs/libGL/ for OpenGL.
Diagram: a thunked Vulkan call crossing the emulation boundary¶
graph LR
subgraph GuestSide["Emulated x86-64 (FEX)"]
APP["Game / DXVK"] --> GTHUNK["Guest libvulkan thunk<br/>x86-64 stub"]
end
subgraph HostSide["Native AArch64"]
HTHUNK["Host vulkan thunk"] --> REAL["Real Vulkan driver"]
end
GTHUNK -->|"marshal args,<br/>cross ABI"| HTHUNK
The hard part is marshalling. The guest and host disagree on pointer size in the
32-bit case, on structure layout, and on calling convention. Callbacks (a
function pointer the host library will call back into) must be trampolined back
across the boundary in the other direction. FEX's thunking handles function
pointers in both directions and converts data-structure layouts at the boundary.
A ThunksDB.json configures this: it maps each guest library to its host
overlay.
Thunking is the single biggest reason FEX-based graphics can approach native
speed: only the game's draw-call setup is emulated, while the driver work runs
native.
66.5.5 The FEX RootFS Requirement¶
When FEX runs as a standalone Linux emulator, it emulates a complete x86-64
process. It then needs an x86-64 root filesystem to supply the guest glibc and
base libraries. On desktop Linux the FEXRootFSFetcher downloads a SquashFS or
EroFS image for exactly that. This is the everything-emulated model. It is the
main axis on which Box64 differs, since Box64 wraps host libraries and does not
emulate a full guest userland.
In GameNative's default ARM64EC configuration, however, FEX does not run as a Linux emulator at all. It plugs into Wine as a CPU-only module (66.5.8). Wine services the Windows system calls, not a guest glibc. So the separate x86 rootfs is no longer needed. The FEX rootfs requirement is therefore a property of the everything-emulated path, not of the ARM64EC default. ARM64EC sheds this requirement, and that is one of its concrete wins.
66.5.6 Box64: Wrapped Native Libraries¶
Box64, by ptitSeb, is a dynarec with the same core job but a different
philosophy about system libraries. Box64 does not emulate the x86-64 glibc, GL,
and Vulkan from a rootfs. Instead, Box64 detects when the guest is about to call
into one of those well-known libraries and substitutes the host's native ARM64
implementation. It wraps each call to translate the calling convention. The
hand-written wrappers live in the Box64 source under src/wrapped/, for example
src/wrapped/wrappedvulkan.c for Vulkan and src/wrapped/wrappedlibc.c for the
C library.
The consequence is that Box64 does not need a full x86 rootfs for the libraries
it wraps; it borrows the host's. That makes it lighter than FEX. It is the
historical default of classic Winlator, where the launch chain is literally
proot then box64 then wine. GameNative's GuestProgramLauncherComponent
builds exactly that box64 <guest exe> command for the glibc path. The
trade-off is that wrapping is only as correct as the wrappers. When a wrapped
library's behavior diverges from what the guest expected, it can break in ways
that a full-emulation approach would not.
The 32-bit story is symmetric. Box64 handles x86-64, and its sibling Box86
handles 32-bit x86. For hosts with no 32-bit ARM environment, Box64 has an
experimental BOX32 mode that emulates a 32-bit environment within the 64-bit
one. Box64's own docs/WINE.md lays out the matrix of Wine variants (x86, x86-64,
x86-64 WoW64, ARM64 WoW64) and which Box combination each requires. It is the
single best primary reference for how the emulator and Wine fit together.
66.5.7 FEX Versus Box64¶
Diagram: the two emulation philosophies¶
graph TB
subgraph FEXapproach["FEX: emulate the process, thunk the hot libs"]
F1["x86-64 rootfs<br/>(guest glibc)"] --> F2["FEXCore JIT"]
F2 -->|"thunk"| F3["Native ARM64<br/>Vulkan / GL"]
end
subgraph BOXapproach["Box64: emulate the app, wrap the system libs"]
B1["Guest x86-64 app"] --> B2["Box64 dynarec"]
B2 -->|"wrap"| B3["Host ARM64<br/>libc / GL / Vulkan"]
end
| Dimension | FEX | Box64 |
|---|---|---|
| System libraries | emulated from an x86-64 rootfs | wrapped to host-native ARM64 |
| Disk footprint | heavier (full rootfs) | lighter (borrows host libs) |
| Native-lib escape | thunks (GL, Vulkan) | wrappers (libc, GL, Vulkan, more) |
| 32-bit code | same core; WoW64/ARM64EC | separate Box86, or BOX32 |
| Accuracy posture | full IR recompiler, rigorous TSO | pragmatic; depends on wrapper fidelity |
| Role in GameNative | default (FEXCore) | bundled alternative |
Neither is universally better. FEX's full-emulation accuracy helps with heavily-modded or unusual titles; Box64's lighter footprint and mature wrappers made it the long-time Winlator default. GameNative ships both and lets a container pick.
66.5.8 ARM64EC and WoW64: Emulating Only the Game¶
The most important recent shift is architectural, not incremental: stop running Wine itself through the emulator. In the "everything emulated" model, the JIT translates the game and all of Wine and the x86 Linux libraries underneath Wine. The emulator is on the hot path for every instruction. The ARM64EC model instead builds Wine natively for ARM64 and emulates only the game's x86-64 code.
ARM64EC ("Emulation Compatible") is a Microsoft ABI, reimplemented in Wine, in which native ARM64 code is laid out to be call-compatible with emulated x86-64 code. Wine's system DLLs are compiled in this ABI. So when the emulated game calls a Windows API, execution transitions into native ARM64 Wine code, not into more emulation. The emulator is reduced to a CPU core that plugs into Wine as a loadable module at the ABI boundary.
In this configuration the emulator ships as a Windows DLL that Wine loads. FEX
ships as libarm64ecfex.dll for 64-bit code. For the 32-bit WoW64 path, the DLL
is either libwow64fex.dll (FEX) or wowbox64.dll (Box64). GameNative selects
between these in its
BionicProgramLauncherComponent based on whether the configured Wine build is
ARM64EC.
Diagram: shrinking the emulated region with ARM64EC¶
graph TB
subgraph Everything["Everything emulated"]
E1["Game (x86-64)"]
E2["Wine (x86-64)"]
E3["Linux libs (x86-64)"]
end
subgraph EC["ARM64EC hybrid"]
H1["Game (x86-64)<br/>EMULATED"]
H2["Wine (ARM64EC)<br/>NATIVE"]
H3["Vulkan / audio (ARM64)<br/>NATIVE"]
end
Everything -->|"only the game stays emulated"| EC
The payoff is large. The Windows API work, the graphics translation, and the audio path all run as native ARM64. Only the game's own instruction stream is translated. This is the design GameNative defaults to, and it is why the stack is fast enough to be usable on a phone at all.
66.6 Wine: Implementing the Windows API¶
Wine is the layer that makes the game think it is running on Windows. Where the emulator handles instructions, Wine handles meaning. Wine's reimplementation of the Windows API, on top of the Linux kernel, services every call that the game makes to that API.
66.6.1 Not an Emulator¶
The name is a recursive acronym, "Wine Is Not an Emulator," and on ARM Android the distinction is not pedantry, it is the architecture. Wine contains no instruction translation whatsoever. It takes a Windows PE binary and loads it into a Linux process. It answers the binary's Windows API and NT system calls with native code that talks to Linux.
On x86-64 Linux that native code runs directly. On ARM Android, Wine is itself either emulated (the everything-emulated model) or compiled native as ARM64EC (the hybrid model). In neither case does Wine do the x86-to-ARM translation. That is always FEX or Box64. The two layers are orthogonal, exactly as 66.1.1 set out.
66.6.2 The PE/Unix Split¶
Modern Wine is organized around a hard boundary between Windows PE code and a
Unix backend. Wine's builtin DLLs (ntdll, kernel32, kernelbase, user32,
gdi32) are compiled as real PE files. So, from the game's point of view, it
calls ordinary Windows DLLs. The subset of DLLs that must touch the host OS are
split into a PE part and a Unix part.
The canonical example is ntdll. The PE side (dlls/ntdll/) builds
ntdll.dll. The Unix side (dlls/ntdll/unix/) builds ntdll.so, an ELF
library dlopen'd at startup whose key files are dlls/ntdll/unix/loader.c,
dlls/ntdll/unix/virtual.c, and dlls/ntdll/unix/signal_x86_64.c. The contract
between the two halves is declared in dlls/ntdll/unixlib.h.
Process startup begins as a perfectly ordinary Linux process. A small loader
dlopens ntdll.so. This library builds the Windows process control structures
(the PEB and TEB), maps the PE ntdll.dll and the initial executable, and
connects to wineserver. Finally, it jumps into PE-side initialization. After
that, control runs "as if it were Windows."
Diagram: Wine's PE and Unix halves and the syscall boundary¶
graph TD
subgraph PE["PE world (Windows binaries)"]
GAME["Game.exe"]
K32["kernel32.dll / user32.dll"]
NTPE["ntdll.dll (PE)"]
end
subgraph UNIX["Unix world (ELF)"]
NTSO["ntdll.so"]
SERVER["wineserver client"]
end
GAME --> K32 --> NTPE
NTPE -->|"__wine_syscall_dispatcher"| NTSO
NTPE -->|"WINE_UNIX_CALL"| NTSO
NTSO --> SERVER
NTSO --> KERNEL["Linux kernel"]
Two mechanisms cross the boundary. NT system calls (NtCreateFile,
NtUserCreateWindowEx) funnel through a __wine_syscall_dispatcher set up in
dlls/ntdll/unix/signal_x86_64.c. The dispatcher saves CPU state, switches to a
Unix stack, indexes a syscall table, and calls the Unix implementation. DLLs
with a
Unix backend that bypass the NT table instead use WINE_UNIX_CALL. Wine routes
Windows API calls through an explicit, NT-like syscall boundary. This is exactly
what makes the ARM64EC and WoW64 transitions of 66.5.8 possible: the boundary is
already there to hook.
66.6.3 wineserver¶
wineserver is a separate daemon that provides Wine roughly the services the Windows kernel provides on Windows. Every Wine process that uses the same prefix shares, through wineserver, the things NT keeps in kernel space. These are handles and the object namespace, synchronization objects (events, mutexes, semaphores), processes and threads, window-management state, and the registry.
Clients talk to it over an AF_UNIX socket. They marshal requests through
wine_server_call. The server's request handlers live in server/request.c.
The socket lives in a per-prefix directory keyed off the prefix's device and
inode, so each prefix gets its own server. A particularly important detail is
that the socket passes file descriptors between processes with the standard
SCM_RIGHTS ancillary-data technique. So a Windows HANDLE can be backed by a
real Linux fd handed over from the server.
The performance caveat matters on a phone. Games synchronize threads constantly,
and the classic "ask wineserver every time" model makes each synchronization a
socket round trip. Wine has progressively moved synchronization out of the
server: esync (eventfd-based), fsync (futex-based), and most recently
ntsync. ntsync is a Linux kernel driver that exposes /dev/ntsync and
models NT synchronization primitives in-kernel.
Whether the faster paths are available depends on the host kernel. An Android
kernel may not expose /dev/ntsync at all. In that case, the stack falls back
to fsync on futexes, which Android does have.
66.6.4 The Prefix, the C: Drive, and the Registry¶
A Wine prefix (the WINEPREFIX, here /home/xuser/.wine inside the rootfs)
is one self-contained virtual Windows installation. It has its own C: drive, its
own registry, and its own wineserver. Inside, drive_c/windows/system32 holds
the 64-bit system DLLs, and drive_c/windows/syswow64 holds the 32-bit ones
(the Windows naming inversion is preserved). dosdevices/ holds the symlinks
that
map drive letters to host paths. The registry is stored as text .reg files at
the prefix root (system.reg for HKLM, user.reg for HKCU) and served live by
wineserver.
This layout is why a container is portable and disposable: it is just a directory tree. GameNative's per-game containers are exactly per-game prefixes, which is how two games with conflicting DLL or registry needs stay isolated.
66.6.5 DLL Overrides: How DXVK Replaces Wine's Direct3D¶
Wine decides, per DLL, whether to use its own builtin implementation or a
native DLL placed in the prefix. The choice is driven by the
WINEDLLOVERRIDES environment variable and by registry entries under
HKCU\Software\Wine\DllOverrides.
This single mechanism is how the entire graphics fast path gets installed. DXVK
and VKD3D-Proton ship PE DLLs named exactly like Wine's builtin Direct3D DLLs
(d3d9.dll, d3d11.dll, dxgi.dll, d3d12.dll). The installer copies them
into the prefix's system32/syswow64 and sets those names to native. The
next time the game calls
D3D11CreateDevice, Wine's loader resolves d3d11.dll to the DXVK file instead
of its own wined3d-backed builtin. Direct3D is now translated to Vulkan
(66.7) rather than to OpenGL.
66.6.6 Where Wine Meets the Emulator¶
This section ties 66.5 and 66.6 together. In the ARM64EC configuration, Wine is
native ARM64EC and its DLLs transition to native code at the syscall boundary.
The emulator (libarm64ecfex.dll or the WoW64 helper) is invoked only to run
the game's x86-64 instruction stream. In the everything-emulated configuration,
all
of Wine is x86-64 and runs on top of Box64 or FEX against the rootfs glibc.
Both configurations present the game an identical Windows. They differ only in how much of the process below the game is emulated versus native. This is the single biggest determinant of frame rate.
66.7 Graphics: Direct3D to the Adreno GPU¶
Graphics is where the stack either succeeds or visibly fails. It is the longest translation chain in the book. A Direct3D call made by an x86-64 game ends up as a Vulkan command executed by the phone's Adreno driver and composited by SurfaceFlinger. Every link in that chain is a separate technology.
66.7.1 The Translation Chain¶
Diagram: the full Direct3D-to-display chain¶
graph TD
GAME["Game: Direct3D 9/11/12 calls"]
DXVK["DXVK / VKD3D-Proton<br/>(D3D to Vulkan)"]
GUESTVK["Guest Vulkan ICD<br/>(thunk or Vortek client)"]
BRIDGE["Boundary cross<br/>(thunk / IPC / wrap)"]
HOSTVK["Android Vulkan loader<br/>libvulkan"]
DRIVER["Adreno Vulkan driver<br/>(or Turnip)"]
SURFACE["ANativeWindow<br/>to SurfaceFlinger"]
GAME --> DXVK --> GUESTVK --> BRIDGE --> HOSTVK --> DRIVER
DRIVER --> SURFACE
The chain has two halves separated by the libc boundary from 66.4. Above the boundary, in the Windows/guest world, Direct3D becomes Vulkan. Below it, on the Android side, Vulkan reaches the real driver and the frame reaches the display. The interesting engineering is entirely in how the boundary is crossed.
66.7.2 D3D to Vulkan: DXVK, VKD3D, and the Fallbacks¶
Several translators cover the Direct3D version space, all installed as Wine DLL overrides (66.6.5):
- DXVK translates Direct3D 9/10/11 to Vulkan. It ships
d3d9.dll,d3d11.dll,dxgi.dlland related DLLs. It is the defaultDEFAULT_DXWRAPPERin GameNative containers. - VKD3D-Proton translates Direct3D 12 to Vulkan, shipping
d3d12.dllandd3d12core.dll. It is the Valve-tuned fork of WineHQ's own vkd3d, optimized for game performance. - D8VK extends DXVK down to Direct3D 8.
- wined3d is Wine's builtin fallback, translating Direct3D to OpenGL rather than Vulkan. It is slower and used only when a game is incompatible with DXVK.
- cnc-ddraw handles legacy DirectDraw titles.
The key architectural point is that on ARM64EC these translators are native ARM64 code (66.5.8). Only the game's D3D calls originate from emulated code. The heavy work, which turns a frame's worth of draw calls into Vulkan command buffers, runs native. This is why DXVK on a phone is far faster than the alternative of translating Direct3D inside a fully-emulated x86 Wine.
66.7.3 Reaching the Real GPU: Turnip and Vortek¶
Vulkan commands must now reach the actual Adreno GPU. There are two strategies for this, and the choice is the most important fork in the graphics design.
Turnip is Mesa's open-source Vulkan driver for Adreno GPUs. The stack ships a Turnip build. It can also side-load a newer build through adrenotools (66.4.2). As a result, the guest has a real, complete Vulkan implementation that talks to the Adreno kernel interface directly.
Turnip exists because the Vulkan driver that ships on a
phone is often incomplete or buggy for the unusual workloads that Wine and DXVK
generate. A known-good Mesa driver sidesteps that problem. In the FEX world, the guest reaches Turnip through the
thunk mechanism (66.5.4): a guest libvulkan stub forwards to the native
Turnip.
Vortek is Winlator's own Vulkan compatibility layer. It takes the IPC
route from 66.4.5 and does not use thunks. It is a client/server design. The
guest links a thin Vortek Vulkan ICD, which is shipped into the rootfs from
an assets/.../vortek-* archive.
The ICD serializes every Vulkan call into a
command stream. It ships the stream over a Unix socket to a native Android
server. The server is the prebuilt libvortekrenderer.so, driven by
VortekRendererComponent. The server replays the commands against the real
Vulkan driver of the device. It can also patch SPIR-V shaders, decode texture
formats that the host driver lacks, and emulate features along the way.
Vortek is the default graphics driver of GameNative precisely because the socket
boundary makes it robust to the glibc/bionic mismatch. The guest and the host
driver never share an address space.
Diagram: Turnip (thunk) versus Vortek (IPC) to the GPU¶
graph TB
subgraph TurnipPath["Turnip: native driver in-process via thunk"]
TG["Guest Vulkan (DXVK)"] -->|"thunk"| TN["Turnip (Mesa) native"]
TN --> TK["Adreno kernel DRM"]
end
subgraph VortekPath["Vortek: serialize over a socket"]
VG["Guest Vortek ICD"] -->|"command stream<br/>over Unix socket"| VS["Native Vortek server"]
VS --> VD["Device Vulkan driver"]
VD --> VK2["Adreno kernel DRM"]
end
A handful of other paths exist for non-Vulkan or low-end cases. Zink is Mesa's OpenGL-on-Vulkan. VirGL is a virtio-gpu virtual 3D renderer with its own client/server. llvmpipe is a pure-CPU software rasterizer of last resort.
Zink is worth a moment because it is the guest-side mirror of an AOSP component.
A guest can need OpenGL while the host only has a good Vulkan driver. Then Zink
re-expresses GL as Vulkan. Android's own ANGLE (external/angle/) does
exactly this for native apps that call OpenGL ES (Chapter 13).
ANGLE is not the default GLES driver of the platform on most devices, as the EGL
loader itself notes (frameworks/native/opengl/libs/EGL/Loader.cpp:555). It is
selected per-app or system-wide. The Windows-game stack does not route through
ANGLE. This is because the guest runs its own GL-to-Vulkan translation (Zink, or wined3d's GL
output) inside the rootfs. It reaches the device through the Vulkan loader. The
parallel is conceptual, not a shared code path.
66.7.4 The Android Vulkan Loader¶
Whichever guest path is used, the bottom of the chain is the same AOSP Vulkan
loader from Chapter 13. The loader discovers and loads the device's Vulkan driver
in frameworks/native/vulkan/libvulkan/driver.cpp. The LoadDriver routine
(frameworks/native/vulkan/libvulkan/driver.cpp:153) opens the HAL driver
(vulkan.<board>.so) through one of two branches. When a driver namespace is
supplied (the updatable, Play-delivered driver path), it uses
android_dlopen_ext with ANDROID_DLEXT_USE_NAMESPACE
(frameworks/native/vulkan/libvulkan/driver.cpp:171). This is the namespace flag
from 66.4.2. The ordinary built-in driver is instead opened with
android_load_sphal_library
(frameworks/native/vulkan/libvulkan/driver.cpp:178) from the sphal namespace.
A native renderer such as Vortek's server, or a thunked Turnip, is in the end just another client of this loader and this driver. This is why the whole edifice works without any platform modification. The GPU is reached through the same public Vulkan interface that any Android game uses.
66.7.5 Presentation: the In-App Surface¶
Rendered frames must become pixels on screen. The game does not get a real X
display or a real window system. It gets GameNative's in-app X server. This
is a small X11 server implemented inside the app (com.winlator.xserver, native
code under app/src/main/cpp/winlator/). It receives the game's output and the
window that the game draws into. The X server's contents are drawn with Vulkan
and presented to an Android Surface.
That final step uses the native window APIs from Chapter 13. Frames reach the
compositor through ANativeWindow. For the CPU-access path, the functions are
ANativeWindow_lock
(frameworks/native/libs/nativewindow/include/android/native_window.h:179) and
ANativeWindow_unlockAndPost
(frameworks/native/libs/nativewindow/include/android/native_window.h:188). The
zero-copy GPU path binds an AHardwareBuffer
(frameworks/native/libs/nativewindow/include/android/hardware_buffer.h:479, via
AHardwareBuffer_allocate) as the Vulkan swapchain image. The GPU then renders
straight into a buffer that SurfaceFlinger can scan out. From SurfaceFlinger onward
(Chapter 24) the game's frame is just another layer composited to the display.
66.7.6 Frame Generation¶
As an optional final touch, GameNative can insert a Vulkan implicit layer that
performs frame generation. The layer intercepts vkQueuePresentKHR and
interpolates synthetic frames between rendered ones. It is a layer in the Vulkan
sense and fits into the loader's layer chain. It shows that much of the graphics
stack is built from standard Vulkan extension points and not from bespoke hooks.
66.8 Audio: from WASAPI to AAudio¶
Audio is a shorter chain than graphics, but it crosses the same libc boundary. The IPC pattern from 66.4.5 solves it. A real audio server runs on the Android side, and the guest connects to it as a client.
66.8.1 Wine's Audio Backends¶
A Windows game emits audio through one of several front-end APIs (the modern
WASAPI via mmdevapi, legacy DirectSound, or the old winmm/waveOut). Wine
layers all of them onto a single backend driver chosen at runtime. The backend
that matters here is winepulse.drv, which targets PulseAudio. The
alternatives are winealsa.drv (ALSA) and wineoss.drv (OSS). In modern Wine,
each backend is a Unix-only library. For example, winepulse.so, built from
dlls/winepulse.drv/pulse.c, calls the host PulseAudio client library.
The PE half lives in mmdevapi itself. It loads the backend with
__wine_load_unix_lib and calls into it through __wine_unix_call
(dlls/mmdevapi/main.c).
In the ARM64EC configuration, the backend and its client library are native ARM64. So audio mixing and transport are not emulated. Only the game's calls into the front-end API cross the emulator.
66.8.2 A PulseAudio Server Inside the App¶
There is no system PulseAudio daemon available to an unrooted app, so the stack
ships one. GameNative's PulseAudioComponent starts a PulseAudio server from the
bundled native libraries (libpulse.so and the libpulsecommon/libpulsecore
PulseAudio 13 libraries in jniLibs/). Wine's winepulse.drv connects to this
server exactly as it would to a desktop PulseAudio, over PulseAudio's normal
socket protocol. The contract is the PulseAudio wire protocol. So the guest
side (glibc, possibly emulated) and the server side (bionic, native) interoperate
across the libc boundary. They do not share an address space.
Diagram: the audio path from game to speaker¶
graph LR
GAME["Game: WASAPI / DirectSound"] --> WP["winepulse.drv"]
WP -->|"PulseAudio protocol<br/>over socket"| PA["PulseAudio server<br/>(in app, native)"]
PA --> AA["AAudio / AudioTrack"]
AA --> HAL["Audio HAL to speaker"]
66.8.3 The ALSA Bridge¶
Winlator also provides a lower-level ALSA route. The rootfs carries a custom ALSA
PCM plugin (Winlator's android_alsa, module_pcm_android_aserver.c) that
exposes an "android aserver" PCM device. On the Android side,
com.winlator.alsaserver implements the server endpoint. A native client
(app/src/main/cpp/winlator/ audio code) ships the PCM frames to the app. Whether
audio takes the PulseAudio route or the ALSA route, the structure is identical: a
guest audio API, a socket, and a native Android endpoint.
66.8.4 Reaching the Speaker via AAudio¶
The Android endpoint ultimately writes the decoded PCM into an Android audio
stream. The natural API is AAudio from Chapter 15. A stream is created with
AAudio_createStreamBuilder
(frameworks/av/media/libaaudio/include/aaudio/AAudio.h:1216). The stream is then
configured and opened, and it is fed with AAudioStream_write. After that, the
frames flow through the audio HAL to the speaker. (Older or targetSdk-constrained builds may use
AudioTrack or OpenSL ES instead, but AAudio is the modern low-latency path.)
The audio chain therefore ends in exactly the same AOSP subsystem any native Android game's audio ends in.
66.8.5 Latency¶
Audio goes through a guest API, a socket, and a server, and the cost is latency.
The stack tunes for it. PulseAudio is configured with a deliberately large
buffer. The containers set a PULSE_LATENCY_MSEC on the order of a hundred-odd
milliseconds. This trades responsiveness for glitch-free playback under the
jitter that emulation and IPC introduce.
It is a pragmatic choice. Perfectly tight audio latency is not achievable through this many layers. For most games, steady playback matters more than a few tens of milliseconds of lag.
66.9 Putting It Together: Tracing Three Paths¶
The clearest way to consolidate the whole chapter is to follow three different operations from the game down to the Android platform. Notice how each one takes a different route through the layers.
Diagram: three operations, three routes through the stack¶
graph TD
subgraph G["Windows game (emulated x86-64)"]
D3D["D3D11 draw call"]
FILE["CreateFile call"]
SND["WASAPI write"]
end
D3D -->|"native ARM64"| DXVKn["DXVK to Vulkan"] --> GPUn["Vortek / Turnip to Adreno"]
FILE -->|"NT syscall"| NTDLL["ntdll syscall dispatcher"] --> WS["wineserver + Linux fd"]
SND -->|"native ARM64"| PULSEn["winepulse to PulseAudio"] --> AAUDIOn["AAudio"]
-
A Direct3D 11 draw call. The game's
ID3D11DeviceContext::Drawis an x86-64 call (emulated). It enters DXVK, which on ARM64EC is native and builds a Vulkan command buffer. The call then crosses the libc boundary by thunk (Turnip) or socket (Vortek) to the Adreno driver. SurfaceFlinger composites the result. Almost none of this is emulated; only the original call site is. -
A
CreateFilecall. The game asks to open a file. This is a Windows API call that becomes an NT system call. The call is funneled through Wine's__wine_syscall_dispatcherintontdll.so. That library itself translates the NT path to a Unix path (nt_to_unix_file_name,dlls/ntdll/unix/file.c). It sends that Unix path to wineserver in acreate_filerequest.The server opens the file and returns a Windows
HANDLE. When needed, the underlying Linux fd is fetched from the server overSCM_RIGHTS. PRoot orlibredirectremaps the path into the rootfs along the way. The Linuxopenatfinally lands in the app's private storage. No GPU, no audio, entirely different machinery from path 1. -
An audio buffer write. The game's WASAPI write enters
winepulse.drv(native on ARM64EC). The driver ships the PCM over the PulseAudio socket to the in-app server. The server writes it into an AAudio stream that reaches the speaker.
Three operations, three completely different paths, and only the parts drawn inside the emulated box are actually translated instruction by instruction. The entire art of the stack is keeping that box small.
66.10 What Android 17 Changes for the Stack¶
None of the translation projects in this chapter ship inside AOSP, so Android 17 does not "add" a Windows-game runtime. What 17 does is move the platform floor that the stack stands on. Three shifts are worth pinning down, because each one either helps or constrains a layer above.
66.10.1 The Platform Interfaces the Stack Rides On Are Stable¶
The whole design works because it only ever touches public, stable AOSP surfaces. These surfaces are:
- the Vulkan loader (
frameworks/native/vulkan/libvulkan/driver.cpp) - the native window APIs
(
frameworks/native/libs/nativewindow/include/android/native_window.h) AHardwareBuffer- AAudio (
frameworks/av/media/libaaudio/include/aaudio/AAudio.h) ASharedMemory(frameworks/native/include/android/sharedmem.h)- the
android_dlopen_extextension flags (bionic/libc/include/android/dlext.h— stable in practice, though the namespace flag itself is documented as internal-use-only) - the linker's W^X rule (
bionic/linker/linker_phdr.cpp)
In Android 17, all of these are present with the same contracts that the earlier sections rely on. This is exactly why a Winlator-class app keeps working across releases without a platform patch. The stack invents nothing at the bottom, so it inherits whatever the public Vulkan, audio, and linker surfaces of the release provide.
66.10.2 Berberis Is Not This Stack, and 17 Reorganized It¶
It is easy to assume Android's own binary translator must be involved here. It is
not. Android 17 reorganized Berberis (Chapter 19). Every module of its
CPU-emulation core was consolidated under a new
frameworks/libs/binary_translation/cpu_emulation/ directory. The
three-tier engine (cpu_emulation/interpreter/, cpu_emulation/lite_translator/,
cpu_emulation/heavy_optimizer/) and the tier dispatcher
(cpu_emulation/translator/) are now siblings under it.
But Berberis translates
riscv64 guest code to x86_64 hosts (frameworks/libs/binary_translation/README.md)
and plugs into ART through the Native Bridge
(frameworks/libs/binary_translation/native_bridge/). That is the wrong direction
for PC games (riscv64 to x86_64, not x86-64 to AArch64). It is also the wrong
integration point (in-ART APK code, not a whole Windows process).
The 17 reorg is real and relevant to Chapter 19. But it changes nothing in this chapter. FEX and Box64 still do the x86-64-to-AArch64 translation here entirely, and no AOSP-shipped translator competes with them.
66.10.3 Where ANGLE Fits, and Where It Does Not¶
Android's GLES-on-Vulkan translator ANGLE (external/angle/, Chapter 13) is
the platform's own answer to "express OpenGL ES on a Vulkan-only driver." It
overlaps conceptually with the Zink and wined3d-to-GL paths inside the guest. It
is tempting to call ANGLE "the default" on Android 17. But the EGL loader's own
comment is explicit that it is not the default GLES driver on most devices
(frameworks/native/opengl/libs/EGL/Loader.cpp:555). It is opt-in per app or via
system configuration.
For this stack, the practical point is unchanged. The guest performs its own GL or D3D translation to Vulkan and reaches the GPU through the Vulkan loader. So the ANGLE setting of the device neither helps nor hinders a Windows game. The two are parallel solutions to the same shape of problem on opposite sides of the libc boundary, not a shared path.
66.11 Try It¶
These exercises use a checkout of the GameNative source and a device or emulator with a Winlator-class app installed. The source reading requires nothing but a clone. The on-device steps assume that you have legally installed such an app and a game that you own.
-
Read the stack off the defaults. Clone GameNative and open
app/src/main/java/com/winlator/container/Container.javaandapp/src/main/java/com/winlator/core/DefaultVersion.java. Write down the default emulator, graphics driver, audio driver, and DX wrapper. Write down the pinned versions of Wine, DXVK, VKD3D, and Turnip. You have just reconstructed the entire default pipeline from two files. -
Find the launch command. Read
app/src/main/java/com/winlator/xenvironment/components/GuestProgramLauncherComponent.java. Locate where it assembles thebox64 <guest exe>command. Then readBionicProgramLauncherComponent.java. Find where it branches on whether the Wine build is ARM64EC. Compare the two launch paths. -
Inspect the bundled components. List
app/src/main/assets/. Identify the.tzst/.txzarchives for FEX, Box64, DXVK, and the Vulkan drivers. Identify the*_download.jsonmanifests that fetch the rest on demand. Note which pieces are in the APK and which are downloaded. -
Watch the processes on device. Run a game in the app. Use
adb shell ps -Aand look for the Wine, wineserver, PulseAudio, and X server processes that run under the app's UID. Then runadb shell cat /proc/<pid>/mapsfor the Wine process. Observe both the JIT's anonymous executable mappings and the rootfs libraries. -
See the sockets. Run
adb shell ls -l /proc/<pid>/fdon the Wine process. Find the Unix-domain sockets that connect it to wineserver, the Vulkan renderer, and PulseAudio. These are the boundary crossings of 66.4.5 made visible. -
Explore the prefix. Locate the container's Wine prefix under the app's data directory. Examine
drive_c/windows/system32for the DXVK DLLs that overrode Wine's builtin Direct3D. Examineuser.regfor theSoftware\Wine\DllOverridesentries that put them there. -
Confirm the AOSP touchpoints. In an AOSP checkout, open
frameworks/native/vulkan/libvulkan/driver.cppat theLoadDriverfunction (line 153). Find both of its branches. The branch withandroid_dlopen_extandANDROID_DLEXT_USE_NAMESPACEis for an updated driver. The branch withandroid_load_sphal_libraryis for the built-in one. This is the loader the whole graphics stack ultimately funnels into.
66.12 Summary¶
A Windows game runs on an unrooted ARM Android phone through a stack of single-purpose translation layers. Each layer bridges one gap between a Windows x86-64 game and the AOSP internals that the rest of this book covers.
| Layer | Problem it solves | Key technology |
|---|---|---|
| GameNative app | orchestration, storefronts, packaging | Pluvia + Winlator graft, GPL-3.0 |
| rootfs (imagefs) | a glibc Linux userland with no root | Ubuntu tree extracted to app storage, entered via PRoot or a bionic loader |
| bionic redirections | glibc software on a bionic system |
path rewriting, SysV-SHM-over-ashmem, IPC servers |
| FEX / Box64 | x86-64 instructions on AArch64 | dynarec, TSO emulation, thunks vs wrapped libs |
| ARM64EC / WoW64 | emulate only the game | native ARM64 Wine, emulator as a DLL |
| Wine | the Windows API on Linux | PE/Unix split, wineserver, DLL overrides |
| DXVK / VKD3D | Direct3D on a Vulkan GPU | D3D to Vulkan, installed as native DLL overrides |
| Turnip / Vortek | Vulkan to the real Adreno GPU | Mesa driver via thunk, or client/server over a socket |
| PulseAudio | Windows audio to the speaker | in-app audio server, out via AAudio |
The recurring lessons:
-
Two orthogonal translations. Instruction-set translation (FEX/Box64) and Windows-API translation (Wine) are entirely separate problems. To conflate them is the most common misunderstanding of how these apps work.
-
Emulate as little as possible. Every performance gain in the stack comes from work that leaves the emulated box. The main cases are thunks and wrappers for the GPU driver, native ARM64 DXVK, and above all ARM64EC. ARM64EC leaves only the game's own code emulated.
-
The libc boundary is the hard part. glibc and
bioniccannot share a process. The three ways across the boundary (IPC, ABI thunking, or native execution onbionic) shape every design decision in the stack. -
It is all standard AOSP underneath. The Vulkan loader,
ANativeWindow,AHardwareBuffer, AAudio, linker namespaces,ASharedMemory, and the W^X rules are the same platform interfaces any Android app uses. The translation stack invents nothing at the bottom. Its genius is to reach those public interfaces from a Windows game many layers above.
Key Source Files Reference¶
| File | Purpose |
|---|---|
bionic/libc/include/android/dlext.h:115 |
ANDROID_DLEXT_USE_NAMESPACE, loads a driver into a chosen linker namespace |
bionic/libc/include/android/dlext.h:80 |
ANDROID_DLEXT_USE_LIBRARY_FD, loads a library from an fd |
bionic/linker/linker_phdr.cpp:1057 |
rejects ELF segments that are both writable and executable (W^X) |
bionic/libc/include/sys/mman.h:183 |
memfd_create, backing for anonymous shared memory |
frameworks/native/include/android/sharedmem.h:78 |
ASharedMemory_create, host side of the SysV-SHM redirection |
frameworks/native/vulkan/libvulkan/driver.cpp:153 |
LoadDriver, discovers and opens the device Vulkan driver |
frameworks/native/libs/nativewindow/include/android/native_window.h:179 |
ANativeWindow_lock, CPU presentation path |
frameworks/native/libs/nativewindow/include/android/native_window.h:188 |
ANativeWindow_unlockAndPost, posts a frame to the compositor |
frameworks/native/libs/nativewindow/include/android/hardware_buffer.h:479 |
AHardwareBuffer_allocate, zero-copy GPU presentation buffer |
frameworks/av/media/libaaudio/include/aaudio/AAudio.h:1216 |
AAudio_createStreamBuilder, the audio path's Android sink |