An unofficial, Android port of UnleashedRecomp — the static recompilation of the Xbox 360 version of Sonic Unleashed. This repository is a fork of the upstream project with an Android (ARM64) target added, using a custom Mesa Turnip Vulkan driver loaded via libadrenotools.
Licensed under GPL‑3.0, same as upstream. This is a personal project shared so others can build on it. Not affiliated with SEGA, hedge‑dev, or the Mesa project.
This repo contains no copyrighted game content. Excluded on purpose:
UnleashedRecompLib/private/— the raw dump (default.xex,default.xexp,shader.ar, …). You provide your own legal dump here.UnleashedRecompLib/ppc/— the C++ thatXenonRecompgenerates from your XEX (ppc_recomp.*.cpp,ppc_func_mapping.cpp). It is derived from the game binary and is produced locally by the build; it is git‑ignored upstream and here too.
You need a legal dump of Sonic Unleashed (Xbox 360) — a matching
default.xex + default.xexp pair (correct region / Title Update) and
shader.ar. Put them in UnleashedRecompLib/private/ before building.
Playable on several Adreno devices via Turnip. Reaches the title screen and in‑ game, roughly 40–60 FPS depending on GPU / resolution scale.
| Device | SoC | Turnip family | State |
|---|---|---|---|
| Adreno 725 | SD 7+ Gen 2 | a7xx gen1 | Main dev target. Playable. Needs per‑draw CP_WAIT_FOR_ME. |
| Adreno 750 | SD 8 Gen 3 | a7xx gen3 | Playable with MSAA off (see below). |
| Adreno 732 | SD 7+ Gen 3 | binned a735 / gen2 | Brought up via a chip‑id hack; ~60 FPS. |
| Adreno 710 | SD 6 Gen 1 class | a7xx gen1 (custom entry) | Playable via community device entries + our WFM fix (see below). |
Adreno 720 / 722 have device entries in the bundled driver too (same community source as the a710 one) but no device has been tested yet. Other Adreno 6xx/7xx + Android 9+ should work (libadrenotools requirement) but are untested.
The engine's renderer (plume, Vulkan backend) relies on
VK_KHR_buffer_device_address and 64‑bit address arithmetic in shaders, which
pulls in the SPIR‑V Int64 capability (shaderInt64).
The stock Qualcomm Adreno driver on many SoCs (e.g. the Adreno 720/725 on
SD 7+ Gen 2) reports only Vulkan 1.1 and shaderInt64 = false. Almost
every graphics pipeline then fails to compile (VK_ERROR_UNKNOWN, internal
Adreno shader‑compiler assertion). This is the original hard blocker.
Solution: load Mesa Turnip (open‑source Adreno Vulkan driver, Vulkan
1.4, full shaderInt64 / buffer_device_address) at runtime via
libadrenotools — the same technique Android emulators use. The title screen
then renders and the game runs.
Note for newer flagships: a device whose stock driver is already Vulkan 1.3+ with
shaderInt64(e.g. Adreno 750) can in principle run on the stock driver with no Turnip. This port still ships/uses Turnip because the primary target (a725) has no such stock driver, and to keep one code path.
android-apk/app/src/main/assets/turnip/vulkan.unleashed26_1_wfm_a732.so
(also copied to driver/ in this repo for convenience) is our source‑built
Mesa 26.1.4 Turnip ("univ" build, 2026‑07‑05) with:
- patch 0001 — an unconditional per‑draw
TU_CMD_FLAG_WAIT_FOR_ME(CP_WAIT_FOR_ME) baked intotu6_emit_flushes()(noTU_DEBUGgate). This fixes the a7xx‑gen1 "shimmer" (see findings). Because it is compiled in,TU_DEBUGmust staynone— settingflushallon a source build enables Mesa's real full per‑draw cache flush and tanks the framerate. - patch 0004 — adds the Adreno 732 chip id to the FD735 device entry.
- patch 0009 — adds custom FD710/FD720/FD722 device entries (absent from all upstream Mesa); entries and blob‑trace‑derived magic registers from Vauzi‑17/710 (see findings).
Covers a710 / a720 / a722 / a725 / a732 / a750. The app extracts it to internal
storage on first launch and can also import an arbitrary Turnip .so dropped
into the external driver_import/ folder.
Two packaging quirks worth knowing (both deliberate, see the comment at the top
of os/android/vulkan_driver_android.cpp): the asset filename stays at its
historical value (..._wfm_a732.so) because existing installs select the
driver by that name in driver_name.txt and it is never overwritten; and the
file carries a trailing UNLEASHED-UNIV-20260705 marker string, because the
app only re‑extracts the asset on a size change and the new build came out
byte‑identical in size to the old one (ELF section padding absorbed the three
added device entries).
The driver is built in CI from a fork of the Turnip build scripts:
SansNope/Banners-Turnip, branch unleashed (a copy of the relevant
scripts is in turnip-driver-ci/ here). Variants are selected by the VARIANT
env var (wfm, wfm-a732, wfm-a710, wfm-univ, clean, …) and Mesa ref by
MESA_REF (wfm-univ on mesa-26.1.4 produced the bundled file).
Textures & models briefly disintegrate / vanish for ~1 frame; reproduces on the
title screen (rotating Earth). Bisected the TU_DEBUG=flushall mask bit‑by‑bit
on device: TU_CMD_FLAG_WAIT_FOR_ME (0x200) alone is necessary and
sufficient. This is CP_WAIT_FOR_ME, a command‑processor front‑end sync — not
a cache flush — so no Vulkan API barrier can fix it (it lives mid‑render‑pass
between draws). Desktop NVIDIA renders the same command stream cleanly →
Turnip/a7xx‑specific. Fixed by baking WFM per‑draw (patch 0001).
The a725 WFM fix is not sufficient on gen3. Bisection first pointed at a per‑draw
FD_CCU_CLEAN_COLOR (color‑cache coherency), but that cost ~40% FPS. Key
later finding: the corruption is gated by the app's MSAA setting — 2× MSAA →
corruption, MSAA off → clean, on an otherwise identical build. So the gen3
issue looks like a Turnip MSAA‑path (tile‑resolve / CCU) problem, and the
CCU flush was only masking it. a750 recipe = WFM driver + MSAA off.
a732 (SM7675) is absent from Mesa's device table. It shares the "cliffs" kgsl core with the a735, so it was brought up by adding its (guessed) chip id to the FD735/gen2 entry (patch 0004). Loaded first try; a full level at ~60 FPS.
An a710 device ran on our a725‑class driver but with strong artifacts, while
a community driver ran it with only minor ones. Root cause of the strong
artifacts: no Mesa release or even main has FD710/FD720/FD722 device
entries, so Turnip falls back to a wrong a7xx profile (GMEM geometry, CCU
count, magic registers). The community driver
(Vauzi‑17/710, a fork of
whitebelyash/AdrenoToolsDrivers) is upstream main plus a script that injects
custom FD710/720/722 entries — chip ids, the gen1 template, and per‑GPU
magic register tables derived from real proprietary‑driver command streams
(.rd traces decoded with Mesa's cffdump). Its embedded git SHA exists in no
public repo (the script commits locally before building) — beware when trying to
reproduce a community binary from its version string.
The remaining minor artifacts on that community driver were our old gen1
shimmer (a710 uses the gen1 template — same disease as a725). So the fix was to
combine the two: Vauzi's entries (patch 0008 against Mesa main, patch
0009 backported to 26.1.4 — all register names already exist there) plus our
WFM patch 0001. Validated clean on the a710 device on both bases, and shipped
in the bundled driver. a720/a722 entries ride along untested.
The per‑draw corruption (both GPUs, with the two‑GPU bisection matrix) is filed
upstream: gitlab.freedesktop.org/mesa/mesa work item 15792. A clean
upstream main build reproduces it, confirming it is not caused by our patches.
A GFXReconstruct capture workflow exists for sending traces to the maintainers
(see Diagnostics).
Reworked to a "clocked producer / trivial consumer" model
(apu/driver/sdl2_driver.cpp, APU_PULL_MODEL): a producer thread runs the
guest once per elapsed 5.33 ms slot (fixes a permanent queue‑deficit that
caused crackle on devices consuming in large HAL bursts), a ~64 ms cushion, a
dead‑stream watchdog, and drift correction. Plus SDL AAudio patches
(thirdparty/SDL/src/audio/aaudio/): PERFORMANCE_MODE_NONE instead of
LOW_LATENCY (avoids the fragile MMAP path) and a larger device buffer.
Invariant: the engine's audio clock must never depend on platform‑stream
liveness, and guest code must never run on the platform audio thread.
Fresh installs default to ResolutionScale 0.5, AntiAliasing None,
AnisotropicFiltering 4, MotionBlur Off. Note the low‑end‑GPU detection in
gpu/video.cpp (ApplyLowEndDefaults) previously forced 2× MSAA on Android —
now overridden to None (this silently caused the a750 artifact saga).
log.txt— every log line plus capturedstderr(where plume/Turnip print Vulkan / GPU‑fault messages) is mirrored toAndroid/data/org.libsdl.app/files/log.txt, unbuffered, with a hang watchdog: a background thread dumps every thread's state (/proc/self/task) to the log if frames stop for >5 s. Useful for freezes on remote devices with no adb. Previous run kept aslog_prev.txt.- GFXReconstruct capture — see Diagnostics below.
Phones don't always have a physical gamepad attached, so this fork adds a virtual on‑screen controller for Android.
What it is — a translucent overlay drawn with the game's own ImGui layer: a
left analog stick, the A/B/X/Y face buttons, LB/RB shoulders, LT/RT triggers and
Start/Back. The button glyphs are reused from the existing controller atlas
(controller.dds), so they match the in‑game prompts. Multi‑touch works (e.g.
move + jump + boost at the same time).
How visibility works — the overlay is shown by default, hides the instant a physical controller sends any input, and reappears on the next screen touch. So it stays out of the way when you pick up a real pad and comes right back when you put it down.
How it hooks in:
UnleashedRecomp/ui/touch_controls.{h,cpp}— new module. AnSDLEventListenertracksSDL_FINGER*events; each frame it composes a syntheticXAMINPUT_GAMEPADfrom whichever on‑screen controls are pressed. A finger that lands on the stick captures it and keeps driving it until released.UnleashedRecomp/hid/driver/sdl_hid.cpp—hid::GetState()injects that synthetic state as player 1 while the overlay is visible (Android only), and falls back to a physical controller when it's hidden.SetControllerInputDevice()hides the overlay when a real pad reports input.UnleashedRecomp/gpu/video.cpp—TouchControls::Init()/Draw()wired into the ImGui overlay pass.UnleashedRecomp/kernel/xam.cpp— a small safety net clears a stuck "START prohibited" state if it is ever latched on with no menu open, so the pause button can't get wedged when switching between touch and a gamepad.
Everything is behind #ifdef __ANDROID__, so desktop builds are unchanged. The
button layout is a set of viewport‑fraction constants at the top of
touch_controls.cpp — easy to retune.
App name — the Gradle label is UnleashedRecomp (the launcher icon is left as
the stock placeholder in this repo). The Android package id (org.libsdl.app) is
deliberately left unchanged, because the game‑file and Turnip driver paths
(/data/data/org.libsdl.app/...) depend on it.
UnleashedRecomp/os/android/— the OS abstraction layer:logger_android.cpp— stderr→logcat/file redirect,log.txtfile sink, hang‑watchdog thread.storage_android.cpp— internal/external paths, writability probe, data root resolution (legacy internal install vs external app storage).vulkan_driver_android.{h,cpp}—AndroidGetCustomVulkanLoader(): loads the Turnip driver viaadrenotools_open_libvulkan(JNI to getnativeLibraryDir), first‑launch driver extraction,driver_import/importer + ELF‑aware WFM byte‑patcher,TU_DEBUGoverride, GFXReconstruct arming.process_android.cpp,media_android.cpp,user_android.cpp,version_android.cpp.
thirdparty/plume/plume_vulkan.cpp— Android bits: custom Vulkan loader via volkvolkInitializeCustom,BACKBUFFER_FORMAT= RGBA on Android,VK_EXT_descriptor_indexingadded to optional extensions (needed on <1.2 drivers), optional validation / GFXReconstruct layer wiring.UnleashedRecomp/gpu/video.cpp—#ifdef __ANDROID__paths (backbuffer format, low‑end defaults, profiler overlay force‑on,Heartbeat()per frame).UnleashedRecomp/ui/touch_controls.{h,cpp}: on‑screen virtual gamepad (Android). See the section above.UnleashedRecomp/apu/driver/sdl2_driver.cpp— the audio v2 pull model.UnleashedRecomp/ui/game_window.cpp— forced landscape viaSDL_HINT_ORIENTATIONS.thirdparty/libadrenotools/— vendored (buildsadrenotoolsstatic lib + 4 dlopen'd "hook" shared libs).thirdparty/SDL/— patched AAudio backend.android-apk/— the Gradle project (classicSDLActivity),AndroidManifest, bundled driver asset,useLegacyPackaging true(required so the Vulkan loader can find app‑bundled layers on disk).turnip-driver-ci/— the Turnip build scripts / patches used to produce the driver.
The whole thing has only been built on Windows so far. You need a Windows box for the host tools + Android cross‑compile; a real Android/Adreno device to run.
- Visual Studio 2022 Build Tools with the C++ workload and the Windows
11 SDK individual component (the SDK is not installed by the workload
alone — CMake's compiler check fails without
rc.exe). - Android NDK r29 (16 KB‑aligned output by default — important on Android 15+).
- JDK 17, Android SDK (platform‑tools/adb, build‑tools).
- vcpkg (bootstrapped), CMake, Ninja.
⚠️ Use a path with NO SPACES for the checkout.cmcldeps.exe(CMake's MSVC RC dependency scanner) mishandles spaces in its own path and fails with a misleadingCreateProcess: %1 is not a valid Win32 application. A junction did not fully fix it — physically use e.g.E:\UnleashedRecompAndroid.⚠️ vcvars64.batmay silently fail to add the SDK bin dir to PATH on some setups. Thebuild_*.batscripts here setPATH/INCLUDE/LIBexplicitly instead of relying on it — adjust the hard‑coded paths inside them to your install.
- Game files: put your
default.xex,default.xexp,shader.arinUnleashedRecompLib/private/. - Submodules / vcpkg:
update_submodules.bat; bootstrap vcpkg. - Host tools:
build_host_tools.batthenbuild_host_tools_target.bat. These buildXenonRecomp/XenosRecomp/file_to_c(they must run on the PC even for an Android target) and generateUnleashedRecompLib/ppc/from your XEX and the shader cache fromshader.ar. - Android configure + build:
build_android_configure.batthenbuild_android_target.bat→ producesout/build/android-arm64/UnleashedRecomp/libmain.so. (If you change anyCMakeLists.txt, the target script's incremental invoke can break — reconfigure with the full env‑completecmakecommand.) - Hook libs + jniLibs (manual, not folded into the scripts): the 4
libadrenotools "hook" libs are dlopen'd by soname, so CMake doesn't pull them
in. Build them and copy them next to
libmain.so:cmake --build out/build/android-arm64 --target hook_impl --target main_hook \ --target file_redirect_hook --target gsl_alloc_hook copy out/build/android-arm64/thirdparty/libadrenotools/src/hook/*.so \ android-apk/app/src/main/jniLibs/arm64-v8a/ copy out/build/android-arm64/UnleashedRecomp/libmain.so \ android-apk/app/src/main/jniLibs/arm64-v8a/⚠️ build_apk.batdoes NOT pick up a rebuiltlibmain.so— you must copy it intojniLibs/arm64-v8a/manually every time before packaging. - APK:
build_apk.bat(GradleassembleDebug) →android-apk/app/build/outputs/apk/debug/app-debug.apk.
- Install the APK. On first launch it creates
Android/data/org.libsdl.app/files/withdriver_import/(+ areadme.txt) and extracts the bundled Turnip driver internally. - Game files go in
Android/data/org.libsdl.app/files/UnleashedRecomp/via MTP / a file manager (⚠️ notadb pushintoAndroid/data— files created by the shell there are owned by the shell uid and the app gets EACCES through FUSE). - Optional: drop a different Turnip
.sointodriver_import/to swap drivers; createtu_debug.txtthere to setTU_DEBUG(keep itnonefor the bundled / any source‑built driver).
Always on. If the game freezes, close it and grab
Android/data/org.libsdl.app/files/log.txt. When frames stop for >5 s the
watchdog appends HANG DETECTED + a per‑thread dump (name + scheduler state +
kernel wait channel) so you can tell a GPU/driver hang (render thread blocked in
an ioctl/fence) from a guest‑side deadlock (thread spinning / parked on a futex).
The app has an opt-in path to record a Vulkan API trace (.gfxr) to send to
Turnip / Mesa maintainers. It is off by default: no capture layer is
committed here, there is no build dependency on it, and nothing runs unless you
enable it. The app-side hook lives in thirdparty/plume/plume_vulkan.cpp and
os/android/vulkan_driver_android.cpp (ApplyGfxreconstructCapture): on launch,
if the file driver_import/gfxrecon_capture.txt exists, the app enables the
VK_LAYER_LUNARG_gfxreconstruct layer (when the layer .so is present) and
passes the capture settings via VK_EXT_layer_settings.
So you always need two things: the layer .so in the APK, and the marker file on
the device. The catch: that marker-only flow relies on VK_EXT_layer_settings,
which the prebuilt LunarG layer does NOT honor, and that prebuilt is
4 KB-aligned so Android 16 won't even load it. Pick one of the two paths below.
Path A — quick capture on a device you control (you have adb). What was used to produce the a725 traces; works with the stock prebuilt layer.
- Download the LunarG/gfxreconstruct
Android release and copy
arm64-v8a/libVkLayer_gfxreconstruct.sointoandroid-apk/app/src/main/jniLibs/arm64-v8a/, then build the APK. - Create the marker
gfxrecon_capture.txtindriver_import/(makes the app load the layer and create thegfxr/output folder). - Configure the layer with system properties (the prebuilt layer ignores the
app-supplied
VK_EXT_layer_settings, so it must be told the path this way):adb shell setprop debug.gfxrecon.capture_file /storage/emulated/0/Android/data/org.libsdl.app/files/gfxr/cap.gfxr adb shell setprop debug.gfxrecon.capture_compression_type LZ4⚠️ The property VALUE is capped at 92 bytes — use a short path/filename. - Launch with:
Starting via
adb shell monkey -p org.libsdl.app -c android.intent.category.LAUNCHER 1monkey/ambypasses Android 16's "app doesn't support 16 KB pages" dialog that would otherwise block a launcher start (the prebuilt layer is 4 KB-aligned). - Optional, for a small file — trim instead of a full capture: set
debug.gfxrecon.capture_android_trigger falsebefore launch, then once you are at the repro scene toggle ittruefor a few seconds and back tofalse. adb pullthe.gfxrfrom.../files/gfxr/.
Path B — adb-free capture (e.g. a remote tester with no adb). Path A's
setprop and monkey steps are impossible without adb, and the launcher is
blocked by the 16 KB dialog. For this you must build the gfxreconstruct layer
from source with a modern NDK so it is (a) 16 KB-aligned (Android 15/16
loads it, launcher start works) and (b) honors VK_EXT_layer_settings (recent
gfxreconstruct dev). Then the marker-file flow works with no adb: the app passes
the output path via VK_EXT_layer_settings, and the tester just drops
gfxrecon_capture.txt, plays to the repro spot, closes the game, and sends
files/gfxr/*.gfxr. (This source build was not needed for our own testing, so it
is not included here.)
- Upstream: hedge-dev/UnleashedRecomp
- Renderer: plume; libadrenotools (bylaws); Mesa Turnip (freedreno)
- Turnip driver builds:
SansNope/Banners-Turnip(fork), K11MCH1 AdrenoToolsDrivers - Adreno 710/720/722 device entries + blob‑traced magic registers: Vauzi‑17/710 (fork of whitebelyash/AdrenoToolsDrivers)
- Mesa issue:
gitlab.freedesktop.org/mesa/mesawork item 15792 - Another stock‑driver Android fork (Vulkan 1.3+ devices only, no Turnip):
winnerspiros/UnleashedRecomp_Android