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