A guided reading of the source

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.

Command stream · what the GPU thread eats PM4 type-3 packets
Source
132k
lines of C++ across 369 files in src/
CPU emulation
None
guest x86-64 runs natively on the host CPU
GPU target
Vulkan 1.3
RDNA 2 shader ISA recompiled to SPIR-V
Tests
47k
lines of regression tests, mostly shader & memory
What all of this producescommercial titles, running
Disgaea 6 running in KytyPS5
Disgaea 6
SILENT HILL: The Short Message running in KytyPS5
SILENT HILL: The Short Message
Neptunia ReVerse running in KytyPS5
Neptunia ReVerse
Paleo Pines running in KytyPS5
Paleo Pines
Every pixel went through the chain described here: PM4 packets parsed by a software command processor, RDNA 2 shaders recompiled to SPIR-V, guest memory tracked by page faults. Screenshots from the KytyPS5 repository.
01

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.

The consequence

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.

Guest — the PS5 and its software Kyty — the emulator's translation layer Host — the real PC, OS and GPU
The four translation problemsselect a tab

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.

src/common/abi.hCMakeLists.txt

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.

02

Map of the codebase

Nine directories under src/. Their relative sizes tell you where the difficulty lives.

Lines of C++ by subsystemsrc/ only · headers included

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

DirectorySideResponsibility
src/common/hostPlatform abstraction: virtual memory, file I/O, threads, timers, exception handling, logging, profiling. Three implementations behind one interface (sysWindows*, sysLinux*, macOS variants).
src/loader/kytyReads 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/kytyThe 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/kytyReimplementations 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/guestThe PS5's side of the GPU: PM4 packet parsing, the hardware register file, tiling maths, and the command-processor state machine.
src/graphics/shader/kytyRDNA 2 machine code in, SPIR-V out. Decoder, control-flow graph, intermediate representation, optimisation passes, SPIR-V emitter.
src/graphics/host_gpu/hostThe Vulkan backend: command scheduling, pipelines, descriptor sets, and the caches that map guest memory ranges onto Vulkan images and buffers.
src/graphics/presentation/hostThe 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/hostAn 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.

Reading tip

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.

03

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.

Startup sequenceclick a step · ← → to move

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.

src/common/subsystems.hsrc/common/subsystems.cppsrc/emulator.cpp

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);
}
src/emulator.cpp
04

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:

ConstantValueMeaning
ET_DYNEXEC0xfe10A main executable that is nonetheless position-independent
ET_DYNAMIC0xfe18A shared library (.prx-style module)
PT_OS_DYNLIBDATA0x61000000Segment holding the dynamic symbol/string/relocation tables
PT_OS_PROCPARAM0x61000001Process parameters block the runtime reads at start-up
PT_OS_RELRO0x61000010Read-only-after-relocation data
DT_OS_MODULE_INFO0x6100000dNames + versions of the modules this file exports
DT_OS_IMPORT_LIB0x61000015Names + versions of the libraries this file imports
src/loader/elf.h

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.

src/loader/runtimeLinker.cppsrc/loader/runtimeLinker.cpp

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.

src/loader/runtimeLinker.cppsrc/loader/jit.h

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.

src/libs/libs.hsrc/loader/runtimeLinker.cppsrc/loader/symbolDatabase.h

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.

src/loader/runtimeLinker.cpp — AllocateUnresolvedImportThunk
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.

Why this design

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.

src/loader/runtimeLinker.cpp

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:

Illegal instructionhostHanded to 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.
↓ not handled
Access violation → GPU faultkytyMemory::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.
↓ genuinely a crash
Guest fault contexthostThe faulting guest thread's name, all 16 general registers, 96 code bytes around the fault and 32 stack words — each range probed before it is read — then EXIT("Unhandled host exception: …"). RuntimeLinker::StackTrace() walks the guest frame chain against the loaded modules where a caller asks for it.
src/loader/runtimeLinker.cppsrc/loader/x64InstructionEmulator.cpp
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;
}
src/loader/runtimeLinker.cpp
05

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.

Guest address space (Windows / Linux)click a band

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:

KindGuest APIWhat it means
DirectsceKernelAllocateDirectMemory
sceKernelMapDirectMemory
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.
FlexiblesceKernelMapFlexibleMemoryOrdinary 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.
PooledsceKernelMemoryPoolReserve
…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.

src/kernel/memory.hsrc/kernel/memory.cpp

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:

PageManagerkytyOwns the actual protection calls. 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.
↓ reports dirtiness
MemoryTrackerkytyDivides the address space into fixed regions, each with per-page dirty bits tagged by source: 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.

src/graphics/host_gpu/pageManager.hsrc/graphics/host_gpu/memoryTracker.htests/MemoryTrackerTests.cpp
06

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.
src/kernel/pthread.hsrc/loader/runtimeLinker.cpp

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.

src/kernel/eventQueue.cpptests/EventQueueLifetimeTests.cpp

The file system

The guest sees a console-shaped tree, which Kyty synthesises from host directories mounted at start-up:

Guest pathBacked byPurpose
/app0the --game directoryThe game's own read-only files
/hostappthe same directoryAlias 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.

src/emulator.cppsrc/kernel/fileSystem.cppsrc/loader/systemContent.cpp
07

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

AreaFilesNotes
Graphics driver (AGC)agc.cpp · libAgcDriver.cppCommand 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 surfacelibKernel.cpp110 KB: the parts of libkernel not big enough to earn their own file in src/kernel/.
NetworkinglibNet.cpp · network.cpp220 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.
Audioaudio.cpp · libAudio.cpp · libAudio2.cpp · ajm.cppOutput mixing plus AJM, the console's hardware audio-decode block: ATRAC9 (via LibAtrac9), AAC and MP3 (via FFmpeg).
VideoavPlayer.cpp · libVideoDec2.cppFull-motion-video playback, decoded with FFmpeg. The AVPlayer implementation credits shadPS4 as a reference.
Fonts & imageslibFont.cpp · libPngDec.cppfont layout; PNG decode via stb.
Async memory (AMPR)libAmpr.cpp93 KB. The PS5's asynchronous mapping/paging engine — its command records are parsed and replayed against Kyty's memory manager.
System serviceslibSaveData · libSystemService · libUserService · libPlayGo · libDialog · libShareSave data, user profiles, chunk-based install progress, message and error dialogs.
Guest printfguestPrintf.cpp25 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.
The pragmatic pattern

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.

08

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

Game engineguestCalls the AGC ("Gen5") graphics API — AgcCbDispatch, AgcCbSetShRegistersDirect, AgcCbReleaseMem and hundreds more.
↓ these are pure builders
agc.cpp · libAgcDriver.cppkytyEach AGC call writes dwords into the game's own command buffer. Kyty reimplements them to produce byte-identical PM4 to the real driver, because the game may inspect and patch the buffer afterwards.
↓ submit
GuestGpu::Submit()kytyBorrows the guest command memory as a 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.
↓ handed to the GPU thread
CommandProcessor::Process()kytyWalks the packet stream. Register-write packets update HW::Context, HW::UserConfig and HW::Shader. Action packets — draw, dispatch, flip, fence — call into the renderer.
↓ draw arrives
RenderExecutor → VulkanhostResolves render targets, compiles or fetches shaders, builds a pipeline from the accumulated register state, binds descriptors, records 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:

PM4 type-3 header decoderedit the hex value

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.

src/graphics/guest_gpu/command_processor/pm4Dispatch.h…/pm4Handlers.cppsrc/graphics/guest_gpu/hardwareContext.h

Things a real command processor does that Kyty must model

MechanismWhat it isWhy it matters
DE / CE countersTwo 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 RAMA 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 eventsRELEASE_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 buffersINDIRECT_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.
PredicationSET_PREDICATION makes subsequent packets conditional on a memory value.Occlusion-culled draws must actually be skipped, not merely drawn cheaply.
Blocking waitsWAIT_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
src/graphics/guest_gpu/tile.cppsrc/graphics/host_gpu/renderer/image/tiler.cppsrc/graphics/host_gpu/shaders/
09

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.
The compilation pipelineclick a stage

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:

Typed SSA valueskytyTranslateProgram creates explicit value dependencies, then RewriteToSsa and simplification make descriptor-address calculations traceable without a second IR.
↓
SrtWalker / TrackResourceskytyWalks those values and builds a plan for descriptor-table reads. Constant offsets get flattened slots; genuinely dynamic reads stay explicit.
↓ at draw time
MaterializeResourceskytyExecutes the plan against the current user data and guest memory, producing a concrete 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().

…/ir/passes/SrtWalker.cpp…/ir/passes/ResourceMaterialization.cpp…/ir/passes/ResourceTracking.cpp…/ir/ResourceSnapshot.hsrc/graphics/host_gpu/renderer/pipeline/pipelineCache.cpp

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.

…/ShaderRecompiler.cpp…/frontend/cfg/ShaderCFG.cpp…/backend/spirv/spirvEmitterFlow.cpp

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.

10

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

CacheKeyed byNotes
TextureCacheguest address + format + extent + binding typeHandles aliasing, depth/stencil pairing, mip expansion, upload, download and LRU collection.
BufferCacheguest address rangeVertex, index, uniform and storage buffers, with a page-table index mapping addresses to buffer IDs.
PipelineCacheshader 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.
DescriptorHeapbinding layoutAllocates sets in batches of 32; descriptor pools retire only after their timeline tick completes.
SamplerCachethe guest S# descriptorSmall and simple by comparison.
FaultManagerfaulted BDA rangesFeeds 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.

…/renderer/commandScheduler.h…/renderer/masterSemaphore.h

What happens on a single draw

  1. Resolve render targets. Read the colour and depth register state, find or create images in the texture cache, transition their layouts.
  2. Fetch shaders. Hash the vertex and pixel shader code plus the derived input info into a ProgramKey; look it up; translate on a miss.
  3. 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.
  4. Upload dirty memory. For every bound resource, ask the memory tracker which pages the CPU touched and copy just those.
  5. 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.
  6. Bind and record. Write descriptor sets, push constants, then vkCmdDrawIndexed or vkCmdDraw.

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.

…/ShaderRecompiler.cpp…/frontend/translate/Translate.h

Diagnostics built into the renderer

FlagEffect
--vulkan-validation trueEnables the Vulkan validation layers.
--shader-validation trueRuns every emitted SPIR-V module through spirv-val before handing it to the driver.
--shader-log-direction FileDumps the decoded RDNA 2 listing, the CFG, and the IR for every shader compiled.
--command-buffer-dump trueWrites a decoded PM4 listing of every submitted buffer.
--spirv-debug-printf trueEnables printf from inside shaders.
--rdLoads the RenderDoc API and captures frames.
--profileStreams 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.
src/main.cppsrc/graphics/host_gpu/renderer/debug.cpp
11

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.

src/graphics/presentation/videoOut.cpp…/window/swapchain.cpp

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.

src/graphics/presentation/window/window.cppsrc/libs/controller.cppsrc/libs/libPad.cpp
12

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

LibraryUsed for
Vulkan-Headers · VulkanMemoryAllocatorThe graphics API and its allocator. Vulkan is loaded dynamically; no SDK is needed to build.
SPIRV-Headers · SPIRV-ToolsEmitting, validating and optimising the recompiler's output (--shader-optimization-type).
SDL3Window, input, and the Vulkan surface. Statically linked.
ffmpeg-core · LibAtrac9Video and audio decoding for AVPlayer and AJM.
fmt · spdlog · nlohmann_json · magic_enumFormatting, logging, JSON (param.json, ETAHen/GoldHEN-style cheats), enum reflection for CLI parsing.
xxHashFast hashing for shader and pipeline cache keys.
tracyFrame profiler, enabled with --profile.
stb · cpuinfo · winpthreadImage 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-named ScalarProvenanceTests (now typed-IR/SRT tracing), ResourceMaterializationTests, and ShaderVertexMetadataTests.
  • Memory, synchronization, and lifetime — VirtualMemoryAllocationTests, MemoryTrackerTests, PageManagerTests, ImagePageTableTests, SyncOnAddressTests, and EventQueueLifetimeTests.

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.

13

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.

Six passes, in orderroughly increasing difficulty
1 · The whole boot, end to end2 hoursmain.cpp → emulator.cpp → RuntimeLinker::Execute(). Small files, and you will finish knowing the shape of the entire program. Do not detour into anything yet.
2 · One HLE function, all the way through1 hourPick something small in 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.
3 · The loader in detailhalf a dayLoadProgramToMemory, ParseProgramDynamicInfo, Relocate, PatchProgram. Run with --game and read the log alongside — the loader prints every segment address, size and protection.
4 · Follow one draw calla dayTurn on --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.
5 · One shader, compileda dayTurn on --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.
6 · The coherency machineryhardestPageManager → 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_IMPLEMENTED as 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.
14

Glossary

The vocabulary that makes the code readable. Roughly in the order you will meet it.

HLEHigh-level emulation. Reimplementing what a console's system libraries do, in native code, instead of running the originals.
Guest / hostGuest = the emulated PS5 and its software. Host = the real PC, its OS and its GPU. Nearly every design decision in the codebase is about the boundary between them.
SELFSigned ELF. Sony's executable container: a small header and segment table wrapping an otherwise standard ELF64 file.
NIDThe 11-character encoded hash that names an exported symbol in place of a readable name. Written as hash#library#module.
PRX / moduleA PS5 shared library. Games ship some of their own alongside eboot.bin; Kyty automatically loads libc when present; the application requests other modules.
TLS / TCBThread-local storage and its thread control block. Reached on PS5 through the fs segment, which is why the loader rewrites those instructions.
SysV ABIThe System V AMD64 calling convention, used by the PS5 (and by Linux and macOS). Differs from the Microsoft x64 convention in register assignment and stack layout.
Direct memoryPS5 physical memory, allocated by physical offset then mapped separately to a virtual address.
Flexible memoryPS5 paged memory from a fixed per-title budget declared in param.json.
AGCThe PS5's low-level graphics API — also called "Gen5" in this codebase. Its functions build PM4 packets rather than issuing draws.
PM4AMD's GPU command packet format. A stream of 32-bit dwords; type-3 packets carry an opcode and a body length.
Command processorThe GPU front end that reads PM4 and updates hardware registers. Kyty implements one in software: CommandProcessor.
DE / CEDrawing Engine and Constant Engine — two cooperating command engines, synchronised by counters, with the CE prefetching constant data ahead of the DE.
EOPEnd of pipe. An event that fires when all preceding GPU work has completed, typically writing a value to memory and raising an interrupt.
Tiling / swizzleThe non-linear memory layout GPUs use for textures. Converting to and from it is tile.cpp plus a family of compute shaders.
HTileA compressed metadata surface accompanying a depth buffer, storing per-tile depth ranges for fast rejection.
DCCDelta Colour Compression. Metadata alongside a render target that compresses colour writes.
RDNA 2The GPU architecture the PS5's graphics core is based on. Its shader instruction set is the recompiler's input language.
Wave / laneA wave is a group of 32 or 64 shader invocations executing in lockstep; each invocation is a lane. Branching is done by masking lanes off, not by jumping.
EXEC maskThe 64-bit register holding which lanes are currently active. Manipulating it is how RDNA 2 expresses control flow — and why converting to SPIR-V is hard.
SGPR / VGPRScalar and vector general-purpose registers. Scalar values are uniform across the wave; vector values are per lane.
Sharp (V# T# S#)A resource descriptor stored in registers or memory: V# buffer, T# image, S# sampler. The shader loads them itself at runtime.
SRTShader Resource Table. A possibly-nested table of descriptors in memory, reached from a pointer in user data. Kyty must statically work out how to walk it.
User dataUp to 64 dwords written into a shader's first scalar registers by SET_SH_REG packets — the only channel by which a draw passes parameters to its shaders.
Fetch shaderA small generated shader that reads vertex attributes into registers before the real vertex shader runs. RDNA 2 has no fixed-function vertex input.
SPIR-VVulkan's binary shader intermediate language, and the recompiler's output. Requires structured control flow.
FlipAsking the display to scan out a different buffer at the next vertical blank. The PS5's equivalent of "present."
VblankVertical blanking interval. Kyty generates a virtual one at a configurable rate; many engines pace themselves off it.

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.