Examples

Worked all the way through

Each example below follows one real thing from the guest's point of view to the host's, with the actual code at every hop. They are ordered by how much of the emulator you need to hold in your head — the first one you could finish tonight, the last one is a weekend.

01

Implement a missing function

~40 min · Start here · touches src/libs/

This is a binding walkthrough using an already implemented function, not a request to add a duplicate export. For an actual contribution, establish a bounded contract and regression test first; reaching a function or advancing one title does not prove correctness.

1 Find something that is missing

Run a title with logging on and let it get as far as it can:

.\_Build\windows-prod\kyty_emulator.exe --game "<title>" `
  --redzone --printf-direction File --printf-output-file _kyty.txt

Then drop that file into the log triage tool. The "Unresolved imports" bucket is your work queue. Each entry looks roughly like:

runtimeLinker: unresolved symbol rPo6tV8D9bM#v#v (libSceSystemService)

rPo6tV8D9bM is the NID — an 11-character encoded id. The guest does not import by name; it imports by this. That is the entire reason a symbol database exists.

2 Find out what it is supposed to do

A NID is a one-way hash, so you cannot decode it back into a name. You look it up. Search the tree first — the odds are good that a neighbouring function in the same library is already implemented and the file is waiting for you:

rg "rPo6tV8D9bM" src/
rg "LIB_DEFINE" src/libs/libSystemService.cpp

A community NID database can suggest a name, not establish its signature. Verify argument count, types, structure layout and ABI against source and call-site evidence. In this snapshot, SystemServiceGetStatus already exists in the source file below; use that definition to study its status pointer and integer return rather than guessing either from the name. The unresolved line above is illustrative, not a current bug report.

3 Write the implementation

Open the library file — here src/libs/libSystemService.cpp. Every HLE function follows the same three rules, and the first one is not optional:

static int KYTY_SYSV_ABI SystemServiceGetStatus(SystemServiceStatus* status) {
	PRINT_NAME();                       // trace: shows up in your log

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

	*status = SystemServiceStatus {};
	status->is_system_ui_overlaid = Dialog::ErrorDialog::GetVisualState().active;
	return OK;
}
KYTY_SYSV_ABI is load-bearing. Guest code was compiled for the System V AMD64 ABI: arguments in rdi, rsi, rdx, rcx, r8, r9, no shadow space. Your host compiler on Windows defaults to the Microsoft x64 ABI: rcx, rdx, r8, r9 and 32 bytes of shadow space. Omit the annotation and the guest passes arguments into registers your function never reads — it will not crash cleanly, it will read garbage. This one macro is why the codebase has a thunking layer at all.

4 Register it against the NID

Implementing it is not enough — the runtime linker has to be able to find it. That happens in the library's LIB_DEFINE block, which is the actual bridge between the encoded id in the guest's import table and your C++ symbol:

LIB_DEFINE(InitSystemService_1) {
	LIB_FUNC("Vo5V8KAwCmk", SystemService::SystemServiceHideSplashScreen);
	LIB_FUNC("fZo48un7LK4", SystemService::SystemServiceParamGetInt);
	LIB_FUNC("rPo6tV8D9bM", SystemService::SystemServiceGetStatus);
	LIB_FUNC("656LMQSrg6U", SystemService::SystemServiceReceiveEvent);
}

That is the whole mechanism. At startup every LIB_DEFINE block runs and populates the symbol database; when the loader hits an unresolved import it looks up the NID and patches the address into the guest's GOT.

5 Verify

Rebuild, re-run, and check the log for two things:

  • The unresolved symbol rPo6tV8D9bM line is gone.
  • Your PRINT_NAME() trace appears — proving the guest actually called through, not just that the link succeeded.

These are binding/reachability checks, not behavior tests. Verify the return code and every relevant output field, including the overlay state, plus defined error paths. Preserve a failing-before/passing-after regression for any actual change. If the trace never fires, report the path as untested; do not assume the reason is another missing import.

Now you try. Work through the output-contract lab, then use the behavioral checklist for a small, understood API. Choose by scope and evidence, not unresolved-import order.
02

A guest thread is born

~1 hr, reading · Core · touches src/libs/, src/loader/

The guest calls scePthreadCreate. There is no PS5 kernel here to service it — so what actually happens? This one is worth tracing because it forces every hard part of HLE into view at once: ABI, TLS, and the fact that guest code runs natively.

1 The guest asks

Guest code calls scePthreadCreate(&tid, attr, entry, arg, name). That is an ordinary call through the GOT, landing on the HLE implementation the loader patched in — the same mechanism you used in example 1.

2 The host creates a real thread

KytyPS5 does not simulate scheduling. It creates a genuine host thread — the guest's threads are OS threads, and the host scheduler runs them. That is why the emulator can keep up at all.

The direction of the ABI crossing flips here. In example 1 the guest called into host code. Now host code (the thread startup routine) must call into guest code — the entry point. The guest entry expects System V; the host thread starts in the Microsoft ABI. This is exactly the case generated thunks exist for, and getting it wrong produces a crash inside the guest's first instruction with a plausible-looking but meaningless stack.

3 TLS has to exist before the first instruction

Guest code reads thread-local storage through the fs segment — typically fs:[0] for the TLS self-pointer and fs:[0x28] for the stack canary. On Windows, fs does not mean that, and the guest binary cannot be asked to change.

So the loader rewrites those instructions at load time, and the thread setup path allocates a per-thread TLS block from the module's PT_TLS template before the guest entry point runs. Drop a module into the module inspector and you can see that PT_TLS segment with its filesz (the initialised template) smaller than its memsz (the zero-filled remainder) — that gap is .tbss, and it has to be zeroed per thread.

4 Priorities and names

Guest pthread priorities are preserved and mapped onto host priorities rather than flattened — a recent change on main, and one that matters for titles that rely on a high-priority audio thread actually being high priority.

Now you try. Set a breakpoint on the scePthreadCreate implementation and run a title. Look at the call stack. You are standing exactly on the ABI boundary: host frames below you, guest frames above.
03

An allocation becomes host memory

~1 hr · Core · touches src/kernel/

The guest asks for direct memory at a specific address. The host has its own ideas about where things live. Reconciling those two is most of what the memory subsystem does — and the page-size mismatch underneath it is the single most consequential detail in the emulator.

1 The guest wants a specific address

PS5 titles allocate from fixed address bands and often require a particular base — they store raw pointers in structures the GPU will later read. The emulator cannot relocate them after the fact, so it reserves the guest's whole address space up front and hands out pieces of it.

2 Reserve, then commit

On Windows that means placeholder reservations: reserve a large range, then split and commit pieces on demand. Repeated map/unmap cycles fragment those placeholders, so the allocator now coalesces adjacent free ones before reuse — without that, a large allocation eventually fails even though the space is nominally free.

On Linux the constraint is different: the emulator reserves only ranges that are actually available, because the kernel has its own opinions about the low address space.

3 The page-size problem

The guest thinks pages are 16 KB. The host says 4 KB. Everything downstream inherits this. When the guest protects one 16 KB page, the host must protect four. When the host takes a fault on one 4 KB page, the emulator must decide whether the guest considers the whole 16 KB dirty. Alignment requirements the guest treats as satisfied may not be, and vice versa.

The Lab's memory tools let you push values through this mapping until it stops being abstract. It is worth ten minutes.

4 Now the GPU wants to read it

Guest memory is shared between CPU and GPU. When the CPU writes a buffer the GPU is about to read, something has to notice. KytyPS5 does it with host page protections: mark the range read-only, take the fault on the first CPU write, record the range as dirty, restore write access.

Page-watcher updates are batched at 4 MiB granularity rather than page-at-a-time — a deliberate trade of precision for far fewer protection changes. If you read MemoryTracker expecting per-page bookkeeping, that is why it does not look like that.

Now you try. Allocate in the guest, then find the corresponding host reservation in a debugger's memory map. Seeing the same bytes at two addresses — one guest, one host — is the moment the whole design clicks.
04

One draw call, end to end

~2 hrs · Deep · touches src/graphics/

From a guest function call to vkCmdDraw. This is the longest path in the emulator and the most rewarding to trace, because every stage is observable.

1 The guest builds a command buffer

The guest does not call the GPU. It writes PM4 packets into a ring buffer and rings a doorbell. A draw is one packet among many; the packets before it set registers that describe the pipeline state the draw will use.

Here is the actual encoding, from src/graphics/guest_gpu/pm4.h:

cmd = 0xC0000000
    | (((len - 2) & 0x3FFF) << 16)   // dword count, including this header
    | ((op   & 0xFF)   <<  8)         // IT_DRAW_INDEX_AUTO = 0x2D
    | ((r    & 0x3F)   <<  2);

So a 3-dword non-indexed draw is 0xC0012D00. Paste that into the PM4 decoder and it will take it apart for you — it uses these exact constants.

2 The command processor reads them back

The emulator's command processor walks the buffer: read a header, decode the opcode, consume len dwords, dispatch to a handler, repeat. Register-write packets (IT_SET_CONTEXT_REG, IT_SET_SH_REG, IT_SET_UCONFIG_REG) accumulate into a shadow copy of the hardware register file. Nothing is drawn yet — the register writes are just state.

3 The draw packet forces a decision

When IT_DRAW_INDEX_AUTO arrives, the accumulated register state has to become a real Vulkan pipeline: shader modules, vertex layout, blend and depth state, render pass compatibility. That is expensive, so it is cached — keyed on the register state that actually affects the pipeline.

This is where most "why is it slow" answers live. A pipeline cache miss means compiling shaders mid-frame. A title that changes one register the cache key includes, every draw, will stutter — and the fix is usually to narrow the key, not to compile faster.

4 Resources are resolved

The shader needs its resources. On this hardware they arrive as sharps — packed descriptors: V# for buffers, T# for textures, S# for samplers — reached through a shader resource table whose pointer was written into user-data SGPRs by an IT_SET_SH_REG packet earlier.

Each one is translated to a Vulkan descriptor. Depth and cube descriptors are now validated rather than trusted, and specialization mismatches are logged, which turns a class of silent corruption into a message you can read.

5 Submit, and signal

vkCmdDraw at last. Later the guest wants to know it finished, which arrives as an IT_RELEASE_MEM packet — mapped onto a Vulkan timeline semaphore signal. Before the CPU can read a render target, an IT_SURFACE_SYNC drives the cache flush and writeback that makes the results visible.

Now you try. Run with --command-buffer-dump true, take the first few hundred dwords of a real dump, and paste them into the decoder. Find the first IT_DRAW_INDEX_AUTO. Everything above it is the state that draw will use.
05

One shader instruction

~2 hrs · Deep · touches src/graphics/shader/recompiler/

Games ship compiled RDNA 2 shader binaries. Vulkan wants SPIR-V. Between them sits a recompiler — and the interesting problem is not the arithmetic, it is the control flow.

1 Arithmetic is the easy part

A GPU instruction like V_ADD_F32 v2, v0, v1 becomes an IR add, becomes a SPIR-V OpFAdd. Mechanical. Most of the opcode table is like this, which is why adding a missing one — V_MIN_U32, V_SUB_NC_U32 and friends were all added recently — is a genuinely approachable contribution.

An unhandled IR opcode is a hard failure. The emulator stops rather than drawing the wrong pixels.

2 Execution is not scalar

Instructions run across a wave of lanes at once, with an EXEC mask saying which lanes are live. A branch does not jump — it narrows the mask, runs both sides, and merges. Registers come in two flavours: SGPR for values uniform across the wave, VGPR for per-lane values. Get that distinction wrong and the translated shader is subtly, unreproducibly incorrect.

3 The hard part: structured control flow

SPIR-V requires structured control flow — every branch has a declared merge point, loops have headers and continue blocks. GCN-derived ISA has none of that. It is arbitrary branches over a flat instruction stream, and the compiler that produced it was free to generate any shape.

So the recompiler has to recover structure that was thrown away:

  • Build a control-flow graph from the branch targets.
  • Normalise loop structure into a form SPIR-V can express.
  • Handle loop control branches, and shared early exits — two paths jumping to the same return, which used to defeat the analysis entirely.

When a graph is genuinely irreducible, there is a fallback: emit a dispatcher — a loop around a switch on a state variable, where each case is one basic block. Always correct, never fast. It exists so that no shader is untranslatable.

4 Watch it happen

.\_Build\windows-prod\kyty_emulator.exe --game "<title>" `
  --redzone --printf-direction File --printf-output-file _kyty.txt `
  --graphics-debug-dump true --shader-log-direction File --shader-log-folder _Shaders

At the pinned revision, graphics debug dumping saves input ISA to _Shaders/original/*.bin and generated SPIR-V to _Shaders/*.spv. Shader logging adds decoded ISA, IR and resource-binding details to the run log. Inspect a small shader with the artifact reader. Dumps appear only when their code paths execute; these flags do not automatically create .rdna2 or .spvasm files.

Now you try. Find a shader in your dump that hit the dispatcher fallback and work out what shape its control flow had. That is a real open problem, not an exercise.
06

Teach it a new PM4 packet

~half a day · Deep · touches src/graphics/guest_gpu/

The counterpart to example 1, on the GPU side. Your log says the command processor hit a packet it does not know. This is how you close that gap — and unlike a missing HLE function, you have to work out the payload layout yourself.

1 Catch it

graphics: pm4 unknown packet 0x00000073 at dword 412

Take the opcode and the surrounding dwords from your command-buffer dump and run them through the decoder. It will show you the declared length even for an opcode it cannot name — and length is the first thing you need, because getting it wrong desynchronises the parser for the rest of the buffer.

2 Work out what it means

Correlate. Look at what the frame was doing, what the neighbouring packets set up, and whether a corresponding AMD PM4 opcode is documented. The opcode space is largely shared with public GCN/RDNA documentation, which is why so much of pm4.h reads like an AMD header.

3 Add the opcode and a handler

Opcodes live in pm4.h as plain constants — the same table the playground decoder was built from:

constexpr uint32_t IT_DRAW_INDEX_AUTO = 0x2D;
constexpr uint32_t IT_SET_CONTEXT_REG = 0x69;
constexpr uint32_t IT_YOUR_PACKET     = 0x73;

Then dispatch it in the command processor and implement the handler. Start by consuming the payload and logging it — a packet you skip correctly is already better than one that derails the parser.

Correct length handling beats correct semantics, at first. A packet you understand but mis-length corrupts everything after it. A packet you do not understand but consume cleanly costs you only that packet's effect.

4 Verify against the frame

Re-run and confirm the unknown-packet message is gone and nothing downstream broke. If you have a RenderDoc capture, compare the frame before and after — that is the only real proof that your interpretation was right rather than merely quiet.

Now you try. Grep your own log for unknown packet. If you have run more than one title, compare the sets — a packet that appears in several is worth more of your time than one that appears once.

Where these lead

Examples 1 and 6 are the two contribution loops — one on each side of the emulator. Examples 2, 3, 4 and 5 are the understanding you need for the interesting cases of both. If you only do one thing from this page, do example 1 tonight; it is the shortest path from "reading about an emulator" to "changing one".