Anatomy of a PlayStation 5 emulator
Start here: the core learning route. This is an alternate reference view. If C++ is new, complete B1–B5 before using its project excerpts. Shared passages are generated from their owning topic pages.
KytyPS5 takes a program compiled for a games console — a machine with its own operating system, its own graphics API, and its own idea of what memory looks like — and persuades it to run on a Windows, Linux or x86-64 macOS host. This page walks the whole path, from main() to a triangle on screen, and explains why each piece exists.
src/



What an emulator like this actually does
The word "emulator" suggests simulating a chip instruction by instruction. KytyPS5 does almost none of that, and understanding why is the single most useful thing to grasp before reading any code.
The PS5 is a PC in an unusual coat
The console's CPU is an 8-core AMD Zen 2 running the x86-64 instruction set — the same instruction set as the machine you are reading this on. Its GPU is AMD RDNA 2, the same architecture as a Radeon RX 6000 card. Its operating system is a derivative of FreeBSD.
That means a compiled PS5 game is already machine code your processor can execute. There is no interpreter, no dynamic recompiler, no basic-block cache anywhere in this repository. The emulator maps the game's code into memory, sets the CPU's instruction pointer to the entry point, and lets the silicon run it.
Because the CPU is free, all the difficulty moves elsewhere: to the calling convention, to the memory layout, to the thousands of operating-system functions the game calls, and above all to the GPU. Nearly half of the codebase is graphics.
The four boundaries that must be bridged
Everything in this project is one of four translations. The colour coding used throughout this page follows them.
The instructions are identical. The conventions are not. The PS5 uses the System V AMD64 calling convention (arguments in rdi, rsi, rdx, rcx, r8, r9). Windows uses the Microsoft x64 convention (rcx, rdx, r8, r9, plus 32 bytes of shadow space). A guest function calling an emulator function would otherwise pass its arguments into the wrong registers.
Kyty solves this by annotating every function the guest can call with a Clang attribute, so the compiler generates a System V prologue for that one function while the rest of the emulator stays native:
#define KYTY_MS_ABI __attribute__((ms_abi)) #define KYTY_SYSV_ABI __attribute__((sysv_abi))
This is also why the build refuses MSVC's cl.exe and requires clang-cl — MSVC has no such attribute. There is a second gap too: PS5 code reads thread-local storage through the fs segment register, which does not address the guest TCB on the host. That one is solved by rewriting the guest's instruction bytes; see §04.
The game expects specific addresses to be available. PS5 software allocates at fixed regions, uses 16 KB pages where the host uses 4 KB, and distinguishes "direct" (physical) memory from "flexible" (virtual) memory in ways Windows and Linux do not.
Kyty therefore reserves whole bands of the host address space up front and hands them out itself, keeping its own page tables so it can answer the guest's queries about protection, type and naming. On macOS it goes further and uses linker flags to pin segments of the emulator binary itself so the required ranges are never taken by the system loader.
Every system call has to be answered by something. A game calls scePthreadCreate, sceKernelOpen, sceAudioOutOutput, sceNpWebApiCreateContext — thousands of distinct entry points across dozens of modules.
Kyty implements them in native C++ (this is called high-level emulation, or HLE) rather than running Sony's real system libraries. A guest call to "create a thread" becomes a real host thread; "open a file" becomes a real host file handle inside a sandboxed directory tree. The src/libs/ directory is 41,500 lines of exactly this.
This is the hard one. The game does not call a graphics API that Kyty can implement function-by-function. It builds raw command packets in memory — the same binary format the real GPU's command processor reads — and hands the buffer to the kernel.
So Kyty must implement a software model of an AMD command processor: parse PM4 packets, maintain the ~4,000 hardware registers they write, and when a draw packet arrives, translate the entire accumulated register state into a Vulkan pipeline. And the shaders inside are RDNA 2 machine code that must be decompiled and re-emitted as SPIR-V. Sections 08–10 are about this.
The whole path, animated
Before the detail: every section from §08 onwards expands one box in this flow.
HLE versus LLE, in one paragraph
Low-level emulation means running the console's real firmware and libraries on a simulated machine — maximally accurate, extremely slow, and legally fraught because it requires copyrighted system files. High-level emulation means reimplementing what those libraries do. KytyPS5 is HLE throughout; its README notes that "no external low-level emulation modules are currently required." The trade-off is that every unimplemented function is a potential crash, and behaviour can differ from the real console in subtle ways.
Map of the codebase
Nine directories under src/. Their relative sizes tell you where the difficulty lives.
Graphics — shader + host_gpu + guest_gpu + presentation — is about 61,000 lines, roughly half the emulator. The shader recompiler alone is larger than the loader, the kernel and the common layer combined.
What each directory is for
| Directory | Side | Responsibility |
|---|---|---|
src/common/ | host | Platform abstraction: virtual memory, file I/O, threads, timers, exception handling, logging, profiling. Three implementations behind one interface (sysWindows*, sysLinux*, macOS variants). |
src/loader/ | kyty | Reads the game's ELF/SELF executable, maps it into guest memory, resolves its imports against the HLE symbol table, applies relocations, patches unsupported instructions, and jumps to the entry point. |
src/kernel/ | kyty | The parts of the PS5 kernel that are too central to live in libs/: the memory manager, the pthread implementation, the file system, event queues, semaphores, event flags. |
src/libs/ | kyty | Reimplementations of the console's user-space system modules — audio, networking, fonts, save data, video decode, dialogs, PNG, JSON, trophies, and the AGC graphics driver. |
src/graphics/guest_gpu/ | guest | The PS5's side of the GPU: PM4 packet parsing, the hardware register file, tiling maths, and the command-processor state machine. |
src/graphics/shader/ | kyty | RDNA 2 machine code in, SPIR-V out. Decoder, control-flow graph, intermediate representation, optimisation passes, SPIR-V emitter. |
src/graphics/host_gpu/ | host | The Vulkan backend: command scheduling, pipelines, descriptor sets, and the caches that map guest memory ranges onto Vulkan images and buffers. |
src/graphics/presentation/ | host | The window (SDL3), the swapchain, the VideoOut flip model, vertical-blank timing, RenderDoc hooks, and the imgui system overlay that hosts the IME and error dialogs (systemOverlay.cpp). |
src/launcher/ | host | An optional Qt 6 front end: scans folders for games, edits per-title settings, shows a compatibility database and trophy viewer, checks for new releases, and spawns kyty_emulator. |
Two executables
kyty_emulator is the emulator itself, driven entirely by command-line flags. launcher is a Qt GUI that exists only to build those command lines for you. If you are learning the code, ignore the launcher completely — it never touches emulation.
The repository root is now the CMake source root: configure with cmake -S . -B _Build/windows. Older notes that use -S src predate the top-level build reorganisation.
Boot: from main() to the game's first instruction
Eleven steps happen between launching the executable and the game's own code running. Step through them — each one exists for a reason, and several of the reasons are surprising.
The subsystem framework
Kyty now uses an explicit lifecycle stack. Each component publishes a Lifecycle type with initialize and optional shutdown/emergency-shutdown callbacks; Emulator::Init() calls them in the required order:
subsystems.Initialize<Config::Lifecycle>(); subsystems.Initialize<Log::Lifecycle>(); subsystems.Initialize<LibKernel::PthreadLifecycle>(); subsystems.Initialize<LibKernel::Memory::Lifecycle>(); subsystems.Initialize<Controller::Lifecycle>(); subsystems.Initialize<Audio::Lifecycle>(); subsystems.Initialize<Graphics::Lifecycle>();
After each successful initialization, Subsystems records its callbacks. Normal destruction and EmergencyShutdownActive() both walk the stack in reverse, so partial startup is safe and graphics shuts down before the services it uses.
Two threads own the process
Note what Execute() does: it spawns a thread for the guest, then the main thread runs the window event loop forever. This inversion is deliberate. Windowing systems on macOS and Windows insist that window and input handling happen on the process's first thread, so the game gets a thread of its own instead.
static void Execute(const std::filesystem::path& game_patch) {
Common::Thread guest_thread([](void* param) {
auto* rt = Common::Singleton<Loader::RuntimeLinker>::Instance();
rt->Execute(*static_cast<const std::filesystem::path*>(param));
}, &patch_path);
Libs::Graphics::WindowRun(); // main thread never returns
std::quick_exit(0);
}
The loader: turning a file into a running program
A PS5 executable is an ELF file with Sony's extensions bolted on. The loader in src/loader/ is about 6,200 lines but it is where most of the genuinely clever tricks live.
Directories and ZArchive content
--game accepts a directory, ELF or .zar ZArchive with eboot.bin at its root. common/archive.cpp exposes virtual paths such as game.zar!/sce_sys/param.json through Common::File. A retained ArchiveReader shares the index and block cache while a mount or launcher scan is active. This is read-only game content; saves and temporary files use writable host locations.
Windows starts through wmain, converts arguments to UTF-8 and preserves native std::filesystem::path values through file operations. Guest paths without a matching mount stay outside the host namespace. The launcher uses gameContent.cpp to inspect folders and archives uniformly, including icons, title metadata and game size.
libSaveData.cpp persists SaveDataMemory2 data and allocation metadata on disk, including search/mount capacity and timestamps. Save-data dialog locking and CP932 conversion have dedicated regression tests. Treat these as observable guest behavior when debugging missing progress or stuck dialogs.
The file format
Games ship an eboot.bin, which is a SELF — a Signed ELF, a small container header followed by (on retail discs, encrypted) ELF segments. Kyty parses the container, then the ELF inside. The ELF is standard x86-64 ELF64 with FreeBSD's OS/ABI marker, but with Sony-specific type and tag values:
| Constant | Value | Meaning |
|---|---|---|
ET_DYNEXEC | 0xfe10 | A main executable that is nonetheless position-independent |
ET_DYNAMIC | 0xfe18 | A shared library (.prx-style module) |
PT_OS_DYNLIBDATA | 0x61000000 | Segment holding the dynamic symbol/string/relocation tables |
PT_OS_PROCPARAM | 0x61000001 | Process parameters block the runtime reads at start-up |
PT_OS_RELRO | 0x61000010 | Read-only-after-relocation data |
DT_OS_MODULE_INFO | 0x6100000d | Names + versions of the modules this file exports |
DT_OS_IMPORT_LIB | 0x61000015 | Names + versions of the libraries this file imports |
Mapping it into memory
LoadProgramToMemory() walks the program headers, computes the total span, rounds it to the guest's 16 KB page size (0x4000), and reserves that much guest memory. Modules are laid out sequentially from a fixed base:
constexpr uint64_t SYSTEM_RESERVED = 0x800000000; constexpr uint64_t CODE_BASE_OFFSET = 0x100000000; constexpr uint64_t CODE_BASE_INCR = 0x010000000; // first module lands at 0x900000000, each subsequent one 256 MB further on static uint64_t g_desired_base_addr = SYSTEM_RESERVED + CODE_BASE_OFFSET;
Each PT_LOAD segment is read from the file to p_vaddr + base_vaddr, then given its final page protection. Executable segments get an extra pass first — PatchProgram() — before protection is applied and the instruction cache is flushed.
Patching the guest's instruction bytes
This is one of the most instructive functions in the project. Two patterns in PS5 code cannot execute on Windows, so the loader scans the guest's .text and rewrites them in place.
1 · Thread-local storage through fs:[0]
PS5 code reads its thread control block with mov rax, qword ptr fs:[0]. On x86-64 Windows the host TEB uses gs; fs:[0] does not address the guest TCB. The loader finds every such instruction and overwrites those exact 9 bytes with a call to a generated stub, which fetches the guest's real TCB pointer and returns it in the same register the original instruction targeted:
// before — 9 bytes 64 48 8B 04 25 00 00 00 00 mov rax, qword ptr fs:[0] // after — same 9 bytes 48 E8 xx xx xx xx rex.w call tls_handler 48 89 C0 mov rax, rax
The leading 48 (REX.W) on the call is deliberate: any 0x66 operand-size prefix in front of the original instruction is left in place, and an AMD CPU would otherwise honour it and execute a 16-bit callw. The REX prefix neutralises it on every vendor, which is why the trailing nop of earlier builds is gone (Jit::Call9).
The stub itself is hand-assembled machine code in jit.h. Because the emulator is compiled with the Microsoft ABI on Windows but the guest runs System V, the stub (SafeCall) pushes the flags and all volatile registers, aligns the stack to 16 bytes, reserves 32 bytes of Win64 shadow space, calls the C++ handler, then restores everything.
2 · The stack canary store
Compiler-generated stack-protector code stores through fs:[0x28]. With a zero FS base this would access 0x28; inspect the instruction rather than inferring it from the address alone. The loader replaces the 12-byte store with nops — and, if it recognises the specific "canary failed → ud2" epilogue that follows, replaces the whole thing with pop rbp; ret.
NIDs: how symbols are named
PS5 modules do not export readable symbol names. They export NIDs — an 11-character base64-ish encoding of a hash of the original name, followed by the library and module it belongs to, separated by #:
b4fpgH5ZXxQ#Graphics5Driver#Graphics5Driver └─ symbol ─┘ └─── library ───┘ └──── module ────┘
RuntimeLinker::Resolve() splits on #, maps the library and module identifiers back through the file's own tables, and then searches, in order: the HLE symbol database, then the export tables of every other loaded guest module.
The HLE side registers its functions with a pair of macros. This is the entire mechanism by which the emulator "implements" a console function:
LIB_VERSION("Graphics5", 1, "Graphics5", 1, 1);
LIB_DEFINE(InitAgcDriver_1) {
LIB_FUNC("23LRUSvYu1M", Gen5::AgcInit);
LIB_FUNC("f3dg2CSgRKY", Gen5::AgcCreateShader);
LIB_FUNC("k3GhuSNmBLU", Gen5::AgcCbDispatch);
}
LIB_FUNC expands into a SymbolResolve record — name, library, library version, module, module major/minor version, type — and inserts the address of the native C++ function into the shared database. When the guest's relocation for that NID is processed, the address of Kyty's C++ function is written into the guest's Global Offset Table. From then on, the guest calls straight into the emulator.
Eager relocation and dynamic-module lifetimes
RelocateRecord() resolves both relocation tables eagerly. Program::rela_bits records successful bindings, so loading another module retries unresolved imports without overwriting existing guest-modified slots. RelocateProgram() refreshes all loaded importers after a dynamic load. Guest PLT/GOT records still exist, but the emulator's former lazy-PLT setup and DynamicInfo::pltgot_vaddr field have been removed.
Explicit module loads increment Program::load_count; the final explicit unload releases the module. Before unmapping, UnloadProgram() identifies imports bound to that module's exact exported address, clears their resolved bits and relocates them again. Matching only a NID could unlink a different provider, so SymbolDatabase::FindByNid() also accepts an address filter.
Unresolved function imports still receive diagnostic thunks; unresolved objects point at invalid memory. A thunk is the fallback for an unresolved import, not the ordinary mechanism for deferring all symbol binding until first call.
What happens when a symbol is missing
The thunk, byte by byte
AllocateUnresolvedImportThunk() emits a 34-byte diagnostic stub. It calls UnresolvedImportStub(record_id), which logs the missing import (subject to the shared 1,024-call log limit) and returns zero. It does not preserve arguments for a lazy retry or tail-call a newly found target.
48 83 EC 08 sub rsp, 8 // align stack for the SysV call 48 BF <record_id:8 bytes> mov rdi, record_id 48 B8 <stub:8 bytes> mov rax, UnresolvedImportStub FF D0 call rax // RAX receives zero 48 83 C4 08 add rsp, 8 0F 57 C0 xorps xmm0, xmm0 // zero floating return register too C3 ret
A later dynamic module load retries unresolved relocation slots eagerly through RelocateAll(). The thunk itself only diagnoses an unresolved call. Returning zero can let a game continue while still hiding missing behavior, so a title that boots with stubs is not evidence that those APIs are implemented.
Failing at load time would make almost every game unbootable, because games import far more than they call. Failing lazily at call time means only the code paths actually taken can break — and the log records which NID was stubbed, which is exactly what a compatibility bug report needs.
The exception handler as a control-flow mechanism
Kyty installs a process-wide handler (a vectored exception handler on Windows, a signal handler running on a per-thread alternate stack elsewhere). It is not only for diagnostics — two of its three branches are load-bearing:
X64InstructionEmulator, which software-emulates instructions the host CPU lacks — SHA-1/SHA-256 extensions, bit-field manipulation and similar — then advances the instruction pointer past them.Memory::HandleGpuFault(). Memory the GPU is using is deliberately write-protected; a guest write to it traps here, so the renderer learns the CPU has modified a texture or buffer and can invalidate its cached copy.EXIT("Unhandled host exception: …"). RuntimeLinker::StackTrace() walks the guest frame chain against the loaded modules where a caller asks for it.Deep dive · why the Windows stack is pre-grown by 768 KB
Windows commits thread stacks lazily using a guard page: touching it triggers an exception that commits another page and moves the guard down. This requires that code touch the stack in order, which is why MSVC emits __chkstk probes for large frames.
PS5 code has no such probes. A guest function with a large local array can write past the guard page, and Windows will then treat that as a genuine stack overflow. Module initialisers also run on the host stack before the guest's own stack is in use.
So before any guest code runs, RuntimeLinker::Execute() deliberately walks the guard page down 768 KB by writing a zero to it in a loop, forcing Windows to commit the whole region up front.
size_t expanded_size = 0;
while (expanded_size < 768 * 1024) {
sys_dbg_stack_info_t stack {};
SysStackUsage(stack);
*reinterpret_cast<uint32_t*>(stack.guard_addr) = 0; // force commit
expanded_size += stack.guard_size;
}
Memory: the address space is part of the ABI
On a console the memory map is documented and games rely on it. Kyty reserves matching bands of the host address space and runs its own allocator inside them.
macOS shifts these ranges (HOST_USER_MIN becomes 0x7000000000) and pins them with linker flags — -segaddr SYSTEM_RESERVED,0x7ffffc000 -segaddr USER_AREA,0x7000000000 — so the dynamic loader can never place a system library where the guest needs to allocate. CMakeLists.txt
Direct, flexible, and pooled memory
The PS5 kernel exposes three ways to get memory, and games use all three. Kyty models each with its own bookkeeping:
| Kind | Guest API | What it means |
|---|---|---|
| Direct | sceKernelAllocateDirectMemorysceKernelMapDirectMemory | A reservation out of physical memory, identified by a physical offset, which is then mapped at a virtual address. Games use it for GPU-visible resources. Allocation and mapping are separate steps, and one physical range can be mapped twice. |
| Flexible | sceKernelMapFlexibleMemory | Ordinary paged memory from a fixed per-title budget. The budget is read from the game's sce_sys/param.json at start-up and applied before the memory subsystem initialises. |
| Pooled | sceKernelMemoryPoolReserve…Commit / …Decommit | A reserve-then-commit pool, closer to VirtualAlloc's two-phase model, with a batch interface so a game can perform dozens of map operations in one call. |
On top of these sit sceKernelVirtualQuery (the guest asking "what is at this address?" — Kyty answers from its own tables, including the name the game gave the range), memory-type protection, PRT apertures for partially-resident textures, and batch mapping.
The separate extended-memory arena
Ordinary guest mappings keep their existing low address bands. Extended allocations use a separate 512 GiB virtual arena at 0x080000000000, an 8 TiB base, described by kExtendedMemoryBase and kExtendedMemorySize in kernel/memory.h. Reserving that address range does not allocate 512 GiB of physical RAM. Extended requests and ordinary mapping searches have separate allocation rules.
GPU tracking accepts the low 1 TiB region and that explicit extended interval; the space between them is not a valid resource range. Sparse image tracking covers 44-bit addresses. Dense buffer/BDA tables pack the low and extended intervals together through BufferCache::PageIndex() (guest address to packed page index) and GuestAddress() (packed byte offset to guest address), avoiding tables for the entire gap.
Automatic backing is recycled through physical-allocation ownership. Reserved mappings are published only after backing commit succeeds, and partial POSIX unmaps preserve still-live mappings. These are allocation/alias invariants tested by VirtualMemoryAllocationTests.cpp. Host primitives are declared directly in common/virtualMemory.h and implemented by sysWindowsVirtual.cpp / sysLinuxVirtual.cpp.
Keeping the CPU and GPU in agreement
This is the subtlest problem in the whole emulator, and it deserves its own explanation.
On a real PS5, CPU and GPU share one physical memory. The CPU and GPU can address the same backing, while the game still needs correct ordering and cache visibility. On a PC, the GPU has its own memory, and Vulkan resources are copies. Kyty must detect when a copy has gone stale.
It does this with hardware page protection. Two classes cooperate:
UpdatePageWatchers(track, vaddr, size, mode) makes a guest range write-protected (or read-write-protected). A guest access then faults, and the fault lands in the exception handler from §04.DirtySource::Cpu or DirtySource::Gpu. Answers IsRegionCpuModified() / IsRegionGpuModified() and drives the upload and download loops.The result is a two-way flow, both driven by page faults rather than by the game telling anyone anything:
- Upload — before a draw reads a texture,
ForEachUploadRange()finds the sub-ranges the CPU dirtied since the last upload, copies just those to the Vulkan image, then flips those pages to GPU-owned and re-protects them. - Download — when the GPU has written a render target and the CPU later reads it,
ForEachDownloadRange()copies back only the ranges the GPU marked dirty.
The tracker is defensive about its own invariants. It refuses re-entry from inside an upload callback (CheckNotInUploadCallback) and hard-fails if an invalidation leaves GPU-owned pages behind — bugs here produce corruption that is otherwise almost impossible to localise.
The kernel layer
Nearly twelve thousand lines implementing the FreeBSD-derived services a PS5 game assumes it is running on.
Threads — the largest single file after the PM4 handlers
src/kernel/pthread.cpp is 130 KB. It is a full pthread implementation: threads with names and priorities and CPU affinity masks, mutexes with all four types and protocol attributes, condition variables, read-write locks, thread-specific keys with destructors, cancellation with both deferred and asynchronous types, and per-thread signal queues.
Guest threads are real host threads, but with two extra pieces of state Kyty must maintain itself:
- A guest stack allocated inside the guest address space, because guest code computes addresses relative to its own stack and those addresses can be handed to the GPU or stored in structures the emulator inspects.
PthreadCreateMainGuestStack()sets this up for the main thread before the entry point runs. - A thread control block reachable from the patched TLS stub, plus a per-thread copy of every loaded module's TLS image.
RuntimeLinker::TlsGetAddr()lazily allocates these;DeleteTlss(thread_id)frees them on exit.
Event queues
sceKernelCreateEqueue and friends are a kqueue-shaped API: a game creates a queue, attaches sources to it (a vertical-blank event, a flip event, a timer, a user event), then blocks in sceKernelWaitEqueue. Kyty routes internal events — notably the display driver's flip and vblank notifications — into these queues.
Lifetime here is genuinely hard: a queue can be destroyed while another thread is blocked on it, or while the graphics thread is mid-way through delivering an event. There is a dedicated 19 KB test file for exactly this.
The file system
The guest sees a console-shaped tree, which Kyty synthesises from host directories mounted at start-up:
| Guest path | Backed by | Purpose |
|---|---|---|
/app0 | the --game directory | The game's own read-only files |
/hostapp | the same directory | Alias some titles use for development builds |
/download0 | _DownloadData/<TITLE_ID> | Downloadable content and patches |
/temp0, /temp | _TempData/<TITLE_ID> | Scratch space, cleared per title |
/savedata… | _SaveData/ | Mounted on demand by the save-data library |
The TITLE_ID comes from sce_sys/param.json, parsed by SystemContentLoadParamSfo(), so each game gets isolated directories. Path translation also has to handle case sensitivity: the console's file system is case-insensitive, Linux's is not, so lookups fall back to a case-folded directory scan.
The HLE library layer
Forty-one thousand lines of "what would the console have done here?" — the widest and shallowest part of the project.
Every file in src/libs/ follows the same shape: declare which library and module it stands in for, implement the functions in native C++ with the System V ABI attribute, and register them by NID.
LIB_VERSION("ContentExport", 1, "ContentExport", 1, 1);
static int KYTY_SYSV_ABI ContentExportInit2(const ContentExportInitParam2* p) {
PRINT_NAME(); // per-thread call tracing
if (p == nullptr) return CONTENT_EXPORT_ERROR_INVALID_PARAM;
LOGF("\t buffer_size = %" PRIu64 "\n", p->buffer_size);
g_initialized = true;
return OK;
}
LIB_DEFINE(InitContentExport_1) {
LIB_FUNC("0GnN4QCgIfs", ContentExport::ContentExportInit2);
}
PRINT_NAME() is worth noticing: it is a per-thread toggle that, when enabled, logs every entry into a guest-facing function with a timestamp and thread ID. Turning it on for one module is often the fastest way to find where a game diverges from expectation.
What is implemented
| Area | Files | Notes |
|---|---|---|
| Graphics driver (AGC) | agc.cpp · libAgcDriver.cpp | Command builders plus a 213 KB table of hardware register default values — the values the real driver writes at context reset, needed because games only set the registers they change. The current library registers AGC versions through 13. |
| Kernel surface | libKernel.cpp | 110 KB: the parts of libkernel not big enough to earn their own file in src/kernel/. |
| Networking | libNet.cpp · network.cpp | 220 KB combined — native sockets (connect, accept, listen, SO_ERROR and TCP_NODELAY translation), resolvers, SSL/HTTP shims, and the NP (PlayStation Network) surface stubbed enough to keep games running offline. |
| Audio | audio.cpp · libAudio.cpp · libAudio2.cpp · ajm.cpp | Output mixing plus AJM, the console's hardware audio-decode block: ATRAC9 (via LibAtrac9), AAC and MP3 (via FFmpeg). |
| Video | avPlayer.cpp · libVideoDec2.cpp | Full-motion-video playback, decoded with FFmpeg. The AVPlayer implementation credits shadPS4 as a reference. |
| Fonts & images | libFont.cpp · libPngDec.cpp | font layout; PNG decode via stb. |
| Async memory (AMPR) | libAmpr.cpp | 93 KB. The PS5's asynchronous mapping/paging engine — its command records are parsed and replayed against Kyty's memory manager. |
| System services | libSaveData · libSystemService · libUserService · libPlayGo · libDialog · libShare | Save data, user profiles, chunk-based install progress, message and error dialogs. |
Guest printf | guestPrintf.cpp | 25 KB implementing the format-string interpreter itself, because the guest passes a System V variadic argument list that cannot simply be forwarded to the host's vprintf. |
Many functions here validate arguments, log them, set a flag, and return OK. That is not laziness — for a large fraction of console APIs, the observable contract really is "accept this configuration and do not fail." The work is in knowing which ones need real behaviour.
Graphics I — the guest GPU and PM4
The game never calls a draw function Kyty can intercept. It writes command packets into a buffer and submits it. To render anything, the emulator has to become a command processor.
The path from game to pixels
AgcCbDispatch, AgcCbSetShRegistersDirect, AgcCbReleaseMem and hundreds more.std::span and pushes a Submission onto one of 57 queues: 1 graphics + 7 compute pipes × 8 queues each. The caller must keep the stream alive until execution completes.HW::Context, HW::UserConfig and HW::Shader. Action packets — draw, dispatch, flip, fence — call into the renderer.vkCmdDraw.Anatomy of a PM4 packet
The stream is a sequence of 32-bit dwords. Each packet starts with a header dword whose top two bits give the packet type; everything Kyty handles is type 3. Decode one yourself:
Field extraction, straight from the source: type = cmd_id >> 30, opcode = (cmd_id >> 8) & 0xff, body length = ((cmd_id >> 16) & 0x3fff) + 1 dwords. Bits 7:2 carry a Sony-specific sub-selector used when the opcode is IT_NOP. src/graphics/guest_gpu/pm4.cpp
Dispatch by jump table
With ~50 opcodes and thousands of register offsets, a switch statement would be unmanageable. Instead Kyty builds four function-pointer tables at start-up and indexes them directly:
extern const std::array<cp_op_parser_func_t, 256> g_cp_op_func; // by opcode extern const std::array<hw_ctx_parser_func_t, CX_NUM> g_hw_ctx_func; // context registers extern const std::array<hw_sh_parser_func_t, SH_NUM> g_hw_sh_func; // shader registers extern const std::array<hw_uc_parser_func_t, UC_NUM> g_hw_uc_func; // user-config registers
A SET_CONTEXT_REG packet says "starting at offset N, here are M values." The processor looks up g_hw_ctx_func[N], which is a small function that knows how to unpack that specific register into a typed field of HW::Context — and returns how many dwords it consumed, so multi-register writes advance correctly. pm4Handlers.cpp, at 142 KB (a recent pass removed the stale PS4/GCN-era registers), is almost entirely these functions.
Things a real command processor does that Kyty must model
| Mechanism | What it is | Why it matters |
|---|---|---|
| DE / CE counters | Two engines run the same stream: the Constant Engine runs ahead prefetching constants, the Drawing Engine follows. Packets synchronise them by counter. | Get this wrong and constant data is stale by a frame or two — subtle, drifting corruption. |
| Constant RAM | A 48 KB scratch area (m_const_ram[0x3000] dwords) the CE writes and the DE dumps into memory. | Used heavily for ring-buffered per-draw constants. |
| End-of-pipe events | RELEASE_MEM / EVENT_WRITE_EOP: "when everything before this has finished, write this value here and optionally raise an interrupt." | This is how the game knows a frame is done. Kyty maps them onto Vulkan timeline semaphores and its own event queues. |
| Indirect buffers | INDIRECT_BUFFER is a call: the processor jumps into another buffer and returns. Pm4Execution keeps a buffer stack. | Engines split state into reusable chunks; without this you see a fraction of the commands. |
| Predication | SET_PREDICATION makes subsequent packets conditional on a memory value. | Occlusion-culled draws must actually be skipped, not merely drawn cheaply. |
| Blocking waits | WAIT_REG_MEM spins until memory matches a condition. Kyty returns Pm4ProcessResult::Blocked and suspends the submission. | A blocked graphics queue must not stall the compute queues — hence the suspend/resume design rather than a busy loop. |
Tiling: the layout problem
GPUs do not store textures row by row. They interleave pixels into small blocks so that a 2D neighbourhood lands in one cache line. AMD's schemes have names like Standard 4 KB, Standard 64 KB, PRT 64 KB, Render-Target 64 KB and Depth 64 KB, each with a different swizzle and different alignment rules per bytes-per-pixel and per mip level.
Guest memory holds tiled data; Vulkan wants linear data (or its own opaque tiling). tile.cpp — address arithmetic — computes sizes, offsets, mip layouts and the XOR swizzles. The actual conversion runs on the host GPU as compute shaders, one per layout family:
gpu_tiler_standard4.comp gpu_tiler_standard4_3d.comp gpu_tiler_standard64.comp gpu_tiler_standard64_3d.comp gpu_tiler_standard256.comp gpu_tiler_prt.comp gpu_tiler_render_target.comp gpu_tiler_prt_3d.comp gpu_tiler_depth.comp gpu_tiler_promote_d16.comp gpu_tiler_color_transform.comp gpu_tiler_demote_d16.comp
Graphics II — the shader recompiler
About 27,000 lines across 80 files under recompiler/ turn RDNA 2 machine code into SPIR-V. Current main has an explicit frontend/decode → frontend/cfg → frontend/translate → typed SSA IR → backend/spirv architecture.
Why it is hard
- The input is machine code, not source. There are no source variables or functions, only packed instructions and scalar/vector registers.
- GPU control flow is unstructured. RDNA 2 uses an execution mask: a 64-bit register where each bit enables one lane. Branches manipulate this mask. SPIR-V, by contrast, demands structured control flow — every branch needs a declared merge block, every loop a header and a continue target.
- Resources are opaque. A texture is a 256-bit "T# descriptor" the shader loads out of memory at runtime. To emit a Vulkan descriptor binding, the compiler must trace backwards to work out where that descriptor came from.
- Wave size differs. RDNA 2 shaders assume waves of 32 or 64 lanes and use cross-lane instructions. Host GPUs have their own subgroup sizes.
TranslateProgram() and CompileProgram() are the reading map. The first runs decode → CFG → translate → SSA and simplification → tessellation lowering → resource tracking and returns the temporary IR in a movable TranslateResult; the second applies a ResourceSpecialization, collects shader info, allocates bindings, analyses requirements and emits SPIR-V. Their phases log elapsed milliseconds and a shader hash. For ShaderType::Mesh — merged ES/GS geometry stages, run as VK_EXT_mesh_shader mesh shaders — DecodeFusedProgram() joins the front and back stage code first. Failures EXIT; nothing takes an error-string parameter. src/graphics/shader/recompiler/ShaderRecompiler.cpp…/ShaderRecompiler.cpp
Sharps, user data, and shader resource tables
This mechanism deserves special attention because it has no analogue in a normal graphics API.
A shader receives up to 64 dwords of user data in its first scalar registers, set by SET_SH_REG packets. Inside those dwords the game may place, in any arrangement it likes:
- An inline V# / T# / S# descriptor — "sharp" in AMD's terminology: a buffer, image or sampler descriptor stored directly in registers.
- A pointer to a table in memory holding descriptors, which the shader loads with
s_load_dword. Tables may nest arbitrarily deep. This is the shader resource table, or SRT.
So to know which Vulkan image to bind, Kyty must evaluate the shader's own address arithmetic. Code follows typed IR values directly:
TranslateProgram creates explicit value dependencies, then RewriteToSsa and simplification make descriptor-address calculations traceable without a second IR.ResourceSnapshot — this exact texture, this exact buffer, this size and format.The important consequence is still the split between reusable shader analysis and concrete binding state. TranslateProgram() yields a retained IR::ResourcePlan that the renderer's program cache keeps; on every draw IR::MaterializeResources() evaluates that plan against current user data and guest memory, producing a ResourceSnapshot (what to bind) and a ResourceSpecialization (what the SPIR-V may assume). The cache then looks up a permutation keyed on the specialization and, on a miss, calls CompileProgram() to emit it. The ShaderStageRuntime borrows compiled-info and snapshot pointers from the cache; a permutation miss first rebuilds temporary IR with TranslateProgram().
When the control flow cannot be structured
Some shaders have genuinely irreducible control flow — loops with multiple entry points, computed jumps through a code table. SPIR-V cannot express these directly. Rather than failing, the recompiler falls back to a dispatcher: the whole shader becomes one loop around a switch on a program-counter variable, with each basic block as a case. This handles control-flow shapes that structured emission cannot represent directly; supported instruction and resource semantics still determine correctness.
BuildGraph() selects the fallback for irreducible graphs; a later Structurize() failure also falls back after restoring the unstructured graph. The reason and failing graph facts are logged rather than hidden.
Where the test weight is
ShaderRecompilerComputeTests.cpp is about 1.36 MB and shaderCfgTests.cpp about 612 KB. Focused resource-tracking, scalar-tracing, resource-materialisation, and vertex-metadata tests complement them. That weight is appropriate: a miscompiled shader can produce wrong pixels with no crash or useful log line.
Graphics III — the Vulkan renderer
The host side. Its central task is caching: turning "this guest address range, in this format" into "this Vulkan object," and knowing when that answer stops being true.
Resource managers and caches
| Cache | Keyed by | Notes |
|---|---|---|
TextureCache | guest address + format + extent + binding type | Handles aliasing, depth/stencil pairing, mip expansion, upload, download and LRU collection. |
BufferCache | guest address range | Vertex, index, uniform and storage buffers, with a page-table index mapping addresses to buffer IDs. |
PipelineCache | shader IDs + attachment formats + vertex input + packed static state (viewport, scissor, depth test and blend colour are dynamic and stay out of the key) | Kyty's packed keys serve the current run; a per-game Vulkan driver cache is persisted across runs. |
DescriptorHeap | binding layout | Allocates sets in batches of 32; descriptor pools retire only after their timeline tick completes. |
SamplerCache | the guest S# descriptor | Small and simple by comparison. |
FaultManager | faulted BDA ranges | Feeds RDNA2 FLAT/BDA misses back through a GPU fault buffer and the buffer cache's page table. |
Command submission
CommandScheduler now uses a growable command pool and a Vulkan
timeline semaphore (MasterSemaphore). It reuses a command buffer when
the GPU's known tick has passed that buffer's last submission, or allocates four more
if none is free. Deferred destruction and descriptor-pool retirement are tied directly
to timeline ticks; there is no fixed eight-buffer ring or per-buffer fence.
There is also a separate priority-operation thread. Some deferred work — a readback the guest is blocking on — cannot wait for the normal drain, so it runs on its own thread with its own tick.
What happens on a single draw
- Resolve render targets. Read the colour and depth register state, find or create images in the texture cache, transition their layouts.
- Fetch shaders. Hash the vertex and pixel shader code plus the derived input info into a
ProgramKey; look it up; translate on a miss. - Materialise resources. Re-run each shader's SRT plan against the current user data to get concrete textures and buffers, then find (or compile) the SPIR-V permutation for the resulting specialization.
- Upload dirty memory. For every bound resource, ask the memory tracker which pages the CPU touched and copy just those.
- Build the pipeline key. Pack the whole static state — viewport, scissor, depth test and blend colour are Vulkan dynamic state and stay out of it; look up or create the
VkPipeline. - Bind and record. Write descriptor sets, push constants, then
vkCmdDrawIndexedorvkCmdDraw.
Vertex fetch has no fixed-function equivalent
On RDNA 2 there is no vertex-input state in hardware. Instead the driver compiles a small fetch shader that reads vertex attributes from buffers and leaves them in registers, and the real vertex shader is called with those registers already populated. Games may supply this fetch shader externally, or embed it in the vertex shader's prologue.
For the embedded case, DetectEmbeddedVertexFetch() analyses decoded instructions before IR construction and produces a Frontend::EmbeddedFetchPlan. That plan is supplied to TranslateProgram(), which emits the vertex-input behaviour directly.
Diagnostics built into the renderer
| Flag | Effect |
|---|---|
--vulkan-validation true | Enables the Vulkan validation layers. |
--shader-validation true | Runs every emitted SPIR-V module through spirv-val before handing it to the driver. |
--shader-log-direction File | Dumps the decoded RDNA 2 listing, the CFG, and the IR for every shader compiled. |
--command-buffer-dump true | Writes a decoded PM4 listing of every submitted buffer. |
--spirv-debug-printf true | Enables printf from inside shaders. |
--rd | Loads the RenderDoc API and captures frames. |
--profile | Streams timings to a Tracy profiler client. |
--gpu <index> | Picks the Vulkan physical device by index (default −1, automatic). An invalid value is a usage error; an out-of-range one falls back to automatic with a log message. Also a launcher setting. |
Presentation, audio and input
The flip model
A PS5 game does not "present a frame." It registers a set of buffers with the video-out driver, renders into one, and then flips — asks the display hardware to scan out that buffer starting at the next vertical blank. The game learns the flip happened through an event queue.
Kyty reproduces the whole protocol:
VideoOutRegisterBuffers2— the game hands over guest addresses and a tiling/format description. Kyty records them; images are created lazily by the texture cache.VideoOutSubmitFlip— queued, not immediate. A flip submitted while another is pending must block, exactly as on hardware.SubmitFlipFromGpu— the interesting case: a flip requested from inside the command stream, which must happen after the preceding draws complete. It becomes a Vulkan submission ordered by timeline semaphore.VideoOutAddVblankEvent/VideoOutWaitVblank— a virtual vblank fires at the configured frequency (default 60 Hz,--vblank-frequency). Many engines pace their entire simulation off this, so the timing has to be steady even when rendering is slow.
Only at the very end does the guest's buffer get blitted into the SDL window's Vulkan swapchain image.
Audio — ports, graphs and decoders
The implementation is now split by responsibility. audio.cpp owns AudioOut/AudioOut2 and AudioIn; acm.cpp owns ACM; audio3d.cpp owns Audio3D; and ngs2.cpp owns NGS2 systems, racks, voices and sampler graphs. Follow those files rather than looking for every API in the former monolithic audio implementation.
AudioOut2 supports 12-channel (7.1.4) data with downmix, user-capability queries, even blocking-write cadence and capture of submitted PCM before shared guest buffers change. NGS2 plays PCM16 and ATRAC9 sampler blocks, preserves stream/loop positions and supports rear low-pass filtering; unsupported custom modules warn and are skipped.
ajm.cpp parses asynchronous jobs through libs/ajm/decoder.h. ATRAC9 uses LibAtrac9; AAC, MP3 and Opus use FFmpeg. libs/ajm/opus_decoder.h adds Opus decode/control handling, and shared reset logic handles gapless state.
--mic <name> selects SDL3 microphone capture for AudioIn; omitting it produces paced silence. dualSenseHaptics.cpp routes vibration-audio ports to DualSense haptics and pad-speaker ports to the controller speaker when the host device is available. Audio haptics are a separate path from PadSetVibration() motor rumble.
SDL3 also supplies gyroscope and accelerometer samples for pad motion APIs. The default keyboard mapping uses Z/C for L2/R2. Windows are resizable and recreate the swapchain on resize; Space no longer acts as a pause shortcut. VideoOut supports RGB10A2 PQ buffers and layered presentation. --vr enables a virtual HMD2 model (libHmd2.cpp), including field-of-view/reprojection state, not a physical-headset backend.
Input
SDL3 supplies gamepad, keyboard, and mouse events on the main thread. libs/controller.cpp maps controllers onto the DualSense button mask and analogue axes; presentation/window/hostInput.cpp applies default PC controls or repeatable --keymap Control=Input bindings, including mouse buttons and mouse-to-right-stick mode. libPad.cpp reports the assembled state through scePadReadState, while PadSetVibration() forwards the two motor strengths to SDL_RumbleGamepad(). PadSetLightBar() and PadSetTriggerEffect() drive a real DualSense's light bar and adaptive triggers through SDL_SetGamepadLED() and SDL_SendGamepadEffect(); a DualShock 4 takes the same pad path, and the touchpad is emulated with the mouse.
Build, dependencies and tooling
Toolchain
CMake ≥ 3.19 drives Ninja. The documented and CI builds use Clang; CMake also recognises GCC on POSIX. MSVC's cl.exe is rejected because the project depends on __attribute__((sysv_abi)), so Windows uses clang-cl. Qt 6 (Concurrent, Network, Widgets) is required for the launcher.
cmake -S . -B _Build/windows -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_C_COMPILER=clang-cl -DCMAKE_CXX_COMPILER=clang-cl \
-DCMAKE_PREFIX_PATH="C:/Qt/6.x.x/msvc2022_64"
cmake --build _Build/windows --target launcher
cmake --install _Build/windows --prefix _Build/windows/install
Third-party libraries
| Library | Used for |
|---|---|
Vulkan-Headers · VulkanMemoryAllocator | The graphics API and its allocator. Vulkan is loaded dynamically; no SDK is needed to build. |
SPIRV-Headers · SPIRV-Tools | Emitting, validating and optimising the recompiler's output (--shader-optimization-type). |
SDL3 | Window, input, and the Vulkan surface. Statically linked. |
ffmpeg-core · LibAtrac9 | Video and audio decoding for AVPlayer and AJM. |
fmt · spdlog · nlohmann_json · magic_enum | Formatting, logging, JSON (param.json, ETAHen/GoldHEN-style cheats), enum reflection for CLI parsing. |
xxHash | Fast hashing for shader and pipeline cache keys. |
tracy | Frame profiler, enabled with --profile. |
stb · cpuinfo · winpthread | Image decode, CPU feature detection, pthread support on Windows. |
Tests
Each test file builds as its own standalone executable (all EXCLUDE_FROM_ALL, so a normal build skips them). They cluster in two places, which is exactly where silent corruption lives:
- Shader compiler —
ShaderRecompilerComputeTests,shaderCfgTests,ResourceTrackingTests, the legacy-namedScalarProvenanceTests(now typed-IR/SRT tracing),ResourceMaterializationTests, andShaderVertexMetadataTests. - Memory, synchronization, and lifetime —
VirtualMemoryAllocationTests,MemoryTrackerTests,PageManagerTests,ImagePageTableTests,SyncOnAddressTests, andEventQueueLifetimeTests.
Smaller suites cover the IME dialog, AudioOut2 ports, the kernel file system and BitArray; 29 portable executables, plus the macOS SSE4a suite, aggregated behind the kyty_tests target and ctest --test-dir _Build/windows.
Note the pattern in memory.h: test hooks are compiled in behind KYTY_VIRTUAL_MEMORY_ALLOCATION_TESTS — TestFailNextPhysicalMemoryUnmap(), TestFailGuestBackingStoreUnmapAfter(n). Failure injection, so the error paths in the allocator are actually exercised.
Working conventions
clang-format runs as a pre-commit hook over src/; clang-tidy is available behind -DKYTY_ENABLE_CLANG_TIDY=ON. The README also carries an explicit AI-use policy: assistance is allowed for research and development, but contributors must understand and test what they submit, disclose the scope of AI involvement, and write the PR text themselves.
A learning path through the source
Reading a 132,000-line emulator front to back does not work. Read it as a sequence of complete journeys instead — each one small, each one ending somewhere satisfying.
main.cpp → emulator.cpp → RuntimeLinker::Execute(). Small files, and you will finish knowing the shape of the entire program. Do not detour into anything yet.src/libs/ — libAppContent.cpp is a good size. Find its LIB_FUNC registration, then follow RuntimeLinker::Resolve() to see how the guest's relocation reaches it. This is the core HLE trick and it is only a few hundred lines.LoadProgramToMemory, ParseProgramDynamicInfo, Relocate, PatchProgram. Run with --game and read the log alongside — the loader prints every segment address, size and protection.--command-buffer-dump, run a 2D game, open the dump. Find an IT_DRAW_INDEX_AUTO packet. Then trace it: pm4Handlers.cpp → CommandProcessor::DrawIndexAuto → RenderExecutor::DrawAuto → renderDraw.cpp. This is the spine of the graphics stack.--shader-log-direction File. Read the decoded RDNA 2 listing, then the CFG, then the IR, then look up the corresponding SPIR-V. Keep AMD's RDNA 2 ISA reference (document 70648) open — the README recommends it, and instruction semantics are not guessable.PageManager → MemoryTracker → TextureCache. Read tests/MemoryTrackerTests.cpp first: the tests state the invariants more clearly than the implementation does.Habits worth adopting early
- Run with logging on and read it. Kyty logs enormously and deliberately: every module load, every segment, every unresolved NID, every shader compile phase with timings. Most questions are answered faster by the log than by the source.
- Treat
EXIT_NOT_IMPLEMENTEDas a map. Grep for it. Each one marks a case the author knew existed and chose not to handle — it is a catalogue of the emulator's frontier. - Read the tests as specification. For the shader compiler and the memory tracker, the tests are more precise than any comment.
- Keep the guest/host boundary in mind at all times. When reading any function, ask: is this pointer a guest address or a host address? Is this code running on the guest's thread or the GPU thread? Most confusing bugs in emulators are a confusion about one of those two questions.
Glossary
The vocabulary that makes the code readable. Roughly in the order you will meet it.
hash#library#module.eboot.bin; Kyty automatically loads libc when present; the application requests other modules.fs segment, which is why the loader rewrites those instructions.param.json.CommandProcessor.tile.cpp plus a family of compute shaders.SET_SH_REG packets — the only channel by which a draw passes parameters to its shaders.Written from a reading of the KytyPS5 source tree on main at d2413fc2 (7 October 2026; CMake project version 0.3.0). Source references use file and function names at that revision. The project is GPL-2.0-only, based on InoriRus/Kyty (MIT), and credits shadPS4 as a reference for the memory model and AVPlayer. It is not affiliated with Sony Interactive Entertainment.