Step 17 · Loading and execution
NIDs and the runtime linker
PS5 modules import by NID — a short encoded id, not a name. Resolving those against hand-written implementations, and patching the results into the module's own tables, is the job that makes everything else possible.
Before this page: Mapping a program into memory · Calling conventions & the ABI wall. View the learning route.
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 this topic. 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.
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.
A relocation being applied: the linker resolves a NID, finds the implementation, and writes its address into the module's table.
The trailing tokens in a symbol name are 16-bit module and library ids in a 64-symbol alphabet. The id encoder in the Playground is that algorithm transcribed exactly from runtimeLinker.cpp — drag the slider and watch which branch a value takes.
This is the topic that leads directly to your first contribution, so it is worth doing properly.
Checkpoint
An unresolved NID is a completely self-contained piece of work: find what the function should do, write it, register it against the id, and a title gets further than it did before. Example 1 in the Examples walks the whole loop.