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.

Read

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

What a relocation record contains

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

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

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

Resolution

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

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

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

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

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

Eager relocation and dynamic-module lifetimes

RelocateRecord() resolves both relocation tables eagerly. Program::rela_bits records successful bindings, so loading another module retries unresolved imports without overwriting existing guest-modified slots. RelocateProgram() refreshes all loaded importers after a dynamic load. Guest PLT/GOT records still exist, but the emulator's former lazy-PLT setup and DynamicInfo::pltgot_vaddr field have been removed.

Explicit module loads increment Program::load_count; the final explicit unload releases the module. Before unmapping, UnloadProgram() identifies imports bound to that module's exact exported address, clears their resolved bits and relocates them again. Matching only a NID could unlink a different provider, so SymbolDatabase::FindByNid() also accepts an address filter.

Unresolved function imports still receive diagnostic thunks; unresolved objects point at invalid memory. A thunk is the fallback for an unresolved import, not the ordinary mechanism for deferring all symbol binding until first call.

How the HLE database is populated

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

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

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

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

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

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

The complete path, end to end

The same five steps in words:

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

If a NID string has a single character wrong, or if the LIB_VERSION module/library version does not match what the game imports, resolution simply does not match — and the import falls through to the stub path of 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:

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

The thunk, byte by byte

AllocateUnresolvedImportThunk() emits a 34-byte diagnostic stub. It calls UnresolvedImportStub(record_id), which logs the missing import (subject to the shared 1,024-call log limit) and returns zero. It does not preserve arguments for a lazy retry or tail-call a newly found target.

src/loader/runtimeLinker.cpp — AllocateUnresolvedImportThunk
48 83 EC 08                sub rsp, 8        // align stack for the SysV call
48 BF <record_id:8 bytes>  mov rdi, record_id
48 B8 <stub:8 bytes>       mov rax, UnresolvedImportStub
FF D0                      call rax          // RAX receives zero
48 83 C4 08                add rsp, 8
0F 57 C0                   xorps xmm0, xmm0  // zero floating return register too
C3                         ret

A later dynamic module load retries unresolved relocation slots eagerly through RelocateAll(). The thunk itself only diagnoses an unresolved call. Returning zero can let a game continue while still hiding missing behavior, so a title that boots with stubs is not evidence that those APIs are implemented.

Allocation and bookkeeping

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

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

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

Practical consequence for you

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

Guest PLT records and eager binding

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

Watch

A relocation being applied: the linker resolves a NID, finds the implementation, and writes its address into the module's table.

Try
Decode the ids yourself

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.

Do

This is the topic that leads directly to your first contribution, so it is worth doing properly.

Checkpoint

This is where you can start contributing

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.