# AnyPS5: a PS5 game turned into a Linux program by relinking, not emulating

> Satyajit Ghana — Head of Engineering @ Inkers Technology
> canonical: https://ai.thesatyajit.com/articles/anyps5-static-relinker
> date: 2026-10-08
> tags: gpu, compilers

The README of AnyPS5 is two sentences long before it gets to the badges. It calls itself a "tool for automatic executables porting to Linux and Windows", with "a relinker that converts executable to the target system's native format and implementations of system prx libraries suitable for dynamic linking. No emulation or separate runtime process."

I read that as marketing. Every console project I know of either emulates the CPU, recompiles it into C, or, in the case of shadPS4 on the PS4, runs the game's x86-64 code natively inside an emulator process that loads and patches it at run time. "No separate runtime process" sounded like one of those wearing a different hat.

It isn't. The output of AnyPS5's relinker is a file that `/lib64/ld-linux-x86-64.so.2` loads like any other program. glibc's dynamic loader binds the game's imports to a directory of `.prx` files, which are ordinary shared libraries whose exported symbols have been renamed to the console's hash names. Nothing interprets the game's instructions or rewrites them in memory. On Linux the CPU code is not even touched. The part of AnyPS5 that does look like an emulator is the graphics driver, 102,000 lines of C++ that parse the GPU's command packets and recompile its shaders to SPIR-V. That part deserves its own section, and it gets one.

<RepoCard repo="boykopovar/AnyPS5" />

## Where it stands

I cloned `boykopovar/AnyPS5` at `4b6ab6b`, the merge of pull request #1797 at 21:40 CEST on 8 October 2026. The tree is 1,687 tracked files and 23 MB. The system libraries under `core/libs/prx` are 172,064 lines of C++ in 115 directories, the graphics driver `libSceAgcDriver` alone is 101,977 of them, the shader recompiler is 50,592 and the relinker 16,064. The GitHub API counts 14,760 stars and 1,173 forks.

The progress page, which a workflow regenerates on every push, rendered like this when I loaded it:

<Figure
  src="https://ai.thesatyajit.com/articles/anyps5-static-relinker/fig3.png"
  alt="Two treemaps side by side. Left, System libraries 84.76% (2575/3038), with libc, libkernel and Agc as the largest blocks, mostly green with grey unimplemented cells. Right, GPU shader instructions 98.37% (1147/1166), blocks for VOPC, DS, VOP1, MUBUF, SOP1, MIMG, VOP3 and others, nearly all green."
  caption="The project's progress map on 8 October 2026: declared system-library functions on the left, RDNA instruction encodings on the right. Green is counted as implemented, grey as not (AnyPS5 progress page, progress.svg)."
/>

Both badges need reading carefully, and the project says as much. The library figure, 84.76%, is 2,575 of 3,038 functions *declared* in the repository: a function counts as done when its body does not call the stub `NotImplemented_nid_no_patch`. It is not a fraction of everything the PS5 firmware exports, and the footnote under the badge says so. I recounted it my own way, with a regex over every `APS5_VABI` definition, and got 3,052 functions and 429 direct stub calls, close enough that the badge is what it says it is.

The shader figure, 98.37%, is weaker than it looks. `tools/progress.py` counts an RDNA instruction as implemented "when the decoder recognizes it". Decoding an opcode and translating it correctly are different jobs, and the 345-line technical-debt file is full of instructions that decode fine and are translated with caveats. I would read that badge as "the decoder is nearly complete".

**Update, 9 October.** Two days and about 150 commits later the shader badge reads 100% (1,166 of 1,166), and people started saying the project was done. The definition did not change: `tools/progress.py:284` still counts an instruction once the decoder recognizes it, so the badge now says the decoder is complete. I checked the next step myself. Of the 1,085 opcodes in `RdnaOpcode`, 1,078 are referenced somewhere in the translator outside the decoder, which is a better sign than the badge, though a reference is not a correct lowering. The seven that are not include the two typed-buffer loads `TBufferLoadFormatX` and `TBufferLoadFormatXyzw`, the `Exp` export family and `ImageGather8hPck`, and the translator still throws "not implemented" on some compare and attribute paths and on any tessellation setup other than one fixed configuration (`ShaderInputInfoBuilder.cpp:177`). The library badge barely moved, 2,578 of 3,044 (84.69%), slightly lower than before because more functions were declared. The compatibility list still has one game.

The compatibility list has one row. Dreaming Sarah, a 2D platformer (PPSA02929), is "In game, playable" on Windows, with a question mark under Linux, at 60 FPS on a GTX 1050 Ti with an i5-7500 and 36 FPS on Intel HD Graphics 620 with an i5-7200. The screenshot the author posted to the project's documentation gist is a Windows window:

<Figure
  src="https://ai.thesatyajit.com/articles/anyps5-static-relinker/fig4.jpg"
  alt="A Windows title bar reading 'Dreaming Sarah | 1505' above a pixel-art forest scene: a girl with a blue ponytail and red shirt walks on a grassy ledge in front of large dark-green tree trunks."
  caption="Dreaming Sarah running as a converted Windows executable, posted by the author on 24 September 2026, white margin cropped. Game art is the game developer's (AnyPS5 documentation gist, ds_ingame1)."
/>

One game in two months sounds thin. I think it is the right first game: a small 2D title exercises the loader, the core libraries and the GPU path end to end without needing every corner of any of them.

## What a PS5 executable is

The PS5 runs a FreeBSD-derived kernel on an eight-core AMD Zen 2 CPU, and its executables are ELF files with Sony's extensions. On the console they ship inside a signed, encrypted container (SELF). AnyPS5's usage document asks for "a clean ELF executable" and does not discuss containers, keys or where the ELF comes from, and neither will I. Everything below is about the ELF inside.

Three things make it different from a Linux program.

### Tables the loader reads but never maps

A Linux executable's `PT_DYNAMIC` segment points at its symbol table, string table and relocations by virtual address. In a PS5 ELF the same tags exist in an OS-specific range (`DT_OS_STRTAB` is `0x61000035`, `DT_OS_SYMTAB` is `0x61000039`, and so on), and their values are *file offsets* into a segment of type `PT_SCE_DYNLIBDATA` (`0x61000000`) that the loader reads but does not map. The relinker handles both forms in one helper:

```cpp
// core/relinker/relinker/src/pipeline/RelinkerPipeline.cpp:101
auto readAsOffset = [&](const std::int64_t osTag, const std::int64_t sysvTag, const char* name) -> FileByteOffset {
    if (requireExactlyOneOf(osTag, sysvTag, name))
        return getTagValue(osTag);
    return _elfReader->TranslateVirtualAddress(getTagValue(sysvTag));
};
```

### Imports named by hash

A PS5 game does not import `sceVideoOutOpen`. It imports `Up36PTk687E`, an 11-character NID ("name ID"), followed by `#` and a library id and `#` and a module id. The NID is the first 8 bytes of SHA-1 over the function name followed by a fixed 16-byte suffix, read little-endian and written as 11 characters of a base64 alphabet that uses `+` and `-`. AnyPS5's build tool computes it like this:

```cpp
// core/libs/nid/src/NidCompute.cpp:8
constexpr uint8_t kNidSuffix[16] = {
    0x51, 0x8D, 0x64, 0xA6, 0x35, 0xDE, 0xD8, 0xC1,
    0xE6, 0xB0, 0x39, 0xB1, 0xC3, 0xE5, 0x52, 0x30
};
...
    const auto digest = Sha1(input);          // line 27: name bytes + kNidSuffix

    uint8_t reversed[8];
    for (int i = 0; i < 8; i++)
        reversed[i] = digest[7 - i];
```

I reimplemented it in Python and in TypeScript (the TypeScript one runs in the widget further down) and hashed five names. `sceKernelUsleep` gives `1jfXLRVzisc`, `sceVideoOutOpen` gives `Up36PTk687E`, `scePadRead` gives `q1cHNfGycLI` and `sceKernelAllocateDirectMemory` gives `rTXw65xmLIA`. All four match the NIDs hard-coded in shadPS4's `LIB_FUNCTION` tables at its 7 October HEAD, and `sceAgcDriverSubmitDcb` gives `UglJIZjGssM`, which matches shadPS4's generated name table. The function is the same one the PS4 used. That matters for everything that follows, because it means a host library can produce the console's names from readable C++ function names at build time, with no table of names shipped at all.

The module id after the second `#` is decoded with the same alphabet and looked up in another OS-specific tag, `0x61000045`, whose value packs a module id in the top 16 bits and a string offset in the low 32 (`RelinkerPipeline.cpp:189`). That is how `Up36PTk687E#…#…` becomes "this comes from `libSceVideoOut.prx`".

### A syscall boundary inside a library

On the console, a game does not issue system calls. It calls `libkernel.prx`, and libkernel issues the `syscall` instructions into the FreeBSD-derived kernel. For AnyPS5 that is good news: if libkernel is reimplemented, there is no kernel interface to emulate. The relinker turns that assumption into a hard check. It linear-sweeps every executable segment with its own x86-64 length decoder and refuses the file if any instruction is a `syscall`, `int 0x80`, `sysenter` or `sysret`:

```cpp
// core/relinker/relinker/src/analysis/SyscallScanner.cpp:41
const bool isSyscall = (b0 == SYSCALL_BYTE0 && b1 == SYSCALL_BYTE1);
const bool isInt80 = (b0 == INT80_BYTE0 && b1 == INT80_BYTE1);
const bool isSysenter = (b0 == SYSENTER_BYTE0 && b1 == SYSENTER_BYTE1);
const bool isSysret = (b0 == SYSRET_BYTE0 && b1 == SYSRET_BYTE1);
```

A game that inlines its own system calls, which some middleware does on other platforms, is rejected at conversion rather than crashing later. The `--skip-syscall-check` flag that turns this off is marked deprecated and "extremely unstable".

A game also ships its own modules beside the executable, in `sce_module/` (or `sce_modules/`, or `prx/`): the C runtime and middleware the developer linked dynamically. These are the same kind of ELF, and AnyPS5 converts them too, rather than reimplementing them. `CONTRIBUTING.md` is explicit that engine and middleware modules a title ships, such as FMOD or Cohtml, are never replaced: "The relinker converts and loads the title's own module". Only libraries that reach the kernel or the hardware are rewritten.

## Relinking: the code stays, the tables change

Here is the step that I did not believe until I read it. The machine code of a PS5 game is already x86-64 for an AMD Zen 2. A Linux or Windows PC on x86-64 executes the same instructions. What stops the game from running on a PC is everything around the instructions: who loads the file, who fills in the addresses of imported functions, how the entry point is called, where thread-local storage lives, and what calling convention the libraries use. Change those, and the instructions can stay as they are.

The project's own architecture document draws the whole flow:

<Figure
  src="https://ai.thesatyajit.com/articles/anyps5-static-relinker/fig1.png"
  alt="Flowchart. PS5 game (input.elf, sce_module/*) feeds the relinker: an optional --to-intel step lowering AMD-only instructions, RelinkerPipeline reading imports by NID, checking syscalls, filtering unused NIDs and building a SysV dynamic section, GuestModuleBuilder converting bundled modules, and LinuxElfPatcher / WindowsPePatcher. Separately, core/libs/prx shared libraries go through nid_patcher, which renames exports to their NIDs. The native program (app.elf / app.exe and app0/sce_module/*) has the OS loader bind imports by NID to libs/*.prx."
  caption="The relinker and the library build meet at the OS loader, which binds imports by NID. Rendered from the Mermaid source in docs/dev/ARCHITECTURE.md (AnyPS5 architecture document, Overview)."
/>

`main.cpp` runs it in that order. `RelinkerPipeline::Relink` reads every relocation in the console's `.rela.dyn` and `.rela.plt` tables. Relative relocations, which only add the load base, are copied over unchanged. Every relocation that names a symbol becomes a `NidReference`: the NID string, the library it resolved to, the relocation type, and the address it patches, which is a slot in the game's global offset table. A relocation type outside a list of 14 standard x86-64 ones fails validation (`ValidationPolicy.cpp:18`).

`SysVDynamicSectionBuilder` then writes those references back out as a standard SysV import: one symbol per reference, named by the NID with everything from the first `#` cut off, and one relocation of the same type at the same GOT address. It is careful about one thing. The game's PLT stubs each have the index of their `.rela.plt` entry compiled into them, so jump-slot relocations must come out in their original order, and the builder throws if one is missing rather than renumber them (`SysVDynamicSectionBuilder.cpp:128`).

`LinuxElfPatcher::Patch` assembles the file. Click through it:

<RelinkLayout />

The original bytes are copied untouched, and everything new is appended at the end of the file and mapped by one new `PT_LOAD`: the string table, the symbol table, both relocation tables, a new `PT_DYNAMIC` with `DT_NEEDED` for every system library, `DT_RUNPATH` of `$ORIGIN/libs` and `DF_BIND_NOW`, a 17-byte entry stub and the name of the interpreter. The header is changed to `ET_DYN` with OS ABI 0, so Linux sees a position-independent executable and places it wherever ASLR likes; the copied relative relocations take care of the rest. The Sony dynamic segment and `PT_SCE_DYNLIBDATA` lose their program headers and are simply never mapped (`SegmentFilter.cpp:13`).

The entry stub is my favourite part of the codebase, because it is so small:

```cpp
// core/relinker/elfpatcher/src/general/EntryStubBuilder.cpp:18
_appendBytes(s, kStubOpMovRdiRsp, sizeof(kStubOpMovRdiRsp));     // mov rdi, rsp
_appendBytes(s, kStubOpAndRsp0xf0, sizeof(kStubOpAndRsp0xf0));   // and rsp, -16
_appendBytes(s, kStubOpXorRsiRsi, sizeof(kStubOpXorRsiRsi));     // xor rsi, rsi
...
s.push_back(kStubOpCallRel32);                                   // call <original e_entry>
...
_appendBytes(s, kStubOpUd2, sizeof(kStubOpUd2));                 // ud2
```

The console's entry point takes a pointer to an argument block and an exit handler. shadPS4 builds that block by hand as a struct of `int argc`, 4 bytes of padding and an array of `argv` pointers (`src/core/linker.h:49`). Linux starts every process with a 64-bit `argc` followed by the `argv` pointers on the stack. Little-endian, those are the same bytes. So `mov rdi, rsp` hands the game a valid argument block, and a null exit handler goes in `rsi`. The project has a test that runs a relinked fixture with arguments and checks `argc` and `argv[0]` from inside it (`test_linux_entry_argv.py`).

### What has to change, and what doesn't

Going through the ABI list one item at a time shows how much of the work the Linux host does for free.

The calling convention is SysV AMD64 on both sides. The macro every exported function carries, `APS5_VABI`, is empty on Linux.

Thread-local storage on x86-64 FreeBSD and on glibc both use the "variant II" layout, with the thread's TLS block below the address in the `fs` segment register. The relinker keeps the game's `PT_TLS` segment as it is, and glibc's loader allocates the block for every thread. The code does nothing special on Linux, and the tests that cover TLS are about Windows, which is where the trouble is.

Memory layout is handled one level up. The console gives games "direct memory" that the CPU and GPU share, mapped at addresses the game picks. AnyPS5's libkernel backs it with one 13,824 MiB region, a `memfd_create` file on Linux and `CreateFileMappingW` on Windows (`libkernel/DirectMemory/DirectMemory.cpp:229` and `:237`), and maps pieces of it with `MAP_SHARED | MAP_FIXED` where the game asks. The usage notes warn that Windows commits the whole allocation the moment a title makes it, so the page file has to cover it.

Syscalls never reach the host kernel unmediated, by the check above.

The instruction set is the one real exception. Zen 2 has instructions that Intel CPUs do not: SSE4a's `EXTRQ`, `INSERTQ`, `MOVNTSS` and `MOVNTSD`, `CLZERO`, `MONITORX`/`MWAITX`, the SHA extensions on Intel parts that lack them, and `RDPRU` and `MCOMMIT`. On an AMD PC none of this matters. For Intel hosts there is `--to-intel`, which decodes every code segment, rewrites each such instruction in place when a same-length replacement exists, and otherwise overwrites it with a 5-byte jump to an out-of-line stub that moves the stack below the 128-byte red zone, does the work, and jumps back (`LinuxElfPatcher.cpp:32`, `Amd64OnlySubstitutionTable.hpp:21`). An instruction shorter than 5 bytes with no in-place form is "kept: no room for a jump" and reported. `RDPRU` and `MCOMMIT` are not lowered at all, and fail the relink.

One detail in there tells you how carefully the project treats fidelity. `VRCPPS` and `VRSQRTPS` compute approximate reciprocals, and AMD and Intel approximate them differently, so the same game computes slightly different floats on each. `--to-intel` replaces them with correctly rounded results, and the technical-debt file states that this is neither AMD's answer nor Intel's. That is the honest choice, and the file is where it belongs.

## The libraries on the other side

Each directory under `core/libs/prx` builds one shared library with a `.prx` suffix. On Linux that is an ELF `.so`, on Windows a DLL. Functions are ordinary C++ with C linkage and their real names:

```cpp
// core/libs/prx/libSceVideoOut/src/Output.cpp:54
int APS5_VABI sceVideoOutOpen(int userId, int busType, int index, const void* param) try {
    validateOpenParam(param);
    if (userId != 255 && userId != 0) {
        throw std::runtime_error(std::string(__func__) + ": VIDEO_OUT_ERROR_INVALID_VALUE");
    }
```

After the link, `nid_patcher` walks the export table and renames each symbol to its NID. A handful of suffixes steer it. A name ending in `_nid_postfix` has the suffix stripped before hashing, so `sceKernelUsleep_nid_postfix` exports the NID of `sceKernelUsleep`, and `mprotect_nid_postfix` exports the NID of `mprotect` without colliding with the host C library's own `mprotect` at link time. A name ending in `_nid_no_patch` is a helper that stays a plain name. `APS5_EXPORT("<nid>", func)` sets the NID by hand for functions whose original names nobody knows, through an assembler alias:

```cpp
// core/libs/prx/libc/include/general/ExportMacros.hpp:5
#define APS5_EXPORT(exportName, funcName) \
__asm__(".globl \"" exportName "_nid_no_patch_cut\"\n\t" \
".set \"" exportName "_nid_no_patch_cut\", " #funcName)
```

So "implementing a system library" here means writing C++ against the console's documented or reverse-engineered behaviour, and the build turns the result into something the converted game's imports bind to. Follow one call through every layer:

<CallTrace />

Two policies run through the 172,000 lines, and both are stated in `CONTRIBUTING.md`.

The first is "Every function either does exactly what it is supposed to or throws." An unimplemented export calls `NotImplemented_nid_no_patch(__func__)`, which throws `std::runtime_error` with the function's name (`libc/src/General.cpp:314`), and the process prints it and ends. Even invalid arguments that the console would answer with an error code throw here, as in `sceVideoOutOpen` above, on the theory that a title passing them means AnyPS5 got something else wrong first. Exceptions are allowed only as "silent stubs" that unblock a title and only affect UI, and every one is listed by name in the technical-debt file: the message dialog finishes at once and answers button 1, the save-data dialog shows nothing, the login dialog reports that the user cancelled.

Compare shadPS4, the PS4 emulator this project borrows struct layouts from and credits in 20 of its files. At its 7 October HEAD, shadPS4's library tree has 2,947 log lines that start with `(STUBBED)`, almost all in functions that log and return success. That is a pragmatic choice that gets more games to boot, and it is the opposite of AnyPS5's. A crash with the function's name in it is easier to fix than a game that runs on with a fake success and breaks three systems later. It also means AnyPS5 will look worse than an emulator of the same maturity for a long time.

The second policy is that missing imports fail at startup. With `DF_BIND_NOW` the loader resolves every NID before the first instruction of the game runs, and `tools/import_audit.py` will tell you before that, from the relinker's `--registry` output and the built `.prx` files, which imports are implemented, which are throwing stubs and which are absent. An `unused-filter` option can drop imports that a control-flow analysis proves unreachable, so that a never-called function nobody has declared does not block the load. It is off by default.

## Windows is where the work is

Everything above was the Linux path, where the host happens to agree with the console about almost everything. Windows agrees about the instruction set and nothing else, and the relinker's Windows half is where most of its cleverness has gone.

The calling convention differs: the Microsoft x64 convention passes the first four arguments in `rcx`, `rdx`, `r8` and `r9`, SysV in `rdi`, `rsi`, `rdx`, `rcx`, `r8` and `r9`. So on Windows `APS5_VABI` expands to `__attribute__((sysv_abi))` (`libc/include/general/VabiMacros.hpp:4`), every export uses the game's convention, and variadic functions such as `printf` use GCC's `__builtin_sysv_va_list` (`libc/src/FormattingWide.cpp:17`). That is one reason the Windows build needs GCC, a specific MinGW-w64 15.2.0 build, rather than MSVC, which has no `sysv_abi`.

Thread-local storage differs completely. Windows keeps its thread block in `gs` and its TLS slots in an array reached through `gs:[0x58]`, and nothing on Windows sets `fs` to anything a game can use. So `WindowsTlsBuilder` finds every instruction in the game with an `fs` segment prefix, replaces it with a jump to a generated stub that loads the thread's TLS pointer from `gs:[0x58]`, indexes it with the module's TLS index and redoes the access against that block, then jumps back. Instructions shorter than the 5-byte jump drag the following instruction into the stub with them. Only displacement 0 (the thread pointer itself), offsets inside the TLS block and `fs:0x28`, the stack-protector canary, are allowed. Anything else stops the conversion with the displacement in the message (`WindowsTlsBuilder.cpp`, from line 120).

Dynamic linking differs too. An ELF symbol is looked up in every needed library in order; a PE import names its DLL. Rather than translate one model into the other, the Windows output imports only 22 `kernel32` functions, and the relinker emits an entry stub in raw machine code that calls `LoadLibraryExA` on each needed `.prx`, finds every NID, writes the GOT slots and then calls the game's entry point. In effect each converted Windows game carries a tiny dynamic loader of its own. The import audit flags the one case where the two models disagree: an import that names one library but is exported only by another resolves on Linux and can fail on Windows.

## The GPU: 55 packet types and a shader compiler

If the CPU side is a linker, the GPU side is a driver. The PS5's GPU is an AMD RDNA 2 part, `gfx1013` per the project's hardware-oracle notes, and games drive it the way AMD's own drivers do: they write PM4 packets, the command processor's native format, into command buffers in ordinary memory, and submit them. AnyPS5's architecture document draws its version:

<Figure
  src="https://ai.thesatyajit.com/articles/anyps5-static-relinker/fig2.png"
  alt="Flowchart. Game command buffers go to libSceAgcDriver/Submit (DCB / ACB), then Execution/Pm4 (state, draws, dispatches). Shader plus state hits a cache check, compiled variant in memory or on disk; if yes, straight to libSceAgcDriver/Graphics: Vulkan pipeline; if no, through core/shader/recompiler: RdnaDecoder, ControlFlow graph and structurize, Translation RDNA to IR, Optimization SSA resources bindings, SpirvBackend emit SPIR-V, then the Vulkan pipeline."
  caption="The graphics path: command buffers are parsed as PM4, and each new shader and state combination goes through the recompiler before it becomes a Vulkan pipeline. Rendered from the Mermaid source in docs/dev/ARCHITECTURE.md (AnyPS5 architecture document, Graphics)."
/>

The game builds its command buffers with `libSceAgc`, a user-mode library that AnyPS5 also reimplements, and submits them with `sceAgcDriverSubmitDcb`. That function is three lines: it hands the packet to `AgcDriver::Submit` on queue 0. A "GPU address" here is just a pointer into the process, because on the console CPU and GPU share one address space and AnyPS5 keeps it that way.

The parser reads each packet's header the way the hardware does: type 3 in the top two bits, a payload length in bits 16 to 29, an opcode in bits 8 to 15.

```cpp
// core/libs/prx/libSceAgcDriver/Execution/src/Pm4.cpp:226
require((header & 0xc0000000u) == 0xc0000000u, "unsupported PM4 packet type");
require(packet.size() == ((header >> 16u) & 0x3fffu) + 2u, "invalid PM4 packet size");
const auto opcode = (header >> 8u) & 0xffu;
```

`Pm4Opcodes.hpp` names 55 opcodes, from `SET_SH_REG` and `SET_CONTEXT_REG`, which load GPU state registers, to `DRAW_INDEX_2`, `DISPATCH_DIRECT`, `WAIT_REG_MEM` and `RELEASE_MEM`. Sony's own commands, such as `FLIP` and `WAIT_FLIP_DONE`, hide inside `NOP` packets with a sub-opcode in bits 2 to 7. Every opcode without a handler has a reason string, and the validator throws it: "nested command buffers, branching and nested flip reservation are not implemented", or, best of all, "GPU LOD statistics are not implemented; synthetic results are forbidden". The same rule as the libraries, applied to a GPU.

Memory is the expensive part. A Vulkan GPU cannot just read a pointer into the game's heap, so buffers and textures have to be mirrored into device memory, and kept in step when the CPU writes them. Linux imports guest memory through `udmabuf` where it can, Windows uses `VK_EXT_external_memory_host` and the `GetWriteWatch` API to find written pages, and the executable's own image, which Windows refuses to import, gets a "mirror" whose writable parts are compared 64 KiB at a time (`Graphics/src/GuestBufferMemory.cpp:41`). This is the same problem every console emulator with unified memory has, and there is no linker trick for it.

### RDNA in, SPIR-V out

Shaders are where "native" stops completely. The game's shaders are RDNA machine code. A PC GPU from NVIDIA or Intel cannot run them, and even an AMD PC GPU cannot be handed raw ISA through Vulkan. So each shader the game binds is recompiled. `Recompiler.cpp` runs the stages in order: `RdnaDecoder` turns the dword stream into instructions; `GraphBuilder` builds a control-flow graph; `Structurizer`, 1,602 lines on its own, turns RDNA's arbitrary branches into the structured selections and loops SPIR-V requires; `InstructionTranslator` lowers RDNA to an intermediate representation; SSA construction, constant folding, dead-code elimination and two lane-specific passes clean it up; `SrtWalker` and `ResourceTracker` work out which resource descriptors the shader reads from memory so they can become Vulkan bindings; and `SpirvBackend` emits the module.

The interesting translation problem is the wave. An RDNA shader runs 32 or 64 lanes in lockstep, and much of its control flow is written as scalar operations on an `EXEC` bitmask: a branch is "clear the bits of the lanes that go the other way". SPIR-V has no such register. AnyPS5 models `EXEC` as a per-invocation boolean, and when a shader reads a lane mask as a *value*, it rebuilds it with a subgroup ballot:

```cpp
// core/shader/recompiler/Translation/src/TranslationContext/ExecMask.cpp:8
std::array<IrU32, 2> TranslationContext::ballotMask(IrU1 value) {
    IrValue& mask = ir.Emit(IrOpcode::Ballot, IrType::U32x4, {&value.Value()});
    return {IrU32(ir.CompositeExtract(mask, 0u)), program.WaveSize() == 64u ? IrU32(ir.CompositeExtract(mask, 1u)) : IrU32(ir.Constant(0u))};
}
```

That works exactly when the host's subgroup is as wide as the console's wave. On an NVIDIA GPU, whose subgroup is 32 lanes, a wave64 shader that reads `EXEC` as a value, or reads another lane with `v_readlane_b32`, sees only the lanes its subgroup holds. The technical-debt file lists each such case rather than hiding it. Compiled variants are cached in memory and on disk, keyed by the code and the state that specialises it, and the debt file notes that recompiling at draw time "should be moved to the relinker stage". That would be the link-time idea carried to the GPU, and it has not happened yet. The [GTA V browser port](/articles/gta5-in-the-browser) shows what that looks like when it is done: there, every compiled Direct3D shader was translated to WGSL ahead of time and shipped in packs, and the run-time layer only replays commands.

The most unusual thing in this half of the repository is `tools/hw-oracle`. It runs a few lines of RDNA assembly on a local AMD GPU through ROCm's HSA runtime, one input row per lane, and prints what the hardware computed, with every floating-point mode bit set explicitly. Contributors are required to say where an instruction's semantics came from, a named GPU measured with the oracle or an exact section of the ISA, LLVM or Mesa. Many entries in the debt file read "measured on RDNA2 (gfx1035)", a laptop iGPU of the same family as the console's `gfx1013`. Most emulator shader translators are written from documentation and fixed when a game looks wrong. Measuring first is slower and much better.

## Is it really not an emulator?

For the CPU, yes, and more strictly than for shadPS4. shadPS4 also executes the game's x86-64 instructions directly on the host CPU. The difference is where the loading happens. shadPS4 is a program that maps the game's ELF into its own address space, applies relocations, looks every import up in a table of host functions built from `LIB_FUNCTION(nid, library, version, module, function)` entries, patches `fs` accesses on Windows and macOS in memory with Zydis and Xbyak, and then jumps to the entry point from inside its own process. AnyPS5 does each of those steps once, ahead of time, and writes the result to disk, where the operating system's loader takes over.

That buys real things. A converted game is an ordinary process: `ldd` lists its libraries, `gdb` and `perf` work on it with the NIDs as symbol names, and a library change is a rebuild of one `.so`. Every patch is visible in a file you can diff. Missing functions fail at load, or before it with the import audit, instead of whenever a level first calls them. And there is no emulator core to start, configure or keep running.

It also costs things. Each title has to be converted, and the conversion has to be redone when the relinker changes. Modules a game loads at run time with `sceKernelLoadStartModule` still need a loader inside libkernel, so the run-time half does not vanish entirely. On Windows the "OS loader" is a stub the relinker wrote, which is a loader by another name.

For the GPU, no. `libSceAgcDriver` interprets a hardware command stream and translates machine code for one GPU into an intermediate language for another, which is what an emulator's GPU core does. That is not a criticism. There is no other way to run RDNA command buffers on an NVIDIA card, and this one is unusually disciplined about it. But "no emulation" is true of the half of the system that was always going to be cheap. The 102,000 lines are where the cost is, and they are emulation.

The comparison with static recompilation is the more interesting one. N64Recomp turns a Nintendo 64 game's MIPS code into C, which is compiled into a new PC program, and XenonRecomp does the same for Xbox 360 PowerPC code. Those projects behind Zelda 64: Recompiled and Unleashed Recompiled have to translate every instruction because the target CPU is different. AnyPS5's target CPU is the same, so its "recompilation" of the CPU degenerates into relinking, and that is why it can be generic. A recomp is a per-game project with per-game source output, function boundaries discovered by hand and per-game patches. AnyPS5's relinker takes any title that passes its checks. What the two share is the graphics answer: a recomp's renderer, like RT64 or Unleashed Recompiled's shader recompilation, is the same kind of translation layer AnyPS5's driver is, and in both cases it is where most of the work and most of the bugs live.

| | shadPS4 (PS4) | AnyPS5 (PS5) | N64Recomp / XenonRecomp |
|---|---|---|---|
| Game CPU code | runs natively, loaded by the emulator | runs natively, loaded by the OS | translated to C or C++, recompiled |
| Imports bound | at run time, from an HLE table | at load time, by the OS loader | at build time, into the generated source |
| Unimplemented calls | often logged and stubbed | throw and end the process | per-game work |
| Per-game effort | none, plus fixes | a conversion run | a project per game |
| GPU | command stream and shaders translated at run time | command stream and shaders translated at run time, cached | a renderer per console |

## How old and how busy

Fetching the full history turned up 3,001 commits on `main`. The first, "initial commit" by `boykopovar`, is dated 3 August 2026, and GitHub says the repository was created that evening. So the project is about two months old. Versions 0.1.0 and 0.1.1 were released on 28 September, with prebuilt relinkers and libraries for Linux and Windows. The pace since then is hard to believe: 221 commits on 4 October, 385 on 5 October and 446 on 8 October, the day I cloned it, counting merges. 121 author names appear in the log. `boykopovar` has 771 commits, and a handful of regulars have between 50 and 540 each.

A lot of it is machine-assisted, and the project asks contributors to say so. Of 1,939 non-merge commits, 787 carry a `Co-Authored-By` trailer naming Claude or Copilot. That is a floor, since disclosure can also go in the pull request text. It is visible in the house style: strict rules, a convention checker that rejects code comments unless the technical-debt file changes in the same pull request, a 30-second budget per test, and documentation written in the same dry, complete sentences throughout. I would not draw conclusions about code quality from the trailers alone. The rules the project enforces on its contributors, human or not, are the more useful signal, and they are good rules.

## The legal line

The README's disclaimer, in full:

> This project is intended for interoperability, research, preservation, and compatibility purposes. It does not include, distribute, or require copyrighted software, firmware, cryptographic keys, or proprietary libraries. Users are responsible for ensuring that any binaries used with this project are obtained and used in accordance with applicable laws and their respective license terms.

That is the standard position of console emulation and porting projects, and it rests on a few well-known pillars. Reimplementing an interface so that independently created software can run is generally lawful: in the United States, *Sony v. Connectix* (2000) held that copying the PlayStation BIOS during reverse engineering to build a compatible emulator was fair use, and the EU Software Directive permits decompilation for interoperability under conditions. What projects avoid is shipping the vendor's code, firmware or keys, and anything that circumvents copy protection, which is where anti-circumvention law such as DMCA section 1201 applies independently of copyright in the game. AnyPS5 ships its own C++ for every system library, generates NIDs from names rather than shipping a NID table (`tools/nid_names.py` notes the name list is "Never downloaded"), and does not handle decryption at all: its input is a plain ELF. Where a game asks for the console's system fonts, it falls back to openly licensed Noto fonts.

The usual risks are elsewhere: trademarks (the project's name invokes one), contributors who have seen leaked SDK material, and users, who are responsible for where their executables come from. None of that is something code review can settle, and I am not offering legal advice. The code I read stays on the interoperability side of the line.

## What I make of it

The relinker is the idea worth stealing. Once the guest CPU and the host CPU agree, an emulator's loader is a linker that runs every time, and AnyPS5 makes it a linker that runs once. The rest follows from that: the OS loader binds imports, the debugger sees a normal process, and failure is moved to the earliest moment it can happen. I expect someone to apply the same move to other x86-64 platforms whose binaries are ELF with hashed imports.

I would not judge the project by its compatibility list yet. One playable 2D game says that the loader, the core libraries and a slice of the GPU path work end to end, nothing more. What I would watch is the technical-debt file, which is the most honest progress report in the repository, and the shader recompiler's move to conversion time, which would let the GPU half follow the CPU half and fail early too.

## How I checked

I shallow-cloned `boykopovar/AnyPS5` at `4b6ab6b` and read the documentation under `docs/`, the relinker (`core/relinker`, all of the pipeline, the Linux and Windows patchers, the entry, TLS and dependency stubs and the AMD-only instruction converter), the NID patcher, a sample of the system libraries, the PM4 parser and the shader recompiler's entry point and EXEC-mask translation. File and line references are to that commit. I did not build or run anything from the repository. The NID function I reimplemented in Python and in TypeScript from `NidCompute.cpp`, and I checked five hashes against a shallow clone of `shadps4-emu/shadPS4` at its 7 October 2026 HEAD; the same clone gave the `(STUBBED)` count and the `EntryParams` layout. Line counts are `wc -l` over `.cpp` and `.hpp` files. The library recount is a regex over `APS5_VABI` definitions, not the project's own scanner. History figures come from fetching the full commit history (`git fetch --unshallow`) and counting with `git log`; stars, forks and release dates come from the GitHub API on 8 October. The progress page, its SVG map and the two Mermaid diagrams were rendered in a headless Chromium; the Dreaming Sarah screenshot is the author's own, from the gist the documentation links to. I could not check the compatibility list's frame rates.
