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.
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.






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.
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.
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
| Strategy | How it works | Cost | Used 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. |
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:
- The instruction set — so the code executes at all.
- The calling convention — so a function's arguments arrive where it expects them.
- The memory layout — so pointers, allocations and alignment assumptions hold.
- The operating system's services — threads, files, memory, timers, networking, audio, input.
- The GPU — including its command format, its register set, its memory layouts and its shader instruction set.
- 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.
- Guideemudev.org — Getting StartedThe community's entry point. Points at per-system references and the r/EmuDev subreddit, which is where most emulator authors actually ask questions.
- ArticleHow Do Emulators Work? A Deep Dive into Emulator DesignWalks the interpreter → dynamic recompiler → HLE spectrum in more depth than this chapter had room for.
- VideoHow does an emulator work? Building an Atari ST emulator from scratchA whole emulator built on screen for a simple machine. The best way to make the fetch–decode–execute loop concrete before tackling anything modern.
- Codeawesome-emu-resourcesA curated index of emulator development material, organised by system and by topic.
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
| Component | What it is | Emulation consequence |
|---|---|---|
| CPU | 8-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. |
| GPU | AMD 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. |
| Memory | 16 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 size | 16 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. |
| Storage | Custom 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:
libkernel, Graphics5Driver (graphics), libSceAudioOut, libSceNet, libSceSaveData… Dozens of shared libraries, each exporting hashed symbols.kqueue-style event queues, mmap-style memory, a Unix-like file system.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.
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.
- ArticleArchitecture 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.
- VideoConsole 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.
- Articlefail0verflow blogWrite-ups behind the talks, including the liner notes for each Console Hacking presentation.
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:
- Allocates memory at the address the executable wants,
- Copies the code segments into it,
- Marks those pages executable,
- 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.
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
| Consequence | Detail |
|---|---|
| good Full CPU speed | No translation overhead. CPU-bound games can in principle exceed console performance on a fast PC. |
| good Vastly less code | A 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 control | There 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 crashes | A 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 match | Kyty 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 leak | Guest 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/:
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.
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:
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:
#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:
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
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.
- SpecSystem V AMD64 psABI — official repositoryThe living standard for the convention the PS5 uses. The register assignment, stack alignment and variadic
alrule described above all come from here. A commonly-cited stable PDF of the earlier draft is also available. - SpecMicrosoft x64 calling conventionThe other half of the mismatch — four register arguments and the mandatory 32-byte shadow space that
Jit::SafeCallhas to reserve by hand. - ReferenceOSDev Wiki — System V ABIA shorter, more approachable summary if the specification itself is heavy going.
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).
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
sceKernelMprotect, Kyty applies the equivalent host protection so that a genuine guest bug still faults the way it would on console.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:
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:
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
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.
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:
| View | Described by | Used by |
|---|---|---|
Sections (.text, .data, .rodata…) | Section headers | The linker at build time. Often stripped from shipped binaries. |
Segments (PT_LOAD, PT_DYNAMIC, PT_TLS…) | Program headers | The 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.
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:
| Type | Meaning | Computation |
|---|---|---|
R_X86_64_RELATIVE | An internal pointer that just needs the load bias added. | *where = base + addend |
R_X86_64_64 | A 64-bit absolute reference to a symbol. | *where = symbol + addend |
R_X86_64_GLOB_DAT | A Global Offset Table entry for a data symbol. | *where = symbol |
R_X86_64_JUMP_SLOT | A Procedure Linkage Table entry for a function. | *where = symbol |
R_X86_64_DTPMOD64 | Thread-local storage: which module owns this variable. | module ID |
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.
- ArticleEli 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.
- SpecELF specification (generic ABI)The section and segment structures of chapter 13, minus Sony's additions. Worth having open when reading
src/loader/elf.h.
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):
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.
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":
| Name | Size | Describes |
|---|---|---|
| V# (buffer resource) | 4 dwords / 128 bits | Base address, stride, record count, data format, component swizzle. |
| T# (image resource) | 8 dwords / 256 bits | Base address, format, width, height, depth, mip levels, tiling mode, array slices, DCC/HTile metadata address. |
| S# (sampler) | 4 dwords / 128 bits | Filter 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:
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.
- ArticleFabian Giesen — A trip through the Graphics PipelineA thirteen-part series on what the hardware actually does between a draw call and a pixel. Slightly older than RDNA 2, but the concepts — command processors, wave scheduling, rasterisation, depth and colour compression, tiling — are exactly the ones this book keeps returning to. The single best background reading for Part V.
- VideoBranch Education — How do Graphics Cards Work? Exploring GPU ArchitectureHighly detailed 3D animations of a real GPU die. The section on single-instruction-multiple-data execution and thread architecture is the clearest visual explanation of waves and lanes you will find.
- SlidesKayvon Fatahalian — How a GPU Works (CMU 15-462)Builds a GPU from first principles: start with a simple core, then add SIMD lanes, then multithreading, and watch a shader core emerge. Pairs with his "A Closer Look at GPUs" paper.
- SpecAMD RDNA 2 Instruction Set Architecture Reference GuideThe 291-page manual for the exact shader ISA the recompiler consumes. The README names this as the primary reference for shader work — chapter 30 is not really workable without it. Announced here; more architecture documentation at GPUOpen.
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 object | What it is | Where Kyty creates it |
|---|---|---|
VkInstance / VkDevice | The library connection and a logical GPU. | vulkanWindow.cpp, stored in GraphicContext. |
VkCommandBuffer | A recorded list of GPU commands. Nothing executes until submitted. | CommandScheduler::CommandPool allocates in batches of four and reuses buffers after their timeline tick completes. |
VkPipeline | Shader 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. |
VkDescriptorSet | A bundle of resource bindings handed to a pipeline. | DescriptorHeap, allocated in batches and retired after the timeline tick completes. |
VkImage / VkBuffer | GPU memory with a format and usage. | TextureCache, BufferCache. |
VkFence / timeline VkSemaphore | CPU–GPU and GPU–GPU synchronisation. A timeline semaphore carries a monotonically increasing counter. | MasterSemaphore. |
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:
| Level | Representation |
|---|---|
| Original shader source | float d = dot(a, b); |
| RDNA 2 machine code | v_mul_f32 v4, v0, v3 then v_fma_f32 v4, v1, v2, v4 … |
| Kyty decoder output | schematic: decoded |
| Kyty IR | IR::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.
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.
- TutorialVulkan 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.
- TutorialVulkan 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.
- HubKhronos — Learn VulkanThe official starting point, including the Khronos-maintained samples and tutorials.
- SpecSPIR-V SpecificationRead section 2.11, "Structured Control Flow", before chapter 30. It defines
OpSelectionMergeandOpLoopMergeand the dominance rules that make the structurisation pass necessary — and that make it able to fail. - PaperAn 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.
- CodeSPIRV-ToolsThe validator and optimiser Kyty links against.
spirv-disfrom this repository is what you use to read a dumped module as text.
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.
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
| Directory | Side | Owns |
|---|---|---|
common/ | host | Everything 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/ | kyty | Parsing SELF/ELF, mapping segments, patching instructions, applying relocations, resolving NIDs, generating stubs, installing the exception handler, and applying ETAHen/GoldHEN-style cheats. |
kernel/ | kyty | The PS5 kernel services too central for libs/: the memory manager, the pthread implementation, the file system, event queues, semaphores, event flags. |
libs/ | kyty | User-space module reimplementations — audio, network, fonts, save data, video, dialogs, JSON, PNG, trophies, and the AGC graphics driver that builds PM4. |
graphics/guest_gpu/ | guest | The PS5 GPU's own side: PM4 packet definitions and parsing, the hardware register file, the command-processor state machine, tiling arithmetic. |
graphics/shader/ | kyty | A compiler. Decoder, control-flow graph, IR, dataflow analyses, resource tracking, SPIR-V emitter. |
graphics/host_gpu/ | host | Vulkan: instance and device setup, command scheduling, pipelines, descriptors, and the five caches that map guest memory to Vulkan objects. |
graphics/presentation/ | host | SDL3 window, Vulkan swapchain, the VideoOut flip model, virtual vertical blank, RenderDoc integration. |
launcher/ | host | Qt 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
| File | Why it is big |
|---|---|
libs/agcRegisterDefaults.inc | A generated table of the values the graphics driver writes at context reset. |
libs/agc.cpp | AGC command-buffer builder functions and validation. |
guest_gpu/…/pm4Handlers.cpp | PM4 opcode and hardware-register handlers. |
kernel/memory.cpp | Direct, flexible and pooled memory, page attributes, naming, batches and PRT apertures. |
kernel/pthread.cpp | A pthread implementation with FreeBSD semantics. |
libs/libNet.cpp | The large network HLE surface. |
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"
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
| Library | Role |
|---|---|
Vulkan-Headers | API declarations. Vulkan is loaded dynamically, so no SDK is needed to build. |
VulkanMemoryAllocator | Sub-allocates GPU memory. Vulkan's raw allocation is too coarse to use directly. |
SPIRV-Headers, SPIRV-Tools | Emission, validation (spirv-val) and optimisation (spirv-opt) of recompiled shaders. |
SDL3 | Window, keyboard, mouse, gamepad, and the Vulkan surface. Statically linked. |
ffmpeg-core | Video decode for AVPlayer; AAC and MP3 for the AJM audio block. |
LibAtrac9 | Sony's ATRAC9 audio codec, which FFmpeg does not cover. |
fmt, spdlog | Formatting and logging. |
nlohmann_json | param.json and ETAHen/GoldHEN-style cheat files. |
magic_enum | Enum reflection — this is why CLI flags accept enumerator names directly. |
xxHash | Fast hashing for shader and pipeline cache keys. |
tracy | Frame profiler, enabled with --profile. |
stb, cpuinfo, winpthread | Image decode, CPU feature detection, pthreads on Windows. |
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:
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:
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:
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;
}
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.
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.
The whole thing as code
Emulator::Run is short enough to read in full, and it is the boot sequence:
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
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
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
}
}
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.
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.
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:
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
| Field | Value | Meaning |
|---|---|---|
e_ident[EI_OSABI] | ELFOSABI_FREEBSD = 9 | Confirms the FreeBSD heritage. |
e_machine | EM_X86_64 = 62 | x86-64, as expected. |
e_type | ET_DYNEXEC = 0xfe10 | Sony-specific: a main executable that is nevertheless position-independent. |
e_type | ET_DYNAMIC = 0xfe18 | Sony-specific: a shared module. |
Custom segment types
p_type | Value | Contents |
|---|---|---|
PT_LOAD | 1 | Standard: map this into memory. |
PT_DYNAMIC | 2 | Standard: the dynamic tag array. |
PT_TLS | 7 | Standard: the thread-local storage template image. |
PT_OS_DYNLIBDATA | 0x61000000 | Sony: 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_PROCPARAM | 0x61000001 | Sony: the process-parameter block the runtime reads at start-up. |
PT_OS_RELRO | 0x61000010 | Sony: 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:
| Tag | Value | Purpose |
|---|---|---|
DT_OS_SYMTAB / SYMTABSZ | 0x61000039 / 0x6100003f | Dynamic symbol table and its size. |
DT_OS_STRTAB / STRSZ | 0x61000035 / 0x61000037 | String table holding NIDs and library names. |
DT_OS_RELA / RELASZ / RELAENT | 0x6100002f / 31 / 33 | General relocations. |
DT_OS_JMPREL / PLTRELSZ | 0x61000029 / 0x6100002d | PLT relocations — the function imports. |
DT_OS_PLTGOT | 0x61000027 | Address of the Global Offset Table. |
DT_OS_MODULE_INFO | 0x6100000d | Name and version of a module this file exports. |
DT_OS_NEEDED_MODULE | 0x6100000f | Name and version of a module this file imports. |
DT_OS_EXPORT_LIB / IMPORT_LIB | 0x61000013 / 0x61000015 | Library names and versions on each side. |
DT_OS_FINGERPRINT | 0x61000007 | Build 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:
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.
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:
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
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.
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_protectexists because some PS5 executables declarePT_LOADsegments with no access flags at all. ApplyingNoAccessliterally would make them unreadable; the loader leaves them mapped instead.- The TLS image is a template.
PT_TLSdescribes the initial contents; each thread gets its own copy, allocated lazily byTlsGetAddr.
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.
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
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:
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:
#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:
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:
Libs::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.jmprela table contains a R_X86_64_JUMP_SLOT record naming 23LRUSvYu1M#Graphics5#Graphics5, targeting a GOT slot.Resolve() 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.KYTY_SYSV_ABI prepared it for.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.
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:
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:
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
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.
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:
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.
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:
| Option | Result |
|---|---|
| Abort at load time | Almost nothing boots. Games import far more than they call — an unused code path drags in an entire library. |
| Write null into the GOT | The game crashes on the first call with no context. The log says nothing useful. |
| Write a generated stub | Only 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.
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.
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.
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
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.
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:
mov [rax], rbx. The game has no idea anything special is happening.SIGSEGV handler elsewhere.true.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 withIsReadableRangefirst 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)));
}
}
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.
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
| Kind | Guest API | Model |
|---|---|---|
| Direct | sceKernelAllocateDirectMemorysceKernelMapDirectMemorysceKernelReleaseDirectMemory |
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 | sceKernelMapFlexibleMemorysceKernelAvailableFlexibleMemorySize |
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:
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
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:
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.
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:
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 path | Host backing | Purpose |
|---|---|---|
/app0 | the --game directory | The game's own files, read-only in practice. |
/hostapp | the same directory | Alias 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. |
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.binrather 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.
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
| File | Provides |
|---|---|
kernel/semaphore.cpp | Counting semaphores with timeouts and cancellation. |
kernel/eventFlag.cpp | Event flags — a bitmask on which threads wait for any or all bits. Common in console middleware. |
libs/libUlt.cpp | ULT, Sony's user-level cooperative threading library — fibers with their own scheduler. |
libs/libKernel.cpp | Everything else in libkernel: time, process info, dynamic library management, Posix:: aliases. |
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.
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:
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
| Element | Why 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_ABI | The 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 logging | Almost every function logs its inputs. When reverse-engineering behaviour, the log is the documentation. |
LIB_DEFINE + LIB_FUNC | Registration. Called once from Libs::InitAll, before any guest code runs. |
How PRINT_NAME works
#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.
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.
Tour of the libraries
What is implemented, how big each area is, and what is genuinely hard about it.
| Area | Files | Notes |
|---|---|---|
| Graphics driver (AGC) | agc.cpplibAgcDriver.cppagcRegisterDefaults.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.cppnetwork.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.cpplibAudio.cpplibAudio2.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.cppajm/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.cpplibVideoDec2.cppvideoDec2Decoder.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.cpplibFontFt.cpp |
Glyph layout, metrics and rendering. Large because the API surface is large. |
| System services | libSaveDatalibSystemServicelibUserServicelibPlayGolibDialog + dialog.cpplibShare |
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 | libJson2libPngDeclibRtclibCes.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.cppcontroller.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:
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.
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:
- 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.
- 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.
- 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
| Symptom | Cause | Fix |
|---|---|---|
| NID still reported as stubbed | The 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 garbage | Possible 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 function | Your 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 misbehaves | The 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. |
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.
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.
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.
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.
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:
// 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
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();
}
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:
#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:
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
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:
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:
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
| Mechanism | What it is | Why 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
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.
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
| Space | Packet | Holds | Kyty type |
|---|---|---|---|
| Context (CX) | IT_SET_CONTEXT_REG | Per-draw pipeline state: render targets, depth/stencil, blend, viewports, scissors, rasteriser modes. | HW::Context |
| Shader (SH) | IT_SET_SH_REG | Shader addresses, resource counts, and the user-data registers of chapter 28. | HW::Shader |
| User config (UC) | IT_SET_UCONFIG_REG | Global 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:
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.
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:
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:
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.
};
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:
TranslateProgram creates typed values; RewriteToSsa and simplification expose where descriptor-address inputs came from.ResourceSnapshot: this exact address, this format, this size.The plan is stored on IR::ResourcePlan; IR::Program inherits it:
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:
// 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.
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
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
| Surface | What it is | Kyty's approach |
|---|---|---|
| HTile | Per-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. |
| DCC | Delta 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 / FMask | Fast-clear mask and MSAA fragment mask. | Addresses are carried in HW::RenderTarget; used to detect fast-clear-only targets. |
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.
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().
TranslateResult TranslateProgram(std::span<const uint32_t> code, const CompileOptions&);
CompileResult CompileProgram(TranslateResult, const CompileOptions&,
const IR::ResourceSpecialization&, uint32_t push_data_start_dword);
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:
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;
};
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.
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 type | D16 load | D16 store |
|---|---|---|
| Unsigned integer | Clamp the formatted value to 0xffff. | Extract the source’s low unsigned 16 bits, then encode the destination format. |
| Signed integer | Clamp to [-32768, 32767]. | Sign-extend the source’s low 16 bits before destination conversion. |
| Float, normalized or scaled | Convert 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:
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.
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:
// 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;
};
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.
- SpecAMD RDNA 2 ISA Reference GuideMandatory for this chapter. Every
Opcodein the decoder, every modifier bit inOperand, and the EXEC-mask semantics ofs_and_saveexec_b64are defined here. Keep it open besideShaderDecoder.cpp. - SpecSPIR-V Specification — Structured Control FlowThe rules the
Structurizepass 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. - ArticleA 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.
- CodeshadPS4An 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.
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
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
| Cache | Keyed by | Notes |
|---|---|---|
TextureCache 75 KB | address + format + extent + binding type | Aliasing, depth/stencil pairing, mip expansion, upload, download and LRU collection. It is owned with the buffer cache by RenderContext. |
BufferCache 32 KB | guest address range | Page-indexed vertex, index, uniform and storage buffers, batched uploads/downloads, LRU collection and the BDA fault path. |
PipelineCache 23 KB | shader IDs + full static state + attachment formats | Owns in-process maps and a per-game Vulkan driver cache saved to disk. |
DescriptorHeap 3 KB | descriptor-set layout | Allocates sets in batches of 32 and retires whole pools against timeline ticks. |
SamplerCache 7 KB | the guest S# | Small and simple by comparison. |
FaultManager 8 KB | faulted BDA page ranges | Nested 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:
#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
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:
pm4Handlers.cpp — unpack index_count and flags from the packet body into a DrawAutoArgs.graphicsRun.cpp — apply predication, resolve the current register state, forward to the executor.renderDraw.cpp — set debug info, build a DrawCallInfo.renderDraw.cpp — resolve colour and depth targets, then GetGraphicsPrograms: translate on a miss, materialise the snapshot, compile the specialisation if it is new.renderDraw.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;
- TutorialVulkan GuideIts chapters on descriptor sets, pipelines and the render loop map onto
DescriptorHeap,PipelineCacheandCommandScheduler. The fastest way to understand why those classes are shaped as they are. - DocsKhronos Vulkan Guide — SynchronizationFences, semaphores, events and pipeline barriers. This is the part of Vulkan most easily got subtly wrong, and it is exactly what
MasterSemaphoreand the fence-retention helpers are managing. Khronos also has Understanding Vulkan Synchronization as a gentler introduction. - CodeVulkanMemoryAllocatorThe allocator Kyty links. Its documentation explains why raw
vkAllocateMemoryis unusable directly and what the sub-allocation strategies cost.
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
Memory::ProtectGuestHostMemory(), which respects guest mapping permissions.DirtySource::Cpu or DirtySource::Gpu. Drives the upload and download loops.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:
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_funcruns with region locks held, which is why it is required to benoexcept. - 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
- Resource tracking installs write watchers through
PageManager, protecting guest pages whose CPU changes need tracking. - The game later writes to the texture. Ordinary code —
mov [rax], rbx. - The CPU faults. The OS delivers it to
KytyExceptionHandler(chapter 18). Memory::HandleGpuFaultcoordinates invalidation or readback as required, updates tracking and protection, and returnstrue.- Execution resumes; the write completes. The game never knew.
- The next draw that reads the texture calls
ForEachUploadRange, which reports exactly that page as dirty and copies it.
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.
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
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:
- Register. The game hands over guest addresses plus a tiling and format description. Kyty records them; the texture cache creates images lazily.
- Render. Ordinary draws into one of those buffers, through the normal command stream.
- Flip.
VideoOutSubmitFlipis queued, not immediate. A flip submitted while another is pending must block, exactly as on hardware — games rely on this for pacing. - 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.
| File | Role |
|---|---|
presentation/videoOut.cpp | The whole flip model, buffer sets, attributes, events, output modes, colour settings. |
presentation/window/vulkanWindow.cpp | Instance and device creation, extension and feature selection, queue setup. |
presentation/window/swapchain.cpp | Swapchain lifecycle, format selection, present. |
presentation/window/window.cpp | SDL3 window and the main-thread event loop; keyboard, mouse and gamepad handling. |
presentation/renderDoc.cpp | Loads the RenderDoc API at runtime for frame capture (--rd). |
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.
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:
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().
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
| Flag | Default | Effect |
|---|---|---|
--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-height | 1280 / 720 | Window size. |
--user-name / --user-id | Kyty / 1000 | Local console user identity. |
--present-mode | Mailbox | Fifo, Mailbox, or Immediate. |
--gpu <index> | -1 | Vulkan physical device by enumeration index. -1 picks automatically. |
--controller-color <#RRGGBB> | unset | Override the controller light-bar colour; quote the value in shell commands. |
--controller-volume <0-100> | 50 | DualSense speaker volume. |
--controller-vibration <0-100> | 100 | DualSense vibration intensity. |
--hide-cursor | off | Hide the cursor after two seconds without pointer activity. |
--trophy-notifications <true|false> | true | Show trophy unlock notifications and play their sound. |
--skip-notice-screen <true|false> | false | Report the notice-screen skip flag to supported games; the launcher labels it “Skip boot logos”. |
--mic | unset | Capture from the named microphone. Omit it for paced silent AudioIn. |
--vr | off | Enable the virtual HMD2 headset model; this is not physical VR-headset support. |
--amd-cpu | off | Enable AMD CPU instruction patches, including reciprocal-square-root emulation. |
--tessellation | off | Enable native LS / hull / evaluation-stage draws. Tessellation patches are skipped by default. |
--fullscreen | false | Borderless desktop fullscreen. |
--vblank-frequency <n> | 60 | Virtual vertical-blank rate. Changing it changes game speed for engines that pace off vblank. |
--console-language <0-29> | 1 | Console language; 1 is English (US). |
--vulkan-validation | false | Vulkan validation layers. |
--gpu-assisted-validation | false | Very slow GPU-side shader-access bounds checking; implies Vulkan validation. |
--shader-validation | false | Runs every emitted module through spirv-val before the driver sees it. |
--shader-optimization-type | None | None, Size or Performance — passes to spirv-opt. |
--shader-log-direction | Silent | Silent, Console or File. Dumps decoded RDNA 2, the CFG and the IR per shader. |
--shader-log-folder | _Shaders | Where those dumps land. |
--command-buffer-dump | false | Decoded PM4 listing of every submitted buffer. |
--command-buffer-dump-folder | _Buffers | Where those land. |
--graphics-debug-dump | false | Additional graphics dumps, including textures into _Textures. |
--printf-direction | Silent | Silent, Console or File for the emulator logger; it also gates guest printf and several debug dumps. |
--printf-output-file | _kyty.txt | Logger/guest-printf target when the direction is File. |
--profile | off | Enable the Tracy profiler; scopes use native tracy::ScopedZone. |
--spirv-debug-printf | false | Enables printf from inside shaders. |
--readback-linear-images | false | Read back writable linear images on submit — a coherency workaround. |
--playgo-hack | false | Use the supplied PlayGo stub fallback. |
--redzone | false | Windows only: protect the guest SysV red zone. |
--keymap <Control=Input> | empty | DualSense mapping; repeat the flag for multiple mappings. |
--rd | off | Loads 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
| Symptom | Turn on | Look at |
|---|---|---|
| Exits immediately | default | The last log lines — a failed subsystem, a missing eboot.bin, or an EXIT_NOT_IMPLEMENTED in the loader naming its condition. |
| Crashes before rendering | default | Search 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 nothing | PRINT_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 true | Whether IT_DRAW_* packets arrive at all. If not, the problem is upstream of graphics. |
| Geometry wrong or missing | --rd | The captured draw in RenderDoc: vertex buffers, pipeline, viewport, scissor. |
| Wrong colours, garbage pixels | --shader-log-direction File | The pixel shader's IR. Also check for dispatcher fallback. |
| Textures flicker or go stale | --readback-linear-images true | A coherency bug. Start at MemoryTracker, not the texture cache. |
| Device lost / validation errors | --vulkan-validation true --shader-validation true | Validation output first; malformed SPIR-V second. |
| Very slow | --profile | Tracy. Distinguish shader compilation (cold cache) from per-draw cost. |
| Hangs with no CPU use | debugger, all threads | A 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 see | It 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_ABI | Guest 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 inIT_NOPpackets.V#/T#/S#— buffer, image and sampler descriptors; "sharp" in code.Gen5— the AGC graphics API.Prospero— the PS5 itself.
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.
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.
- DocsRenderDoc — 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. - CodeTracy ProfilerWhat
--profilestreams to. TheKYTY_PROFILER_FUNCTION()macros scattered through the PM4 handlers and the recompiler feed its frame view. - Codespirv-dis and spirv-val
spirv-disturns a dumped module into readable assembly;spirv-valis what--shader-validation trueruns internally. Both are worth having on your PATH separately from the build. - CodeKytyPS5 — 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.
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
| File | Coverage |
|---|---|
ShaderRecompilerComputeTests.cpp | Executable shader semantics, rasterization, caches, transfers and scheduler subsets. |
shaderCfgTests.cpp | Control-flow recovery, structurization and dispatcher fallback. |
VirtualMemoryAllocationTests.cpp | Guest mappings, physical ownership, extended allocations, loader and instruction patches. |
ResourceTrackingTests.cpp | Typed resource analysis, scalar reads, indirect buffers/images and bindings. |
ResourceMaterializationTests.cpp | In-place snapshot refresh, specialization and uniform-fill classification. |
ScalarProvenanceTests.cpp | Typed value/SRT evaluation; historical filename retained. |
MemoryTrackerTests.cpp | CPU/GPU ownership, dirty regions and tracking ranges. |
PageManagerTests.cpp | Page protection, fault handling and watch lifetimes. |
ImagePageTableTests.cpp | Sparse image-page ownership, including extended guest addresses. |
EventQueueLifetimeTests.cpp | Event callbacks, deletion and queue lifetime. |
KernelFileSystemTests.cpp | Mounted paths, directory iteration, Unicode, isolation and AIO batch wait/notification contracts. |
SyncOnAddressTests.cpp | Address waits/wakes, timed deadlines and interruptions. |
AudioOut2PortTests.cpp | Audio port layouts, submission timing, capabilities and shared-buffer capture. |
Ngs2SamplerTests.cpp | PCM/ATRAC9 sampling, looping and graph playback. |
PadHapticsTests.cpp | Vibration-audio and controller-speaker routing. |
ImeDialogTests.cpp | Dialog state, focus and lifecycle. |
SaveDataMemoryTests.cpp | Persistent save memory, allocation metadata and capacity. |
SaveDataDialogTests.cpp | Save dialog state and lock behavior. |
CesTests.cpp | CP932/Shift-JIS and UTF-8 conversions. |
HttpUriParseTests.cpp | HTTP URI parsing, including empty query strings. |
ArchiveFileTests.cpp | ZArchive paths, reads, seeks and directory access. |
AvPlayerFileTests.cpp | AVPlayer file access, including archive-backed data. |
LruCacheTests.cpp | Least-recently-used cache behavior. |
BitArrayTests.cpp | Bit-range operations. |
ShaderVertexMetadataTests.cpp | Vertex metadata and semantic extraction. |
TrophySystemTests.cpp | Trophy metadata, progress, persistence and notification state. |
AudioOutTimingTests.cpp | Submission cadence across guest sample rates. |
ControllerSettingsTests.cpp | Light-bar colour, speaker volume and vibration settings. |
PngEncTests.cpp | PNG encoder parameters, output and error handling. |
macosSse4aTests.cpp | x86-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
Reference
A reading order for the source, and the vocabulary that makes it legible.
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.
main.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.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.LoadProgramToMemory, ParseProgramDynamicInfo, Relocate, PatchProgram. Run a game and read the log alongside — every segment address, size and protection is printed. Chapters 13–17.--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.--shader-log-direction File. Read the decoded listing, then the CFG, then the IR. Keep AMD's document 70648 open. Chapters 28 and 30.PageManager → 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_IMPLEMENTEDas 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
| Difficulty | Task | Chapter |
|---|---|---|
| Easiest | Implement a stubbed HLE function you found in a log. | 24 |
| Easy | Add a CLI flag and thread it through ConfigOptions. | 11 |
| Moderate | Handle a PM4 opcode or register range that currently hits EXIT_NOT_IMPLEMENTED. | 26–27 |
| Moderate | Add a regression test for a shader construct that currently miscompiles. | 30, 36 |
| Hard | Support an RDNA 2 instruction the decoder marks unsupported. | 30 |
| Hardest | Anything in the texture cache or memory tracker. | 31–32 |
What to read alongside each pass
| Pass | Read alongside |
|---|---|
| 1 — the boot | Nothing external needed. The chapters here are self-contained. |
| 2 — one HLE function | Eli Bendersky on PIC, GOT and PLT — the mechanism this pass depends on. |
| 3 — the loader | The ELF generic ABI and the System V AMD64 psABI. |
| 4 — one draw | A trip through the Graphics Pipeline, and RenderDoc's quick start. |
| 5 — one shader | The RDNA 2 ISA guide and SPIR-V §2.11 Structured Control Flow. Both, open at once. |
| 6 — coherency | tests/MemoryTrackerTests.cpp. There is no better external substitute; the tests are the specification. |
- CodeKytyPS5This project. Issues, pull requests and the build workflow.
- CodeInoriRus/KytyThe original project KytyPS5 is a heavily modified fork of, released under MIT. Useful for seeing which parts are inherited and which were rewritten.
- CodeshadPS4An 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.
Glossary
Every term used in this book, defined. Filter to find one quickly.
param.json.rcx rdx r8 r9, plus 32 bytes of caller-reserved shadow space.hash#library#module.eboot.bin.rdi rsi rdx rcx r8 r9.MasterSemaphore.fs segment, which is why the loader rewrites those instructions.SET_SH_REG packets — the only channel by which a draw passes parameters to its shaders.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
- Guideemudev.org — Getting StartedThe community entry point, plus r/EmuDev where emulator authors actually ask questions.
- ArticleHow Do Emulators Work? A Deep Dive into Emulator DesignThe interpreter → recompiler → HLE spectrum in depth.
- VideoBuilding an Atari ST emulator from scratchWatch a whole emulator get built for a simple machine.
- ArticleArchitecture of Consoles — Rodrigo CopettiHardware analyses of essentially every console. Read the PlayStation entries.
- VideoConsole Hacking 2016 — "PS4: PC Master Race" (33C3)fail0verflow on PS4 hardware and reverse engineering. YouTube mirror, slides, blog.
- Codeawesome-emu-resourcesCurated index of emulator development material by system and topic.
Systems background
- SpecSystem V AMD64 psABIThe convention the PS5 uses. Draft PDF; OSDev summary if the spec is heavy going.
- SpecMicrosoft x64 calling conventionThe other half of the mismatch, including the mandatory shadow space.
- ArticlePosition Independent Code in shared librariesGOT and PLT explained from disassembly, plus the x64 follow-up. The most important background link in this book.
- SpecELF generic ABIObject file structure, program loading and dynamic linking, minus Sony's extensions.
Graphics
- ArticleA trip through the Graphics Pipeline — Fabian GiesenThirteen parts on what the hardware does between a draw call and a pixel. The best background for Part V.
- VideoHow do Graphics Cards Work? Exploring GPU ArchitectureDetailed 3D animations of a real die; the clearest visual account of SIMD lanes and thread architecture.
- SlidesHow a GPU Works — Kayvon FatahalianBuilds a shader core from first principles. Pairs with "A Closer Look at GPUs".
- SpecAMD RDNA 2 ISA Reference Guide (70648)The manual for the shader ISA the recompiler consumes. Named by the README as the primary reference. More at GPUOpen.
- TutorialVulkan TutorialBuild a triangle from nothing. The pipeline and command-buffer chapters make chapter 31 far more legible.
- TutorialVulkan GuideA more engine-shaped take; its structure maps closely onto Kyty's caches. See its Great Resources index.
- HubKhronos — Learn VulkanOfficial starting point and samples.
- DocsVulkan Guide — SynchronizationFences, semaphores, barriers. Also Understanding Vulkan Synchronization.
- SpecSPIR-V SpecificationSection 2.11 on structured control flow is the one that matters for chapter 30. Gentler: the introduction white paper.
- CodeVulkanMemoryAllocatorThe allocator Kyty links, and why raw
vkAllocateMemoryis unusable directly.
Tools and related projects
- DocsRenderDoc — Quick StartWhat
--rdcaptures into. Full docs. - CodeTracy ProfilerWhat
--profilestreams to. - CodeSPIRV-Tools
spirv-disandspirv-val, both worth having on your PATH. - CodeKytyPS5 · InoriRus/Kyty · shadPS4This project, the project it forked from, and the independent PS4 emulator credited as a reference.
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.