A comprehensive, step-by-step guide to porting a PS3 game to native PC using ps3recomp.
- Overview
- Phase 1: Game Selection and Assessment
- Phase 2: Binary Acquisition and Decryption
- Phase 3: Analysis
- Phase 4: Disassembly
- Phase 5: Code Generation
- Phase 6: Project Setup
- Phase 7: Building and Debugging
- Phase 8: Stubbing and Implementation
- Phase 9: Graphics
- Phase 10: Audio and Input
- Phase 11: SPU Handling
- Phase 12: Testing and Polish
- Tips and Best Practices
- Case Study: flOw
Porting a PS3 game with ps3recomp is a per-game project. Unlike emulation (where one binary runs many games), static recompilation requires understanding each game's specific needs. The reward is a truly native port with full modding potential.
- A legally obtained copy of the PS3 game
- Decryption keys or a fSELF version of the game binary
- Python 3.10+ for the recompiler tools
- CMake 3.20+ and a C17/C++20 compiler
- RPCS3 as a reference (for testing and behavior verification)
- Patience and debugging skills
| Game Complexity | Estimate | Examples |
|---|---|---|
| Simple PSN title | Days to weeks | flOw, fl0w, Flower |
| Standard indie game | Weeks to months | Journey, Limbo, Braid |
| Full retail title | Months | Standard action/adventure games |
| Complex AAA title | Months to years | The Last of Us, Uncharted |
The main variables are: how many unique system APIs the game uses, whether it uses SPU programs extensively, and how complex its graphics pipeline is.
For your first port, look for games that:
- Work well in RPCS3 — this means the game's behavior is well-understood
- Are simple — fewer system API calls = fewer HLE modules needed
- Use common engines — PhyreEngine, Unreal Engine 3, etc. have known patterns
- Don't rely on SPU-heavy compute — audio-only SPU use is much simpler than physics/particle SPU use
- Have a small binary — fewer functions to recompile and debug
Before touching ps3recomp, run the game in RPCS3 with logging enabled:
- Enable
Log Level: Allin RPCS3 settings - Play through the game's startup and early gameplay
- Check the log for:
- Which modules are loaded (
cellSysmoduleLoadModule) - Which NID calls are frequent
- Any "TODO" or "Unimplemented" warnings
- SPU thread creation patterns
- Which modules are loaded (
This tells you which HLE modules the game needs most.
Cross-reference the game's imports against MODULE_STATUS.md:
# After ELF analysis
python tools/prx_analyzer.py game/EBOOT.ELF --stubsIf coverage is 95%+, the game is a good candidate.
PS3 game binaries are encrypted in SELF (Signed ELF) format. You need a decrypted ELF:
Option A: fSELF (fake SELF)
- Some development/debug versions of games use fSELF which can be trivially "decrypted"
- The elf_parser tool handles fSELF automatically
Option B: RPCS3 Decryption
- RPCS3 can dump decrypted ELFs when loading a game
- Enable "Dump Executable" in RPCS3 debug settings
- Look for the decrypted file in RPCS3's cache directory
Option C: ps3recomp's fSELF rebuilder
Only for debug / prototype fSELF binaries, which are unencrypted — this
rebuilds the original ELF without keys. A retail EBOOT.BIN is encrypted and
needs Option A or B.
python tools/unfself.py EBOOT.BIN --output game/EBOOT.ELF
python tools/unfself.py EBOOT.BIN --info # describe it, write nothing| File | Location on Disc | Purpose |
|---|---|---|
EBOOT.BIN |
PS3_GAME/USRDIR/EBOOT.BIN |
Main game executable |
PARAM.SFO |
PS3_GAME/PARAM.SFO |
Game metadata (title, ID, version) |
*.sprx |
PS3_GAME/USRDIR/*.sprx |
Additional game modules (if any) |
TROPHY.TRP |
PS3_GAME/TROPDIR/*/TROPHY.TRP |
Trophy configuration |
| Game assets | PS3_GAME/USRDIR/* |
Textures, models, audio, scripts |
elf_parser.py writes JSON to stdout — it has no --output; redirect it.
mkdir -p analysis
python tools/elf_parser.py game/EBOOT.ELF --all > analysis/elf_info.json
python tools/elf_parser.py game/EBOOT.ELF --imports > analysis/imports.json
python tools/elf_parser.py game/EBOOT.ELF --exports > analysis/exports.jsonThe flags select what to report:
- (default) — header, entry point, architecture
--imports— imported functions (module + NID)--exports— exported symbols (if any)--sections/--segments/--relocs, or--allfor everything
- How many imports? — fewer is better; 100-200 is typical
- Which modules? — check coverage against ps3recomp's HLE modules
- Any unresolved NIDs? — these need stubs or implementations
- Entry point? — the address where execution starts
- TOC (Table of Contents)? — the r2 value needed for data access
- PRX modules? — does the game load additional .sprx files?
- Memory layout? — where does code, data, and BSS live?
# Resolve the NIDs your imports dump listed (one hex NID per line):
python tools/nid_database.py --batch analysis/nids.txt --json > resolved.json
python tools/nid_database.py --lookup 0xAB8B4DA4 # or one at a timeReview the output for unresolved NIDs. For each one:
- Check RPCS3 for the function name
- Determine if it's critical or can be stubbed
- Add it to the game's stubs.cpp if needed
Stage 1 of the pipeline. ppu_loader.py reads the .opd table — the PS3's own
list of function descriptors — and writes the function boundaries, the firmware
import table, the segment manifest and the entry/TOC pair in one pass:
python tools/ppu_loader.py game/EBOOT.ELF -o analysis/
# analysis/EBOOT.functions.json [{start,end,toc,opd}]
# analysis/EBOOT.imports.json [{library,nid,stub}] <- Phase 5 needs this
# analysis/EBOOT.image.json PT_LOAD manifest
# analysis/EBOOT.loader.json entry OPD -> (code, toc), module TOCfind_functions.py is the heuristic scanner — prologue, leaf and branch-target
detection — for what the OPD does not cover. --seed-json merges in the
loader's list rather than starting over:
python tools/find_functions.py game/EBOOT.ELF \
--seed-json analysis/EBOOT.functions.json --output analysis/functions.jsonReview the function list:
- How many functions? (typical: 500–5000 for an indie game, 10000+ for AAA)
- Are there symbols? (debug builds have names; retail usually stripped)
- Any suspiciously large "functions"? (might be data misidentified as code)
python tools/ppu_disasm.py game/EBOOT.ELF \
--functions analysis/functions.json \
--annotate \
--output disasm/If the game uses SPU programs:
python tools/extract_spu_images.py game/EBOOT.ELF --output spu_programs/SPU ELF segments are embedded within the main PPU ELF. Each one needs separate analysis.
The lifter takes the ELF, not the disassembly listing:
python tools/ppu_lifter.py game/EBOOT.ELF \
--functions analysis/EBOOT.functions.json \
--hle-stubs analysis/EBOOT.imports.json \
--output recomp/This generates:
ppu_recomp_NNN.cpp— batches of recompiled functions (C++, no post-step)ppu_recomp.h— declarations, helpers andppu_recomp_register()
--hle-stubs is what makes firmware imports callable. Each import stub
address listed in EBOOT.imports.json is emitted as its own function whose body
is ps3_hle_call(<nid>, ctx). Leave it out and the lifter translates the import
trampolines literally — li r12,0; oris r12,r12,hi; lwz r12,lo(r12); ... bctr —
against an import table nothing ever patched, and the first import call the game
makes lands on a raw instruction word:
[ppu] unresolved indirect call -> 0x39800000
# spu_disasm writes to stdout; redirect it
python tools/spu_disasm.py spu_programs/spu_0.elf > spu_disasm/spu_0.txt
# SPU lifter generates similar C output for each SPU programLifting produces the game's code. It does not produce the bridge from the game's firmware imports to this runtime's HLE implementations, and that is a separate generated file:
python tools/gen_hle_nids.py --all --out src/gen/ppu_hle_nids.cppCompile it into the port. It defines ppu_hle_register_all(), which
ppu_hle_init() calls at startup. runtime/ppu/ppu_hle.cpp carries a weak
do-nothing version, so leaving this out is not a link error — the port builds,
starts, and then behaves as though every firmware import returned 0. The usual
first symptom is an indirect call to something that is not an address:
[ppu] unresolved indirect call -> 0x39800000
0x39800000 is the PowerPC instruction li r12,0, the first word of an import
stub. The runtime prints a warning at startup when it finds no handlers
registered, which names this directly.
The same message means the other half of Phase 5 is missing when the handler
table is present: a lift run without --hle-stubs never reaches the table at
all. Check both before chasing it as a lifter bug.
Regenerate it whenever you update ps3recomp: the table is generated from the toolkit's registered modules, so one built against a different revision can reference handlers this one does not have.
cp -r ps3recomp/templates/project/ my_game/Edit my_game/config.toml (reference only: nothing reads it yet, so the paths also go on the tool command lines):
[input]
elf_path = "../game/EBOOT.ELF"
[modules]
# Enable only the modules your game uses
cellSysutil = "hle"
cellGcmSys = "hle"
cellPad = "hle"
cellAudio = "hle"
cellFs = "hle"
cellFont = "hle"
# ... add all modules from your import analysis
[debug]
log_hle_calls = true
log_missing_nids = true
break_on_unimplemented = true # Stop on unimplemented callscp recomp/*.c recomp/*.cpp my_game/recompiled/
cp data_segments.c my_game/recompiled/Create the virtual filesystem:
mkdir -p my_game/hdd0/game/TITLEID/USRDIR
mkdir -p my_game/hdd0/home/00000001/trophy/TITLEID
# Copy game assets
cp -r PS3_GAME/USRDIR/* my_game/hdd0/game/TITLEID/USRDIR/cd my_game
cmake -B build -G Ninja -DPS3RECOMP_DIR=/path/to/ps3recomp
cmake --build build 2>&1 | tee build.logExpect compilation errors on first try. Common issues:
Undefined functions:
error: undefined reference to 'recomp_func_00012345'
→ Function was referenced but not in the function list. Add it or add a stub.
Type mismatches:
error: incompatible pointer types
→ The lifter may need manual adjustment for complex calling conventions.
./build/my_game 2>&1 | tee run.logExpected output:
=== ps3recomp game runner ===
Initializing runtime...
Loaded 1234 recompiled functions
Creating main PPU thread...
[WARNING] Unimplemented NID 0xABCDEF01 in module cellFoo
[WARNING] Unimplemented NID 0x12345678 in module cellBar
- Run under a debugger (GDB/LLDB/Visual Studio)
- Set
break_on_unimplemented = trueto catch missing functions immediately - Compare with RPCS3 — run the same game in RPCS3 with logging and compare the call sequences
- Use log output —
log_hle_calls = trueshows every HLE function call - Binary search for crashes — if the game crashes in recompiled code, narrow down the problematic function by checking the guest address from the backtrace
In stubs.cpp, add implementations for unresolved NIDs:
#include "ps3emu/ps3types.h"
#include "ps3emu/error_codes.h"
#include <cstdio>
// Stub for a non-critical function
extern "C" int32_t cellFooDoSomething(uint32_t param1, uint32_t param2)
{
printf("[STUB] cellFooDoSomething(0x%x, 0x%x)\n", param1, param2);
return CELL_OK;
}
// Stub for a function that returns data
extern "C" int32_t cellBarGetInfo(uint32_t* out_info)
{
if (out_info) *out_info = 0;
printf("[STUB] cellBarGetInfo -> 0\n");
return CELL_OK;
}- Functions that crash if missing — stub immediately
- Functions that produce wrong behavior — implement properly
- Functions called during init — must at least return CELL_OK
- Functions called during gameplay — implement for correctness
- Rarely-called functions — stub last
RSX graphics is the hardest part of any PS3 port. The game writes NV47xx GPU commands to a command buffer, and these need to be translated to a modern graphics API.
Phase 1: Null renderer
- Set
backend = "null"in config - Verify all game logic runs correctly without graphics
- This proves the recompilation and HLE are working
Phase 2: State tracking
- cellGcmSys already tracks RSX state (tiles, zcull, display buffers)
- Log all command buffer writes to understand the rendering pipeline
- Identify the vertex formats, shader programs, and draw call patterns
Phase 3: Graphics backend
- Implement a Vulkan or D3D12 renderer that consumes the command buffer
- Start with basic triangle rendering
- Progressively add shader translation, texture sampling, blending modes
cellGcmSys (HLE module — state management):
- Command buffer control (put/get pointers)
- Local memory allocation (VRAM heap)
- IO memory mapping (main memory → GPU accessible)
- Tile and zcull configuration
- Display buffer registration, flip and VBlank callbacks
- Timestamps and report data
RSX Command Processor (libs/video/rsx_commands.h/.c):
- NV47xx FIFO command buffer parsing (type 0 increasing, type 2 non-increasing)
- State tracking: surfaces, viewport, scissor, clear, blend, depth/stencil, cull, color mask, alpha test
- Texture state: 16 units with offset/format/address/filter/rect
- Vertex attributes: 16 attribs with format/offset/stride
- Shader program registers (fragment address, vertex load slot)
- Draw dispatch: draw_arrays, draw_indexed
- Backend callback interface (
rsx_backend) with 12 dispatch points
Null backend (libs/video/rsx_null_backend.h/.c):
- Win32 window creation
- Displays RSX clear color via GDI
- FPS counter and debug overlay
- Proves command flow works before implementing a real backend
What's NOT done yet:
- D3D12/Vulkan backend (actual triangle rendering)
- Vertex/fragment program translation (RSX shaders → HLSL/SPIR-V)
- Texture upload and sampling
- Framebuffer management and resolve
cellAudio provides real audio output. Most games just need:
cellAudioInit()— starts the mixing thread- Write PCM samples to port buffers
cellAudioSetNotifyEventQueue()— sync with the mixing interval
Common issues:
- Audio crackling → increase buffer size in config
- No sound → check that the audio backend matches your OS
- Wrong sample rate → PS3 native is 48000 Hz
cellPad provides real gamepad input via XInput (Windows) or SDL2.
Button mapping (XInput → PS3):
| XInput | PS3 |
|---|---|
| A | Cross |
| B | Circle |
| X | Square |
| Y | Triangle |
| LB | L1 |
| RB | R1 |
| LT | L2 (analog) |
| RT | R2 (analog) |
| Back | Select |
| Start | Start |
| Guide | PS |
Determine how the game uses SPUs:
-
SPURS framework — most games use this
- Check for
cellSpursInitialize,cellSpursCreateTaskcalls - The SPURS HLE handles management; actual SPU tasks need attention
- Check for
-
Raw SPU — some games create SPU threads directly
- Check for
sys_spu_thread_group_createsyscalls - Each SPU program needs separate analysis
- Check for
-
SPU task types — identify what the SPU programs do:
- Audio mixing (ATRAC3+, PCM mixing) → replace with host decoding
- Physics (Havok, Bullet) → intercept and run on host CPU
- Decompression (zlib, LZ) → replace with host zlib
- Vertex processing → handle in graphics backend
- Custom compute → most complex; may need SPU interpreter
If you can identify the SPU task type:
// In stubs.cpp — intercept a known SPU audio mixing task
void hle_spu_audio_mixer(spu_context* ctx)
{
// Read parameters from SPU local store
uint32_t src_addr = spu_ls_read32(ctx, 0x100);
uint32_t dst_addr = spu_ls_read32(ctx, 0x104);
uint32_t num_samples = spu_ls_read32(ctx, 0x108);
// Perform the mixing on the host CPU
float* src = (float*)(vm_base + src_addr);
float* dst = (float*)(vm_base + dst_addr);
for (uint32_t i = 0; i < num_samples; i++)
dst[i] += src[i];
}When the HLE route does not fit (custom compute, or code you would rather run than rewrite), lift the SPU programs and let the runtime execute them.
Lifting. tools/extract_spu_images.py <ppu_elf> --output DIR writes every
embedded SPU ELF it finds. tools/spu_lifter.py lifts one image to C
(spu_recomp.c / spu_recomp.h); tools/build_spu_workloads.py lifts a whole
directory (--images DIR --lifted OUTDIR --out spu_workloads.c --constructor)
or discovers the images itself (--input FILE, repeatable), giving each image
its own symbol prefix so they link together. The project template does this at
configure time when PORT_SPU_INPUTS lists the files to scan
(templates/project/CMakeLists.txt, cmake/PS3RecompSPU.cmake); set
PORT_ENABLE_SPU=OFF to leave SPU lifting out. Details of the lifter:
SPU_LIFTER.md.
Workload registration. The generated spu_workloads.c calls
spu_workload_register (or spu_workload_register_img, which also binds the
image's indirect-branch table id) from runtime/spu/spu_workload.c, keyed by a
fingerprint of the image's bytes. cellSpurs looks the image the game passes in
up by that fingerprint, so a table generated from a different release of the
game never matches.
Job modules. SPURS job modules that are streamed into local store are not
separate ELFs, so extract_spu_images.py does not find them. Depending on the
title they sit in the PPU binary's data, in the title's data files
(tools/extract_jobmods.py handles modules in zlib streams inside SDATA
archives), or exist only at run time: SPU_DUMP_MISS=<dir> writes every SPU
image the runtime does not recognise to a file, one per fingerprint
(runtime/spu/spu_workload.c). Lift the raw .bin files with
tools/lift_jobmods.py --jobmods DIR --out OUTDIR, which also writes
jobmods_register.c. Job modules are keyed differently from ELF images: each
registers its lifted functions under its own image id and the module's first 16
bytes, which are matched when the module is streamed into the job code buffer.
--merge-chunks (added in PR #386) passes through to the lifter and emits each
run of chunks between call targets as one C function, for speed; every chunk
stays registered for indirect entry.
Lifted sources are generated output. Lift again after updating ps3recomp or changing the lifter: sources from an older lifter still compile and link, but can disagree with the current runtime.
Code that is not lifted. SPU code with no lifted entry runs in the SPU
interpreter (runtime/spu/spu_interp.c), and a branch from interpreted code
to a lifted entry resumes the lifted code; see
SPU_INTERPRETER_LIFTED_HANDOFF.md. To see
what still runs interpreted, set SPU_INTERP_STATS=<seconds>: the interpreter
then prints, once per that many seconds, its step rate and the six busiest
1 KB local-store pages by image id.
- Game starts without crashes
- Main menu is functional
- Gameplay runs at correct speed
- Audio plays correctly
- Input is responsive
- Save data loads and saves
- Trophy unlocks work
- No memory leaks (check with AddressSanitizer/Valgrind)
- Performance is acceptable (profile with host profiling tools)
- Compile with optimizations —
-O2or/O2makes a huge difference - Profile — use perf/VTune/Instruments to find hotspots
- Batch DMA — if SPU DMA is a bottleneck, consider batching transfers
- Reduce logging — disable
log_hle_callsfor release builds - Memory access patterns — the endian conversion can be a bottleneck; consider caching
- Start with RPCS3 — always verify game behavior in RPCS3 first
- Incremental approach — get one screen working, then the next
- Version control everything — commit early and often
- Document your findings — each game has quirks; write them down
- Check RPCS3's game compatibility wiki — known issues are documented
- Join the community — other porters may have solved your problem
- Don't fight the lifter — if generated code is wrong, fix the lifter, not individual functions (unless it's truly a one-off issue)
- Test on multiple platforms — if you're only on Windows, test on Linux too (or vice versa)
The first game port actively in progress using ps3recomp. This section tracks real-world progress and lessons learned.
Game: flOw by thatgamecompany (NPUA80001) Engine: PhyreEngine (Sony first-party) Repository: sp00nznet/flow
- Small PSN title (~10 MB ELF, ~50 MB total)
- Simple gameplay (2D physics-based, no complex 3D)
- Works perfectly in RPCS3 (behavior reference available)
- Limited SPU usage (audio only, no physics/compute)
- 140 imports across 12 modules — excellent ps3recomp coverage
| Milestone | Status |
|---|---|
| ELF analysis & import resolution | Complete — 140/140 NIDs resolved |
| Function boundary detection | Complete — 51,658 functions found (OPD + heuristic) |
| PPU code lifting | Complete — all 51,658 functions lifted to C++ |
| Build & link | Complete — 37 MB native x86-64 executable |
| CRT startup | Working — TLS init, mutex creation, enters game code |
| HLE module bridges | 7/12 real — cellSysutil, cellGcmSys, cellAudio, cellPad, cellFs, cellSysmodule, sysPrxForUser |
| Game main loop | In progress — crashes in early init due to address sign-extension |
| Graphics (RSX → D3D12) | Not started |
| Audio (cellAudio → WASAPI) | Wired — real WASAPI backend via ps3recomp |
| Input (cellPad → XInput) | Wired — real XInput backend via ps3recomp |
1. TOC Save in Import Stubs
The PPC64 ELF ABI requires saving the TOC (r2) to sp+40 before inter-module calls. The lifter doesn't emit this instruction, so after an import stub call, the caller's ld r2, 40(r1) reads garbage. Fix: The nid_dispatch() function in import_stubs.cpp saves the current TOC:
static void nid_dispatch(ppu_context* ctx, uint32_t nid, const char* name) {
vm_write64((uint32_t)ctx->gpr[1] + 0x28, ctx->gpr[2]); /* save TOC */
void* handler = ps3_resolve_func_nid(nid);
if (handler) ((int64_t(*)(ppu_context*))handler)(ctx);
}2. HLE Bridge Pattern
ps3recomp's HLE functions take explicit C parameters, but recompiled code passes everything through ppu_context GPRs. Bridge functions extract parameters per the PPC64 ABI (r3-r10 = args, r3 = return), translate guest pointers (vm_base + addr), and byte-swap struct output for big-endian guest memory:
static int64_t bridge_cellVideoOutGetResolution(ppu_context* ctx) {
uint32_t resId = (uint32_t)ctx->gpr[3];
uint32_t res_addr = (uint32_t)ctx->gpr[4];
CellVideoOutResolution host_res;
s32 rc = cellVideoOutGetResolution(resId, &host_res);
if (rc == CELL_OK && res_addr) {
vm_write16(res_addr, host_res.width); /* big-endian write */
vm_write16(res_addr + 2, host_res.height);
}
ctx->gpr[3] = (uint64_t)(int64_t)rc;
return rc;
}3. 32-bit vs 64-bit Address Space
The PS3 uses 32-bit effective addresses in 64-bit GPRs. Arithmetic like sp -= 0xA0 can sign-extend the result to 64 bits. The vm_bridge.cpp truncates addresses with (uint32_t)addr, but code that uses GPRs for address comparison or branching may need masking in the lifter.
4. WASAPI COM GUIDs
When linking cellAudio's WASAPI backend on MSVC, the COM interface GUIDs (IID_IAudioClient, etc.) aren't automatically defined. Fix: Define them manually in cellAudio.c using DEFINE_GUID() macros rather than relying on uuid.lib.
Binary: EBOOT.elf (10.1 MB, ELF64 big-endian PowerPC64)
Functions found: 51,658 (36,827 OPD + 14,831 heuristic)
Functions lifted: 51,658 (100%)
Generated C++: 156 MB (3.14 million lines)
Native binary: 37 MB (x86-64 Windows)
Import libraries: 12 (cellSysutil, cellGcmSys, cellSysmodule, cellSpurs,
cellAudio, cellSync, cellNetCtl, sceNp, sys_net,
sys_io, sys_fs, sysPrxForUser)
Import functions: 140 (all resolved)
Empty stubs: 16,718 (branch targets not in function list)
Unlifted insns: ~24,660 (VMX/AltiVec SIMD — needed for PhyreEngine)