
### Xbox static recompilation for Nintendo Switch and Linux
The original Xbox (NTSC-U) release of **Need for Speed: Underground 2**, lifted
instruction by instruction to C and running natively, with no emulator.





[Features](#-features) · [Playing on Switch](#-playing-on-switch) · [Building](#%EF%B8%8F-building) · [Configuration](#%EF%B8%8F-configuration) · [Status](#-status)
---
> [!IMPORTANT]
> **No game data is included.** You need your own copy of the Xbox disc,
> extracted (`default.xbe`, `NFSUNDER/`, ...).
## ✨ Features
- 🏁 **Native code**: the whole game runs as recompiled C, built with
[xboxrecomp](https://github.com/sp00nznet/xboxrecomp)
- 🎮 **Two renderers**: Vulkan (NVK on Switch) and OpenGL, with render scaling
up to 4x
- 📺 **Widescreen 16:9** by default, using the game's own wide mode
- 🪞 **Car reflections** (Vulkan), switchable in the game's
**Options → Video → Car Reflections**; Off also skips the six reflection
passes the game draws every race frame
- 🔍 **Resolution Scale** in **Options → Video** (Vulkan): 1x, 1.5x
(default), 2x, 2.5x, applied at once
- 🖼️ **Cleaner picture** (Vulkan): mipmaps with trilinear and anisotropic
filtering, mipmapped car reflections, FXAA, square pixels in 16:9 races
(1280x720 at 1.5x); **Anti-Aliasing**, **Anisotropic** (Off-16x) and
**Square Pixels** in **Options → Video**
- 🎬 **Full-screen movies** decoded with FFmpeg (VP6)
- 🔊 **Audio** through an emulated Xbox APU, with 5.1 downmixed to stereo
- 👥 **Two-player split screen**, with Joy-Con pairs or one sideways Joy-Con
per player
- 📳 **Rumble** on Switch HD rumble and SDL controllers, strength set in
**Options → Gameplay → Vibration** (Off, 10%-100%)
- 🔤 **Switch wording** in the menus (Start → `+`, no Xbox Live or hard-disk
names)
- 💾 Saves stay next to the game in `game/UDATA`; the **Options → Audio**
volumes (the Xbox game never saved them) are kept in `nfsu2x_options.txt`
## 🕹️ Playing on Switch
1. Copy the NRO to `sdmc:/switch/nfsu2x/` (`nfsu2x-vulkan.nro` for the Vulkan
build).
2. Copy the **extracted** disc (not the ISO) to `sdmc:/switch/nfsu2x/game/`.
3. Start it with **title takeover**: hold **R** while launching any game.
Applet mode leaves too little memory.
```
sdmc:/switch/nfsu2x/
├── nfsu2x-vulkan.nro
├── nfsu2x_env.txt optional settings, KEY=VALUE per line
├── nfsu2x_options.txt written by the in-game options the port adds and
│ the Audio volumes
└── game/
├── default.xbe
└── ...
```
Buttons map by label (Switch A = Xbox A). Settings go in `nfsu2x_env.txt`
(see [Configuration](#%EF%B8%8F-configuration)) and the log is written to the
same folder.
## 🛠️ Building
1. Lift the XBE to C
```sh
# Writes NFSU2_GEN_DIR (default /root/nfsu2x/gen)
NFSU2_XBE=/path/to/game/default.xbe NFSU2_GEN_DIR=/path/to/gen tools/regen.sh
```
2a. Linux (SDL2 + OpenGL or Vulkan)
```sh
cmake -S . -B build -G Ninja -DNFSU2_GEN_DIR=/path/to/gen # add -DNFSU2_VULKAN=ON for Vulkan
cmake --build build
NFSU2_GAME_DIR=/path/to/game build/nfsu2_recomp
```
2b. Nintendo Switch (devkitA64, switch-sdl2, switch-mesa)
```sh
NFSU2_GEN_DIR=/path/to/gen NFSU2_GAME_SRC=/path/to/game switch/build.sh
# Vulkan build (needs mesa-switch NVK and glslang for Switch)
VULKAN=1 JOBS=6 NFSU2_GEN_DIR=/path/to/gen switch/build.sh
```
### Project layout
| Path | What |
|---|---|
| `src/main.c` | boot and runtime defaults |
| `src/recomp_manual.c` | hand-written overrides of lifted functions |
| `src/switch_nx.c` | Switch log device, env file, exception handler, loading screen |
| `config/seed_functions.json` | entry points the static pass cannot see |
| `xboxrecomp/` | the toolkit (MIT), vendored with this port's changes: NV2A renderers (Vulkan, OpenGL), SDL audio, Switch platform layer, translator fixes |
| `tools/regen.sh` | XBE → lifted C (`gen/`, never committed) |
| `switch/build.sh` | Switch NRO build + SD-card staging |
## ⚙️ Configuration
On Linux these are ordinary environment variables. On the Switch they go in
`sdmc:/switch/nfsu2x/nfsu2x_env.txt`, one `KEY=VALUE` per line (`#` starts a
comment). Anything left out runs at its default, which is the fastest normal
configuration. Most of the list is for debugging, so the sections below are folded.
```ini
# sdmc:/switch/nfsu2x/nfsu2x_env.txt
RECOMP_GL_SCALE=1.5
RECOMP_WIDESCREEN=0
```
Build and code generation
| Variable | Meaning |
|---|---|
| `XBOXRECOMP_DIR` | Toolkit to build or regenerate with (default: the vendored `xboxrecomp/`). |
| `NFSU2_XBE` | `default.xbe` to lift (`tools/regen.sh`). |
| `NFSU2_GEN_DIR` | Directory for the lifted C (default `/root/nfsu2x/gen`). |
| `LIFT_ONLY=1` | `regen.sh`: skip disasm / function id / ABI analysis and only re-lift (enough after translator or `recomp_manual.c` changes; seed changes need the full run). |
| `RECOMP_LEAF_LOCALS=0` | Translator: keep MMX leaf-function registers in globals instead of C locals. |
| `RECOMP_REG_LOCALS=0` | Translator: keep eax..edi/esp in globals instead of C locals. |
| `RECOMP_X87_LOCALS=0` | Translator: keep the x87 stack top in its global instead of a local. |
| `NFSU2_GAME_SRC` | `switch/build.sh`: extracted disc to stage (default `/root/nfsu2x/game`). |
| `SD_ROOT` | `switch/build.sh`: staging SD-card root (default `/switch_sd`). |
| `BUILD_DIR` | `switch/build.sh`: build directory (default `/root/nfsu2x/build-switch`, `-vk` with `VULKAN=1`). |
| `JOBS` | `switch/build.sh`: parallel compile jobs (6 fits `-O2` in RAM). |
| `VULKAN=1` | `switch/build.sh`: build the Vulkan renderer (`nfsu2x-vulkan.nro`). |
| `NVK_SDK`, `GLSLANG_DIR` | `switch/build.sh` with `VULKAN=1`: mesa-switch NVK install and Switch glslang. |
| `FFMPEG_DIR` | `switch/build.sh`: LGPL VP6-only FFmpeg for the movies (`tools/build_ffmpeg_vp6.sh`). |
Game and host
| Variable | Meaning |
|---|---|
| `NFSU2_GAME_DIR` | Extracted disc (Linux; default `./game`). The Switch always uses `sdmc:/switch/nfsu2x/game/`. |
| `NFSU2_GL=0` | Use the executor's CPU renderer instead of the GPU renderer. |
| `NFSU2_APU=0` | With `RECOMP_AC97_READY=plain`: no emulated APU (no sound). |
| `NFSU2_SIM_STEPS` | Longest game-time step per frame, in 1/60 s (default 6 = 100 ms; 3 = the original 50 ms cap, which slows races below 20 fps). |
| `NFSU2_FPS_CAP=30` | Keep the game's 30 fps cap in races and the open world (default: uncapped, up to 60 fps). |
| `NFSU2_NATIVE_VP6` | Movie decoder: 1 = FFmpeg (default when built with it), 0 = the lifted decoder, 2 = both and compare. |
| `NFSU2_MOVIE_FLASH=1` | Keep the opening trailer's white flash frames (shown black by default). |
| `NFSU2_SWITCH_TEXT` | Switch wording in the menus (Start → +, no Xbox Live/hard-disk names): 1 on (Switch default), 0 off. |
| `NFSU2_EXIT_TRACE=1` | Print the guest state at exit. |
| `RECOMP_WIDESCREEN=0` | Tell the game the TV is 4:3 (default 16:9). |
| `RECOMP_CMDLINE` | Command line handed to the title (e.g. `+map intro`). |
| `HOME`, `XDG_DATA_HOME` | Linux: fallback save directory (`$XDG_DATA_HOME/xboxrecomp`, else `~/.local/share/xboxrecomp`) when none is configured. NFSU2's own saves go to `/UDATA`. |
Switch (Horizon)
| Variable | Meaning |
|---|---|
| `NFSU2_NO_LOG=1` / `NFSU2_LOG=0` | No log file, no `[perf]` reports, no profiler. |
| `NFSU2_LOG_SYNC=1` | Write every log line to the card at once (slow; for hangs). |
| `NFSU2_LOADER=0` | No loading screen (logo + bar) before the first frame. |
| `RECOMP_NX_PROFILE` | Sampling profiler into `prof.bin`: 1 = busy threads, 2 = every thread above 2%. |
| `RECOMP_NX_SHM=1` | Guest RAM from shared memory instead of code memory. |
| `RECOMP_NX_AUDIO_PRIO=0` | Leave the APU and SDL audio threads at priority 59 (default: above the game threads). |
| `RECOMP_NX_GUEST_RT=1` | Run the time-critical guest thread (EA mixer) at a real-time host priority. |
| `RECOMP_NX_JOYCON=single` | One sideways Joy-Con per player. |
| `RECOMP_NX_JOYCON_ROTATE=0` | Don't rotate the sideways Joy-Con stick. |
| `RECOMP_GUEST_ONE_CORE` | 1 = pin guest threads to one core, 2 = guest threads only (interrupts float). |
Scheduling and kernel
| Variable | Meaning |
|---|---|
| `RECOMP_GIL=0` | No guest lock (guest threads run in parallel; races the title's streaming). |
| `RECOMP_GIL_EAGER=1` | Hand the guest lock over by guest priority (main thread and time-critical threads pre-empt). |
| `RECOMP_KERNEL_FAST=0` | Release the guest lock in every kernel call, even the cheap time queries and IRQL changes. |
| `RECOMP_FRAME_LAG=1` | Wait on the previous frame's fence instead of the current one (game and GPU work overlap). |
| `RECOMP_VBLANK` | Vblank interrupt for D3D (default 1). |
| `RECOMP_WORKERS=inline` | Run the title's worker routines inline instead of on threads. |
| `RECOMP_ASYNC_IO=1` | Asynchronous NtReadFile. |
| `RECOMP_CS_MODE=single` | Every guest critical section behind one recursive lock. |
| `RECOMP_NATIVE=0` | Use the lifted culling functions instead of the native C versions. |
| `RECOMP_NATIVE_CHECK=1` | Run both and report any difference. |
| `RECOMP_QUIET` | 1 = no periodic kernel/GPU summaries (Switch default), 0 = show them. |
| `RECOMP_NO_LOG=1` | No `xbox_kernel.log`. |
| `XBOX_LOG_LEVEL` | Kernel log level (0 errors … trace). |
| `RECOMP_KERNEL_LOG_BUDGET` | Kernel calls logged before the log goes quiet. |
Graphics (GL and Vulkan renderers)
| Variable | Meaning |
|---|---|
| `RECOMP_PB_EXEC` | Execute the NV2A pushbuffer (default 1; the title draws through it). |
| `RECOMP_GL_SCALE` | Render resolution multiple, 0.5..4 (fractions allowed). Vulkan: only until Options → Video → Resolution Scale is changed (default there 1.5x). |
| `RECOMP_GL_THREAD=1` | GL renderer: GL calls on their own thread. |
| `RECOMP_GL_DIRECT=0` | Convert every vertex to float4 instead of uploading it as stored. |
| `RECOMP_GL_DXT=0` | Decode DXT textures on the CPU instead of uploading them compressed. |
| `RECOMP_GL_SHARED_Z=0` | One depth buffer per colour surface (old behaviour; lights show through walls). |
| `RECOMP_GL_TEX_MB` | Texture cache budget in MB (least recently used go first). |
| `RECOMP_PROG_CACHE` | Shader/pipeline cache file (`progcache.bin`), `=0` off. |
| `RECOMP_VP=0` | No vertex programs (their batches are skipped). |
| `RECOMP_GL_DUMP=[,every]` | Write presented frames as BMP (every N frames, default 60). |
| `RECOMP_GL_WATCH=` | Log draws into or sampling that address, with pixel read-backs. |
| `RECOMP_GL_TRACE=1` | Shader sources and compile/link errors. |
| `RECOMP_GL_FINISH=1` | Wait for the GPU after every operation (finds the call that hangs it). |
| `RECOMP_FPS_LOG=1` | Presented frames per 10 s. |
| `RECOMP_VK_MIPS=0` | Vulkan: no mipmaps (distant textures shimmer, the old look). |
| `RECOMP_VK_ANISO` | Vulkan: anisotropic filtering, 1..16 (default 16; 1 = off). |
| `RECOMP_VK_LOD_BIAS` | Vulkan: mip bias, negative = sharper (default -0.25). |
| `RECOMP_VK_FXAA=0` | Vulkan: no FXAA (plain scaled blit to the screen). |
| `RECOMP_VK_SQUARE=0` | Vulkan: in 16:9, no extra 4/3 columns for square pixels (races 960x720 instead of 1280x720 at 1.5x). |
| `RECOMP_GL_SCALE_X` | Vulkan: a different horizontal scale (absolute; Resolution Scale keeps the ratio). |
| `RECOMP_VK_CUBE=0` | Vulkan: no cube maps (car reflections off), whatever Options → Video says. |
| `RECOMP_VK_HEADLESS=1` | Vulkan on Linux: no window (also implied by `SDL_VIDEODRIVER=offscreen`). |
| `RECOMP_VK_VALIDATION=1` | Vulkan on Linux: Khronos validation layer. |
| `RECOMP_VK_TRACE=` | Vulkan: name every step of the first n draws/clears/flips. |
| `RECOMP_TEX_STATS=1` | List each texture format and size decoded. |
| `RECOMP_TEX_STATE=1`, `RECOMP_TEX_DUMP=`, `RECOMP_TEX_DUMP_EVERY=` | Texture register state; dump textures to BMP. |
| `RECOMP_FB_DUMP=`, `RECOMP_FB_WINDOW=1`, `RECOMP_FB_VA=`, `RECOMP_FB_WINDOW_DUMP_EVERY=` | Framebuffer window / dumps (CPU presenter). |
| `RECOMP_FFP_TRACE`, `RECOMP_SKIP_TRACE`, `RECOMP_TRACE_FLIP=`, `RECOMP_PB_EXEC_VERBOSE`, `RECOMP_PB_UNHANDLED_ALL`, `RECOMP_PB_SCAN`, `RECOMP_NV2A_TRACE`, `RECOMP_RASTER_TEST`, `RECOMP_FIND_NAN`, `RECOMP_FIND_QUAD` | Pushbuffer / NV2A debugging traces. |
| `RECOMP_FMV_HOST`, `RECOMP_FMV_DUMP=` | Host-side movie player (off) and movie frame dumps. |
Audio
| Variable | Meaning |
|---|---|
| `RECOMP_AUDIO=0` | No host audio device (the APU then paces by wall clock). |
| `RECOMP_AUDIO_VOLUME` | Master volume 0..100 (default 50; output is soft-limited to −6 dBFS). |
| `RECOMP_AUDIO_BLOCKS` | Host queue depth in 256-sample blocks (default 8, Switch 12). |
| `RECOMP_AUDIO_DUMP=,,` | Record the exact PCM sent to the device (48 kHz s16 stereo), written once full. |
| `RECOMP_APU_VOICE_DUMP=,,,` | Record one APU voice before/after its filter (float32) and log its registers once a second (v0F4 = menu music). |
| `RECOMP_AC97_READY` | AC97 codec presence for DirectSound (default `plain`). |
| `RECOMP_APU_MIXDOWN_ALL=0` | Mix only bins 0/1 to stereo instead of every bin. |
| `RECOMP_APU_DSP_ACK=`, `RECOMP_DSP_ACK=` | Acknowledge DSP command doorbells at these guest addresses. |
| `RECOMP_APU_TRACE=1` | Front-end methods and a per-second voice summary (disturbs the audio itself). |
| `RECOMP_APU_RING_STATS=1` | Stale ring-voice reads every 10 s (Linux; always on in the Switch `[perf]` report). |
Input
| Variable | Meaning |
|---|---|
| `RECOMP_USB` | Emulated OHCI USB with the pads (default 1). |
| `RECOMP_USB_PADS=1` | Only one pad (default two). |
| `RECOMP_USB_PORT`, `RECOMP_USB_PORT2` | Root port of pad 1 / pad 2 (0-based). |
| `RECOMP_USB_HC`, `RECOMP_USB_NDP` | Host controller for the pads and its number of ports. |
| `RECOMP_PAD_LAYOUT=position` | Map buttons by position (Xbox layout) instead of by label. |
| `RECOMP_PAD_SCRIPT`, `RECOMP_PAD2_SCRIPT` | Timed presses, e.g. `4000:start:300,9000:a:200` (ms from the first pad read). |
| `RECOMP_PAD_PRESS=` | Press these buttons periodically. |
| `RECOMP_KEYBOARD=1` | Keyboard as a pad (Linux). |
| `RECOMP_RUMBLE=0` | No rumble. |
| `RECOMP_RUMBLE_TRACE=1`, `RECOMP_INPUT_DIAG=1`, `RECOMP_KEY_TRACE=1`, `RECOMP_USB_TRACE=1` | Input debugging traces. |
Debugging
| Variable | Meaning |
|---|---|
| `RECOMP_WATCHDOG_SECS=` | Snapshot the main guest thread after s seconds without progress (and exit). |
| `RECOMP_WATCHDOG_KEEP=1` | Keep running after the watchdog snapshot. |
| `RECOMP_WATCH=`, `RECOMP_WATCH_RAW=1` | Name the guest code that changes a guest dword. |
| `RECOMP_KERNEL_WATCH=`, `RECOMP_KERNEL_WATCH_ALL=1` | Sample a guest dword around every kernel call. |
| `RECOMP_PEEK=`, `RECOMP_PEEK_CHAIN=` | Print guest dwords / follow a pointer chain periodically. |
| `RECOMP_POKE=` | Write guest dwords at boot. |
| `RECOMP_TRAP_NULL=1` | Fault on guest null-page access. |
| `RECOMP_UNIMPL_TRAP=1` | Abort at the first unimplemented instruction. |
| `RECOMP_IRQL_TRACE=1` | Log the first IRQL transitions. |
| `RECOMP_CS_WATCH=`, `RECOMP_CS_TRACE_CRT=1` | Critical-section tracing. |
| `RECOMP_TRACE_ARGS=`, `RECOMP_TRACE_DEREF=1`, `RECOMP_TRACE_BUDGET`, `RECOMP_TRACE_PROFILE=1` | Function-entry traces (functions chosen at regen) and a call profile. |
| `RECOMP_FORCE_RETURN` | Read but currently has no effect. |
## 🚦 Status
| Platform | State |
|---|---|
| 🐧 Linux | Boot, movies, profile, Main Menu, Quick Race and Career, with audio |
| 🎮 Switch hardware | Boot, movies, profile load/create, Main Menu, races |
Still open: cube maps on OpenGL (Vulkan has them: car reflections), bump/dot-product texture modes, fixed-function
lighting.
## 📚 More
- [`CLAUDE.md`](CLAUDE.md): detailed engineering notes
- [`PERF_NOTES.md`](PERF_NOTES.md): performance work and measurements
- [`VULKAN_NOTES.md`](VULKAN_NOTES.md): the Vulkan renderer
Need for Speed and Underground are trademarks of Electronic Arts. This is
an unofficial fan project, not affiliated with or endorsed by EA or Microsoft.