Zero assumed knowledge · 39 chapters · one document

How a PlayStation 5 emulator works

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.

A long-form reference covering the emulator's major boundaries, from native guest execution to shader translation. It assumes basic C++ reading skills; begin with the Start here route and B1–B5 if those are new. The source walkthroughs cover selected paths, not every subsystem or console behavior.

Guest — the PS5 and its software Kyty — the translation layer Host — the real PC, OS and GPU

Colour is used for one purpose only in this book: which side of the emulation boundary a thing lives on. Once you internalise that split, most of the architecture explains itself.

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
Dreaming Sarah running in KytyPS5
Dreaming Sarah
Paleo Pines running in KytyPS5
Paleo Pines
Hellboy running in KytyPS5
Hellboy

Every pixel here went through the whole chain this book describes: PM4 packets parsed by a software command processor, RDNA 2 shaders recompiled to SPIR-V, guest memory tracked by page faults, and the result blitted into an SDL window. Screenshots from the KytyPS5 repository.

Part I

Foundations

Eight chapters of background. If you already know x86-64 calling conventions, virtual memory, ELF linking and how a GPU pipeline works, skim to Part II. If any of those is new, read this part properly — the rest of the book assumes it.

01

What an emulator is

An emulator makes one computer behave like another, so that software built for the second runs on the first. That is the whole idea. Everything else is engineering trade-offs about where you cut.

The simplest possible emulator

Imagine a toy machine with four registers and a handful of instructions. An emulator for it is a loop:

uint8_t memory[65536]{}; // load the toy program before executing
uint32_t reg[4]{};
uint16_t pc = 0;
auto fetch = [&]() { return memory[pc++]; }; // 16-bit PC wraps

for (;;) {
    switch (fetch()) {
        case 0x01: { // LOAD reg, little-endian imm32
            const uint8_t r = fetch();
            if (r >= 4) return; // invalid register
            uint32_t value = 0;
            for (unsigned byte = 0; byte < 4; ++byte)
                value |= uint32_t(fetch()) << (8 * byte);
            reg[r] = value;
            break;
        }
        case 0x02: { // ADD dst, src
            const uint8_t d = fetch(), s = fetch();
            if (d >= 4 || s >= 4) return;
            reg[d] += reg[s];
            break;
        }
        case 0xFF: return; // HALT
        default: return;  // invalid opcode
    }
}

This illustrates an interpreter: fetch an instruction, decode it, perform its operation, repeat. A complete implementation also defines invalid instructions, exceptions and every other part of the guest machine.

The loop adds decoding and dispatch work to every guest instruction. Its cost depends on the interpreter, host and workload; CPU clock rates alone cannot predict emulation speed. Caching translated code or executing compatible guest instructions natively can avoid much of that repeated work.

Execution strategies and HLE

StrategyHow it worksCostUsed by
Interpretation Decode and execute one guest instruction at a time, every time. Repeated decode/dispatch overhead. Instruction stepping is straightforward, but accuracy still depends on the implementation. Reference implementations, fallback paths and systems whose workloads fit the host’s available performance.
Dynamic recompilation (JIT) Translate a block of guest instructions into host instructions once, cache the result, then jump into the cached block whenever that address is reached again. Amortises translation cost. Self-modifying code, precise exceptions and flags require careful handling. Common in emulators for systems with a different guest CPU, including PS2 and GameCube.
Native execution Guest and host share an instruction set, so guest code is simply mapped into memory and jumped to. Avoids general CPU translation. Host feature gaps, ABI crossings, services and faults still have costs. KytyPS5 on a compatible x86-64 execution environment.
High-level emulation (HLE) Not a CPU strategy but a library strategy: instead of running the console's operating system, reimplement what its functions do in native code. Performance varies. Correctness depends on matching guest-visible results, side effects, ordering and timing. KytyPS5’s service replacements; graphics also requires packet interpretation and shader translation.
Jargon · LLE versus HLE

Low-level emulation (LLE) reproduces lower-level hardware interfaces, often allowing original firmware or libraries to execute. It can preserve behavior that an HLE replacement omits, but neither accuracy nor speed follows from the label alone.

High-level emulation (HLE) replaces those libraries with reimplementations. It can avoid executing those system-library binaries, but each replacement must match the guest-visible contract. A missing function can fail immediately or fall back to a logging zero-return stub; silent incorrect results can cause failures much later. HLE does not by itself establish compatibility.

KytyPS5 primarily replaces system services with HLE. Its README says no external low-level emulation modules are required. The loader can still execute a game-supplied /app0/sce_module/libc.prx; “HLE” does not mean every instruction outside the main executable is replaced.

What an emulator must actually reproduce

A CPU is only one of the things a program depends on. To run an unmodified console game you must reproduce all of:

  1. The instruction set — so the code executes at all.
  2. The calling convention — so a function's arguments arrive where it expects them.
  3. The memory layout — so pointers, allocations and alignment assumptions hold.
  4. The operating system's services — threads, files, memory, timers, networking, audio, input.
  5. The GPU — including its command format, its register set, its memory layouts and its shader instruction set.
  6. The timing model — vertical blank, frame pacing, audio clocks. Games are full of code that assumes 60 Hz.

Most ordinary guest CPU instructions execute directly in a compatible x86-64 environment. Targeted instruction compatibility is still necessary; the remaining responsibilities account for most of this project.

Learn more · emulation fundamentals
02

The machine being emulated

You cannot emulate what you do not understand. Here is the PlayStation 5, described in the terms that matter to an emulator author rather than to a shopper.

The hardware

ComponentWhat it isEmulation consequence
CPU8-core AMD Zen 2, x86-64, 16 hardware threads, ~3.5 GHz.Shares the x86-64 ISA with an x86-64 PC; individual instruction features still need checking.
GPUAMD RDNA 2, 36 compute units, custom. Programmed through a command-packet interface, not a driver API.Must be modelled in software: packet parser, register file, and a shader compiler from RDNA 2 to SPIR-V.
Memory16 GB GDDR6, unified — CPU and GPU address the same physical memory.Host Vulkan resources can have separate backing or layouts. Kyty must keep CPU-visible guest bytes and cached GPU resources coherent, including on unified-memory hosts.
Page size16 KB, not the 4 KB PCs use.Guest VM APIs use 16 KB granularity where required. The renderer’s tracker uses 4 KB pages; one guest page spans four of them.
StorageCustom NVMe with hardware decompression.Implemented through file I/O, asynchronous requests and supported decompression paths. Ordering, completion and error behavior matter alongside throughput.
Audio"Tempest" 3D audio engine plus a fixed-function decode block (AJM).AJM takes batched decode jobs, so Kyty parses the job format and routes to FFmpeg / LibAtrac9.

The software stack

The PS5's operating system is a descendant of FreeBSD. That heritage shows everywhere in the API surface:

Game / engineguestUnreal, Unity, or an in-house engine, compiled to x86-64 with Sony's Clang-based toolchain.
↓ calls
System modules (.prx / .sprx)guestlibkernel, Graphics5Driver (graphics), libSceAudioOut, libSceNet, libSceSaveData… Dozens of shared libraries, each exporting hashed symbols.
↓ syscalls
Kernel (FreeBSD-derived)guestProcesses, threads (pthreads), kqueue-style event queues, mmap-style memory, a Unix-like file system.
↓ writes packets to
GPU command processorguestReads PM4 packets straight out of memory. There is no "driver" between the game and the hardware in the PC sense.

KytyPS5 replaces the middle two layers wholesale with native C++, and models the fourth in software. The game executes natively, with relocations and targeted compatibility patches applied by the loader.

Naming you will meet

Prospero is Sony's internal codename for the PS5, and it is the namespace name used throughout Kyty's graphics code (Libs::Graphics::Prospero). AGC is the PS5's low-level graphics API, referred to as Gen5 in the source. GNM was its PS4 predecessor, and some symbol names still carry that lineage.

What a game file actually is

A PS5 title directory contains, at minimum:

MyGame/
├─ eboot.bin              the main executable — a SELF (Signed ELF)
├─ sce_sys/
│   ├─ param.json         title ID, version, memory budget, capabilities
│   ├─ icon0.png
│   └─ …
├─ *.prx / *.sprx         optional extra modules shipped with the game
└─ (game data files)

Kyty reads param.json for the title ID and the flexible-memory budget, mounts the directory as /app0, then loads eboot.bin.

src/emulator.cppsrc/loader/systemContent.cpp
Learn more · console internals
  • Article
    Architecture of Consoles — Rodrigo CopettiDeep, readable hardware analyses of essentially every console. Read the PlayStation entries to see how the design lineage arrives at the PS5. Widely recommended as the first thing to read before emulating any system.
  • Video
    Console Hacking 2016 — "PS4: PC Master Race" (33C3)Hector Martin and fail0verflow on PS4 hardware and how they reverse-engineered it far enough to run Linux with 3D acceleration. The closest thing to a primary source on how this hardware actually differs from a PC. Also on YouTube, with slides.
  • Article
    fail0verflow blogWrite-ups behind the talks, including the liner notes for each Console Hacking presentation.
03

Why there is no CPU emulator

KytyPS5 has no general guest-CPU interpreter or dynamic binary translator. It does generate ABI stubs and instruction trampolines, and it has a separate GPU shader compiler. This chapter explains how native guest-CPU execution fits those pieces together.

The instruction sets are the same

A PS5 game contains x86-64 machine code. In a compatible x86-64 host environment, most instructions can execute directly. Kyty handles specific instruction-feature gaps separately. The emulator:

  1. Allocates memory at the address the executable wants,
  2. Copies the code segments into it,
  3. Marks those pages executable,
  4. Sets up a stack and jumps to the entry point.

From that instruction onwards the CPU executes ordinary game instructions directly. The emulator is not "in the loop" at all — it only regains control when the game calls a function the emulator provides, or when the hardware raises a fault.

The mental model that matters

KytyPS5 is not a simulator that steps through the game. It is a host environment that the game runs inside. It is closer in spirit to Wine — which runs Windows programs on Linux — than to an emulator like Dolphin. Everything the emulator does is reactive: it responds to calls, to page faults, and to command buffers.

What that buys, and what it costs

ConsequenceDetail
good Full CPU speedNo translation overhead. CPU-bound games can in principle exceed console performance on a fast PC.
good Vastly less codeA competent x86-64 JIT is tens of thousands of lines on its own. Kyty spends that budget on graphics instead.
hard No general instruction-level controlThere is no general translator through which every memory instruction passes. Kyty uses hardware page protection for coherency and targeted rewriting for specific host incompatibilities, so the exception handler remains essential.
hard Crashes are real crashesA guest bug corrupts the emulator's own process. There is no sandbox. Hence the forensic crash dump: the faulting guest thread, all registers, the code bytes around the fault, the top of the stack.
hard Host CPU features must matchKyty patches or handles selected instruction-feature gaps. Unsupported forms can still fault; it is not a general instruction emulator — see chapter 18.
hard ABI and OS assumptions leakGuest code assumes System V registers, a working fs segment, 16 KB pages, and a FreeBSD kernel. None of that is true on Windows. Chapters 4, 16 and 19 are about closing those gaps.

The path a frame actually takes

Before any of the detail, here is the whole system in one animation. Every chapter from 25 onwards is an expansion of one of these eight boxes.

Where the difficulty actually went

Measured in lines of C++ in src/:

Where the code is158,608 lines · 391 files
libs — HLE system libraries
52,039
graphics/shader — recompiler
34,843
graphics/host_gpu — Vulkan
18,355
kernel — threads, memory, files
13,148
graphics/guest_gpu — PM4
10,650
common — platform layer
8,148
loader — ELF, linking
7,366
graphics/presentation
7,418
launcher — Qt front end
5,880

Counts are physical lines in .cpp and .h files under src/ at d2413fc2, including blank lines. The total includes the three entry-point files; the bars show the nine subsystem directories. Graphics accounts for 71,266 lines.

04

Prerequisite: x86-64 and calling conventions

The instruction sets match; the conventions do not. This chapter is the minimum x86-64 you need, and then the specific mismatch that shapes a surprising amount of KytyPS5.

The registers

x86-64 has sixteen 64-bit general-purpose registers. Their names are historical and inconsistent, which is confusing at first and then stops mattering:

rax rbx rcx rdx        the original four, extended to 64-bit
rsi rdi                "source index" / "destination index"
rbp rsp                frame pointer / stack pointer
r8 r9 r10 r11          added with x86-64
r12 r13 r14 r15

xmm0 … xmm15           128-bit vector registers, used for float arguments too
rip                    instruction pointer (not directly writable)
rflags                 condition flags

Plus segment registers, a leftover from 16-bit days. In 64-bit mode most are ignored, but two survive with a specific job: fs and gs provide a per-thread base address, used to reach thread-local storage. Remember fs — it comes back in chapter 16 and causes real trouble.

What a calling convention is

Machine code has no concept of a "function argument." When you write f(a, b), the compiler must decide: which register or stack slot does a go in? Who cleans up the stack? Which registers may f destroy? A calling convention (or ABI, application binary interface) is the agreed answer.

Two conventions matter here, and they disagree about almost everything:

Where do the arguments go?change the count
System V AMD64 — used by the PS5, Linux, macOS
Microsoft x64 — used by Windows

System V passes six integer arguments in registers; Microsoft passes four, and requires the caller to reserve 32 bytes of "shadow space" on the stack even when all arguments fit in registers. The register order is different too — rcx is the fourth argument under System V and the first under Microsoft.

They also differ on which registers a function must preserve. Under System V, rbx rbp r12–r15 are callee-saved. Under Microsoft, that list additionally includes rsi rdi and all 128 bits of xmm6–xmm15 (the upper YMM/ZMM portions remain volatile). A function compiled for one convention and called by the other will silently corrupt registers the caller expected to survive.

How KytyPS5 bridges it

Clang lets you set the convention per function with an attribute. Kyty defines two macros and uses them everywhere the boundary is crossed:

src/common/abi.h
#define KYTY_MS_ABI   __attribute__((ms_abi))
#define KYTY_SYSV_ABI __attribute__((sysv_abi))

Any function the guest can call is marked KYTY_SYSV_ABI. The compiler then emits a System V prologue for that one function, while the rest of the emulator is compiled with the platform's native convention:

int KYTY_SYSV_ABI ContentExportInit2(const ContentExportInitParam2* init_param) {
    PRINT_NAME();
    if (init_param == nullptr) return CONTENT_EXPORT_ERROR_INVALID_PARAM;
    ...
}

The reverse direction exists too. When the emulator generates machine code that guest code will call — the TLS stubs of chapter 16 — that stub must switch back to the Microsoft convention before calling into C++. You can read the switch happening by hand in Jit::SafeCall:

src/loader/jit.h — SafeCall, abbreviated
sub    rsp,0x80            make room
pushfq                     save flags
push   rcx / rdx / r8 / r9 / r10 / r11 / rbx / rdi / rsi
mov    rbx,rsp
and    rsp,0xfffffffffffffff0   align stack to 16 bytes
sub    rsp,0x20                 reserve Win64 shadow space
movabs rcx,<handler>
call   rcx
mov    rsp,rbx             restore, pop everything back
...
ret
Why the build refuses MSVC

Microsoft's compiler has no sysv_abi attribute. There is no way to write this code in MSVC, so the root CMakeLists.txt rejects cl.exe and the supported Windows build uses clang-cl. The POSIX branches also recognise GCC, although the documented and CI builds use Clang.

One more x86-64 detail: variadic functions

System V variadic calls carry the vector-register argument count in al. The current diagnostic import stub does not forward variadic calls; guestPrintf.cpp interprets the guest argument list explicitly instead of passing it to a host vprintf.

It is also why Kyty cannot simply forward a guest printf to the host's vprintf: the argument list layout differs. src/libs/guestPrintf.cpp implements the guest format-string interpreter and argument decoding for exactly this reason.

Learn more · the two calling conventions
05

Prerequisite: virtual memory and page protection

Page protection is not a side topic in this project — it is the mechanism by which the emulator detects that the game has touched something the GPU cares about. You need to understand it properly.

Addresses are lies

When a program reads address 0x900001000, that is a virtual address. The CPU's memory management unit translates it, through page tables the OS maintains, into a physical address in RAM. The translation happens per page — a fixed-size block, 4 KB on PC, 16 KB on PS5.

Each page table entry carries permission bits: readable, writable, executable. If a program accesses a page in a way the bits forbid, the CPU raises a fault. The OS delivers it to the process as an exception (Windows) or a signal (Linux, macOS).

Jargon · reserve versus commit

Reserving address space claims a range of virtual addresses so nothing else can use them, without allocating any physical memory. Committing backs part of that range with real memory.

This split is why an emulator can claim a terabyte of address space up front (see the memory map below) while using only a few gigabytes of RAM. Kyty reserves entire bands and hands out committed pieces from inside them.

The three things Kyty does with protection

1 · Enforce the guest's own protectionskytyWhen the game calls sceKernelMprotect, Kyty applies the equivalent host protection so that a genuine guest bug still faults the way it would on console.
2 · Detect CPU writes to GPU datakytyMemory the GPU has cached as a Vulkan image is deliberately write-protected. When the game writes to it, the fault tells the renderer its copy is stale. The fault is the notification.
3 · Commit reserved ranges lazilykytyEligible faults in the guest backing ranges can commit missing backing. A reserved address alone does not make an access valid; guest mappings, permissions and ownership still matter.

All three funnel into one function — the exception handler installed by the loader — which is why chapter 18 exists.

The page-size mismatch

The PS5 uses 16 KB pages; PCs use 4 KB. Kyty's guest allocator works in 0x4000-byte units:

src/loader/runtimeLinker.cpp
constexpr uint64_t GUEST_PAGE_SIZE = 0x4000;
program->base_size_aligned = AlignUp(program->base_size, GUEST_PAGE_SIZE);

One 16 KB guest page spans four 4 KB host pages. Guest VM alignment and renderer tracking are separate: TRACKER_PAGE_SIZE is 4 KB, so a tracking fault does not inherently dirty the entire 16 KB guest page. Host protection calls accept ranges and can cover several pages in one call.

The address map Kyty imposes

Kyty does not let the host OS choose where things go. It divides the address space into bands and enforces them:

Guest address space (Windows / Linux)click a band
0x00_0004_0000log scale — the user area is ~16× the others combined0xFB_FFFF_FFFF

macOS shifts these ranges 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

Why bands, not just "ask the OS"

Console software hard-codes addresses and assumes allocations land in particular ranges. It also passes pointers to the GPU, which in Kyty means those pointers must be distinguishable from emulator pointers. Fixing the bands makes both problems tractable: use the actual range bounds and loaded-segment metadata to classify an address. A hexadecimal prefix alone does not prove that a value is guest code, heap data or a host pointer.

06

Prerequisite: ELF and dynamic linking

A game is a file. Something has to turn that file into a running program. On Linux that job belongs to ld.so; in KytyPS5 it belongs to RuntimeLinker. To read that code you need to know what a dynamic linker does.

The ELF layout

ELF — Executable and Linkable Format — is the Unix executable format, and the PS5 uses it. An ELF file has two overlapping views:

ViewDescribed byUsed by
Sections (.text, .data, .rodata…)Section headersThe linker at build time. Often stripped from shipped binaries.
Segments (PT_LOAD, PT_DYNAMIC, PT_TLS…)Program headersThe loader at run time. This is the view Kyty cares about.

A program header entry says: "take p_filesz bytes at file offset p_offset, place them at virtual address p_vaddr, zero-fill up to p_memsz, and apply these permissions." Loading is essentially a loop over those entries.

src/loader/elf.h — the actual struct Kyty parses
struct Elf64_Phdr {
    Elf64_Word  p_type;    PT_LOAD, PT_DYNAMIC, PT_TLS, …
    Elf64_Word  p_flags;   PF_R | PF_W | PF_X
    Elf64_Off   p_offset;  where in the file
    Elf64_Addr  p_vaddr;   where in memory
    Elf64_Addr  p_paddr;   reserved
    Elf64_Xword p_filesz;  bytes present in the file
    Elf64_Xword p_memsz;   bytes needed in memory (remainder is zeroed)
    Elf64_Xword p_align;
};

Why relocation exists

A shared library does not know where it will be loaded. It is compiled position-independently, and every absolute address inside it is left as a placeholder. The loader must go back and fix them. Each fix is a relocation: a record saying "at offset X, write value Y."

The principal relocation types are:

TypeMeaningComputation
R_X86_64_RELATIVEAn internal pointer that just needs the load bias added.*where = base + addend
R_X86_64_64A 64-bit absolute reference to a symbol.*where = symbol + addend
R_X86_64_GLOB_DATA Global Offset Table entry for a data symbol.*where = symbol
R_X86_64_JUMP_SLOTA Procedure Linkage Table entry for a function.*where = symbol
R_X86_64_DTPMOD64Thread-local storage: which module owns this variable.module ID
Jargon · GOT and PLT

The Global Offset Table is an array of pointers. Instead of embedding an absolute address in code, the compiler emits "load the pointer from GOT slot 12, then use it." The loader fills the GOT, so the code itself never needs patching.

The Procedure Linkage Table is the same idea for calls. A call to an external function jumps to a small PLT stub, which jumps to whatever address the GOT holds.

This is the hook KytyPS5 uses. When the loader writes the address of its own C++ function into a GOT slot, every subsequent guest call to that "console function" lands in the emulator instead. No patching of the game's code is needed.

Symbols, and why the PS5's are unreadable

Normally a dynamic symbol has a name: printf, malloc. PS5 modules instead export NIDs — an 11-character encoding of a hash of the original name — qualified by the library and module they belong to:

b4fpgH5ZXxQ#Graphics5Driver#Graphics5Driver
└─ symbol ─┘ └─── library ───┘ └──── module ────┘

Kyty stores its HLE implementations under the same key, so resolution is a dictionary lookup. You will meet the exact mechanism in chapters 15 and 22.

Thread-local storage in one paragraph

A variable declared thread_local needs a different copy per thread. The usual implementation gives each thread a block of memory and a pointer to it, reachable through a segment register — fs on x86-64 Linux and FreeBSD. Code that reads a thread-local reads fs:[offset].

On x86-64 Windows the host TEB uses gs; a guest fs access does not address the guest TLS block. Chapter 16 is entirely about the fix.

Learn more · linking, PIC, GOT and PLT
  • Article
    Eli Bendersky — Position Independent Code in shared librariesThe clearest explanation anywhere of why the GOT and PLT exist and how they work, built up from disassembly. If one link in this book is worth reading before continuing, it is this one — the GOT is the exact hook KytyPS5's entire HLE design hangs on.
  • Article
    …and the x64 follow-upThe same material for x86-64, which is the architecture that actually matters here — RIP-relative addressing changes the picture noticeably.
  • Spec
    ELF specification (generic ABI)The section and segment structures of chapter 13, minus Sony's additions. Worth having open when reading src/loader/elf.h.
07

Prerequisite: how a GPU actually draws

Half of KytyPS5 is graphics, and the graphics code assumes you know how the hardware works — not how an API looks. This chapter covers the hardware model.

The pipeline

To draw a triangle, a GPU runs a fixed sequence of stages. Some are programmable (you supply a shader), some are fixed-function (configured by registers):

Input assemblyfixedRead the index buffer, group vertices into primitives (triangles, lines, points).
↓
Vertex shaderprogrammableRuns once per vertex. Reads attributes, outputs a clip-space position plus arbitrary interpolated values.
↓
RasterisationfixedConvert each triangle into the set of pixels it covers. Interpolate the vertex outputs across that area.
↓
Pixel (fragment) shaderprogrammableRuns once per covered pixel. Samples textures, computes lighting, outputs colour — and optionally depth.
↓
Depth / stencil test, blendingfixedReject hidden pixels, combine the new colour with what is already in the render target.

Separately, compute shaders bypass all of this: they run a grid of threads doing arbitrary work, used for physics, culling, post-processing and — in Kyty — for detiling textures.

How shaders actually execute: waves and lanes

This is the part that trips people up, and it is essential for chapter 30.

A GPU does not run one shader invocation at a time. It runs a wave — 32 or 64 invocations — in lockstep on one instruction stream. Each invocation is a lane. One instruction, executed simultaneously for all lanes on their own data.

So what happens at an if? Lanes can take different logical paths, but the wave shares an instruction stream. Instead the hardware keeps an execution mask: a 64-bit register where bit n enables lane n. Branching is implemented by masking:

          // if (x > 0) { A } else { B }
v_cmp_gt_f32  vcc, v0, 0      compare in every lane → 64-bit result in vcc
s_and_saveexec_b64  s[0:1], vcc   enable only lanes where the test passed
          … vector code for A — masked lanes do not update results …
s_andn2_b64   exec, s[0:1], exec  flip to the other lanes
          … code for B …
s_mov_b64     exec, s[0:1]        restore

In this example both vector instruction sequences are visited and disabled lanes do not update their vector results. Scalar instructions are wave-wide, and scalar branches can skip blocks; EXEC masking is not a universal rule for every instruction. This is why GPU code is fast when threads agree and slow when they diverge — and, more importantly for us, why GPU control flow is data manipulation rather than program structure. Recovering structure from it is the hardest job the shader recompiler has.

Jargon · scalar versus vector registers

RDNA 2 has two register files. VGPRs (vector registers) hold a different value per lane — a pixel's colour, a vertex's position. SGPRs (scalar registers) hold one value shared by the whole wave — a loop counter, a base address, a resource descriptor.

Image and buffer instructions consume resource descriptors through scalar registers, but shaders can load those descriptors from memory using runtime-selected addresses. Kyty must trace that dataflow and, where supported, discover a bounded set of compatible descriptor candidates.

Descriptors: how a shader reaches a texture

In a PC API you call BindTexture(slot, texture). On RDNA 2 there are no slots. A texture is described by a descriptor — a packed block of bits holding its address, format, size, tiling and swizzle — and the shader simply has that block in its registers, or loads it from memory itself.

AMD calls these "sharps":

NameSizeDescribes
V# (buffer resource)4 dwords / 128 bitsBase address, stride, record count, data format, component swizzle.
T# (image resource)8 dwords / 256 bitsBase address, format, width, height, depth, mip levels, tiling mode, array slices, DCC/HTile metadata address.
S# (sampler)4 dwords / 128 bitsFilter modes, clamp modes, anisotropy, LOD range and bias, border colour.

You can decode real ones here. The bit layouts are taken directly from Kyty's shaderBindings.h:

Sharp decoder — V# / T# / S#edit any dword

Every accessor here corresponds to a real method — ShaderBufferResource::Base48(), ShaderTextureResource::Width5(), ShaderSamplerResource::XyMagFilter() and so on. src/graphics/shader/shaderBindings.h

Tiling: textures are not stored in rows

A linear image stores pixel (x, y) at base + y*pitch + x*bpp. GPUs do not do this, because a shader sampling a texture reads a 2D neighbourhood, and a linear layout puts vertically adjacent pixels far apart in memory.

Instead pixels are interleaved into small blocks so that a 2D neighbourhood lands in one cache line. AMD has several schemes — 256 B, 4 KB, 64 KB, plus special layouts for render targets, depth buffers and partially-resident textures — each with its own swizzle function and alignment rules that vary with bytes-per-pixel and mip level.

Guest memory holds tiled data. Vulkan wants either linear data or its own opaque layout. Converting between them is chapter 29.

Learn more · how GPUs really work
08

Prerequisite: Vulkan and SPIR-V

Kyty targets Vulkan 1.3. You do not need to be able to write a Vulkan renderer to read this book, but you need the vocabulary and one key idea: Vulkan is explicit about everything, which is both why it suits an emulator and why it makes some things awkward.

The objects you will meet

Vulkan objectWhat it isWhere Kyty creates it
VkInstance / VkDeviceThe library connection and a logical GPU.vulkanWindow.cpp, stored in GraphicContext.
VkCommandBufferA recorded list of GPU commands. Nothing executes until submitted.CommandScheduler::CommandPool allocates in batches of four and reuses buffers after their timeline tick completes.
VkPipelineShader stages plus static pipeline state. Supported dynamic state, such as viewport and depth bounds, is set separately.PipelineCache, keyed on shader IDs and a packed state struct.
VkDescriptorSetA bundle of resource bindings handed to a pipeline.DescriptorHeap, allocated in batches and retired after the timeline tick completes.
VkImage / VkBufferGPU memory with a format and usage.TextureCache, BufferCache.
VkFence / timeline VkSemaphoreCPU–GPU and GPU–GPU synchronisation. A timeline semaphore carries a monotonically increasing counter.MasterSemaphore.
Why "explicit" matters here

In OpenGL the driver guessed what you meant, tracked state changes and re-compiled shaders behind your back. Vulkan removes all of that: you declare everything up front and manage synchronisation yourself.

For an emulator this is exactly right, because the guest has already declared everything — the PM4 register state is a complete pipeline description. Kyty's job is translation, not inference. The awkward part is the reverse: Vulkan bakes state into immutable pipeline objects, whereas the guest changes registers freely, so Kyty must detect which changes actually require a new pipeline. That is what the pipeline cache key is for.

SPIR-V, and why it is stricter than machine code

Vulkan does not accept GLSL or HLSL. It accepts SPIR-V: a binary intermediate language of 32-bit words. Every value has a declared type, every capability used must be requested, and — critically — control flow must be structured.

Structured means: every conditional branch declares in advance where its two paths reconverge (the merge block), and every loop declares both its merge block and its continue target. In SPIR-V assembly:

OpSelectionMerge %merge None      "these branches rejoin at %merge"
OpBranchConditional %cond %then %else
%then = OpLabel
  ...
  OpBranch %merge
%else = OpLabel
  ...
  OpBranch %merge
%merge = OpLabel

Compare that with the RDNA 2 snippet in chapter 7, which had no merge point at all — just mask manipulation and fall-through. Reconstructing the declarations SPIR-V demands, from code that never had them, is the single hardest pass in the recompiler, and it is allowed to fail: there is a documented fallback for shaders whose control flow cannot be structured.

A worked mapping

To make the shape of the translation concrete, here is one line of the same operation at four levels:

LevelRepresentation
Original shader sourcefloat d = dot(a, b);
RDNA 2 machine codev_mul_f32 v4, v0, v3 then v_fma_f32 v4, v1, v2, v4 …
Kyty decoder outputschematic: decoded Opcode::V_MUL_F32 with destination v4 and sources v0, v3
Kyty IRIR::ValueOpcode::FPMul32 with typed operands and source-location metadata
SPIR-V%r = OpFMul %float %a %b

Multiply is easy. The work is in the thousand cases that are not: packed 16-bit maths, saturating conversions, cross-lane shuffles, image sampling with a dozen modifier bits, buffer loads that need bounds checking SPIR-V does not provide for free.

Tools in the build

Kyty links SPIRV-Tools, so it can run spirv-val on everything it emits (--shader-validation true) and spirv-opt to optimise (--shader-optimization-type). Turning validation on is the fastest way to catch a malformed module before the GPU driver does something unpredictable with it.

Learn more · Vulkan and SPIR-V
  • Tutorial
    Vulkan TutorialThe standard first Vulkan tutorial: builds a triangle from nothing, covering instance, device, swapchain, pipeline and command buffers. You do not need to finish it to read this book, but working through the pipeline and command-buffer chapters makes chapter 31 far more legible.
  • Tutorial
    Vulkan Guide (vkguide.dev)A more practical, engine-shaped take on the same material — closer to how a renderer is actually structured. Its Great Resources page is a good onward index.
  • Hub
    Khronos — Learn VulkanThe official starting point, including the Khronos-maintained samples and tutorials.
  • Spec
    SPIR-V SpecificationRead section 2.11, "Structured Control Flow", before chapter 30. It defines OpSelectionMerge and OpLoopMerge and the dominance rules that make the structurisation pass necessary — and that make it able to fail.
  • Paper
    An Introduction to SPIR-VA short white paper on what SPIR-V is and why it looks the way it does. Much gentler than the specification.
  • Code
    SPIRV-ToolsThe validator and optimiser Kyty links against. spirv-dis from this repository is what you use to read a dumped module as text.
Part II

The skeleton

What the project is made of, how it is built, and what happens between double-clicking the executable and the game's first instruction.

09

Repository layout

Nine directories under src/, plus third-party dependencies and tests. Here is what lives where and, more usefully, what each directory is responsible for.

KytyPS5/
├─ CMakeLists.txt          targets, compiler checks, dependencies and tests
├─ src/
│  ├─ main.cpp             argument parsing
│  ├─ emulator.cpp         init, mount, load, execute
│  ├─ common/              platform services, logging and shared utilities
│  ├─ loader/              ELF/SELF loading, linking and instruction patching
│  ├─ kernel/              memory, pthreads, files and events
│  ├─ libs/                HLE system libraries
│  ├─ graphics/
│  │  ├─ guest_gpu/        PM4, registers and tiling
│  │  ├─ shader/           RDNA 2 → typed IR → SPIR-V
│  │  ├─ host_gpu/         Vulkan backend
│  │  └─ presentation/     window, swapchain, flip and overlay
│  └─ launcher/            optional Qt front end
├─ 3rdparty/               submodules and dependency configuration
├─ tests/                  29 portable executables, plus x86-64 macOS SSE4a
└─ docs/                   upstream project media and documentation

Responsibilities

DirectorySideOwns
common/hostEverything OS-specific, behind one interface: virtual memory (sysWindowsVirtual / sysLinuxVirtual), file I/O, threads, timers, exception handling, logging, the singleton and subsystem frameworks, string and date utilities.
loader/kytyParsing SELF/ELF, mapping segments, patching instructions, applying relocations, resolving NIDs, generating stubs, installing the exception handler, and applying ETAHen/GoldHEN-style cheats.
kernel/kytyThe PS5 kernel services too central for libs/: the memory manager, the pthread implementation, the file system, event queues, semaphores, event flags.
libs/kytyUser-space module reimplementations — audio, network, fonts, save data, video, dialogs, JSON, PNG, trophies, and the AGC graphics driver that builds PM4.
graphics/guest_gpu/guestThe PS5 GPU's own side: PM4 packet definitions and parsing, the hardware register file, the command-processor state machine, tiling arithmetic.
graphics/shader/kytyA compiler. Decoder, control-flow graph, IR, dataflow analyses, resource tracking, SPIR-V emitter.
graphics/host_gpu/hostVulkan: instance and device setup, command scheduling, pipelines, descriptors, and the five caches that map guest memory to Vulkan objects.
graphics/presentation/hostSDL3 window, Vulkan swapchain, the VideoOut flip model, virtual vertical blank, RenderDoc integration.
launcher/hostQt 6 GUI. Scans folders for eboot.bin, edits per-title settings, shows compatibility data and trophies, launches kyty_emulator with the right flags. Touches no emulation code at all.

Files with broad responsibilities

FileWhy it is big
libs/agcRegisterDefaults.incA generated table of the values the graphics driver writes at context reset.
libs/agc.cppAGC command-buffer builder functions and validation.
guest_gpu/…/pm4Handlers.cppPM4 opcode and hardware-register handlers.
kernel/memory.cppDirect, flexible and pooled memory, page attributes, naming, batches and PRT apertures.
kernel/pthread.cppA pthread implementation with FreeBSD semantics.
libs/libNet.cppThe large network HLE surface.
10

Build system and dependencies

CMake plus Ninja plus Clang. The constraints are unusual enough to be worth explaining rather than just listing.

Configuring

# Windows — from an x64 Native Tools Command Prompt for VS 2022
cmake -S src -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 kyty_emulator   # emulator only
cmake --build _Build/windows --target launcher        # + Qt front end
cmake --install _Build/windows --prefix _Build/windows/install
# Linux
sudo apt-get install --no-install-recommends \
  clang lld ninja-build cmake git glslang-tools pkg-config \
  libgl1-mesa-dev libx11-dev libxcursor-dev libxext-dev libxfixes-dev \
  libxi-dev libxrandr-dev libxss-dev libxtst-dev libxkbcommon-dev \
  libasound2-dev libpulse-dev libudev-dev libdbus-1-dev libwayland-dev wayland-protocols

cmake -S . -B _Build/linux -G Ninja -DCMAKE_BUILD_TYPE=Release \
      -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \
      -DCMAKE_PREFIX_PATH="$Qt6_DIR"
Three build facts that cause most failures

The repository root is the CMake source root. Use cmake -S .; older commands using -S src refer to the previous build layout.

MSVC is rejected. The project needs __attribute__((sysv_abi)), so clang-cl is required on Windows. This is checked at configure time.

The audio and input packages are not optional on Linux. Without the ALSA, PulseAudio, udev and Wayland development packages, the bundled SDL3 silently configures itself without those backends and the resulting build has no sound and no gamepad hotplug.

Current build options

The root CMakeLists.txt requires CMake 3.19 or newer (the root declares compatibility through 4.3; dependencies may impose additional requirements). Use clang-cl on Windows and the documented Clang toolchain on Linux/macOS. glslangValidator must be on PATH. CMake automatically selects sccache, then ccache, unless a compiler launcher was already supplied.

Qt is optional: configure a separate directory with -DKYTY_BUILD_LAUNCHER=OFF to build kyty_emulator and kyty_tests without it. SDL3 provides window, input and audio services. FFmpeg comes from the pinned static FFmpeg-core release, including VP9/WebM; system FFmpeg packages are not required. Dependencies include both submodules and configure-time downloads, including ZArchive and zstd.

cmake -S . -B _Build/no-qt -G Ninja -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \
  -DKYTY_BUILD_LAUNCHER=OFF
cmake --build _Build/no-qt --target kyty_emulator kyty_tests --parallel
ctest --test-dir _Build/no-qt --output-on-failure

On NixOS, enter nix develop or nix-shell first. The shell supplies the toolchain, Qt paths and SDL3 backend dependencies; configure still needs network access. See flake.nix and nix/devshell.nix.

macOS is an unusual case

macOS builds target x86-64 and run under Rosetta 2 on Apple Silicon. That is not a compromise — it is required. The PS5's code is x86-64, and Kyty executes it natively, so the emulator must itself be an x86-64 process for the guest code to run inside it. The guest's instructions then go through the same Rosetta translation as the emulator's own.

Vulkan comes from MoltenVK, which implements it over Metal. The build re-signs the binary with JIT entitlements, because generating and executing code (the stubs of chapters 16–17) requires them under the hardened runtime.

Dependencies

LibraryRole
Vulkan-HeadersAPI declarations. Vulkan is loaded dynamically, so no SDK is needed to build.
VulkanMemoryAllocatorSub-allocates GPU memory. Vulkan's raw allocation is too coarse to use directly.
SPIRV-Headers, SPIRV-ToolsEmission, validation (spirv-val) and optimisation (spirv-opt) of recompiled shaders.
SDL3Window, keyboard, mouse, gamepad, and the Vulkan surface. Statically linked.
ffmpeg-coreVideo decode for AVPlayer; AAC and MP3 for the AJM audio block.
LibAtrac9Sony's ATRAC9 audio codec, which FFmpeg does not cover.
fmt, spdlogFormatting and logging.
nlohmann_jsonparam.json and ETAHen/GoldHEN-style cheat files.
magic_enumEnum reflection — this is why CLI flags accept enumerator names directly.
xxHashFast hashing for shader and pipeline cache keys.
tracyFrame profiler, enabled with --profile.
stb, cpuinfo, winpthreadImage decode, CPU feature detection, pthreads on Windows.
11

Subsystems and configuration

Kyty has no dependency-injection framework or service locator. Current code uses a singleton template where global identity is needed, plus a small explicit lifecycle stack that guarantees reverse-order normal or emergency teardown.

The subsystem contract

A managed component exposes a lightweight Lifecycle type. It always names an initialize callback and may provide normal and emergency shutdown callbacks:

src/common/subsystems.h
struct Lifecycle {
    static constexpr const char* name       = "Graphics";
    static constexpr auto        initialize = Graphics::Initialize;
    static constexpr auto        shutdown   = Graphics::Shutdown;
    static constexpr auto        emergency_shutdown = Graphics::EmergencyShutdown; // optional
};

template <typename Lifecycle>
void Subsystems::Initialize() {
    Lifecycle::initialize();
    m_active.push_back({ShutdownCallback<Lifecycle>(),
                        EmergencyCallback<Lifecycle>()});
}

Subsystems stores callbacks only after initialization succeeds. Its destructor walks normal shutdown callbacks in reverse; EmergencyShutdownActive() walks the optional emergency callbacks in reverse for quick_exit. That distinction matters when guest threads are still running.

Ordering is explicit

The dependency graph and topological sort were removed. Emulator::Init() now spells out the valid order directly, and the lifecycle stack automatically reverses it during teardown:

src/emulator.cpp
subsystems.Initialize<Config::Lifecycle>();
Config::Load(cfg);
subsystems.Initialize<Log::Lifecycle>();
// load param.json and set the flexible-memory budget
subsystems.Initialize<Loader::Timer::Lifecycle>();
subsystems.Initialize<LibKernel::PthreadLifecycle>();
subsystems.Initialize<Profiler::Lifecycle>();
subsystems.Initialize<Network::Lifecycle>();
subsystems.Initialize<LibKernel::Memory::Lifecycle>();
subsystems.Initialize<LibKernel::FileSystem::Lifecycle>();
subsystems.Initialize<Controller::Lifecycle>();
subsystems.Initialize<Audio::Lifecycle>();
subsystems.Initialize<Graphics::Lifecycle>();

The order itself is the dependency documentation. Configuration precedes logging because the logger reads its sink settings; param.json precedes memory because it can change the flexible-memory budget; graphics comes last because it consumes most of the services above it.

Configuration reaches the emulator through the command line

ConfigOptions remains the emulator's runtime configuration object. The Qt launcher now exposes global and per-game settings, persists them with QSettings, then converts the selected profile into emulator arguments:

src/common/emulatorConfig.h
struct ConfigOptions {
    uint32_t    screen_width       = 1280;
    uint32_t    screen_height      = 720;
    std::string user_name          = "Kyty";
    int32_t     user_id            = 1000;
    PresentMode present_mode       = PresentMode::Mailbox;
    bool        fullscreen_enabled = false;
    uint32_t    vblank_frequency   = 60;
    uint32_t    console_language   = 1;
    // validation, shader logging, command-buffer and graphics dumps…
    LogDirection printf_direction = LogDirection::Silent;
    bool gpu_assisted_validation_enabled = false;
    bool readback_linear_images           = false;
    bool playgo_hack_enabled              = false;
    Keymap keymap;
};

Enum-valued flags are parsed with magic_enum, so the accepted strings are literally the enumerator names — --shader-log-direction File works because LogDirection::File exists:

template <typename E>
static bool ParseEnum(const std::string& value, E& out) {
    auto enum_value = magic_enum::enum_cast<E>(value.c_str());
    if (!enum_value.has_value()) return false;
    out = enum_value.value();
    return true;
}
Where the Qt launcher fits

It stores global and per-title profiles, input mappings, compatibility metadata, and the selected ELF, then builds a command line and spawns kyty_emulator. Direct CLI runs remain useful for debugging, but the launcher is now a real configuration surface rather than only a game picker.

12

The boot sequence

Eleven steps between launching the executable and the game's own code running. Several exist for reasons that are not obvious, so each step below states why it is where it is.

Startup, step by stepclick, or use ← →

The whole thing as code

Emulator::Run is short enough to read in full, and it is the boot sequence:

src/emulator.cpp
void Run(const RunOptions& options) {
    if (options.app0_dir.empty()) EXIT("app0 directory is required\n");
    if (options.elf.empty())      EXIT("ELF is required\n");

    const auto param_json = options.app0_dir / "sce_sys" / "param.json";
    Common::Subsystems subsystems(true);
    Init(options.config, param_json, subsystems); // subsystems, in dependency order

    ClearDebugTextureFolder();
    PrintSystemInfo();

    int ok = atexit(KytyClose);
    EXIT_NOT_IMPLEMENTED(ok != 0);

    Libs::LibKernel::FileSystem::Mount(options.app0_dir, "/app0");
    Libs::LibKernel::FileSystem::Mount(options.app0_dir, "/hostapp");
    MountSandboxDirs();                         // /download0, /temp0, /temp

    auto* rt = Common::Singleton<Loader::RuntimeLinker>::Instance();
    Libs::InitAll(rt->Symbols());               // register every HLE NID

    LoadElf(options.elf);                       // map eboot.bin into guest memory
    Execute(options.game_patch);                // spawn guest thread, run window loop
}

Two threads leave Execute

src/emulator.cpp
static void Execute(const std::filesystem::path& game_patch) {
    auto patch_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 main thread becomes the window and input loop; the game gets a spawned thread. This is required — macOS and Windows both insist that window creation and event pumping happen on the process's first thread — and it has a useful side effect: a hung game does not freeze the window.

And then the guest thread

src/loader/runtimeLinker.cpp
void RuntimeLinker::Execute(const std::filesystem::path& game_patch) {
    Libs::LibKernel::PthreadInitSelfForMainThread();
    auto* main_stack_top = Libs::LibKernel::PthreadCreateMainGuestStack();

#if KYTY_PLATFORM == KYTY_PLATFORM_WINDOWS
    // Guest code has no Windows stack probes and may jump over the guard page.
    size_t expanded_size = 0;
    while (expanded_size < static_cast<size_t>(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;
    }
#endif

    const auto libc_path = Libs::LibKernel::FileSystem::GetRealFilename("/app0/sce_module/libc.prx");
    auto* libc = Common::File::IsFileExisting(libc_path) ? LoadProgram(libc_path) : nullptr;
    RelocateAll();                 // resolve every import in every module

    if (!game_patch.empty()) GamePatch::Apply(game_patch, m_programs.front());
    if (libc != nullptr && libc->dynamic_info->init_vaddr != 0) {
        StartModule(libc, 0, nullptr, nullptr);
    }

    if (auto entry = GetEntry(); entry != 0) {
        auto* params = reinterpret_cast<EntryParams*>(
            (reinterpret_cast<uintptr_t>(main_stack_top) - 0x100u) & ~static_cast<uintptr_t>(0x0f));
        std::memset(params, 0, sizeof(EntryParams));
        params->argc    = 1;
        params->argv[0] = "KytyEmu";
        RunEntry(entry, params, ProgramExitHandler, ...);   // never returns normally
    }
}
Why 768 KB of stack is touched on purpose

Windows commits thread stacks lazily with a guard page: touching it faults, the OS commits another page and moves the guard down. This only works if code touches the stack in order, which is why MSVC emits __chkstk probes for large stack frames.

PS5 code, compiled for FreeBSD, has no such probes. A function with a large local array can write past the guard page, and Windows will then treat that as a real stack overflow and terminate the process. Module initialisers also run on the host stack before the guest stack is in use.

So Kyty walks the guard page down 768 KB by hand before any guest code runs, forcing the whole region to commit up front.

Once RunEntry jumps, the CPU is executing the game. Everything the emulator does from then on is reactive: it responds to function calls, to page faults, and to submitted command buffers.

Part III

Running guest code

The loader, the memory manager and the kernel layer. This is where a file becomes a process, and where the awkward differences between FreeBSD-on-a-console and Windows-on-a-PC get closed one at a time.

13

SELF and ELF in detail

Sony's executable format is standard ELF64 wrapped in a container, with a set of custom type and tag values bolted on. Here is the complete set Kyty understands.

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 SELF container

eboot.bin starts with a small header describing compressed or encrypted segments, followed by the ELF proper:

src/loader/elf.h
struct SelfHeader {
    uint8_t  ident[12];
    uint16_t size1;
    uint16_t size2;
    uint64_t file_size;
    uint16_t segments_num;
    uint16_t unknown;
    uint32_t pad;
};

struct SelfSegment {
    uint64_t type;
    uint64_t offset;
    uint64_t compressed_size;
    uint64_t decompressed_size;
};

Elf64::IsSelf() tests for the container; IsNextGen() distinguishes PS5-era files from PS4-era ones. Retail files are encrypted, which is why the project cannot and does not distribute games — you supply files you already have the right to use.

The ELF header values that are not standard

FieldValueMeaning
e_ident[EI_OSABI]ELFOSABI_FREEBSD = 9Confirms the FreeBSD heritage.
e_machineEM_X86_64 = 62x86-64, as expected.
e_typeET_DYNEXEC = 0xfe10Sony-specific: a main executable that is nevertheless position-independent.
e_typeET_DYNAMIC = 0xfe18Sony-specific: a shared module.

Custom segment types

p_typeValueContents
PT_LOAD1Standard: map this into memory.
PT_DYNAMIC2Standard: the dynamic tag array.
PT_TLS7Standard: the thread-local storage template image.
PT_OS_DYNLIBDATA0x61000000Sony: a blob holding the symbol table, string table and relocation tables. The dynamic tags below index into this blob, not into the file.
PT_OS_PROCPARAM0x61000001Sony: the process-parameter block the runtime reads at start-up.
PT_OS_RELRO0x61000010Sony: data that becomes read-only after relocation. Kyty maps it like PT_LOAD.

Custom dynamic tags

Standard ELF has DT_SYMTAB, DT_STRTAB, DT_RELA and so on. Sony duplicated the whole set in the OS-specific range so they can refer into PT_OS_DYNLIBDATA:

TagValuePurpose
DT_OS_SYMTAB / SYMTABSZ0x61000039 / 0x6100003fDynamic symbol table and its size.
DT_OS_STRTAB / STRSZ0x61000035 / 0x61000037String table holding NIDs and library names.
DT_OS_RELA / RELASZ / RELAENT0x6100002f / 31 / 33General relocations.
DT_OS_JMPREL / PLTRELSZ0x61000029 / 0x6100002dPLT relocations — the function imports.
DT_OS_PLTGOT0x61000027Address of the Global Offset Table.
DT_OS_MODULE_INFO0x6100000dName and version of a module this file exports.
DT_OS_NEEDED_MODULE0x6100000fName and version of a module this file imports.
DT_OS_EXPORT_LIB / IMPORT_LIB0x61000013 / 0x61000015Library names and versions on each side.
DT_OS_FINGERPRINT0x61000007Build identifier.

The module and library tables matter more than they look. A NID is only unique within a library, so resolution needs all three parts. GetDynModules and GetDynLibs unpack these into DynamicInfo:

src/loader/runtimeLinker.h — abbreviated
struct DynamicInfo {
    char*       str_table;      uint64_t str_table_size;
    Elf64_Sym*  symbol_table;   uint64_t symbol_table_total_size;
    Elf64_Rela* rela_table;     uint64_t rela_table_total_size;
    Elf64_Rela* jmprela_table;  uint64_t jmprela_table_size;
    uint64_t    init_vaddr, fini_vaddr;
    uint64_t    init_array_vaddr, init_array_size;
    std::vector<ModuleId>    export_modules, import_modules;
    std::vector<LibraryId>   export_libs,    import_libs;
};

The encoded identifiers

Module and library identifiers in a NID string are not names — they are small integers encoded in a base64-like alphabet. EncodeId64 converts an index into the one- or two-character form, and resolution then looks that up in the file's own import tables to find the real name and version. This is why the same NID string can mean different things in two different modules.

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

Mapping a program into memory

One function does the whole job. It is worth reading line by line because almost every design decision in the loader is visible in it.

Deciding how much space to reserve

The mapped size is the highest address any loadable segment reaches, rounded up to a guest page, plus optional instruction-trampoline space and room for the main module’s generated TLS handler:

src/loader/runtimeLinker.cpp
static uint64_t CalcBaseSize(const Elf64_Ehdr* ehdr, const Elf64_Phdr* phdr) {
    uint64_t base_size = 0;
    for (Elf64_Half i = 0; i < ehdr->e_phnum; i++) {
        if (phdr[i].p_memsz != 0 &&
            (phdr[i].p_type == PT_LOAD || phdr[i].p_type == PT_OS_RELRO)) {
            uint64_t last_addr = phdr[i].p_vaddr + GetAlignedSize(phdr + i);
            if (last_addr > base_size) base_size = last_addr;
        }
    }
    return base_size;
}
program->base_size         = CalcBaseSize(ehdr, phdr);
program->base_size_aligned = AlignUp(program->base_size, 0x4000);   // 16 KB guest page

uint64_t tls_handler_size  = is_shared ? 0 : Jit::SafeCall::GetSize();
program->mapped_size       = program->base_size_aligned + tls_handler_size;

program->base_vaddr = Libs::LibKernel::Memory::AllocateProgramMemory(
    g_desired_base_addr, program->mapped_size,
    Common::VirtualMemory::Mode::ExecuteReadWrite,
    Common::PathToString(program->file_name.filename()).c_str());

g_desired_base_addr += CODE_BASE_INCR * (1 + program->mapped_size / CODE_BASE_INCR);

Note that the region is initially mapped ExecuteReadWrite. Correct protection is applied per segment afterwards — the loader needs write access to copy data in and to patch instructions, before locking things down.

The layout constants

src/loader/runtimeLinker.cpp
constexpr uint64_t SYSTEM_RESERVED  = 0x800000000u;
constexpr uint64_t CODE_BASE_INCR   = 0x010000000u;   // 256 MB between modules
constexpr uint64_t CODE_BASE_OFFSET = 0x100000000u;

static uint64_t g_desired_base_addr = SYSTEM_RESERVED + CODE_BASE_OFFSET;   // 0x900000000

The preferred main-module base is 0x900000000. The next preferred base advances by enough 256 MiB steps for the mapped size. Check the actual load log and module segments when resolving a fault address; a numeric address band alone does not identify a caller.

File on disk → guest memorywhat LoadProgramToMemory does
eboot.bin (SELF → ELF64) guest memory base_vaddr = 0x900000000 ELF header + phdrs PT_LOAD R-X .text PT_LOAD R-- .rodata PT_LOAD RW- .data (filesz < memsz) PT_TLS (template) PT_OS_DYNLIBDATA PT_OS_PROCPARAM +0x00000000 ExecuteRead code ← PatchProgram runs here +0x… Read read-only data +0x… ReadWrite data tail zero-filled to memsz per-thread TLS copies (lazy) proc_param_vaddr mapped_size

Solid arrows are direct copies: file bytes land at p_vaddr + base_vaddr. Dashed arrows are recorded rather than copied — the TLS segment is a template each thread copies from later, and the process-parameter block is just an address the runtime reads. The .data segment shows the filesz < memsz case: the difference is zero-filled, which is how .bss arrives.

The segment loop

for (Elf64_Half i = 0; i < ehdr->e_phnum; i++) {
    if (phdr[i].p_memsz != 0 &&
        (phdr[i].p_type == PT_LOAD || phdr[i].p_type == PT_OS_RELRO)) {

        uint64_t segment_addr        = phdr[i].p_vaddr + program->base_vaddr;
        uint64_t segment_file_size   = phdr[i].p_filesz;
        uint64_t segment_memory_size = GetAlignedSize(phdr + i);
        auto     mode                = GetMode(phdr[i].p_flags);

        program->elf->LoadSegment(segment_addr, phdr[i].p_offset, segment_file_size);

        bool skip_protect = (phdr[i].p_type == PT_LOAD && is_next_gen &&
                             mode == Common::VirtualMemory::Mode::NoAccess);

        if (Common::VirtualMemory::IsExecute(mode)) {
            PatchProgram(program, segment_addr, segment_memory_size);   // chapter 16
        }

        if (!skip_protect) {
            Memory::SetProgramMemoryProtection(segment_addr, segment_memory_size, mode);
            if (Common::VirtualMemory::IsExecute(mode)) {
                Common::VirtualMemory::FlushInstructionCache(segment_addr, segment_memory_size);
            }
        }
    }

    if (phdr[i].p_type == PT_TLS) {
        program->tls.image_vaddr = phdr[i].p_vaddr + program->base_vaddr;
        program->tls.init_size   = std::min(phdr[i].p_filesz, GetAlignedSize(phdr + i));
        program->tls.image_size  = GetAlignedSize(phdr + i);
        program->tls.tcb_offset  = program->tls.image_size;
    }

    if (phdr[i].p_type == PT_OS_PROCPARAM) {
        program->proc_param_vaddr = phdr[i].p_vaddr + program->base_vaddr;
    }
}

Three details worth extracting:

  • Patch before protect. The rewriting in chapter 16 needs write access, and the instruction cache flush must happen after the bytes change. Reversing the order would either fault or execute stale instructions.
  • skip_protect exists because some PS5 executables declare PT_LOAD segments with no access flags at all. Applying NoAccess literally would make them unreadable; the loader leaves them mapped instead.
  • The TLS image is a template. PT_TLS describes the initial contents; each thread gets its own copy, allocated lazily by TlsGetAddr.

Loading order across modules

RuntimeLinker::Execute() resolves /app0/sce_module/libc.prx through the mounted filesystem and loads it automatically if present, then calls RelocateAll(). It starts libc’s initializer when available. Other PRXs are loaded and started when the application requests them through the kernel module APIs; there is no directory-wide preload or dependency-sorted startup pass.

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

Relocation and NID resolution

This is the join between guest and emulator. Understanding it makes the entire HLE design obvious.

What a relocation record contains

src/loader/elf.h
struct Elf64_Rela {
    Elf64_Word GetSymbol() const { return r_info >> 32u; }        // index into symtab
    Elf64_Word GetType()   const { return r_info & 0xffffffff; }  // R_X86_64_*

    Elf64_Addr   r_offset;   // where to write, relative to the module base
    Elf64_Xword  r_info;     // symbol index + relocation type
    Elf64_Sxword r_addend;   // constant to add
};

Relocation is a loop over two tables — rela_table (data references) and jmprela_table (function calls through the PLT) — computing a value and storing it at base_vaddr + r_offset.

Resolution

For the types that reference a symbol, the loader takes the symbol's name from the string table and calls Resolve. The name is a NID triple:

src/loader/runtimeLinker.cpp
void RuntimeLinker::Resolve(const std::string& name, SymbolType type, Program* program,
                            SymbolRecord* out_info, bool* bind_self) {
    Common::LockGuard lock(m_mutex);
    auto ids = Common::Split(name, '#');          // "nid#library#module"

    if (ids.size() == 3) {
        const LibraryId* l = FindLibrary(*program, ids.at(1));
        const ModuleId*  m = FindModule(*program, ids.at(2));

        auto resolve_by_nid = [this, type](const std::string& nid, SymbolRecord* out) -> bool {
            // 1. the HLE database — Kyty's own implementations
            if (m_symbols != nullptr) {
                if (const auto* rec = m_symbols->FindByNid(nid, type); rec != nullptr) {
                    *out = *rec; return true;
                }
            }
            // 2. every loaded guest module's export table
            for (auto* p: m_programs) {
                if (p != nullptr && p->export_symbols != nullptr) {
                    if (const auto* rec = p->export_symbols->FindByNid(nid, type); rec != nullptr) {
                        *out = *rec; return true;
                    }
                }
            }
            return false;
        };
        ...

The HLE database is searched first. That is the whole trick: if Kyty implements a function, the guest gets Kyty's address even when a real module also exports it.

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.

How the HLE database is populated

Every implementation registers itself with two macros. LIB_VERSION declares the identity of the module being impersonated, and LIB_FUNC binds a NID to a C++ function pointer:

src/libs/libs.h
#define LIB_ADD(n, f, t)                                            \
    {                                                               \
        Loader::SymbolResolve sr {};                                \
        sr.name                 = n;            // the NID          \
        sr.library              = g_library;                        \
        sr.library_version      = g_library_version;                \
        sr.module               = g_module;                         \
        sr.module_version_major = g_module_version_major;           \
        sr.module_version_minor = g_module_version_minor;           \
        sr.type                 = t;                                \
        auto        func     = reinterpret_cast<uint64_t>(f);       \
        const char* dbg_name = "" #f;           // for logs         \
        s->Add(sr, func, dbg_name);                                 \
    }

#define LIB_FUNC(n, f)   LIB_ADD(n, f, Loader::SymbolType::Func)
#define LIB_OBJECT(n, f) LIB_ADD(n, f, Loader::SymbolType::Object)

In use — this is a real fragment of the graphics driver:

src/libs/libAgcDriver.cpp
LIB_VERSION("Graphics5", 1, "Graphics5", 1, 1);

LIB_DEFINE(InitGraphicsDriver_1) {
    LIB_FUNC("23LRUSvYu1M", Gen5::GraphicsInit);
    LIB_FUNC("f3dg2CSgRKY", Gen5::AgcCreateShader);
    LIB_FUNC("k3GhuSNmBLU", Gen5::AgcCbDispatch);
    LIB_FUNC("wr23dPKyWc0", Gen5::AgcCbReleaseMem);
    ...
}

The complete path, end to end

The same five steps in words:

1 · RegistrationkytyLibs::InitAll() runs every LIB_DEFINE block, inserting thousands of (NID, library, module, version) → function-pointer records into the shared SymbolDatabase. Nothing from the game has been loaded yet.
↓
2 · The game's importguestThe game's jmprela table contains a R_X86_64_JUMP_SLOT record naming 23LRUSvYu1M#Graphics5#Graphics5, targeting a GOT slot.
↓
3 · ResolutionkytyResolve() splits the name, maps the library and module identifiers through the game's own tables, finds the record, and returns the address of Gen5::GraphicsInit.
↓
4 · Patch the GOTkytyThe address is written into the GOT slot. The game's code is untouched.
↓
5 · The callguestThe game calls its PLT stub, which jumps through the GOT — straight into Kyty's C++ function, with arguments in System V registers, which is exactly what KYTY_SYSV_ABI prepared it for.
Two ways this silently fails

If a NID string has a single character wrong, or if the LIB_VERSION module/library version does not match what the game imports, resolution simply does not match — and the import falls through to the stub path of chapter 17. The function is never called and nothing announces it. When an implementation you wrote is never reached, check those two things first, then grep the log for the NID appearing as unresolved.

16

Patching guest instructions

Two instruction patterns in PS5 code cannot execute on Windows. Since there is no recompiler to fix them during translation, the loader rewrites the bytes in place before the code is ever run.

Problem 1 — thread-local storage through fs

PS5 code reads its thread control block with a single instruction:

64 48 8B 04 25 00 00 00 00     mov rax, qword ptr fs:[0]

On x86-64 Windows the host TEB is addressed through gs. The guest’s fs:[0] assumption does not provide its TCB on the host, so the loader redirects the recognized access to a guest TLS helper.

The fix replaces those exact nine bytes with a call to a generated stub, then moves the result into whatever register the original targeted:

src/loader/runtimeLinker.cpp
const uint8_t tls_pattern[5] = {0x64, 0x48, 0x8B, 0x00, 0x25};

for (auto* ptr = start_ptr; ptr <= end_ptr; ptr++) {
    auto*  inst_ptr     = ptr;
    size_t prefix_count = 0;
    // compilers pad this sequence with up to three 0x66 prefixes
    while (prefix_count < 3 && inst_ptr < start_ptr + size && *inst_ptr == 0x66) {
        inst_ptr++; prefix_count++;
    }

    const uint8_t modrm = inst_ptr[3];
    if (memcmp(inst_ptr, tls_pattern, 3) == 0 && (modrm & 0xc7u) == 0x04u &&
        inst_ptr[4] == tls_pattern[4] &&
        *reinterpret_cast<const uint32_t*>(inst_ptr + 5) == 0) {

        const auto reg = (modrm >> 3u) & 7u;      // which register was the destination
        EXIT_NOT_IMPLEMENTED(reg == 4u);          // rsp would be nonsense

        auto* code = new (ptr) Jit::Call9;
        code->SetFunc(reg == 0 ? program->tls.handler_vaddr
                               : program->tls.handler_vaddr + Jit::TlsRegStub::GetOffset(reg));
        ...
    }
}

Jit::Call9 is exactly nine bytes, so the replacement is size-neutral and no addresses shift:

src/loader/jit.h
struct Call9 {
    void SetFunc(Handler func) { // compute a rip-relative displacement }
    static uint64_t GetSize() { return 9; }

    // call func      E8 xx xx xx xx
    // mov  rax,rax   48 89 C0
    // nop            90
    uint8_t code[9] = {0xE8, 0, 0, 0, 0, 0x48, 0x89, 0xC0, 0x90};
};

The mov rax,rax byte is rewritten by SetOutputReg so the value lands in the register the original instruction wanted. For registers other than rax the stub jumps to a per-register variant at a fixed offset (TlsRegStub::GetOffset(reg)), each of which does call handler; mov <reg>, rax.

What the handler does

src/loader/runtimeLinker.cpp
static KYTY_MS_ABI uint8_t* TlsMainGetAddr() {
    if (g_tls_cached_main_program == g_tls_main_program && g_tls_cached_main_tcb != nullptr) {
        return g_tls_cached_main_tcb;                       // fast path
    }
    g_tls_cached_main_program = g_tls_main_program;
    g_tls_cached_main_tcb =
        RuntimeLinker::TlsGetAddr(g_tls_main_program) + g_tls_main_program->tls.tcb_offset;
    return g_tls_cached_main_tcb;
}

Note KYTY_MS_ABI: this is called from generated code that has already switched conventions. The SafeCall stub of chapter 4 sits between them, saving flags and volatile registers, aligning the stack and reserving shadow space.

Why this is expensive but acceptable

A single instruction has become a call plus register spills. Thread-local reads are common but not usually in the hottest loops, and the cached fast path keeps the handler short. The alternative — a full recompiler — costs vastly more.

Problem 2 — the stack canary

Compiler stack-protector code stores a guard value through fs:[0x28]. With an unsuitable FS base this can fault, including at address 0x28 when that base is zero. The loader recognizes this store pattern:

src/loader/runtimeLinker.cpp
const uint8_t fs_store_pattern[8] = {0x64, 0xc7, 0x04, 0x25, 0x28, 0x00, 0x00, 0x00};

for (auto* ptr = start_ptr; ptr <= end_ptr; ptr++) {
    if (memcmp(ptr, fs_store_pattern, sizeof(fs_store_pattern)) == 0) {
        LOGF("Patch fs:[0x28] store at addr: [%016" PRIx64 "]\n", (uint64_t)ptr);

        // recognise the specific "canary failed → ud2" epilogue
        if (ptr + 16 < start_ptr + size && ptr[12] == 0xcd && ptr[13] == 0x45 &&
            ptr[14] == 0x90 && ptr[15] == 0x0f && ptr[16] == 0x0b) {
            ptr[0] = 0x5d;                    // pop rbp
            ptr[1] = 0xc3;                    // ret
            std::memset(ptr + 2, 0x90, 15);   // nop the rest
        } else {
            std::memset(ptr, 0x90, 12);       // just nop the store
        }
    }
}

This is a compatibility rewrite of a recognized instruction pattern. It changes the guest’s stack-protector behavior; it is not a general implementation of the guest TLS or stack-canary contract.

Optional AMD patches and host instruction gaps

--amd-cpu enables the shared guestInstructionPatcher.cpp path on Windows, Linux and x86-64 macOS. PatchGuestInstructions(..., protect_memory, emulate_amd, host_features) uses function boundaries from .eh_frame and registered trampoline space. It patches supported VRSQRTPS forms and fills host-feature gaps for EXTRQ, INSERTQ, RDPID and CLWB, preferring native trampolines with safe trap fallbacks. Windows red-zone protection is controlled separately by --redzone.

The patch result records per-family counts for reciprocal square root, EXTRQ, INSERTQ, RDPID and CLWB, distinguishing native patches from trap fallback. Host features decide which compatibility path is needed; x86-64 macOS signal handling also preserves the SSE4a context. Native TLS keeps fiber context valid across host-thread migration, and fiber ownership preserves small guest stacks.

How to see this yourself

Run any title and grep the log for Patch tls at addr. Take one of those addresses into a debugger and look at the nine bytes: you will find E8 … where the ELF file on disk has 64 48 8B 04 25 00 00 00 00. Seeing that once makes the whole mechanism concrete in a way no description does.

17

Unresolved imports and generated thunks

Real games import thousands of functions Kyty has never implemented. The strategy for handling that is the difference between an emulator that boots nothing and one that boots most things.

The design decision

Three options exist when a symbol will not resolve:

OptionResult
Abort at load timeAlmost nothing boots. Games import far more than they call — an unused code path drags in an entire library.
Write null into the GOTThe game crashes on the first call with no context. The log says nothing useful.
Write a generated stubOnly code paths actually taken can break; the stub logs which NID is missing and returns zero. This is what Kyty does.

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.

Allocation and bookkeeping

static std::vector<StubbedImportRecord> g_stubbed_imports;
static std::vector<uint64_t>            g_unresolved_stub_thunk_pages;
static constexpr uint64_t               UNRESOLVED_STUB_PAGE_SIZE = 4096;

// thunks are packed into 4 KB executable pages, 34 bytes each (120 per page)
if (g_unresolved_stub_thunk_pages.empty() ||
    g_unresolved_stub_thunk_offset + thunk_size > UNRESOLVED_STUB_PAGE_SIZE) {
    auto page = Memory::AllocateRuntimeMemory(0, UNRESOLVED_STUB_PAGE_SIZE,
                    Common::VirtualMemory::Mode::ExecuteReadWrite, "unresolved_import_thunk");
    g_unresolved_stub_thunk_pages.push_back(page);
    g_unresolved_stub_thunk_offset = 0;
}
...
Common::VirtualMemory::FlushInstructionCache(code, thunk_size);

The record ID baked into each thunk indexes g_stubbed_imports, which holds the NID, symbol type, binding and owning program — everything needed to log a useful message.

Practical consequence for you

When a game misbehaves rather than crashing, the first thing to check is the log for stubbed imports. A function returning 0 when the game expected a real value is a very common cause of "boots but the menu is empty" bugs — and fixing it is often a twenty-line LIB_FUNC addition.

Guest PLT records and eager binding

Guest ELF files still contain PLT relocations and GOT slots. Current Kyty resolves those slots eagerly with the other relocations; the old custom PLT code generator and shared lazy handler have been removed. Read ForEachRelocation(), RelocateRecord() and Program::rela_bits for the current implementation.

18

Exceptions as control flow

Because guest code normally runs natively, Kyty does not instrument every memory access. The CPU's own fault mechanism becomes the emulator's primary hook — two of the handler's three branches are load-bearing, not diagnostic.

The handler

src/loader/runtimeLinker.cpp
static bool KytyExceptionHandler(const Common::HostException::ExceptionInfo& exception_info) {
    const auto* info = &exception_info;

    // 1 — an instruction this CPU does not have
    if (info->type == ExceptionType::IllegalInstruction &&
        Loader::X64InstructionEmulator::TryEmulate(info->native_context)) {
        return true;
    }

    if (info->type == ExceptionType::AccessViolation) {
        const auto access = /* map Read/Write/Execute */;

        // 2 — the GPU cares about this page
        if (Libs::LibKernel::Memory::HandleGpuFault(access, info->access_violation_vaddr)) {
            return true;
        }
    }

    // 3 — a genuine crash: print what can be read safely, then stop
    std::printf("--- Guest fault context ---\n");
    std::printf("thread: %s\n", thread_name);          // which guest thread, by its pthread name
    std::printf("rax=... rbx=... rcx=... rdx=...\n" ...);  // all sixteen registers
    // 96 code bytes around pc, 32 stack qwords from rsp — each range probed first
    EXIT("Unhandled host exception: type=%u code=%u pc=0x%016" PRIx64
         " access=%u address=0x%016" PRIx64 "\n", ...);
}

Reservations are committed explicitly. A fault in reserved-but-uncommitted memory is a real bug and falls through to the dump.

Returning true means "handled, resume execution." The guest never learns anything happened.

Branch 1 — emulating missing instructions

The PS5's Zen 2 supports instructions your CPU might not, notably the SHA extensions. When one executes, the CPU raises an illegal-instruction fault, and X64InstructionEmulator decodes the bytes at the fault address, performs the operation in software against the saved register context, and advances the instruction pointer past it.

src/loader/x64InstructionEmulator.cpp — the SHA1 message schedule, in software
static void Sha1Msg1(XmmWords& dest, const XmmWords& src2) {
    const uint32_t w0 = dest.w[3];  const uint32_t w1 = dest.w[2];
    const uint32_t w2 = dest.w[1];  const uint32_t w3 = dest.w[0];
    const uint32_t w4 = src2.w[3];  const uint32_t w5 = src2.w[2];
    dest.w[3] = w2 ^ w0;   dest.w[2] = w3 ^ w1;
    dest.w[1] = w4 ^ w2;   dest.w[0] = w5 ^ w3;
}

It is slow — a fault plus a software emulation per instruction — but these appear in checksum and hashing routines rather than in hot loops, and the alternative is not running at all.

Branch 2 — the GPU fault, and why it is the design

This is the most important branch in the emulator. Memory the GPU has cached as a Vulkan image or buffer is deliberately write-protected. When the game writes to it:

Guest writes to a texture it uploaded last frameguestAn ordinary mov [rax], rbx. The game has no idea anything special is happening.
↓ page is write-protected → CPU faults
OS delivers the exceptionhostA vectored exception handler on Windows; a SIGSEGV handler elsewhere.
↓
HandleGpuFault marks the page CPU-dirtykytyThe memory tracker records that this range changed, un-protects it so the write can proceed, and returns true.
↓ execution resumes; the write completes
Next draw re-uploads only those pageskytyThe renderer asks the tracker which sub-ranges are dirty and copies just those into the Vulkan image.

Page protection provides a notification when native guest code touches tracked memory. Guest GPU commands still carry ordering and cache operations; the emulator combines those commands with fault tracking to keep host CPU and Vulkan resources coherent.

Branch 3 — the crash dump

When nothing handles the fault, the handler prints a --- Guest fault context --- block — everything it can read without faulting again — and then exits through EXIT:

  • The guest thread name from the pthread layer (or (host thread) if the fault was not on a guest thread) — which identifies the thread, not the executing module; use the faulting PC to locate the code.
  • All sixteen general-purpose registers, four per line, as rax=….
  • 96 bytes of code around the fault address — pc-48 .. pc+48, the faulting byte at offset 48 — probed with IsReadableRange first so the dump does not itself fault.
  • 32 stack qwords read from rsp, again only if readable.
  • The final Unhandled host exception: type=… code=… pc=… access=… address=… line: the exception type, the native code, the program counter, the access kind and the address that was touched.

This fault path prints guest context rather than a host stack walk. The guest frame-pointer walker is RuntimeLinker::StackTrace(), which prints Stack trace [thread = N]: followed by one [i] address, off=…, module line per frame, resolved against the loaded modules; the libc abort path calls it. Accepting a frame only when the return address lands inside a loaded module's segments is precisely what fixing the module layout in chapter 14 buys.

void RuntimeLinker::StackTrace(uint64_t frame_ptr, uint64_t stack_ptr) {
    void* stack[20];
    int   depth = WalkGuestStack(frame_ptr, stack_ptr, stack, 20);
    LOGF("Stack trace [thread = %d]:\n", Common::Thread::GetThreadIdUnique());
    for (int i = 0; i < depth; i++) {
        auto  vaddr = reinterpret_cast<uint64_t>(stack[i]);
        auto* p     = FindProgramByAddr(vaddr);          // which module owns this return address?
        LOGF("[%d] %016" PRIx64 ", off=%016" PRIx64 ", %s\n", i, vaddr,
             (p == nullptr ? 0 : vaddr - p->base_vaddr), (p == nullptr ? "???" : name_of(p)));
    }
}
Reading a crash

An address=0x28 can arise from a segment-relative access or a null pointer plus an offset. Decode the instruction and inspect the registers before tracing the cause through earlier calls. A PC inside a mapped guest segment tells you where the fault occurred, not whether the guest or emulator caused it. Resolve the loaded module and inspect preceding calls, memory state and generated patches.

19

Memory management

src/kernel/memory.cpp implements three allocation models, page attributes, naming and queries — because games observe all of them.

Three ways a game gets memory

KindGuest APIModel
Direct sceKernelAllocateDirectMemory
sceKernelMapDirectMemory
sceKernelReleaseDirectMemory
A reservation out of physical memory, identified by a physical offset. Mapping it to a virtual address is a separate step, and the same physical range may be mapped more than once. Games use this for anything the GPU touches.
Flexible sceKernelMapFlexibleMemory
sceKernelAvailableFlexibleMemorySize
Ordinary paged memory from a fixed per-title budget, declared in param.json and applied before the memory subsystem initialises.
Pooled sceKernelMemoryPoolReserve
…Commit / …Decommit / …Batch
Two-phase reserve-then-commit, closer to VirtualAlloc. The batch interface lets a game perform dozens of map operations in a single call.

What the guest can ask about memory

sceKernelVirtualQuery is the one to notice, because it forces Kyty to keep real bookkeeping rather than just forwarding to the host allocator:

src/kernel/memory.h
struct VirtualQueryInfo {
    uintptr_t start;
    uintptr_t end;
    uint64_t  offset;
    int32_t   protection;
    int32_t   memory_type;
    uint32_t  is_flexible  : 1;
    uint32_t  is_direct    : 1;
    uint32_t  is_stack     : 1;
    uint32_t  is_pooled    : 1;
    uint32_t  is_committed : 1;
    uint32_t  is_gpu_prt   : 1;
    uint32_t  amm_usage    : 1;
    uint32_t  reserved     : 1;
    char      name[KERNEL_MAXIMUM_NAME_LENGTH];   // the game's own label for the range
    uint8_t   gpu_mask_id;
    uint8_t   reserved2;
};
static_assert(sizeof(VirtualQueryInfo) == 72);

The static_assert is not decoration. This struct is written into guest memory, so its layout must match the console's byte for byte. Several structs in the kernel headers carry the same guard.

The internal interface

Four allocation entry points serve different purposes, and choosing the wrong one is a real bug class:

uint64_t AllocateProgramMemory(uint64_t search_addr, uint64_t size, Mode mode, const char* name);
uint64_t AllocateRuntimeMemory(uint64_t search_addr, uint64_t size, Mode mode, const char* name,
                               bool fixed = false);
uint64_t AllocateGuestStackMemory(uint64_t search_addr, uint64_t size, Mode mode, const char* name);

bool ProtectGuestMemory(uint64_t vaddr, uint64_t size, Mode mode, Mode* old_mode = nullptr);
// Transient PageManager watch state; does NOT change the mapping's semantic protection.
bool ProtectGuestHostMemory(uint64_t vaddr, uint64_t size, Mode mode);

That last comment is the key distinction. ProtectGuestMemory changes what the guest believes the protection is, and a later sceKernelVirtualQuery will report it. ProtectGuestHostMemory changes only the host page bits so the tracker can catch a write — the guest must never see it.

The GPU-facing hooks

void RegisterCallbacks(callback_func_t alloc_func, callback_func_t free_func);
void InstallGpuResources(Graphics::RenderContext* resources) noexcept;
bool HandleGpuFault(Graphics::PageFaultAccess access, uint64_t fault_vaddr) noexcept;
void InvalidateMemory(uint64_t vaddr, uint64_t size);
bool TryWriteBacking(uint64_t vaddr, const void* data, uint64_t size);
bool TryReadBacking(uint64_t vaddr, void* data, uint64_t size);

The memory manager notifies the graphics layer whenever guest memory is mapped or unmapped, so caches can drop resources whose backing store has gone away — a common source of use-after-free bugs if it is missed.

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.

Failure injection

Compiled behind a flag, the allocator exposes deliberate failure hooks so its error paths are actually exercised by tests rather than only in the field:

#if defined(KYTY_VIRTUAL_MEMORY_ALLOCATION_TESTS)
void TestFailNextPhysicalMemoryUnmap();
void TestFailPhysicalMemoryUnmapAfter(uint32_t successful_unmaps);
void TestFailGuestBackingStoreUnmapAfter(uint32_t successful_unmaps);
void TestFailNextFixedReserveRangeRegistration();
bool TestPlaceholderRangeIsFree(uint64_t vaddr, uint64_t size);
bool TestGuestAddressRangeIsOwned(uint64_t vaddr, uint64_t size);
#endif
src/kernel/memory.htests/VirtualMemoryAllocationTests.cpp
20

Threads and thread-local storage

Short sleeps block too

KernelUsleep() and KernelNanosleep() use signal-polling sleep helpers. Nonzero short waits use blocking waits with signal polling. Windows uses its high-resolution waitable timer path; POSIX uses an absolute monotonic deadline that survives signal interruptions. Zero-duration requests still have their explicit signal-dispatch behavior.

Guest threads are real host threads — but with two pieces of state Kyty has to maintain itself, and a set of FreeBSD semantics that do not come free.

What pthread.cpp implements

131 KB covering: threads with names, priorities and CPU affinity masks; mutexes in all four types with protocol attributes; condition variables; read-write locks; thread-specific keys with destructors; cancellation in both deferred and asynchronous modes; per-thread signal queues; and the timing calls (KernelUsleep, KernelNanosleep, clock queries).

Both the Sony-prefixed and POSIX-named entry points are registered, because games use both:

LIB_FUNC("7H0iTOciTLo", Posix::pthread_mutex_lock);
LIB_FUNC("2Z+PpY6CaJg", Posix::pthread_mutex_unlock);
LIB_FUNC("IafI2PxcPnQ", LibKernel::PthreadMutexTimedlock);
LIB_FUNC("Op8TBGY5KHg", Posix::pthread_cond_wait);

Guest stacks

A guest thread cannot simply use the host thread's stack. Guest code computes addresses relative to its own stack, and those addresses may be handed to the GPU or stored in structures Kyty inspects — so they must lie inside the guest address bands:

void  PthreadInitSelfForMainThread();
void* PthreadCreateMainGuestStack();
bool  PthreadGetGuestStack(Pthread thread, uint64_t* stack_addr, uint64_t* stack_size);

The guest stack is allocated with AllocateGuestStackMemory, and RunEntry switches to it before calling the entry point. The entry parameters are placed at the top of that stack, 16-byte aligned as the ABI requires:

auto* params = reinterpret_cast<EntryParams*>(
    (reinterpret_cast<uintptr_t>(main_stack_top) - 0x100u) & ~0x0fu);
params->argc    = 1;
params->argv[0] = "KytyEmu";

Per-thread TLS blocks

Each loaded module has a TLS template from its PT_TLS segment. Every thread needs its own copy of every module's template. Kyty allocates them lazily:

src/loader/runtimeLinker.h
struct ThreadLocalStorage {
    struct Block {
        uint8_t*                     ptr        = nullptr;
        application_heap_free_func_t free_func  = nullptr;  // the game's own free()
        bool                         vm_alloc   = false;
        uint64_t                     alloc_size = 0;
    };

    uint64_t image_vaddr;    // the template
    uint64_t init_size;      // bytes to copy from it
    uint64_t image_size;     // total per-thread size
    uint64_t tcb_offset;     // where the control block sits within the block
    uint64_t handler_vaddr;  // the generated stub from chapter 16

    std::vector<uint8_t>           init_image;
    std::unordered_map<int, Block> tlss;      // thread id → block
    Common::Mutex                  mutex;
};

Note free_func. If the game has installed its own heap through SetApplicationHeapApi, TLS blocks are allocated from that heap and must be released through it — freeing them with delete[] would corrupt the game's allocator. FreeTlsBlock handles all three cases (game heap, guest VM allocation, plain new[]).

Blocks are released per thread on exit via DeleteTlss(thread_id), and per module on unload via DeleteTls(program, thread_id).

The application heap bridge

Games can install their own allocator, which the runtime is then expected to use:

void  SetApplicationHeapApi(void* const api[10]);
void* ApplicationHeapMalloc(uint64_t size);
void* ApplicationHeapMemalign(uint64_t alignment, uint64_t size);

An array of ten function pointers, of which Kyty uses malloc, free and posix_memalign. This matters because memory Kyty allocates on the game's behalf may be freed by the game, and vice versa.

21

Event queues, files, and the rest of the kernel

Event queues are kqueue

FreeBSD's kqueue is a unified event mechanism, and the PS5 exposes it almost unchanged. A game creates a queue, attaches sources, and blocks:

src/kernel/eventQueue.h
constexpr int16_t KERNEL_EVFILT_TIMER     = -7;
constexpr int16_t KERNEL_EVFILT_READ      = -1;
constexpr int16_t KERNEL_EVFILT_WRITE     = -2;
constexpr int16_t KERNEL_EVFILT_USER      = -11;
constexpr int16_t KERNEL_EVFILT_FILE      = -4;
constexpr int16_t KERNEL_EVFILT_GRAPHICS  = -14;
constexpr int16_t KERNEL_EVFILT_VIDEO_OUT = -13;
constexpr int16_t KERNEL_EVFILT_HRTIMER   = -15;

struct KernelEvent {
    uintptr_t ident;    // what happened to
    int16_t   filter;   // what kind of thing
    uint16_t  flags;
    uint32_t  fflags;
    intptr_t  data;
    void*     udata;    // the game's opaque cookie
};

The two graphics filters are the interesting ones. The video-out driver's flip and vertical-blank notifications, and the GPU's end-of-pipe interrupts, are delivered as events into these queues — which is how a game learns that a frame finished.

Why lifetime is the hard part

A queue can be destroyed while another thread is blocked in KernelWaitEqueue, or while the graphics thread is part-way through delivering an event into it. Kyty pins queues with a shared pointer for the duration of any operation:

using KernelEqueueRef = std::shared_ptr<KernelEqueuePrivate>;
[[nodiscard]] KernelEqueueRef KernelPinEqueue(KernelEqueue eq);

Each attached filter also carries a std::shared_ptr<void> owner and a delete_event_func, so a source can keep its own state alive exactly as long as the event exists. tests/EventQueueLifetimeTests.cpp exercises event lifetime and ownership.

KernelAddTimerEvent() registers periodic microsecond timers using KERNEL_EVFILT_TIMER and EV_CLEAR; delivery accumulates elapsed expirations and advances the deadline. High-resolution timers remain a separate one-shot path. Persistent user events are emitted once per wait batch and remain available to a later wait. APR events use their own filter. These semantics matter independently of queue lifetime.

The file system

The guest sees a console-shaped tree synthesised from host directories:

Guest pathHost backingPurpose
/app0the --game directoryThe game's own files, read-only in practice.
/hostappthe same directoryAlias some development builds use.
/download0_DownloadData/<TITLE_ID>Downloadable content and patches.
/temp0, /temp_TempData/<TITLE_ID>Scratch space.
/savedata…_SaveData/Mounted on demand by the save-data library.
src/emulator.cpp
static void MountSandboxDirs() {
    std::string title_id;
    if (!Loader::SystemContentParamSfoGetString("TITLE_ID", &title_id) || title_id.empty()) {
        title_id = "UNKNOWN";
    }
    MountOrCreateDir("_DownloadData/" + title_id, "/download0");
    MountOrCreateDir("_TempData/"     + title_id, "/temp0");
    MountOrCreateDir("_TempData/"     + title_id, "/temp");
}

Two details that cause real bugs:

  • Case sensitivity. Host path lookup differs across platforms. The filesystem code includes a case-insensitive fallback for mounted paths; trace that fallback and its tests when a dump’s spelling differs from the guest request.
  • Path translation. Every guest path must be converted to a host path through the mount table. GetRealFilename() does this, and it is why the loader is handed /app0/eboot.bin rather than a host path.

AIO waits: batch state is part of the API

KernelAioWaitRequests() in src/libs/libKernel.cpp is registered under NID lgK+oIWkJyA. It accepts 1–128 request IDs and writes one state per ID. Mode 1 waits for all IDs to be terminal; mode 2 also permits an unnotified completed/aborted request to satisfy the wait. If all IDs are terminal, either mode returns. For one ID, the mode argument is ignored. An invalid ID is reported as KERNEL_ERROR_ESRCH in its state slot rather than necessarily failing the entire call.

usec == nullptr supplies no deadline; a non-null pointer supplies a microsecond budget, including an immediate check for a zero budget. Completion is checked before a timeout is returned, so an already completed request can succeed with zero time available. Success writes the remaining budget. Polls and waits share the atomic KERNEL_AIO_STATE_NOTIFIED bit, and a final notifying scan handles competition between waiters. Null ID/state pointers, invalid counts and invalid multi-ID modes return before modifying output states.

src/libs/libKernel.cpp — the single-request wrapper
int KYTY_SYSV_ABI KernelAioWaitRequest(int32_t id, int32_t* state, uint32_t* usec) {
    return KernelAioWaitRequests(&id, 1, state, KERNEL_AIO_WAIT_AND, usec);
}

The read/write submission functions currently perform their KernelPread/KernelPwrite work inline; an AIO-shaped API does not by itself imply a background I/O worker. TestAioBatches() in tests/KernelFileSystemTests.cpp checks zero-timeout completion, shared notification state, batch validation and per-ID errors. Trace both the call’s return value and each output state when diagnosing a wait.

The remaining primitives

FileProvides
kernel/semaphore.cppCounting semaphores with timeouts and cancellation.
kernel/eventFlag.cppEvent flags — a bitmask on which threads wait for any or all bits. Common in console middleware.
libs/libUlt.cppULT, Sony's user-level cooperative threading library — fibers with their own scheduler.
libs/libKernel.cppEverything else in libkernel: time, process info, dynamic library management, Posix:: aliases.
Part IV

The HLE library layer

Forty-one thousand lines answering the question "what would the console have done here?" — the widest and shallowest part of the project, and the easiest place to start contributing.

22

Anatomy of an HLE module

Every file in src/libs/ has the same shape. Learn it once and their shared pattern becomes readable.

A complete module, start to finish

This is ContentExport in full — the smallest real example in the tree:

src/libs/libs.cpp
namespace LibContentExport {

LIB_VERSION("ContentExport", 1, "ContentExport", 1, 1);
//           library name  ver  module name    major minor

namespace ContentExport {

constexpr int CONTENT_EXPORT_ERROR_INVALID_PARAM = -2137182186; /* 0x809D3016 */

struct ContentExportInitParam2 {
    void*   malloc_func;
    void*   free_func;
    void*   user_data;
    size_t  buffer_size;
    int64_t reserved0;
    int64_t reserved1;
};

static bool g_initialized = false;

static int KYTY_SYSV_ABI ContentExportInit2(const ContentExportInitParam2* init_param) {
    PRINT_NAME();                                   // per-thread call tracing

    LOGF("\t init_param  = 0x%016" PRIx64 "\n", (uint64_t)init_param);

    if (init_param == nullptr) {
        return CONTENT_EXPORT_ERROR_INVALID_PARAM;   // validate like the real thing
    }

    LOGF("\t malloc_func = 0x%016" PRIx64 "\n"
         "\t free_func   = 0x%016" PRIx64 "\n"
         "\t user_data   = 0x%016" PRIx64 "\n"
         "\t buffer_size = %" PRIu64 "\n",
         (uint64_t)init_param->malloc_func, (uint64_t)init_param->free_func,
         (uint64_t)init_param->user_data,  (uint64_t)init_param->buffer_size);

    g_initialized = true;
    return OK;
}

} // namespace ContentExport

LIB_DEFINE(InitContentExport_1) {
    LIB_FUNC("0GnN4QCgIfs", ContentExport::ContentExportInit2);
}

} // namespace LibContentExport

The five conventions

ElementWhy it is there
LIB_VERSION(lib, ver, mod, maj, min)Declares which console module this file impersonates. Resolution matches on all five values, so a wrong version silently prevents binding.
KYTY_SYSV_ABIThe guest calls this directly, so it needs a System V prologue. Forgetting it produces arguments read from the wrong registers — usually garbage pointers.
PRINT_NAME()Logs entry with a timestamp and thread ID when the module's thread-local flag is set. Free tracing, already wired up.
Argument loggingAlmost every function logs its inputs. When reverse-engineering behaviour, the log is the documentation.
LIB_DEFINE + LIB_FUNCRegistration. Called once from Libs::InitAll, before any guest code runs.

How PRINT_NAME works

src/libs/libs.h
#define PRINT_NAME_ENABLED g_print_name

#define LIB_NAME(l, m)                                                   \
    [[maybe_unused]] static thread_local bool PRINT_NAME_ENABLED = false; \
    static constexpr char g_library[] = l;                               \
    static constexpr char g_module[]  = m;

#define PRINT_NAME()                                                     \
    if (PRINT_NAME_ENABLED) {                                            \
        if (Log::GetDirection() != Log::Direction::Silent) {              \
            const auto t = Loader::Timer::GetTime().ToString("HH24:MI:SS.FFF"); \
            LOGF_COLOR(Log::Color::Cyan, "[%d][%s] %s::%s::%s()\n",       \
                       Common::Thread::GetThreadIdUnique(), t.c_str(),   \
                       g_library, g_module, __func__);                   \
        }                                                                \
    }

The flag is thread_local and per translation unit, so you can enable tracing for one module on one thread without drowning in output from everything else. Set PRINT_NAME_ENABLE(true) anywhere inside the module.

The pragmatic pattern

A great 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 game calls Init, checks for a non-negative return, and moves on. The engineering work is in knowing which functions need real behaviour, and that knowledge comes from watching games fail.

Error codes are not arbitrary

src/libs/errno.h is console error constants. Returning the right negative value matters: games branch on specific codes, and returning a generic failure where the game expected ERROR_NOT_FOUND can send it down a completely different path.

23

Tour of the libraries

What is implemented, how big each area is, and what is genuinely hard about it.

AreaFilesNotes
Graphics driver (AGC) agc.cpp
libAgcDriver.cpp
agcRegisterDefaults.inc
Hundreds of command-buffer builder functions, plus a generated table of the register values the real driver writes at context reset. Games only set registers they change, so the defaults must match the hardware exactly or state leaks between draws. Covered in chapter 25.
Kernel surface libKernel.cpp The parts of libkernel that did not earn a file in src/kernel/: time and clocks, process info, dynamic library queries, and a Posix:: namespace aliasing the POSIX-named exports onto the same implementations.
Networking libNet.cpp
network.cpp
Sockets on the host's native socket API (connect, accept, listen, UDP, error translation), resolvers, SSL and HTTP shims, plus the NP (PlayStation Network) surface stubbed carefully enough that games run offline rather than hanging on a login that will never succeed.
Audio output audio.cpp
libAudio.cpp
libAudio2.cpp
Port management, mixing and output on a dedicated thread; 12-channel (7.1.4) AudioOut2 ports are downmixed to stereo or 8-channel for SDL. The guest submits fixed-size blocks and expects the call to block when buffers are full — that back-pressure is part of how games pace themselves.
Audio decode (AJM) ajm.cpp
ajm/decoder.h
Models a fixed-function hardware block. The guest submits a batch of jobs describing input/output buffers and codec parameters; Kyty parses that job format and routes each, through the decoder interface in ajm/decoder.h, to LibAtrac9 (ATRAC9) or FFmpeg (AAC, MP3).
Video avPlayer.cpp
libVideoDec2.cpp
videoDec2Decoder.cpp
Full-motion-video playback and a general video-decode API, both on FFmpeg. The README credits shadPS4 as a reference for the AVPlayer implementation.
Async memory (AMPR) libAmpr.cpp The PS5's asynchronous mapping and paging engine. Like the GPU, it consumes a stream of command records rather than function calls, so Kyty parses the record formats and replays them against the memory manager.
Fonts libFont.cpp
libFontFt.cpp
Glyph layout, metrics and rendering. Large because the API surface is large.
System services libSaveData
libSystemService
libUserService
libPlayGo
libDialog + dialog.cpp
libShare
Save-data mounting, user profiles, chunk-based install progress (games query how much of themselves has "downloaded"), message and error dialogs, share features.
Data formats libJson2
libPngDec
libRtc
libCes.cpp
JSON parsing, PNG decode via stb, real-time clock conversions, and CP932 / Shift-JIS ↔ UTF-8 text conversion (libCes, one of the newest modules alongside libConvertKeycode.cpp and libWebBrowserDialog.cpp).
Input libPad.cpp
controller.cpp
DualSense state over a HIDAPI driver — light bar and adaptive-trigger writes reach the real pad, the touchpad is emulated with the mouse — plus customizable keyboard/mouse mapping and SDL rumble forwarding.
Guest printf guestPrintf.cpp A from-scratch format-string interpreter. Necessary because the guest passes a System V variadic argument list, which cannot simply be forwarded to the host's vprintf.
C runtime libC.cpp The subset of libc the console exports as a module, including the C++ ABI hooks games call during static initialisation.

Loading order

Everything is registered from one function, before any guest code exists:

src/libs/libs.cpp
void InitAll(Loader::SymbolDatabase* s) {
    InitAudio_1(s);
    InitConvertKeycode_1(s);
    LibAmpr::InitAmpr_1(s);
    InitAppContent_1(s);
    Coredump::InitCoredump_1(s);
    LibContentDelete::InitContentDelete_1(s);
    ...
    LibCes::InitCes_1(s);
    ...
    InitAgcDriver_1(s);
    InitLibKernel_1(s);
    ...
    LibGen5::InitVideoOut_1(s);
    InitWebBrowserDialog_1(s);
}

Each InitXxx_1 is declared just above with LIB_DEFINE and called directly. Order does not matter here — they all write into the same database and no two register the same NID within the same library and version.

24

Worked example: implementing a missing function

A small, understood HLE contract can make a good first contribution. An unresolved import is a research lead, not proof that returning success will improve compatibility. Use the developer curriculum to turn a hypothesis into evidence.

Step 1 — find what is missing

Run the game with logging to a file and search for stub messages:

kyty_emulator.exe --game "D:\Games\Example" --redzone --printf-direction File --printf-output-file _kyty.txt

The loader logs every import it could not resolve, with the NID and the owning module. A line naming a function the game then calls is your target.

Step 2 — work out what it should do

Combine these sources; none alone proves the complete contract:

  1. Call-site evidence. Inspect registers, verified argument memory and how the caller uses results. One call does not reveal every argument, optional field or error path.
  2. Symbol and type evidence. A NID database can suggest a name, not prove a signature. Verify parameter count/types, signedness, structure size/offsets and the guest ABI from lawful reference material and code evidence.
  3. Behavioral evidence. Specify success, failure, output writes and side effects before coding. Preserve a failing regression case. A guessed value that advances one title is a hypothesis, not a verified implementation.

Step 3 — write it

Add to the appropriate module file — or create one following the template in chapter 22:

The following is a fictional structural sketch, not a real API contract or copy-ready implementation. Establish size/flags validation, guest-pointer policy, ownership and output semantics for the actual function before using this pattern.

struct ExampleParam {
    uint32_t size;
    uint32_t flags;
    void*    buffer;
};

static int KYTY_SYSV_ABI ExampleGetStatus(const ExampleParam* param, int* out_status) {
    PRINT_NAME();

    LOGF("\t param      = 0x%016" PRIx64 "\n"
         "\t out_status = 0x%016" PRIx64 "\n",
         (uint64_t)param, (uint64_t)out_status);

    if (param == nullptr || out_status == nullptr) {
        return EXAMPLE_ERROR_INVALID_PARAM;
    }
    LOGF("\t size = %" PRIu32 ", flags = 0x%08" PRIx32 "\n", param->size, param->flags);

    *out_status = 0;          // only if zero is established by this API's contract
    return OK;
}

Then register it next to the owning module's entries. The example NID below is a placeholder, not a discovered export:

LIB_DEFINE(InitExample_1) {
    LIB_FUNC("aBcDeFgHiJk", Example::ExampleGetStatus);   // <- the NID from the log
}

Step 4 — verify it is actually reached

Rebuild and check that the import binds to the intended address. A breakpoint or trace can establish whether that path was called. If it was not reached, record it as untested. Neither a resolved slot nor a function-entry log proves correct return values, output bytes or side effects.

Step 5 — prove the guest-visible contract

Keep a test that fails before the change and passes after it. Check normal input, size/flag boundaries, null handling where the contract defines it, output layout and values, error codes and side effects. Use sentinels to detect unintended writes on error; do not dereference arbitrary invalid guest addresses just to add a negative test. Check ownership and concurrency when state survives the call.

Build affected targets and run the focused regression plus adjacent tests. If you run a title, record the exact binary, configuration and route with before/after observations. A unit test, a binding check and game progression are separate evidence. Lab C demonstrates why the return code alone is insufficient.

When it does not bind

SymptomCauseFix
NID still reported as stubbedThe NID string does not match exactly — one wrong character is enough — or the LIB_VERSION library/module name or version differs from what the game imports.Compare against the log line, which prints the full nid#library#module.
Bound, but arguments are garbagePossible ABI mismatch or wrong signature. A correct name does not guarantee correct parameter types/count.Verify both KYTY_SYSV_ABI and the complete signature against call-site/type evidence.
Bound and called, then the game crashes inside your functionYour struct layout does not match the console's. Guest pointers point at guest-laid-out data.Dump the raw bytes at the pointer and rebuild the struct from them. Add a static_assert on the size once you are confident.
Everything works but the game still misbehavesThe return value or output parameter is wrong for what the game expects.Recheck the contract, output layout, error namespace, state changes and synchronization. Add a regression for the mismatch instead of trying arbitrary success values.
Contribution etiquette

The README asks that code contributions be focused, build on the platforms they touch, and include tests where practical. It also carries an explicit AI-use policy: assistance is allowed for research and development, but you must understand and test what you submit, disclose the scope of any AI involvement, and write the pull-request text yourself.

Part V

Graphics

Half the emulator. Nine chapters covering how work reaches the GPU, how Kyty models a command processor, how RDNA 2 shaders become SPIR-V, and how a PC's separate GPU memory is kept consistent with a console's unified memory.

25

AGC and PM4: how work reaches the GPU

The game never calls a draw function Kyty can intercept. It writes packets into a buffer and hands the buffer to the kernel. Understanding that distinction is the key to the whole graphics stack.

What an AGC call actually does

On a PC, vkCmdDraw goes into a driver that eventually produces hardware commands. On the PS5 the game is the driver. AGC functions are thin builders that write dwords into memory the game owns:

uint32_t* KYTY_SYSV_ABI AgcCbDispatch(CommandBuffer* buf, uint32_t thread_group_x,
                                            uint32_t thread_group_y, uint32_t thread_group_z, ...);
uint32_t  KYTY_SYSV_ABI AgcCbDispatchGetSize();
uint32_t* KYTY_SYSV_ABI AgcCbSetShRegistersDirect(CommandBuffer* buf, ...);
uint32_t* KYTY_SYSV_ABI AgcCbReleaseMem(CommandBuffer* buf, uint8_t action, ...);

Note the …GetSize() companions. A game asks for a command’s size in bytes so it can lay out its buffer in advance, then calls the builder to fill its dwords. Keep the return type and size unit attached to each API. This is why Kyty's reimplementations must produce byte-identical output to the real driver: the game may compute offsets into the buffer, patch values later, or jump over regions.

src/libs/agc.cpp — byte size of an indirect shader-register command
uint64_t KYTY_SYSV_ABI AgcDcbSetShRegistersIndirectGetSize() {
    PRINT_NAME();
    return 5u * sizeof(uint32_t); // 20 bytes, five dwords
}

src/libs/libAgcDriver.cpp registers this function as LIB_FUNC("nNlUtdDDvZ0", Gen5::AgcDcbSetShRegistersIndirectGetSize). Its declaration in agc.h, the returned byte count and the paired command builder are one ABI contract.

Patch functions prove the point

The AGC API includes functions such as AgcSetCxRegIndirectPatchSetAddress, AgcJumpPatchSetTarget and AgcWriteDataPatchSetAddressOrOffset. These exist so a game can build a command buffer once and rewrite specific fields each frame. A reimplementation that produced structurally different packets would break them.

Submission

When the buffer is ready the game submits it, and that call reaches GuestGpu::Submit:

src/graphics/guest_gpu/graphicsRun.h
// Submitted command memory is borrowed and must remain valid until GPU execution completes.
void Submit(std::span<const uint32_t> draw_commands,
            std::span<const uint32_t> constant_commands);

void SubmitCompute(uint32_t queue, std::span<const uint32_t> commands);

Two buffers, not one — the draw stream and the constant stream, which correspond to the two engines described in chapter 26.

The current implementation does not copy either stream. A submission stores borrowed std::span views into guest memory. This is closer to the hardware contract: the submitter owns the memory and must keep it unchanged until execution completes. It also makes lifetime bugs visible instead of hiding them behind an emulator-only copy.

The queue model

src/graphics/guest_gpu/graphicsRun.h — selected private fields
class GuestGpu {
private:
    static constexpr uint32_t ComputePipeCount     = 7;
    static constexpr uint32_t QueuesPerComputePipe = 8;
    static constexpr uint32_t ComputeQueueCount    = ComputePipeCount * QueuesPerComputePipe; // 56
    static constexpr uint32_t QueueCount           = 1 + ComputeQueueCount;                   // 57
    ...
private:
    std::array<std::deque<Submission>, QueueCount> m_queues;
    std::deque<Common::UniqueFunction<void>>       m_commands;
    std::unique_ptr<CommandProcessor>                                m_gfx_cp;
    std::array<std::unique_ptr<CommandProcessor>, ComputeQueueCount> m_compute_cp;
    std::jthread m_thread;
};

One graphics queue plus seven compute pipes of eight queues each, mirroring the hardware. Each queue has its own CommandProcessor instance, because each carries independent register state. Games rely on this for asynchronous compute: work on different queues must progress independently.

The submission record

struct Submission {
    SubmissionType type     = SubmissionType::Graphics;
    uint32_t       queue_id = 0;
    std::span<const uint32_t> commands;
    std::span<const uint32_t> constant_commands;
    Pm4Execution   command_execution;      // resumable cursor
    Pm4Execution   constant_execution;
    bool           started          = false;
    bool           command_complete = false;
    bool           constant_complete = false;
    bool           blocked          = false;
    uint64_t       flip_request_id  = 0;
};

The blocked flag and the embedded Pm4Execution cursors are what let a submission that hits a wait packet be suspended and resumed later, while other queues keep running.

Talking to the GPU thread

void SendCommand(Common::UniqueFunction<void>&& command);        // fire and forget
void SendCommandSync(Common::UniqueFunction<void>&& command);    // block until done

Both are re-entrancy aware — if you are already on the GPU thread they run the callable inline rather than deadlocking:

void GuestGpu::SendCommand(Common::UniqueFunction<void>&& command) {
	EXIT_IF(!command);
	if (IsGpuThread()) {
		command();
		return;
	}
	Common::LockGuard lock(m_queue_mutex);
	EXIT_IF(!m_accepting);
	m_commands.push_back(std::move(command));
	m_pending_commands.fetch_add(1, std::memory_order_release);
	m_work_available.Signal();
}
26

The command processor

Kyty implements, in software, the front end of an AMD GPU: the unit that reads packets from memory and turns them into state changes and work.

Packet format

The stream is a sequence of 32-bit dwords. Each packet begins with a header whose top two bits give the type. Everything Kyty handles is type 3:

src/graphics/guest_gpu/pm4.h
#define KYTY_PM4(len, op, r)                                        \
    (0xC0000000u | (((static_cast<uint16_t>(len) - 2u) & 0x3fffu) << 16u) | \
     (((op) & 0xffu) << 8u) | (((r) & (Pm4::R_NUM - 1u)) << 2u))

#define KYTY_PM4_R(cmd_id)   (((cmd_id) >> 2u) & (Pm4::R_NUM - 1u))
#define KYTY_PM4_LEN(cmd_id) ((((cmd_id) >> 16u) & 0x3fffu) + 2u)

And the extraction, from the packet dumper:

src/graphics/guest_gpu/pm4.cpp
const auto packet_type = static_cast<PacketType>(cmd_id >> 30u);
const bool sh_gx       = (cmd_id & 0x2u) == 0;
const uint8_t op       = ((cmd_id >> 8u) & 0xffu);
const auto    r        = KYTY_PM4_R(cmd_id);
len                    = ((cmd_id >> 16u) & 0x3fffu) + 1;   // body dwords
PM4 type-3 header decoderedit the hex, or pick a preset

The colour bands map to the fields: type, count, opcode, then the Sony sub-selector. Note that the handlers assert on exact header values — CpOpDrawIndexAuto requires precisely 0xC0012D00 — so a malformed stream fails loudly rather than silently.

Dispatch by jump table

With around 50 opcodes and thousands of register offsets, a switch would be unmanageable. Kyty builds five function-pointer tables and indexes them directly:

src/graphics/guest_gpu/command_processor/pm4Dispatch.h
extern const std::array<cp_op_parser_func_t, 256>       g_cp_op_func;        // by opcode
extern const std::array<cp_op_parser_func_t, Pm4::R_NUM> g_cp_op_custom_func; // IT_NOP sub-ops
extern const std::array<hw_ctx_parser_func_t, Pm4::CX_NUM> g_hw_ctx_func;   // context registers
extern const std::array<hw_sh_parser_func_t,  Pm4::SH_NUM> g_hw_sh_func;    // shader registers
extern const std::array<hw_uc_parser_func_t,  Pm4::UC_NUM> g_hw_uc_func;    // user-config registers

void GraphicsInitJmpTables();

A handler receives the header, a pointer to the body and the remaining size, and returns how many dwords it consumed — which is how multi-register writes advance correctly:

…/pm4Handlers.cpp — a complete handler
KYTY_CP_OP_PARSER(CpOpDrawIndexAuto) {
    KYTY_PROFILER_FUNCTION();

    EXIT_NOT_IMPLEMENTED(cmd_id != 0xc0012d00);       // exact encoding expected

    uint32_t index_count = buffer[0];
    uint32_t flags       = buffer[1];

    EXIT_NOT_IMPLEMENTED((flags & ~0x22u) != 0);      // only two flag bits are handled

    cp.DrawIndexAuto(index_count, 0);

    return 2;                                        // consumed 2 body dwords
}

That is the pattern throughout pm4Handlers.cpp: assert on what is not understood, unpack the fields, call into CommandProcessor, report the size.

Mechanisms a real command processor has

MechanismWhat it isWhy it matters
DE / CE counters Two engines walk the stream. The Constant Engine runs ahead prefetching constant data; the Drawing Engine follows. IT_INCREMENT_CE_COUNTER, IT_WAIT_ON_CE_COUNTER and IT_WAIT_ON_DE_COUNTER_DIFF keep them in step. Get this wrong and constants are stale by a frame or two — drifting, hard-to-localise corruption.
Constant RAM A 48 KB scratch area — uint32_t m_const_ram[0x3000] — that the CE writes and the DE dumps into memory. Heavily used for ring-buffered per-draw constants.
End-of-pipe events IT_RELEASE_MEM / IT_EVENT_WRITE_EOP: "when everything before this has completed, write this value there, and optionally raise an interrupt." How the game learns a frame is done. Kyty maps these onto Vulkan timeline semaphores and its own event queues.
Indirect buffers IT_INDIRECT_BUFFER is a call: jump into another buffer, then return. Pm4Execution keeps a stack of cursors. Engines split state into reusable chunks. Without this you see a fraction of the commands.
Predication IT_SET_PREDICATION makes subsequent packets conditional on a memory value. Occlusion-culled draws must actually be skipped. ShouldSkipPredicatedPackets() gates them.
Blocking waits IT_WAIT_REG_MEM spins until memory matches a condition. Kyty returns Pm4ProcessResult::Blocked and suspends the submission rather than busy-waiting, so a stalled graphics queue does not starve compute.

The resumable cursor

…/command_processor/commandProcessor.h
class Pm4Execution {
public:
	[[nodiscard]] bool MadeProgress() const noexcept { return m_made_progress; }

private:
	friend class CommandProcessor;

	struct BufferCursor {
		std::span<const uint32_t> commands;
		uint32_t                  offset_dw = 0;
	};

	std::vector<BufferCursor> m_buffer_stack;
	std::span<const uint32_t> m_next_buffer;
	bool                      m_chain         = false;
	bool                      m_suspended     = false;
	bool                      m_made_progress = false;
};

enum class Pm4ProcessResult { Complete, Blocked };
	Pm4ProcessResult Process(Pm4Execution& execution, std::span<const uint32_t> commands);

m_made_progress lets the scheduler detect a queue that is blocked and getting nowhere, so it can move on instead of spinning.

27

Hardware registers and state

A draw packet carries almost no information. Everything that matters — targets, blend modes, shaders, viewports — arrived earlier as register writes. Modelling that register file is a substantial part of the guest GPU code.

Three register spaces

SpacePacketHoldsKyty type
Context (CX)IT_SET_CONTEXT_REGPer-draw pipeline state: render targets, depth/stencil, blend, viewports, scissors, rasteriser modes.HW::Context
Shader (SH)IT_SET_SH_REGShader addresses, resource counts, and the user-data registers of chapter 28.HW::Shader
User config (UC)IT_SET_UCONFIG_REGGlobal settings: primitive type, index type, object IDs, gradient factors.HW::UserConfig

Each CommandProcessor owns one of each, so queues never contaminate one another:

HW::Context&    GetCtx()   { return m_ctx; }
HW::UserConfig& GetUcfg()  { return m_ucfg; }
HW::Shader&     GetShCtx() { return m_sh_ctx; }

Registers become typed structures

Kyty does not store raw dwords. Each handler unpacks bit fields into a named struct, which is what makes the renderer readable:

src/graphics/guest_gpu/hardwareContext.h
struct ColorInfo {
    bool     fmask_compression_enable        = false;
    bool     fmask_data_compression_disable  = false;
    bool     cmask_fast_clear_enable         = false;
    bool     dcc_compression_enable          = false;
    bool     blend_clamp                     = false;
    bool     blend_bypass                    = false;
    uint32_t format                          = 0;
    uint32_t channel_type                    = 0;
    uint32_t channel_order                   = 0;
};

struct RenderTarget {
    ColorBase       base;          // 64-bit GPU address
    ColorPitch      pitch;
    ColorSlice      slice;
    ColorView       view;          // array slices, mip level
    ColorInfo       info;
    ColorAttrib     attrib;        // tile mode, samples, fragments
    ColorAttrib2    attrib2;       // width, height, mip count
    ColorAttrib3    attrib3;       // depth, dimension, pipe alignment
    ColorDccControl dcc;
    ColorCmask      cmask;         // fast-clear metadata
    ColorFmask      fmask;         // MSAA fragment mask
    ColorClearWord0 clear_word0;
    ColorDccAddr    dcc_addr;
    ...
};

Extraction uses a macro that names the field explicitly:

#define KYTY_PM4_GET(u, r, f) (((u) >> Pm4::r##_##f##_SHIFT) & Pm4::r##_##f##_MASK)

r.polymode_front_ptype = KYTY_PM4_GET(buffer[0], PA_SU_SC_MODE_CNTL, POLYMODE_FRONT_PTYPE);

The shift and mask constants come from pm4.h, which defines those guest register fields.

Why agcRegisterDefaults.inc is 215 KB

The largest file in the repository is a table of default register values. It exists because of how the real driver works: at context reset it writes a complete default state, and games then only set what they change. If Kyty's defaults differ anywhere, state leaks between draws in ways that are almost impossible to attribute — a blend mode set by one draw silently applying to the next.

Games can also ask for these defaults directly, which is why AgcGetRegisterDefaults2 is an exported function.

Register decoding versus draw validation

DecodeRenderControl() in pm4Handlers.cpp records DB_RENDER_CONTROL, including copy_depth_to_color and copy_stencil_to_color, without rejecting those bits immediately. A later state write can replace them before a draw uses them. RenderExecutor::ResolveRenderDepthTarget() in src/graphics/host_gpu/renderer/depthRenderTarget.cpp handles inactive or unbound attachments, then rejects unsupported depth-copy state for a draw with a bound attachment. Accepting a register write does not mean the corresponding rendering operation is implemented.

Enumerations from the guest's world

The register values are console enums, and gpu_defs.h declares them all in a Prospero namespace:

enum class PrimitiveType : uint32_t {
    kNone = 0, kPointList = 1, kLineList = 2, kLineStrip = 3,
    kTriList = 4, kTriFan = 5, kTriStrip = 6, kRectList = 7, kPatch = 9,
    kLineListAdjacency = 10, ..., kQuadListLegacy = 19, kPolygon = 21,
};

enum class TileMode : uint32_t {
    kLinear = 0x00, kStandard256B = 0x01, kStandard4KB = 0x05, kStandard64KB = 0x09,
    kPrt = 0x11, kDepth = 0x18, kRenderTarget = 0x1b,
};

enum class BlendFactor : uint32_t { kZero = 0, kOne = 1, kSrcColor = 2, ... };
enum class BufferFormat : uint32_t { kInvalid = 0, k8UNorm = 1, ..., kBc7Srgb = 182 };

kRectList is worth noting: a three-vertex primitive describing an axis-aligned rectangle, used constantly for full-screen passes. Vulkan has no direct equivalent; Kyty handles the required pipeline path automatically and can synthesize tessellation-control/evaluation shaders with BuildRectListShaders().

Colour component remapping

A neat piece of design worth calling out. The shader exports RGBA, but the render target's channel order may differ. Rather than recompiling for every permutation, Kyty carries a one-byte mapping:

struct ColorComponentMapping {
    uint8_t packed = 0xe4u;   // RGBA identity: 0, 1, 2, 3 packed 2 bits each

    constexpr uint32_t Map(uint32_t component) const {
        return component < 4u ? (packed >> (component * 2u)) & 0x3u : component;
    }
    constexpr bool IsIdentity() const { return packed == 0xe4u; }
};

inline constexpr ColorComponentMapping ColorMappingAbgr {0x1bu};   // 3, 2, 1, 0
inline constexpr ColorComponentMapping ColorMappingBgra {0xc6u};   // 2, 1, 0, 3

One byte, cheap to carry inside shader and pipeline cache keys — which is exactly where it needs to live.

28

Sharps, user data, and shader resource tables

How a shader finds its textures. There is no analogue in any PC graphics API, and it is the reason the shader recompiler needs a dataflow analysis.

User data: the only channel into a shader

A shader stage receives up to 64 dwords in its first scalar registers, written by IT_SET_SH_REG packets. That is the entire parameter-passing mechanism. Inside those dwords a game may place, in any arrangement it chooses:

  • An inline descriptor — a V#, T# or S# sitting directly in registers.
  • A pointer to a table in memory containing descriptors, which the shader loads with s_load_dwordx4. Tables may point at further tables, to arbitrary depth. This is the shader resource table, or SRT.
  • Plain constants — a vertex offset, a flag, an index.

The AGC layer names the well-known slots:

src/graphics/shader/shaderBindings.h
enum class AgcDirectResourceType : uint32_t {
    GdsCounterRange          = 0,
    ShaderResourceTable      = 1,
    SubPtrFetchShader        = 2,
    PtrSoBufferTable         = 3,
    PtrInternalGlobalTable   = 4,
    PtrExtendedUserData      = 5,
    GsFlags                  = 6,
    GdsMemoryRange           = 7,
    PtrVertexBufferTable     = 8,
    StereoXOffset            = 9,
    PtrVertexAttribDescTable = 10,
};

The three descriptor types

You decoded these interactively in chapter 7. Here are the actual accessors, which double as documentation of the bit layout:

src/graphics/shader/shaderBindings.h — V#, buffer resource
struct ShaderBufferResource {
    uint32_t fields[4] = {0};
    [[nodiscard]] uint64_t Base48() const {
        return (fields[0] | (static_cast<uint64_t>(fields[1]) << 32u)) & 0xFFFFFFFFFFFFu;
    }
    [[nodiscard]] uint16_t Stride() const { return (fields[1] >> 16u) & 0x3FFFu; }
    [[nodiscard]] uint32_t NumRecords() const { return fields[2]; }
    [[nodiscard]] uint64_t GetSize() const {
        // The 14-bit stride and 32-bit record count require a 64-bit product.
        return Stride() == 0 ? NumRecords() : static_cast<uint64_t>(Stride()) * NumRecords();
    }
    [[nodiscard]] uint8_t                RawFormat() const { return (fields[3] >> 12u) & 0x7Fu; }
    [[nodiscard]] Prospero::BufferFormat Format() const {
        return static_cast<Prospero::BufferFormat>(RawFormat());
    }
    [[nodiscard]] uint8_t OutOfBounds() const { return (fields[3] >> 28u) & 0x3u; }
    [[nodiscard]] uint8_t Type() const { return (fields[3] >> 30u) & 0x3u; }
    // Other accessors omitted.
};
shaderBindings.h — T#, image resource (8 dwords)
struct ShaderTextureResource {
    uint32_t fields[8] = {0};
    [[nodiscard]] uint64_t Base40() const {
        return ((fields[0] | (static_cast<uint64_t>(fields[1]) << 32u)) & 0xFFFFFFFFFFu) << 8u;
    }
    [[nodiscard]] Prospero::BufferFormat Format() const {
        return static_cast<Prospero::BufferFormat>((fields[1] >> 20u) & 0x1FFu);
    }
    [[nodiscard]] uint16_t Width5() const {
        return ((fields[1] >> 30u) & 0x3u) | (((fields[2] >> 0u) & 0xFFFu) << 2u);
    }
    [[nodiscard]] uint16_t Height5() const { return (fields[2] >> 14u) & 0x3FFFu; }
    [[nodiscard]] uint8_t            BaseLevel() const { return (fields[3] >> 12u) & 0xFu; }
    [[nodiscard]] uint8_t            LastLevel() const { return (fields[3] >> 16u) & 0xFu; }
    [[nodiscard]] Prospero::TileMode TileMode() const {
        return static_cast<Prospero::TileMode>((fields[3] >> 20u) & 0x1Fu);
    }
    [[nodiscard]] Prospero::ImageType Type() const {
        return static_cast<Prospero::ImageType>((fields[3] >> 28u) & 0xFu);
    }
    [[nodiscard]] uint64_t MetaAddr() const {
        return ((fields[6] >> 24u) & 0xFFu) | (static_cast<uint64_t>(fields[7]) << 8u);
    }
    // Other accessors omitted.
};

Notice how much a T# carries: not just address and size but tiling mode, mip range, array slices, swizzle, and the address of a separate compression-metadata surface. All of that has to be translated into a Vulkan image with a compatible format and layout.

The problem this creates

Suppose a pixel shader samples a texture. In the machine code you will see something like:

s_load_dwordx4  s[8:11], s[0:1], 0x20      // load a pointer from user data + 0x20
s_load_dwordx8  s[12:19], s[8:9], 0x40     // load a T# from that table + 0x40
image_sample    v[0:3], v[4:5], s[12:19], s[20:23] dmask:0xf

To bind a Vulkan descriptor, Kyty must determine which texture s[12:19] will hold — and that requires evaluating the shader's own address arithmetic before the shader ever runs. Current code does this through three layers:

1 · Typed SSA valueskytyTranslateProgram creates typed values; RewriteToSsa and simplification expose where descriptor-address inputs came from.
↓
2 · TrackResourceskytyWalks those values and records descriptor-table reads. Constant offsets get flattened slots; genuinely dynamic reads stay explicit.
↓ at draw time, every draw
3 · MaterializeResourceskytyExecutes the recipe against the current user data and guest memory, producing a concrete ResourceSnapshot: this exact address, this format, this size.

The plan is stored on IR::ResourcePlan; IR::Program inherits it:

src/graphics/shader/recompiler/ir/ShaderIR.h — ResourcePlan and Program (selected fields)
struct SrtRead {
    Value    value;
    uint32_t flat_offset = 0;
};

struct ResourcePlan {
    ShaderType stage;  uint64_t shader_hash;
    uint32_t user_data_base, user_data_count = 64;
    std::vector<DescriptorSource> descriptor_sources;
    std::vector<SrtRead>          srt_reads;
    bool srt_plan_complete, resource_tracking_complete;
    ShaderInfo info;
};

struct Program : ResourcePlan {
    BlockList        blocks;
    bool             dispatcher_fallback = false;
    BindingLayout    bindings;
};

struct DescriptorValue {
    std::array<uint32_t, 8> dwords      = {};   // a materialised V#, T# or S#
    uint32_t                dword_count = 0;
};

The retained ResourcePlan owns immutable descriptor/SRT analysis and reusable evaluation scratch. TrackResources(ir, decoded, native_cfg) performs scalar-read and indirect-image planning; there is no separate SRT-plan call in the top-level pipeline. ExtractResourcePlan() derives stable clean-slot and specialization policy once. A shared memoized SrtWalker refreshes descriptors, active branches, flattened reads and specialization in place. Bounded indirect image tables, guarded Phis and GPU-selected raw buffers follow this same value graph.

ShaderStageRuntime borrows two pointers: compiled program info and the cache entry's current ResourceSnapshot. The renderer consumes them under its serialization contract; keeping that snapshot as an immutable per-draw copy would describe the former implementation.

Why compilation and binding are separate

The stable plan analysis is extracted once per source entry; the cached snapshot is refreshed in place from current user data on each lookup. TranslateProgram() produces the plan, CompileProgram() emits SPIR-V for one specialisation, and the pipeline cache sits between them:

src/graphics/host_gpu/renderer/pipeline/pipelineCache.cpp — ProgramCache::Get()
// Schematic ProgramCache::Get flow:
if (source_exists) {
    EXIT_IF(!IR::MaterializeResources(plan, runtime, resources, specialization));
    if (matching_permutation) return borrowed_stage_runtime;
}
auto translated = ShaderRecompiler::TranslateProgram(params.code, options);
// First source miss: extract plan and materialize resources here.
auto result = ShaderRecompiler::CompileProgram(std::move(translated), options,
                                               specialization, push_data_start_dword);

The renderer borrows the refreshed snapshot through ShaderStageRuntime { const IR::CompiledShaderInfo* program; const IR::ResourceSnapshot* resources; } in shader.h, and binding preparation writes descriptors from it. A descriptor read that cannot be satisfied is an EXIT_IF.

29

Tiling and image layout

Guest memory holds swizzled data; Vulkan wants something else. tile.cpp describes guest layouts and tiler.cpp orchestrates conversion using compute shaders.

The layout families

src/graphics/guest_gpu/tile.h
enum class TileBlockFamily : uint32_t {
    Standard256B,      // small textures
    Standard4KB,
    Standard4KB3D,
    Standard64KB,      // the common case for large textures
    Standard64KB3D,
    Prt64KB,           // partially-resident (sparse) textures
    Prt64KB3D,
    RenderTarget64KB,  // colour attachments
    Depth64KB,         // depth/stencil, with its own interleaving
    Count,
};

Each family has a different swizzle, and the parameters change with bytes-per-element and with mip level. Small mip levels share a packed mip tail whose offsets still follow the tiled layout:

struct TileSurfaceDescription {
	Prospero::BufferFormat format    = Prospero::BufferFormat::kInvalid;
	Prospero::TileMode     tile_mode = Prospero::TileMode::kLinear;
	TileSurfaceDimension   dimension = TileSurfaceDimension::Dim2D;
	uint32_t               width     = 0;
	uint32_t               height    = 0;
	uint32_t               depth     = 1;
	uint32_t               levels    = 1;
	uint32_t               layers    = 1;
};

// Width/height fields are measured in addressable elements, not texture texels.
struct TileMipLayout {
	uint64_t offset        = 0;
	uint64_t size          = 0;
	uint32_t width         = 0;
	uint32_t height        = 0;
	uint32_t padded_width  = 0;
	uint32_t padded_height = 0;
	uint32_t tail_x        = 0;
	uint32_t tail_y        = 0;
	bool     operator==(const TileMipLayout&) const = default;
};

struct TileSurfaceLayout {
	TileSurfaceDescription description;
	TileTextureBlockLayout texture;
	uint32_t               first_tail_level = 0;
	uint64_t               block_slice_size = 0;
	uint64_t               total_size       = 0;
	TileMipLayout          mips[16]         = {};
};

The API

bool TileGetBlockLayout(TileBlockFamily family, uint32_t bytes_per_element,
                        TileBlockLayout& layout);
bool TileGetTextureElementLayout(Prospero::BufferFormat format, TileTextureElementLayout& layout);
bool TileGetTextureBlockLayout(Prospero::BufferFormat format, Prospero::TileMode tile, bool volume,
                               TileTextureBlockLayout& layout);
bool TileGetTiledTextureLayout(const TileSurfaceDescription& description,
                               TileSurfaceLayout&            layout);
bool TileGetBlockOffset(const TileBlockLayout& layout, uint32_t x, uint32_t y, uint32_t z,
                        uint32_t& byte_offset);
bool TileGetBlockXor(const TileBlockLayout& layout, uint32_t block_x, uint32_t block_y,
                     uint32_t& byte_offset);
bool TileGetBlockXor(const TileBlockLayout& layout, uint32_t block_x, uint32_t block_y,
                     uint32_t block_z, uint32_t& byte_offset);

bool     TileGetDepthSize(uint32_t width, uint32_t height, uint32_t pitch,
                          Prospero::DepthFormat z_format, Prospero::StencilFormat stencil_format,
                          bool htile, TileSizeAlign& stencil_size, TileSizeAlign& htile_size,
                          TileSizeAlign& depth_size, uint32_t num_fragments_log2 = 0);
uint32_t TileGetRenderTargetPitch(uint32_t width, uint32_t bytes_per_element,
                                  uint32_t num_fragments_log2 = 0);
uint32_t TileGetDepthPitch(uint32_t width, uint32_t bytes_per_element,
                           uint32_t num_fragments_log2 = 0);
bool     TileGetRenderTargetSize(uint32_t width, uint32_t height, uint32_t pitch,
                                 uint32_t bytes_per_element, TileSizeAlign& total_size,
                                 uint32_t num_fragments_log2 = 0);
bool     TileGetDccSize(uint32_t width, uint32_t height, uint32_t slices,
                        uint32_t bytes_per_element, uint32_t levels, Prospero::TileMode tile,
                        TileSizeAlign& total_size, uint32_t num_fragments_log2 = 0);
bool     TileGetCmaskSize(uint32_t width, uint32_t height, uint32_t slices, uint32_t levels,
                          TileSizeAlign& total_size);
void     TileGetTextureSize(Prospero::BufferFormat format, uint32_t width, uint32_t height,
                            uint32_t levels, Prospero::TileMode tile, TileSizeAlign* total_size,
                            TileSizeOffset* level_sizes, TilePaddedSize* padded_size);
void     TileGetTextureTotalSize(Prospero::BufferFormat format, uint32_t width, uint32_t height,
                                 uint32_t depth, uint32_t levels, Prospero::TileMode tile,
                                 bool volume_texture, TileSizeAlign& total_size);
uint32_t TileGetTexturePitch(Prospero::BufferFormat format, uint32_t width,
                             Prospero::TileMode tile);

TileGetBlockXor deserves a note. Beyond the in-block interleave, AMD applies an XOR of block coordinates into the address so that adjacent blocks land in different memory channels and banks. Miss it and images come out looking like a shuffled checkerboard rather than obviously wrong.

Conversion runs on the GPU

Detiling a 4K texture on the CPU would be unacceptable, so Kyty ships a compute shader per layout family. They are compiled at build time and embedded into the binary (embed_spirv.cmake):

src/graphics/host_gpu/shaders/
├─ gpu_tiler_common.inc            shared helpers
├─ gpu_tiler_standard.inc          shared 256B/4KB logic
├─ gpu_tiler_standard4.comp        standard 4 KB
├─ gpu_tiler_standard64.comp       standard 64 KB
├─ gpu_tiler_standard256.comp      standard 256 B
├─ gpu_tiler_standard4_3d.comp     3D variants
├─ gpu_tiler_standard64_3d.comp
├─ gpu_tiler_prt.comp              partially-resident
├─ gpu_tiler_prt_3d.comp
├─ gpu_tiler_render_target.comp    colour attachments
├─ gpu_tiler_depth.comp            depth/stencil
├─ gpu_tiler_promote_d16.comp      16-bit depth → 32-bit
├─ gpu_tiler_demote_d16.comp       and back
└─ gpu_tiler_color_transform.comp      channel order fix-ups

The last three are not tiling at all — they are format adaptations for cases where the host GPU cannot represent a guest format directly. promote_d16 / demote_d16 exist because some drivers handle 16-bit depth poorly, so Kyty may store it as 32-bit and convert on the way in and out.

Compression metadata

SurfaceWhat it isKyty's approach
HTilePer-tile depth range and compression state alongside a depth buffer, used for fast rejection and fast clears.Sizes are computed by TileGetHtileSize; clear state is tracked so a "cleared" surface need not be materialised.
DCCDelta Colour Compression metadata for render targets.The address comes from the T#'s MetaAddr(). The texture cache tracks meta surfaces and their clear masks (IsMeta, IsMetaCleared, ClearMeta, TouchMeta).
CMask / FMaskFast-clear mask and MSAA fragment mask.Addresses are carried in HW::RenderTarget; used to detect fast-clear-only targets.
Why fast clears matter so much

A "cleared" render target on real hardware may contain no pixel data at all — only a metadata surface saying "everything is this colour." If Kyty ignored that and read the pixels, it would get stale garbage. Tracking clear state is therefore not an optimisation but a correctness requirement.

30

The shader recompiler

Typed LDS and instruction coverage

The recompiler adds V_CMPX_NLE_F16 and native 64-bit LDS atomic OR. Shared-memory storage exposes compatible typed views instead of separate unrelated 32-bit and 64-bit stores; shader-info collection and SPIR-V emission carry the required capabilities. Read spirvEmitterMemory.cpp, spirvEmitterMemoryHelpers.cpp and the compute/CFG regression tests together.

At main commit d2413fc2 (7 October 2026), the recompiler lives under src/graphics/shader/recompiler/. It is organised as an explicit compiler pipeline: frontend/decode, frontend/cfg, frontend/translate, a typed value IR and its passes, then backend/spirv.

Why it is hard, restated precisely

  • The input has no structure. No types, no functions, no variables — 64-bit instruction words operating on 106 scalar and 256 vector registers.
  • Control flow is data. As chapter 7 showed, branching is EXEC-mask manipulation. SPIR-V demands declared merge and continue blocks.
  • Resources are opaque. As chapter 28 showed, finding which texture a shader uses requires evaluating its own address arithmetic.
  • Wave semantics differ. The shader assumes 32- or 64-lane waves and uses cross-lane instructions; host GPUs have their own subgroup sizes and support levels.
  • Failure is invisible. A miscompiled shader produces wrong pixels with no crash and no log line. This is why the tests are enormous.

The pipeline

Two functions orchestrate everything. TranslateProgram() runs for each new permutation, with resource-plan extraction only on the first program miss; CompileProgram() runs once per resource specialisation and emits SPIR-V. Both log the expensive phases with the shader hash. Failures EXIT — nothing takes a std::string* error. Merged ES/GS geometry stages are compiled as ShaderType::Mesh Vulkan mesh shaders via DecodeFusedProgram().

src/graphics/shader/recompiler/ShaderRecompiler.cpp — TranslateProgram / CompileProgram
TranslateResult TranslateProgram(std::span<const uint32_t> code, const CompileOptions&);
CompileResult   CompileProgram(TranslateResult, const CompileOptions&,
                               const IR::ResourceSpecialization&, uint32_t push_data_start_dword);
The current phasesclick a stage

Enable --shader-log-direction File and you get the decoded listing, the CFG and the IR for every shader, plus these phase timings in the main log — enough to locate a slow or failing compile without a debugger.

Stage 1 — the decoder

RDNA 2 instructions come in encoding families, each with its own operand layout:

…/frontend/decode/ShaderDecoder.h
enum class Family {
    Unknown,
    SOP1, SOP2, SOPK, SOPC, SOPP,     // scalar: 1-src, 2-src, immediate, compare, program
    VOP1, VOP2, VOP3, VOP3P, VOPC,    // vector: 1-src, 2-src, 3-src, packed, compare
    VINTRP,                             // interpolation
    SMEM,                               // scalar memory — this is how descriptors are loaded
    MUBUF, MTBUF,                       // untyped / typed buffer access
    FLAT,                               // flat address-space access
    DS,                                 // local data share (workgroup memory)
    MIMG,                               // image sample and load
    EXP                                 // export — how a shader outputs anything
};

The decoded instruction is a wide struct — deliberately, because RDNA 2 instructions carry a great many modifier bits:

struct Instruction {
    uint32_t pc, word, word_count;
    uint32_t raw[MaxInstructionRawWords];   // up to 5 words
    Family   family;
    uint32_t opcode_id;
    Opcode   opcode;
    Operand  dst, dst2, src0, src1, src2, src3;
    uint32_t src_count;
    uint32_t offset, secondary_offset;
    uint32_t dmask;                         // which channels an image op returns
    uint32_t data_dwords, data_bits, data_format, number_format;
    uint32_t image_sample_flags;             // bias, LOD, gradients, offsets, compare…
    ImageDimension image_dimension;
    uint32_t image_address_components;
    uint32_t image_nsa_addr[MaxImageNsaAddressComponents];
    bool     glc, slc, idxen, offen;        // cache and addressing modifiers
    int32_t  branch_offset;
    uint32_t branch_target;
    struct { uint32_t target, en; bool done, compr, vm; } exp;
    std::string unsupported_reason;
};

And an operand carries its own modifier set — SDWA sub-dword addressing, DPP cross-lane patterns, negation, absolute value, clamping, output modifiers:

struct Operand {
    OperandKind kind; uint32_t value; int32_t signed_val; float float_val; uint32_t reg;
    uint32_t sdwa_sel, sdwa_dst_unused, omod;
    uint32_t dpp_ctrl, dpp_row_mask, dpp_bank_mask;
    bool sdwa_sext, dpp_fetch_inactive, dpp_bound_ctrl;
    bool op_sel, op_sel_hi, negate, negate_hi, absolute, clamp, dpp;
};
You cannot guess this

The README names AMD's RDNA 2 Instruction Set Architecture Reference Guide, document 70648, as the primary reference for this code. Operand encodings are dense and irregular; working on the decoder without the manual open is not feasible.

Operand widths and formatted buffer access

V_MIN3_U16 uses VOP3 opcode 0x353. ApplyNativeVop3B16TernarySelectors() selects the low or high half of each source and the destination; Integer16Ternary() widens those selected unsigned values, emits UMinTri32, then writes one 16-bit half while preserving the other. The same helper uses signed inputs and SMedTri32 for V_MED3_I16. Destination/source overlap and inactive EXEC lanes need tests, not just a correct minimum formula.

…/frontend/translate/Integer.cpp — shared 16-bit ternary lowering
void Translator::Integer16Ternary(const Decoder::Instruction& inst, IR::ValueOpcode opcode,
                                  bool sign) {
    const auto result = IR::U32(ir.Emit(
        opcode, {ReadU16AsU32(inst.src0, sign), ReadU16AsU32(inst.src1, sign),
                 ReadU16AsU32(inst.src2, sign)}));
    Write16Bits(DestinationOperand(inst), result);
}

BUFFER_LOAD_FORMAT_D16_X and BUFFER_STORE_FORMAT_D16_X are MUBUF opcodes 0x80 and 0x84. The opcode is eight bits, including bit 25 of the first dword. These formatted operations carry a 16-bit value in a VGPR, but the descriptor’s format determines the memory representation. Translation therefore uses a 32-bit IR load/store container while retaining MemoryInfo::data_bits == 16 for conversion in spirvEmitterMemory.cpp. This is separate from unsupported D16 BVH intersection encodings.

Descriptor component typeD16 loadD16 store
Unsigned integerClamp the formatted value to 0xffff.Extract the source’s low unsigned 16 bits, then encode the destination format.
Signed integerClamp to [-32768, 32767].Sign-extend the source’s low 16 bits before destination conversion.
Float, normalized or scaledConvert the formatted float result to half-float bits.Interpret the source’s low 16 bits as half-float, widen, then encode the destination format.

The D16 load writes the low destination half and preserves the upper half. Descriptor swizzles, out-of-bounds values and EXEC masking remain part of the contract. Read BufferLoadFormatD16XCases(), BufferStoreFormatD16XCases() and Vop3Min3U16CapturedAndSelectors() in tests/ShaderRecompilerComputeTests.cpp; the formatted cases are included in --buffer-format-store-only, and the U16 minimum case is included in --med3-i16-only.

64-bit comparisons and EXEC

The frontend includes I64/U64 false/true comparisons and selected relational CMPX variants. EmitIntegerCompare() reads full-width register pairs and sign-extends signed 32-bit literals for I64 comparisons, including V_CMPX_EQ_I64. EmitCompareResult() masks the result with the incoming EXEC; CMPX replaces EXEC and its low/high halves without writing VCC. Ordinary comparisons write their mask destination. The compute test switch --integer64-compare-only exercises signed/unsigned edge values, literal extension and wave32/wave64 masks. Check the individual opcode table before claiming another comparison or modifier is supported.

Stage 2–3 — control flow

The CFG is built by splitting at branch targets, then computing dominators, post-dominators, back edges, natural loops and strongly connected components:

…/frontend/cfg/ShaderCFG.h
struct BasicBlock {
    uint32_t              id, start_pc, end_pc, inst_begin, inst_end;
    std::vector<uint32_t> predecessors, successors;
    std::vector<uint32_t> dominators, post_dominators;
    Terminator            terminator;
};

struct Terminator {
    TerminatorKind  kind;          // Branch, ConditionalBranch, IndirectBranch, Return, Unsupported
    BranchCondition condition;     // SccZero, VccNonZero, ExecZero… — hardware condition registers
    uint32_t        true_block, false_block;
    uint32_t        merge_block, continue_block;   // filled in by Structurize
    uint32_t        indirect_pc_sgpr;
    std::vector<uint32_t> indirect_target_pcs, indirect_targets;
    bool            loop_header;
};

Note that branch conditions are typed to hardware registers — SccZero, VccNonZero, ExecZero. A GPU branch tests a condition register, not an arbitrary value.

Structurize() then computes the merge and continue blocks SPIR-V requires, using nearest-common-post-dominator for selections and loop-exit analysis for loops. When it cannot, it says so precisely:

enum class FailureKind {
    None, InvalidInput, UnsupportedInstruction, InvalidBranchTarget,
    MissingFallthrough, InvalidLabel,
    IrreducibleControlFlow,       // an SCC with more than one entry point
    StructuredControlFlow         // structure could not be recovered
};

The dispatcher fallback

Failure is a supported outcome, not an error. When structure cannot be recovered the recompiler emits the whole shader as a dispatcher: one loop around a switch on a program-counter variable, each basic block 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 an irreducible graph. A later Structurize() failure also selects it after restoring the original unstructured graph. Kyty records the failure kind, block, graph counts, and reason in the log.

Native tessellation and stage metadata

ShaderVertexInputInfo::logical_stage selects Vertex, Mesh, Local, TessellationControl or TessellationEvaluation. PrepareTessellationPrograms() prepares three programs; Tessellation.cpp analyses LS/HS memory dataflow to derive strides and lowers cross-stage memory. spirvEmitterTessellation.cpp emits the Vulkan tessellation stages. Enable this path with --tessellation; patch draws are skipped by default.

Wave size is stage metadata, not a universal 64-lane constant: vertex and pixel stages carry their own widths and the NGG prologue respects guest wave32. Mesh stages retain their own workgroup limits. Recent opcode work includes FP64 arithmetic/conversions, DPP8, packed integer and half-float operations, partial SDWA destinations, image float-maximum atomics and buffer-cache policy bits. Host FP64 denormal limitations are logged.

Full-float BVH32/BVH64 intersections compile through BvhIntersect and spirvEmitterBvh.cpp. The helper reads guest nodes through BDA and executes triangle/box math in SPIR-V; normal compute dispatch preserves surrounding shader work. A16, D16 and incomplete NSA forms remain unsupported. C11 traces the supported encodings, node layouts and GPU fixtures; this is not a Vulkan ray-query backend or a claim of universal game support.

Stage 4 — translation into typed IR

frontend/translate/Translate.cpp owns program setup while category files such as Scalar.cpp, Vector.cpp, and Memory.cpp dispatch decoded opcodes into typed value operations.

…/ir/opcodes/ValueOpcodes.inc — selected typed IR definitions
VALUE_OPCODE(Phi, Opaque)
VALUE_OPCODE(IAdd32, U32, U32, U32)
VALUE_OPCODE(BitwiseAnd32, U32, U32, U32)
VALUE_OPCODE(UMinTri32, U32, U32, U32, U32)
VALUE_OPCODE(FPAdd32, F32, F32, F32)
VALUE_OPCODE(FPMul32, F32, F32, F32)
VALUE_OPCODE(LoadBufferU32, U32, BufferResource, U32, U32, U32, U1)
VALUE_OPCODE(StoreBufferU32, Void, BufferResource, U32, U32, U32, U32, U1)
VALUE_OPCODE(BvhIntersect, U32x4, U32x4, ImageAddress, U1)

This table generates IR::ValueOpcode and its type metadata. Each row names the operation, result type and operand types. For example, a buffer operation carries a resource, addressing values and a U1 execution predicate; it is distinct from the guest instruction enum decoded earlier.

The retained ResourcePlan holds descriptor/SRT analysis and reusable evaluation scratch. Program derives from it and adds blocks used during translation and emission:

…/ir/ShaderIR.h — ResourcePlan and Program (selected fields)
// Retained resource analysis; evaluation scratch is mutable and renderer-serialized.
struct ResourcePlan {
    ShaderType stage;  uint64_t shader_hash;
    uint32_t user_data_base, user_data_count = 64;
    std::list<Inst>               value_storage;
    std::vector<MemoryInfo>       memory_info;
    std::vector<DescriptorSource> descriptor_sources;
    std::vector<ResourceBlock>    control_flow;
    mutable std::vector<uint8_t>         active_sources;
    std::vector<SrtRead>          srt_reads;
    std::vector<uint8_t>          clean_flat_slots;
    bool requires_specialization_memory, srt_plan_complete, resource_tracking_complete;
    ShaderInfo      info;
    UniformFillPlan uniform_fill;
};

struct Program : ResourcePlan {
    std::vector<std::unique_ptr<Block>> block_storage;
    BlockList        blocks;
    uint32_t         wave_size = 64, scratch_dwords = 0;
    bool             dispatcher_fallback = false;
    CFG::FailureKind cfg_failure_kind;
    std::string      fallback_reason;
    std::vector<BlockInfo>  block_info;
    std::vector<ExportInfo> export_info;
    BindingLayout    bindings;
};
The pass order matters

RewriteToSsa() runs before constant propagation, identity removal, and dead-code elimination. EliminateReadLane() may trigger that cleanup sequence again. Resource analysis then builds the SRT plan and classifies descriptors. Materialisation is not a compiler pass — it runs in the pipeline cache at draw time.

Stages 5–8 — resources

TrackResources() walks typed values and records reachable shader-resource-table reads. The same pass classifies buffers, images, samplers and sampled pairs. At draw time MaterializeResources() reads current user data and guest memory into a ResourceSnapshot and a ResourceSpecialization; CompileProgram() then calls ApplyResourceSpecialization() to bake those facts into the IR.

Stages 9–10 — layout and requirements

CollectShaderInfo() records stage inputs and outputs. AllocateBindings() assigns ordinary resources plus the support bindings current emission needs:

enum class DescriptorBindingKind : uint32_t {
    Buffers  = 0u,
    // … 48 typed image bindings; a binding can hold an array …
    Samplers = 49,
    Gds, BdaPagetable, FaultBuffer,
    FlattenedSrt, ShaderData, SharedMemory, Count = 56,
};

BdaPagetable and FaultBuffer support buffer-device-address translation and recoverable FLAT-access faults. FlattenedSrt and ShaderData expose data the guest ABI expected in scalar registers or tables. AnalyzeProgramRequirements() then records subgroup, derivative, image, LDS, scratch, and pixel-mask capabilities before emission.

Stage 11 — SPIR-V emission

backend/spirv divides emission among module setup, control flow, arithmetic, memory, image operations, mesh shaders and software BVH intersections. Follow spirvEmitterModule.cpp into the emitter for the IR opcode you are investigating.

Embedded vertex fetch

For an embedded fetch prologue, DetectEmbeddedVertexFetch() analyses decoded instructions before IR construction and creates a Frontend::EmbeddedFetchPlan. That plan is passed through TranslateOptions::embedded_fetch, so translation emits the vertex-input behaviour directly.

Where the test weight is

The shader executable includes focused instruction, resource, ray-intersection, transfer and scheduler suites. Read the case matching the behavior under study and distinguish compilation validation from an executed GPU result.

Learn more · shader compilation
  • Spec
    AMD RDNA 2 ISA Reference GuideMandatory for this chapter. Every Opcode in the decoder, every modifier bit in Operand, and the EXEC-mask semantics of s_and_saveexec_b64 are defined here. Keep it open beside ShaderDecoder.cpp.
  • Spec
    SPIR-V Specification — Structured Control FlowThe rules the Structurize pass is trying to satisfy: a header block declares its merge point before control flow diverges, and header and merge bracket a construct defined by dominance and post-dominance. Reading this makes the dispatcher fallback obviously necessary rather than mysterious.
  • Article
    A trip through the Graphics Pipeline — parts on shader coresBackground on why shaders execute in waves at all, and what the hardware is doing around the code the recompiler is translating.
  • Code
    shadPS4An independent PS4 emulator solving the same RDNA-to-SPIR-V problem with different design choices. Comparing the two shader recompilers is genuinely instructive, and KytyPS5's README credits it as a reference for the memory model and AVPlayer.

GPU-selected descriptors require a bounded set of compatible candidates. Resource tracking discovers buffer/image tables and nested selectors, materialization refreshes their current values, and binding/emission maps the selected candidate to native resources. Compatible image views can share a contiguous descriptor array. Unsupported shapes retain explicit guards: deleting a guard does not supply the missing candidate discovery or bindings.

Raw scalar/descriptor reads use the buffer backing path (TryReadBufferBacking()); formatted image synchronization is handled by the appropriate resource binding. Compute materialization also receives SrtRuntime::workgroup_counts. During compilation, host evaluation values are discarded while dependencies still used by GPU memory operations are retained.

Compute LDS normally uses workgroup memory. If the requested LDS exceeds maxComputeSharedMemorySize, lds_storage selects the SharedMemory storage-buffer binding. BindSharedMemory() reserves one LDS region per workgroup and checks the total against maxStorageBufferRange; indirect dispatch dimensions must be read before sizing it. Typed LDS views share the same backing. Thread-dimension dispatches use ceiling division to obtain group counts and mask lanes outside the requested thread dimensions.

31

The Vulkan renderer

Blend mapping and readback lifetimes

PipelineStaticParameters is 116 bytes. Depth-bounds enable and range are dynamic state. Inactive blend fields are canonicalized, and identical color/alpha equations share a key. Logical-alpha handling belongs to ShaderPixelInputInfo::alpha_blend_source and the effective blend factors, with None, SourceAlpha, SourceAlphaOne and SourceAlphaZero variants. blendMapping.cpp classifies supported remaps; unsupported combinations disable blending with a warning. Sampler LOD bias is clamped to the host limit.

The 64 MiB download ring is the common readback path, not a hard transfer limit. Oversized transfers allocate a temporary download Buffer; the deferred completion operation owns it until GPU completion, then invalidates host caches and copies back. See BufferCache::DownloadBufferMemory() and CommandScheduler::DeferPriorityOperation().

The host side. Its central job is caching: turning "this guest address range, in this format, used this way" into "this Vulkan object" — and knowing when that answer stops being true.

Device setup

src/graphics/host_gpu/graphicContext.h
inline constexpr uint32_t VULKAN_TARGET_API_VERSION = VK_API_VERSION_1_3;

struct GraphicContext {
    vk::Instance       instance;
    vk::PhysicalDevice physical_device;
    vk::Device         device;
    VmaAllocator       allocator;
    bool     memory_budget_ext_enabled;
    bool     supports_block_texel_view;
    bool     compute_subgroup_size_control_enabled;
    bool     sample_rate_shading_enabled;
    bool     mesh_shader_enabled;          // VK_EXT_mesh_shader, for merged geometry stages
    uint32_t subgroup_size, min_subgroup_size, max_subgroup_size;
    uint32_t max_push_descriptors;
    vk::ShaderStageFlags required_subgroup_size_stages;
    Common::Mutex queue_mutex;
    uint32_t      queue_family;
    vk::Queue     queue;
    ...
};

One queue, guarded by a mutex. The 57 guest queues of chapter 25 are multiplexed onto it — ordering is preserved through timeline semaphores rather than through hardware queues.

Format capability queries are memoised, because they are asked constantly and are not cheap:

vk::FormatProperties GetFormatProperties(vk::Format format) const {
    std::scoped_lock lock(m_format_properties_mutex);
    auto [it, inserted] = m_format_properties.try_emplace(format);
    if (inserted) physical_device.getFormatProperties(format, &it->second);
    return it->second;
}

Resource managers and caches

CacheKeyed byNotes
TextureCache 75 KBaddress + format + extent + binding typeAliasing, depth/stencil pairing, mip expansion, upload, download and LRU collection. It is owned with the buffer cache by RenderContext.
BufferCache 32 KBguest address rangePage-indexed vertex, index, uniform and storage buffers, batched uploads/downloads, LRU collection and the BDA fault path.
PipelineCache 23 KBshader IDs + full static state + attachment formatsOwns in-process maps and a per-game Vulkan driver cache saved to disk.
DescriptorHeap 3 KBdescriptor-set layoutAllocates sets in batches of 32 and retires whole pools against timeline ticks.
SamplerCache 7 KBthe guest S#Small and simple by comparison.
FaultManager 8 KBfaulted BDA page rangesNested in BufferCache; processes RDNA2 FLAT/BDA misses through a GPU fault buffer and page table.

The pipeline key

Vulkan bakes fixed-function state into the pipeline object, so every state difference needs a distinct pipeline — unless the state is declared dynamic and set per draw instead. Kyty packs everything that is not dynamic into one byte-comparable struct:

…/pipeline/pipelineCache.h
#pragma pack(push, 1)
struct PipelineStaticParameters {
    bool negative_one_to_one, depth_clip_enable;
    vk::PrimitiveTopology topology;
    bool primitive_restart_enable;
    uint32_t samples;
    bool sample_shading_enable;
    uint32_t color_mask[8];
    bool cull_front, cull_back, face, provoking_vtx_last;
    vk::PolygonMode polygon_mode;
    uint8_t color_srcblend[8], color_comb_fcn[8], color_destblend[8];
    uint8_t alpha_srcblend[8], alpha_comb_fcn[8], alpha_destblend[8];
    bool separate_alpha_blend[8], blend_enable[8];
};
#pragma pack(pop)
static_assert(sizeof(PipelineStaticParameters) == 116);

struct GraphicsPipelineKey {
    PipelineRenderingState rendering;
    std::array<uint64_t, 3> vertex_shader_ids;  // VS/mesh, or LS/HS/TES
    uint64_t ps_shader_id;
    PipelineVertexInputState vertex_input;
    PipelineStaticParameters static_params;
};

The packing and the size assertion exist because the hash uses XXH3_64bits on the packed static state. Padding bytes would be uninitialised, so identical states could hash differently — producing a slow, silent pipeline explosion. The static_assert makes any accidental padding a build error.

PipelineCache now has two levels. Kyty's maps avoid rebuilding a program or pipeline during one run; a per-game VkPipelineCache is also loaded and saved so the driver can reuse its own compiled data on the next run.

Renderer ownership and transfers

RenderContext directly owns PageManager, BufferCache, TextureCache and the mapped-range set. Its fault, invalidation, map/unmap, BDA preparation and garbage-collection methods replaced the separate GPU-resource manager. The owning renderer serializes pipeline-cache use, including saving while the GPU is running; the cache is not independently thread-safe.

Buffer readback is batched through a 64 MiB download ring. Clean guest buffers can stream directly; GPU-written aliases still require synchronization before CPU use. Image dirty-region queries honor CPU ownership and image-local readback validity. Texture alias resolution shares canonical layouts across linear/tiled, 1D/2D, mip-tail and depth/stencil views. DCC/CMASK clears must become real pixels before an expanded alias or video-out consumer can use them.

Draw preparation reuses shader bindings and reads vertex semantics directly from metadata. Release-label work is batched without redundant submissions; indirect dispatch counts are read from GPU buffers. These changes reduce per-draw work while keeping ordering and resource lifetimes explicit.

Command submission

…/renderer/commandScheduler.h
class CommandScheduler {
public:
    void Begin(HW::Context&, HW::UserConfig&, HW::Shader&);
    void BeginRendering(const RenderState&);
    void Flush();
    void Finish();
    void Wait(uint64_t tick);
    void DeferOperation(Common::UniqueFunction<void>&&);
    void DeferPriorityOperation(Common::UniqueFunction<void>&&);
    ...
private:
    MasterSemaphore m_master;              // timeline semaphore, monotonic tick
    class CommandPool {
        static constexpr size_t GrowStep = 4;
        std::vector<vk::CommandBuffer> m_buffers;
        std::vector<uint64_t>          m_ticks;
    } m_command_pool;
    CommandBuffer m_command;
    std::queue<PendingOperation> m_pending_operations, m_priority_operations;
    std::jthread m_priority_thread;
};

There is no fixed eight-buffer ring and no per-buffer fence now. The command pool reuses a buffer after its timeline tick completes and grows by four when all allocated buffers are busy. Deferred destruction is tagged with the current tick and runs only after MasterSemaphore::IsFree says the GPU has passed it.

The separate priority thread exists because some deferred work — a readback the guest is blocking on — cannot wait for the normal drain without deadlocking.

What happens on one draw

Following the real call chain:

CpOpDrawIndexAutoguestpm4Handlers.cpp — unpack index_count and flags from the packet body into a DrawAutoArgs.
↓
CommandProcessor::DrawIndexAutokytygraphicsRun.cpp — apply predication, resolve the current register state, forward to the executor.
↓
RenderExecutor::DrawAutohostrenderDraw.cpp — set debug info, build a DrawCallInfo.
↓
PrepareDrawRenderStatehostrenderDraw.cpp — resolve colour and depth targets, then GetGraphicsPrograms: translate on a miss, materialise the snapshot, compile the specialisation if it is new.
↓
ExecutePreparedDrawhostrenderDraw.cpp — PrepareBindings, then CreatePipeline, then bind and record vkCmdDraw.

Each step logs its phase name via LogDrawPhase, so a crash or hang inside a draw can be localised from the log alone.

Texture cache internals worth knowing

enum class BindingType : uint8_t { Texture, Storage, RenderTarget, DepthTarget, VideoOut };

ImageId       FindImage(ImageDesc& desc, bool exact_format = false);
ImageId       FindImageFromRange(uint64_t address, uint64_t size, bool ensure_valid = true);
vk::ImageView FindTexture(ImageId id, const ImageDesc& desc);
void          MarkGpuWritten(ImageId id);
void          InvalidateMemoryFromGPU(uint64_t address, uint64_t size);
void          RunGarbageCollector();

Aliasing is the routine case, not the exception — games reuse memory aggressively, so two images legitimately overlap:

std::vector<ImageId> FindImagesInRegion(uint64_t address, uint64_t size, bool page_overlap) const;
OverlapResult        ResolveOverlap(const ImageInfo& requested, BindingType binding,
                                    ImageId cached, ImageId merged);
ImageId              ResolveDepthOverlap(const ImageInfo& requested, BindingType binding, ImageId cached);
ImageId              ExpandImage(const ImageInfo& info, ImageId source);
void                 AssociateStencil(ImageId depth, GuestRange stencil);

And memory pressure is managed with explicit thresholds:

uint64_t m_total_used_memory   = 0;
uint64_t m_trigger_gc_memory   = 0;
uint64_t m_pressure_gc_memory  = 1536ull * 1024 * 1024;      // 1.5 GB
uint64_t m_critical_gc_memory  = 3ull * 1024 * 1024 * 1024;   // 3 GB
Common::LeastRecentlyUsedCache<ImageId, uint64_t> m_lru_cache;
Learn more · the Vulkan side
  • Tutorial
    Vulkan GuideIts chapters on descriptor sets, pipelines and the render loop map onto DescriptorHeap, PipelineCache and CommandScheduler. The fastest way to understand why those classes are shaped as they are.
  • Docs
    Khronos Vulkan Guide — SynchronizationFences, semaphores, events and pipeline barriers. This is the part of Vulkan most easily got subtly wrong, and it is exactly what MasterSemaphore and the fence-retention helpers are managing. Khronos also has Understanding Vulkan Synchronization as a gentler introduction.
  • Code
    VulkanMemoryAllocatorThe allocator Kyty links. Its documentation explains why raw vkAllocateMemory is unusable directly and what the sub-allocation strategies cost.
32

Memory coherency

Sparse texture uploads

BufferCache::ObtainBufferForImage() reads streamed mip ranges through Memory::TryReadSparseBacking(). The guest address-space owner validates the complete range and rejects private mappings before the backing store copies resident pieces and zero-fills holes. A hole inside an owned sparse reservation is not the same as an arbitrary invalid guest address; do not replace failed reads with unconditional zeroes.

The single hardest problem in the project, and the source of the bugs that are hardest to reproduce. A console has unified memory; a PC does not.

The problem, stated plainly

On a PS5, CPU and GPU share one pool of physical memory. A game writes vertex data with the CPU, then draws with it. Shared backing avoids a mandatory copy between separate CPU and GPU allocations, but ordering and visibility still require the guest’s synchronization and cache-coherency rules.

On a PC, the GPU has its own memory. Kyty's Vulkan resources are copies of guest memory. So the emulator must answer two questions that the game never asks:

  • Has the CPU changed this data since I last copied it to the GPU? (→ upload)
  • Has the GPU changed this data since the CPU last read it? (→ download)

And it must answer them without any cooperation from the game.

Two cooperating classes

PageManagerkytyTracks per-page write and access watchers. Template methods add or remove watchers; effective permissions become read-only or no-access. Protection goes through Memory::ProtectGuestHostMemory(), which respects guest mapping permissions.
↓ faults become dirty bits
MemoryTrackerkytyDivides the address space into fixed-size regions, each with per-page dirty bits tagged by source — DirtySource::Cpu or DirtySource::Gpu. Drives the upload and download loops.
src/graphics/host_gpu/pageManager.h
enum class PageFaultAccess { Read, Write, Execute, Unknown };
class PageManager final {
public:
    template <bool track>
    void UpdatePageWatchers(uint64_t vaddr, uint64_t size);
    template <bool track, bool is_read = false>
    void UpdatePageWatchersForRegion(uint64_t base_addr, RegionBits& mask);
};

The upload path

Before a draw reads a texture, the renderer asks which sub-ranges the CPU dirtied:

src/graphics/host_gpu/memoryTracker.h
template <typename RangeFunc, typename UploadFunc>
    void ForEachUploadRange(uint64_t vaddr, uint64_t size, bool is_written, RangeFunc&& range_func,
                            UploadFunc&& upload_func) {
        static_assert(std::is_nothrow_invocable_v<RangeFunc&, uint64_t, uint64_t>);
        static_assert(std::is_nothrow_invocable_v<UploadFunc&>);
        CheckNotInUploadCallback();
        Iterate<true>(vaddr, size, [](RegionManager*, uint64_t, uint64_t) {});
        const auto* previous_upload_owner = std::exchange(s_upload_owner, this);
        Iterate<false>(vaddr, size, [&](RegionManager* manager, uint64_t offset, uint64_t bytes) {
            manager->lock.lock();
            manager->ForEachModifiedRange<DirtySource::Cpu, true>(manager->GetCpuAddr() + offset,
                                                                  bytes, range_func);
            if (!is_written) {
                manager->lock.unlock();
            }
        });
        upload_func();
        if (is_written) {
            Iterate<false>(vaddr, size,
                           [](RegionManager* manager, uint64_t offset, uint64_t bytes) {
                               manager->template ChangeState<DirtySource::Gpu, true>(
                                   manager->GetCpuAddr() + offset, bytes);
                               manager->lock.unlock();
                           });
        }
        s_upload_owner = previous_upload_owner;
    }

Three things to notice:

  • Only dirty sub-ranges are copied. The transfer granularity depends on the buffer/image path, tracking pages and tiling; a changed tile does not guarantee a one-tile copy.
  • Locks are held across the callback. The user's range_func runs with region locks held, which is why it is required to be noexcept.
  • Protection is re-armed afterwards. Otherwise the next CPU write would go unnoticed.

The download path

Download iteration visits the requested GPU-dirty ranges under each region lock. The callback is required to be noexcept; clear controls whether GPU-dirty state is cleared:

template <bool clear, typename Func>
    void ForEachDownloadRange(uint64_t vaddr, uint64_t size, Func&& func) {
        static_assert(std::is_nothrow_invocable_v<Func&, uint64_t, uint64_t>);
        CheckNotInUploadCallback();
        Iterate<false>(vaddr, size, [&](RegionManager* manager, uint64_t offset, uint64_t bytes) {
            std::scoped_lock lock(manager->lock);
            const auto       address = manager->GetCpuAddr() + offset;
            manager->template ForEachModifiedRange<DirtySource::Gpu, false>(address, bytes, func);
            if constexpr (clear) {
                manager->template ChangeState<DirtySource::Gpu, false>(address, bytes);
            }
        });
    }

The invariants, enforced by hard failure

This code is unusually defensive, and the reason is worth internalising: a coherency bug produces corruption many frames later, with no traceable link to its cause. So Kyty converts each one into an immediate crash at the point of the mistake.

void CheckNotInUploadCallback() const noexcept {
    if (s_upload_owner == this) {
        EXIT("memory tracker re-entered from upload callback\n");
    }
}

And in InvalidateRegion, the state update runs twice — once to decide whether a flush is needed, once to verify the flush actually released every GPU-owned page:

template <typename Flush>
void InvalidateRegion(uint64_t vaddr, uint64_t size, Flush&& on_flush) {
    ...
    if (!update_cpu_state()) return;      // nothing GPU-owned; done
    std::forward<Flush>(on_flush)();      // flush GPU work
    if (update_cpu_state()) {
        EXIT("memory invalidation retained GPU-owned pages\n");
    }
}

How a CPU write is actually caught, end to end

  1. Resource tracking installs write watchers through PageManager, protecting guest pages whose CPU changes need tracking.
  2. The game later writes to the texture. Ordinary code — mov [rax], rbx.
  3. The CPU faults. The OS delivers it to KytyExceptionHandler (chapter 18).
  4. Memory::HandleGpuFault coordinates invalidation or readback as required, updates tracking and protection, and returns true.
  5. Execution resumes; the write completes. The game never knew.
  6. The next draw that reads the texture calls ForEachUploadRange, which reports exactly that page as dirty and copies it.
Where to start reading

tests/MemoryTrackerTests.cpp, before the implementation. Each test name is a sentence stating an invariant, and together they specify the model far more clearly than the templates do. tests/PageManagerTests.cpp covers the watch semantics in isolation.

33

Presentation and the flip model

A game does not "present a frame." It registers buffers, renders into one, and asks the display hardware to scan it out at the next vertical blank. Reproducing that protocol faithfully matters more than it sounds.

The protocol

src/graphics/presentation/videoOut.h
int VideoOutOpen(int user_id, int bus_type, int index, const void* param);
int VideoOutRegisterBuffers2(int handle, int set_index, int buffer_index_start,
                             const VideoOutBuffers* buffers, int buffer_num,
                             const VideoOutBufferAttribute2* attribute, int category, void* option);
int VideoOutSubmitFlip(int handle, int index, int flip_mode, int64_t flip_arg);
int VideoOutIsFlipPending(int handle);
int VideoOutGetFlipStatus(int handle, VideoOutFlipStatus* status);
int VideoOutAddVblankEvent(EventQueue::KernelEqueue eq, int handle, void* udata);
int VideoOutAddFlipEvent(EventQueue::KernelEqueue eq, int handle, void* udata);
int VideoOutWaitVblank(int handle);

Step by step:

  1. Register. The game hands over guest addresses plus a tiling and format description. Kyty records them; the texture cache creates images lazily.
  2. Render. Ordinary draws into one of those buffers, through the normal command stream.
  3. Flip. VideoOutSubmitFlip is queued, not immediate. A flip submitted while another is pending must block, exactly as on hardware — games rely on this for pacing.
  4. Notify. Flip and vertical-blank events are delivered into the game's event queues.

The GPU-side flip

The interesting case is a flip requested from inside the command stream, which must happen after the preceding draws complete:

class VideoOutDriver final {
public:
    int  SubmitFlipFromGpu(Graphics::CommandBuffer& buffer, int handle, int index,
                           int flip_mode, int64_t flip_arg, uint64_t& request_id);
    void PrepareFlip(uint64_t request_id, Graphics::CommandBuffer& buffer);
    void CompleteFlip(uint64_t request_id);
    void SubmitFlipPreparation(uint64_t request_id);
    void WaitForSubmitSlot();
    void WaitFlipDone(int handle, int index);
};

It becomes a Vulkan submission ordered by timeline semaphore, and the command processor has matching entry points — Flip(), FlipWithInterrupt(), PrepareCpuFlip(), WaitFlipDone().

Virtual vertical blank

A timer fires at the configured rate — 60 Hz by default, --vblank-frequency — and pushes events into every queue that registered for them. This must stay steady even when rendering is slow, because many engines drive their entire simulation from it. A game that waits on vblank and gets it late does not just render late; it simulates late.

The final blit

Only at the very end does the guest's buffer reach the host window. The swapchain path is ordinary Vulkan: acquire an image, blit or resolve the guest buffer into it, present. swapchain.cpp handles format negotiation, present modes and resize.

Presentation statistics and the window thread

Presenter::Impl::Present() increments WindowLoopState::presented_frames after a successful presentation path. WindowContext::Run() samples that atomic counter on the main thread at roughly one-second intervals, computes FPS from the frame-count difference and elapsed SDL milliseconds, and calls UpdateTitle(frame_num, current_fps). HostInputWaitEvent(event, max_wait_ms) bounds the input wait so title updates also happen when no input arrives.

Follow src/graphics/presentation/window/swapchain.cpp, window/window.cpp and window/hostInput.cpp. The title uses SDL_SetWindowTitle() directly on the main thread; presentation does not wait for a title callback. The relaxed frame counter supplies display statistics, not a Vulkan completion fence. C4 explains the ordering distinction.

FileRole
presentation/videoOut.cppThe whole flip model, buffer sets, attributes, events, output modes, colour settings.
presentation/window/vulkanWindow.cppInstance and device creation, extension and feature selection, queue setup.
presentation/window/swapchain.cppSwapchain lifecycle, format selection, present.
presentation/window/window.cppSDL3 window and the main-thread event loop; keyboard, mouse and gamepad handling.
presentation/renderDoc.cppLoads the RenderDoc API at runtime for frame capture (--rd).
Part VI

The rest of the system

Audio, input and video; the debugging tools built into the emulator; and how the project tests things that cannot be tested by looking at them.

34

Audio, input and video

APR command order and waits

src/libs/libAmpr.cpp stores file reads, memory operations, address writes/waits and events in one append-ordered command sequence. The worker advances a submission cursor one command at a time. A blocked address wait holds up its own priority queue while other ready priorities can proceed; completion is published only when the sequence completes or fails. APR file reads reuse worker-owned staging storage rather than allocating a new buffer for each read.

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.

Video playback

avPlayer.cpp handles cutscenes through FFmpeg and the archive-capable file layer. Startup, pause/seek validation, end-of-file events, loop timestamps, aspect ratio and padded image output now have explicit handling. videoDec2Decoder.cpp implements the lower-level decoder surface.

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 delivers events on the main thread; libs/controller.cpp maps game controllers to the DualSense model:

src/graphics/presentation/window/window.cpp
case SDL_CONTROLLER_BUTTON_A:             return Controller::PAD_BUTTON_CROSS;
case SDL_CONTROLLER_BUTTON_B:             return Controller::PAD_BUTTON_CIRCLE;
case SDL_CONTROLLER_BUTTON_X:             return Controller::PAD_BUTTON_SQUARE;
case SDL_CONTROLLER_BUTTON_Y:             return Controller::PAD_BUTTON_TRIANGLE;
case SDL_CONTROLLER_BUTTON_BACK:          return Controller::PAD_BUTTON_TOUCH_PAD;
case SDL_CONTROLLER_BUTTON_START:         return Controller::PAD_BUTTON_OPTIONS;
case SDL_CONTROLLER_BUTTON_LEFTSTICK:     return Controller::PAD_BUTTON_L3;
case SDL_CONTROLLER_BUTTON_LEFTSHOULDER:  return Controller::PAD_BUTTON_L1;
case SDL_CONTROLLER_BUTTON_DPAD_UP:       return Controller::PAD_BUTTON_UP;
...

Triggers are analogue and rescaled separately from sticks. presentation/window/hostInput.cpp adds default keyboard controls and repeatable --keymap Control=Input mappings for keys and mouse buttons; it can also drive the right stick from relative mouse movement. libPad.cpp reports state through scePadReadState. In the other direction, PadSetVibration() scales the guest's large/small motor bytes and forwards them to SDL_RumbleGamepad().

35

Debugging and tooling

Profiling must preserve fault handlers

--profile enables Tracy. The build sets TRACY_NO_CRASH_HANDLER=ON so connecting a profiler does not replace the emulator's exception/signal handlers: instruction emulation and CPU/GPU coherency depend on those handlers. See 3rdparty/CMakeLists.txt; the setting is independent of whether a recording client is attached.

The emulator ships with substantial instrumentation. Learning it is the difference between guessing and knowing.

Every flag, and what it is for

FlagDefaultEffect
--game <dir|elf|zar>—A directory, ELF, or .zar archive; directories and archives load /app0/eboot.bin.
--game-patch <json>—An ETAHen cheat file.
--screen-width / --screen-height1280 / 720Window size.
--user-name / --user-idKyty / 1000Local console user identity.
--present-modeMailboxFifo, Mailbox, or Immediate.
--gpu <index>-1Vulkan physical device by enumeration index. -1 picks automatically.
--controller-color <#RRGGBB>unsetOverride the controller light-bar colour; quote the value in shell commands.
--controller-volume <0-100>50DualSense speaker volume.
--controller-vibration <0-100>100DualSense vibration intensity.
--hide-cursoroffHide the cursor after two seconds without pointer activity.
--trophy-notifications <true|false>trueShow trophy unlock notifications and play their sound.
--skip-notice-screen <true|false>falseReport the notice-screen skip flag to supported games; the launcher labels it “Skip boot logos”.
--micunsetCapture from the named microphone. Omit it for paced silent AudioIn.
--vroffEnable the virtual HMD2 headset model; this is not physical VR-headset support.
--amd-cpuoffEnable AMD CPU instruction patches, including reciprocal-square-root emulation.
--tessellationoffEnable native LS / hull / evaluation-stage draws. Tessellation patches are skipped by default.
--fullscreenfalseBorderless desktop fullscreen.
--vblank-frequency <n>60Virtual vertical-blank rate. Changing it changes game speed for engines that pace off vblank.
--console-language <0-29>1Console language; 1 is English (US).
--vulkan-validationfalseVulkan validation layers.
--gpu-assisted-validationfalseVery slow GPU-side shader-access bounds checking; implies Vulkan validation.
--shader-validationfalseRuns every emitted module through spirv-val before the driver sees it.
--shader-optimization-typeNoneNone, Size or Performance — passes to spirv-opt.
--shader-log-directionSilentSilent, Console or File. Dumps decoded RDNA 2, the CFG and the IR per shader.
--shader-log-folder_ShadersWhere those dumps land.
--command-buffer-dumpfalseDecoded PM4 listing of every submitted buffer.
--command-buffer-dump-folder_BuffersWhere those land.
--graphics-debug-dumpfalseAdditional graphics dumps, including textures into _Textures.
--printf-directionSilentSilent, Console or File for the emulator logger; it also gates guest printf and several debug dumps.
--printf-output-file_kyty.txtLogger/guest-printf target when the direction is File.
--profileoffEnable the Tracy profiler; scopes use native tracy::ScopedZone.
--spirv-debug-printffalseEnables printf from inside shaders.
--readback-linear-imagesfalseRead back writable linear images on submit — a coherency workaround.
--playgo-hackfalseUse the supplied PlayGo stub fallback.
--redzonefalseWindows only: protect the guest SysV red zone.
--keymap <Control=Input>emptyDualSense mapping; repeat the flag for multiple mappings.
--rdoffLoads the RenderDoc API for frame capture.

The configuration you will actually use

# general debugging
kyty_emulator.exe --game "D:\Games\Example" ^
  --printf-direction File --printf-output-file _kyty.txt ^
  --vulkan-validation true --shader-validation true

# graphics investigation
kyty_emulator.exe --game "D:\Games\Example" ^
  --command-buffer-dump true --shader-log-direction File --graphics-debug-dump true

Symptom to first move

SymptomTurn onLook at
Exits immediatelydefaultThe last log lines — a failed subsystem, a missing eboot.bin, or an EXIT_NOT_IMPLEMENTED in the loader naming its condition.
Crashes before renderingdefaultSearch for Guest fault context. Decode the faulting instruction before interpreting a small address: a null pointer plus an offset and an unpatched segment-relative access can look similar.
Boots, a feature does nothingPRINT_NAME_ENABLE(true)The per-call trace for the suspect module, plus stubbed-NID lines in the log.
Black screen, no errors--command-buffer-dump trueWhether IT_DRAW_* packets arrive at all. If not, the problem is upstream of graphics.
Geometry wrong or missing--rdThe captured draw in RenderDoc: vertex buffers, pipeline, viewport, scissor.
Wrong colours, garbage pixels--shader-log-direction FileThe pixel shader's IR. Also check for dispatcher fallback.
Textures flicker or go stale--readback-linear-images trueA coherency bug. Start at MemoryTracker, not the texture cache.
Device lost / validation errors--vulkan-validation true --shader-validation trueValidation output first; malformed SPIR-V second.
Very slow--profileTracy. Distinguish shader compilation (cold cache) from per-draw cost.
Hangs with no CPU usedebugger, all threadsA WAIT_REG_MEM whose condition never becomes true, or a thread waiting on an event queue nothing signals.

Reading the source: five conventions

You will seeIt means
EXIT_NOT_IMPLEMENTED(cond)"If this is true, I did not handle this case." A marker of the frontier, not an impossibility claim. Grepping these maps what the emulator cannot yet do.
EXIT("…")An unrecoverable invariant violation — a bug, not a gap.
KYTY_SYSV_ABIGuest code calls this directly. Never call it from emulator code without thinking about the convention.
PRINT_NAME()Free per-call tracing, already wired up, off by default per thread.
Common::Singleton<T>::Instance()How cross-cutting services are reached. There is no dependency injection in this codebase.

Naming that maps to hardware

  • HwCtx* / HwSh* / HwUc* — handlers for context, shader and user-config register writes.
  • CpOp* — a command-processor operation: the handler for one PM4 opcode.
  • IT_* — PM4 opcodes. R_* — Sony sub-selectors carried in IT_NOP packets.
  • V# / T# / S# — buffer, image and sampler descriptors; "sharp" in code.
  • Gen5 — the AGC graphics API. Prospero — the PS5 itself.
The two questions to ask constantly

Reading any pointer: guest address or host address? Reading any function: which thread runs this — guest, GPU, or main/window? Most genuinely confusing emulator bugs are a confusion about one of those, and the code does not always spell out the answer.

Notice-screen preference

--skip-notice-screen true sets ConfigOptions::skip_notice_screen, which defaults to false. The launcher persists the same option and exposes it as Skip boot logos. Follow config.cpp → mainDialog.cpp → src/main.cpp → Config::SkipNoticeScreen() to trace a saved preference into the guest API.

src/libs/libSystemService.cpp — the guest query
static int KYTY_SYSV_ABI SystemServiceGetNoticeScreenSkipFlag(bool* value) {
    PRINT_NAME();

    if (value == nullptr) {
        return SYSTEM_SERVICE_ERROR_PARAMETER;
    }

    *value = Config::SkipNoticeScreen();

    return OK;
}

The game decides how to use the returned flag. This can skip notices or logos in games that honor the query; it does not remove every game’s intro sequence. The neighboring set/auto-set entry points currently return success without changing this configured value.

Local trophies and notifications

libTrophy.cpp and common/trophies.cpp handle local unlock/progress state and persistence. The launcher viewer and the in-game overlay display that state; --trophy-notifications false disables toast presentation. These paths do not synchronize trophies with PSN. Read tests/TrophySystemTests.cpp for the persistence contract.

Click a trophy in the launcher to open ShowTrophyInspector() in src/launcher/src/trophyViewerDialog.cpp. It shows its grade, locked/unlocked status, local earned time, description and reward when present. A locked hidden trophy initially masks its metadata; Show Hidden Trophy reveals the details within that dialog without unlocking it. Labels use plain text, and the callback owns the trophy metadata and unlock snapshot through move captures.

WrapTrophyTitle() in src/graphics/presentation/systemOverlay.cpp wraps notification titles by words and explicit line breaks, then grows the toast height to fit. The icon stays at its base size and is vertically centered; a word wider than the text area remains intact and is clipped. This presentation path is separate from trophy persistence.

Learn more · the tools
  • Docs
    RenderDoc — Quick StartCapture a frame with --rd, then use the Event Browser to step through it. For a wrong-geometry bug this shows you the vertex buffer contents, the bound pipeline and the viewport in one place. Full documentation.
  • Code
    Tracy ProfilerWhat --profile streams to. The KYTY_PROFILER_FUNCTION() macros scattered through the PM4 handlers and the recompiler feed its frame view.
  • Code
    spirv-dis and spirv-valspirv-dis turns a dumped module into readable assembly; spirv-val is what --shader-validation true runs internally. Both are worth having on your PATH separately from the build.
  • Code
    KytyPS5 — issues and pull requestsRead recent issues before reporting one. The Game Emulation Bug Report template asks for the complete log file, which after this chapter you know how to produce.
36

Tests

The aggregate suite covers shader compilation, resource binding, memory, archive I/O, audio, save data and dialogs. CMake defines 29 portable test executables plus an x86-64 macOS SSE4a executable.

The targets

cmake --build _Build/windows --target memory_tracker_tests
.\_Build\windows\memory_tracker_tests.exe

# Build the aggregate, then run the CTest registrations.
cmake --build _Build/windows --target kyty_tests
ctest --test-dir _Build/windows --output-on-failure

# 29 portable targets; macOS also adds macos_sse4a_tests:
ime_dialog_tests
shader_cfg_tests
scalar_provenance_tests
image_page_table_tests
memory_tracker_tests
page_manager_tests
archive_file_tests
avplayer_file_tests
bit_array_tests
lru_cache_tests
shader_vertex_metadata_tests
resource_materialization_tests
resource_tracking_tests
event_queue_lifetime_tests
kernel_file_system_tests
sync_on_address_tests
shader_recompiler_compute_tests
virtual_memory_allocation_tests
audio_out2_port_tests
ngs2_sampler_tests
pad_haptics_tests
save_data_memory_tests
ces_tests
save_data_dialog_tests
http_uri_parse_tests
FileCoverage
ShaderRecompilerComputeTests.cppExecutable shader semantics, rasterization, caches, transfers and scheduler subsets.
shaderCfgTests.cppControl-flow recovery, structurization and dispatcher fallback.
VirtualMemoryAllocationTests.cppGuest mappings, physical ownership, extended allocations, loader and instruction patches.
ResourceTrackingTests.cppTyped resource analysis, scalar reads, indirect buffers/images and bindings.
ResourceMaterializationTests.cppIn-place snapshot refresh, specialization and uniform-fill classification.
ScalarProvenanceTests.cppTyped value/SRT evaluation; historical filename retained.
MemoryTrackerTests.cppCPU/GPU ownership, dirty regions and tracking ranges.
PageManagerTests.cppPage protection, fault handling and watch lifetimes.
ImagePageTableTests.cppSparse image-page ownership, including extended guest addresses.
EventQueueLifetimeTests.cppEvent callbacks, deletion and queue lifetime.
KernelFileSystemTests.cppMounted paths, directory iteration, Unicode, isolation and AIO batch wait/notification contracts.
SyncOnAddressTests.cppAddress waits/wakes, timed deadlines and interruptions.
AudioOut2PortTests.cppAudio port layouts, submission timing, capabilities and shared-buffer capture.
Ngs2SamplerTests.cppPCM/ATRAC9 sampling, looping and graph playback.
PadHapticsTests.cppVibration-audio and controller-speaker routing.
ImeDialogTests.cppDialog state, focus and lifecycle.
SaveDataMemoryTests.cppPersistent save memory, allocation metadata and capacity.
SaveDataDialogTests.cppSave dialog state and lock behavior.
CesTests.cppCP932/Shift-JIS and UTF-8 conversions.
HttpUriParseTests.cppHTTP URI parsing, including empty query strings.
ArchiveFileTests.cppZArchive paths, reads, seeks and directory access.
AvPlayerFileTests.cppAVPlayer file access, including archive-backed data.
LruCacheTests.cppLeast-recently-used cache behavior.
BitArrayTests.cppBit-range operations.
ShaderVertexMetadataTests.cppVertex metadata and semantic extraction.
TrophySystemTests.cppTrophy metadata, progress, persistence and notification state.
AudioOutTimingTests.cppSubmission cadence across guest sample rates.
ControllerSettingsTests.cppLight-bar colour, speaker volume and vibration settings.
PngEncTests.cppPNG encoder parameters, output and error handling.
macosSse4aTests.cppx86-64 macOS only: SSE4a signal-context restoration.

Failure injection

The allocator compiles in deliberate failure hooks so its error paths are exercised rather than hoped about:

#if defined(KYTY_VIRTUAL_MEMORY_ALLOCATION_TESTS)
void TestFailNextPhysicalMemoryUnmap();
void TestFailPhysicalMemoryUnmapAfter(uint32_t successful_unmaps);
void TestFailGuestBackingStoreUnmapAfter(uint32_t successful_unmaps);
void TestFailNextFixedReserveRangeRegistration();
bool TestPlaceholderRangeIsFree(uint64_t vaddr, uint64_t size);
bool TestGuestAddressRangeIsOwned(uint64_t vaddr, uint64_t size);
uint64_t TestGuestBackingSize();
#endif

The same pattern appears in the loader (TestMainEntryUsesGuestStack, TestModuleRelocationUsesWritableHostMapping) and in pthreads (TestGuestStackOwnerLifecycle).

CI now runs the recompiler suite as well as focused regressions. GPU-dependent cases can report a skip when required rasterization or subgroup capabilities are absent; inspect the test output to distinguish a skipped feature from a passing execution. CTest registrations include focused subsets of the heavy shader executable, so the number of CTest cases is larger than the number of executables. ShaderRayTracingTests.inc covers decoding and compilation; ShaderRayTracingGpuTests.inc exercises software triangle/box intersections and traversal.

Why the ratio is right

Notice what is not heavily tested: the HLE libraries. That is a correct allocation of effort. When an HLE function is wrong, the game misbehaves visibly and the log points at it. When the shader compiler or the memory tracker is wrong, nothing crashes and nothing logs — you get subtly wrong pixels, or corruption several frames after the cause. Tests are the only instrument that works there.

Formatting and static analysis

python -m pip install pre-commit
python -m pre_commit install --install-hooks   # clang-format on staged .cpp/.h/.inc in src

cmake … -DKYTY_ENABLE_CLANG_TIDY=ON            # clang-tidy during builds
Part VII

Reference

A reading order for the source, and the vocabulary that makes it legible.

37

A reading order for the source

Reading 123,000 lines front to back does not work. Read it as a sequence of complete journeys, each one small and each one ending somewhere satisfying.

1 · The whole boot, end to end2 hoursmain.cpp → emulator.cpp → RuntimeLinker::Execute(). Small files; you will finish knowing the shape of the entire program. Resist every temptation to follow a call into graphics or the loader. Chapters 11–12 are the map.
2 · One HLE function, all the way through2 hoursPick something small in src/libs/ — libAppContent.cpp is a good size. Find its LIB_FUNC, then follow RuntimeLinker::Resolve() to see how the guest's relocation reaches it. Chapters 15 and 22.
3 · The loader in detailhalf a dayLoadProgramToMemory, ParseProgramDynamicInfo, Relocate, PatchProgram. Run a game and read the log alongside — every segment address, size and protection is printed. Chapters 13–17.
4 · Follow one drawa dayTurn on --command-buffer-dump, run a 2D game, find an IT_DRAW_INDEX_AUTO. Then trace the six functions of chapter 31. This is the spine of the graphics stack.
5 · One shader, compileda day or twoTurn on --shader-log-direction File. Read the decoded listing, then the CFG, then the IR. Keep AMD's document 70648 open. Chapters 28 and 30.
6 · The coherency machineryhardestPageManager → MemoryTracker → TextureCache. Read tests/MemoryTrackerTests.cpp first — the tests state the invariants more clearly than the implementation. Chapter 32.

Habits worth adopting early

  • Run with logging on and read it. Kyty logs deliberately and enormously: 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 of the ~980 sites marks a case the author knew about and chose not to handle.
  • Read the tests as specification. For the shader compiler and the memory tracker they are more precise than any comment.
  • Keep the boundary in mind. Guest pointer or host pointer? Guest thread, GPU thread, or main thread?

Where to make a first contribution

DifficultyTaskChapter
EasiestImplement a stubbed HLE function you found in a log.24
EasyAdd a CLI flag and thread it through ConfigOptions.11
ModerateHandle a PM4 opcode or register range that currently hits EXIT_NOT_IMPLEMENTED.26–27
ModerateAdd a regression test for a shader construct that currently miscompiles.30, 36
HardSupport an RDNA 2 instruction the decoder marks unsupported.30
HardestAnything in the texture cache or memory tracker.31–32

What to read alongside each pass

PassRead alongside
1 — the bootNothing external needed. The chapters here are self-contained.
2 — one HLE functionEli Bendersky on PIC, GOT and PLT — the mechanism this pass depends on.
3 — the loaderThe ELF generic ABI and the System V AMD64 psABI.
4 — one drawA trip through the Graphics Pipeline, and RenderDoc's quick start.
5 — one shaderThe RDNA 2 ISA guide and SPIR-V §2.11 Structured Control Flow. Both, open at once.
6 — coherencytests/MemoryTrackerTests.cpp. There is no better external substitute; the tests are the specification.
Learn more · related projects
  • Code
    KytyPS5This project. Issues, pull requests and the build workflow.
  • Code
    InoriRus/KytyThe original project KytyPS5 is a heavily modified fork of, released under MIT. Useful for seeing which parts are inherited and which were rewritten.
  • Code
    shadPS4An independent PS4 emulator solving many of the same problems differently. Credited in the README as a reference for the memory model and the AVPlayer implementation.
38

Glossary

Every term used in this book, defined. Filter to find one quickly.

ABIApplication binary interface — the machine-level contract: which registers carry arguments, who preserves what, how the stack is laid out. See System V and Microsoft x64.
AGCThe PS5's low-level graphics API, called Gen5 in this codebase. Its functions build PM4 packets rather than issuing draws.
AJMThe console's fixed-function audio decode block. Takes batched job records rather than function calls; Kyty routes each job to LibAtrac9 or FFmpeg.
AMPRThe PS5's asynchronous memory mapping and paging engine. Like the GPU, it consumes command records.
Back edgeA control-flow edge from a block back to one of its dominators — that is, a loop.
Basic blockA straight-line run of instructions with one entry and one exit. The unit a control-flow graph is built from.
CE / DEConstant Engine and Drawing Engine — two cooperating GPU command engines synchronised by counters, with the CE prefetching constants ahead of the DE.
CFGControl-flow graph. Basic blocks as nodes, branches as edges.
CMask / FMaskFast-clear mask and MSAA fragment mask — metadata surfaces beside a render target.
Command processorThe GPU front end that reads PM4 packets from memory and updates hardware registers. Kyty implements one in software.
DCCDelta Colour Compression. Metadata alongside a render target that compresses colour writes. Its address lives in the T# descriptor.
DescriptorA packed block of bits describing a resource. On RDNA 2 these live in registers or memory rather than in API-managed slots. See sharp.
Direct memoryPS5 physical memory, allocated by physical offset and mapped to a virtual address as a separate step.
Dispatcher fallbackThe recompiler's escape hatch for shaders whose control flow cannot be structured: one loop around a switch on a program-counter variable, each basic block a case.
DominatorBlock A dominates B if every path to B passes through A. Used to find loops and merge points.
EOPEnd of pipe. An event that fires when all preceding GPU work has completed, typically writing a value to memory and raising an interrupt.
ELFExecutable and Linkable Format, the Unix executable format. The PS5 uses it with custom type and tag values.
EXEC maskThe 64-bit register holding which lanes of a wave are currently active. Manipulating it is how RDNA 2 expresses branching — and why converting to SPIR-V is hard.
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.
Flexible memoryPS5 paged memory from a fixed per-title budget declared in param.json.
FlipAsking the display to scan out a different buffer at the next vertical blank. The console's equivalent of "present."
Gen5This codebase's name for AGC, the PS5 graphics API.
GOTGlobal Offset Table — an array of pointers the dynamic linker fills in, so code can reference external symbols without being patched. Kyty writes its own function addresses here.
Guest / hostGuest = the emulated PS5 and its software. Host = the real PC, OS and GPU. Nearly every design decision in the project is about this boundary.
HLEHigh-level emulation. Reimplementing what a console's system libraries do, in native code, instead of running the originals.
HTileA compressed metadata surface accompanying a depth buffer, storing per-tile depth ranges for fast rejection and fast clears.
IrreducibleA control-flow graph containing a loop with more than one entry point. Cannot be expressed in structured form; triggers the dispatcher fallback.
JITJust-in-time compilation — translating guest instructions to host instructions at run time and caching the result. KytyPS5 contains none, because the instruction sets already match.
LaneOne invocation within a wave. 32 or 64 lanes execute the same instruction stream in lockstep.
LLELow-level emulation. Simulating the hardware and running the console's own firmware. Accurate, slow, and requires copyrighted system files.
Merge blockThe block where two branches of a conditional reconverge. SPIR-V requires it to be declared in advance; RDNA 2 has no such concept.
Microsoft x64Windows' calling convention: four integer arguments in rcx rdx r8 r9, plus 32 bytes of caller-reserved shadow space.
NIDThe 11-character encoded hash naming an exported symbol in place of a readable name. Written hash#library#module.
NGGNext Generation Geometry — RDNA's unified geometry pipeline, used by PS5 shader-stage combinations and primitive paths.
Page faultA CPU exception raised when memory is accessed in a way its page permissions forbid. In this project it is a primary control-flow mechanism, not just an error.
PLTProcedure Linkage Table — small stubs that route calls through the GOT so external functions need no code patching.
PM4AMD's GPU command packet format. A stream of 32-bit dwords; type-3 packets carry an opcode and a body length.
Post-dominatorBlock B post-dominates A if every path from A to the exit passes through B. Used to compute merge blocks.
PRTPartially Resident Texture — a sparse texture where only some tiles are backed by memory. Has its own tiling family.
ProsperoSony's internal codename for the PS5, and the namespace name used in Kyty's graphics code.
PRX / SPRXA PS5 shared library module. Games may ship their own alongside eboot.bin.
RDNA 2The GPU architecture the PS5's graphics core is based on. Its shader instruction set is the recompiler's input language.
Rect listA three-vertex primitive describing an axis-aligned rectangle. Used constantly for full-screen passes; has no Vulkan equivalent.
RelocationA record telling the loader to write a computed value at a given offset, because the final address was not known at compile time.
SELFSigned ELF. Sony's executable container: a small header and segment table wrapping an otherwise standard ELF64 file.
SGPR / VGPRScalar and vector general-purpose registers. Scalar values are uniform across the wave; vector values are per lane. Descriptors always live in SGPRs.
SharpAMD's term for a resource descriptor. V# buffer (4 dwords), T# image (8 dwords), S# sampler (4 dwords).
SPIR-VVulkan's binary shader intermediate language, and the recompiler's output. Requires structured control flow and declared types.
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.
StructurisationRecovering declared merge and continue blocks from unstructured control flow, so SPIR-V can express it.
SubgroupVulkan's name for a wave. Subgroup size and supported operations vary by host GPU, which is why Kyty has a portable fallback lane-mask mode.
System VThe System V AMD64 calling convention, used by the PS5, Linux and macOS: six integer arguments in rdi rsi rdx rcx r8 r9.
Tiling / swizzleThe non-linear memory layout GPUs use for images so that a 2D neighbourhood lands in one cache line.
Timeline semaphoreA Vulkan synchronisation object carrying a monotonically increasing counter, rather than a binary signalled state. Kyty's MasterSemaphore.
TLS / TCBThread-local storage and its thread control block. Reached on the PS5 through the fs segment, which is why the loader rewrites those instructions.
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.
VblankVertical blanking interval. Kyty generates a virtual one at a configurable rate; many engines pace their whole simulation off it.
WaveA group of 32 or 64 shader invocations executing one instruction stream in lockstep.
39

Further reading, collected

Every external resource linked in this book, grouped by what it teaches. The chapter number tells you where it was relevant.

Start here if emulation is new

Foundations · chapters 1–3

Systems background

ABI, memory and linking · chapters 4–6, 13–20

Graphics

GPU architecture · chapters 7, 25–29
Vulkan and SPIR-V · chapters 8, 30–33

Tools and related projects

Chapters 35–37
If you only read three

Eli Bendersky on PIC/GOT/PLT for Part III, A trip through the Graphics Pipeline for Part V, and the RDNA 2 ISA guide for chapter 30. Those three cover the background that this book leans on most heavily and could only summarise.

Written from a reading of the KytyPS5 source tree at main commit d2413fc2 (7 October 2026). Source paths and symbols refer to that revision. The project is licensed GPL-2.0-only, is based on a heavily modified version of InoriRus/Kyty (MIT), and credits shadPS4 as a reference for the memory model and the AVPlayer implementation. It is not affiliated with Sony Interactive Entertainment, and distributes no games or system software.