Step 21 · Loading and execution

HLE libraries

This is the bulk of the emulator by volume: reimplementations of the system libraries a game expects to call. Each is ordinary C++ with two unusual obligations — the right ABI annotation, and registration against the NID the guest imports by.

Before this page: NIDs and the runtime linker. View the learning route.

Read

Every file in src/libs/ has the same shape. Learn it once and their shared pattern becomes readable — a standalone file such as libCes.cpp is the same pattern as the inlined ContentExport below.

A complete module, start to finish

This is ContentExport in full — the smallest real example in the tree:

src/libs/libs.cpp — namespace LibContentExport
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

ElementWhy 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_ABIThe 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 loggingAlmost every function logs its inputs. When reverse-engineering behaviour, the log is the documentation.
LIB_DEFINE + LIB_FUNCRegistration. LIB_DEFINE(name) expands to void name(Loader::SymbolDatabase* s); LIB_FUNC fills a SymbolResolve with the NID, library, module and versions and calls s->Add. Called once from Libs::InitAll, before any guest code runs.

How PRINT_NAME works

src/libs/libs.h
#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.

Error codes are not arbitrary

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.

What is implemented, how big each area is, and what is genuinely hard about it.

AreaFilesNotes
Graphics driver (AGC) agc.cpp
libAgcDriver.cpp
agcRegisterDefaults.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 PM4 & the command processor.
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.cpp
network.cpp
Sockets, 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. The socket layer increasingly forwards to the host natively — SO_ERROR, TCP_NODELAY, listen/connect/accept and readiness checks all map straight onto the OS socket API with error-code translation.
Audio output audio.cpp
libAudio.cpp
libAudio2.cpp
Port management, mixing and output on a dedicated thread. 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. AudioOut2 now accepts 12-channel (7.1.4) ports and downmixes them for SDL.
Audio decode (AJM) ajm.cpp
ajm/decoder.h + one header per codec
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 a small decoder interface (ajm/decoder.h) to LibAtrac9 (ATRAC9) or FFmpeg (AAC, MP3). The same AAC decoder now serves the older Audiodec API.
Video avPlayer.cpp
libVideoDec2.cpp
videoDec2Decoder.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.cpp
libFontFt.cpp
Glyph layout, metrics and rendering. Large because the API surface is large.
System services libSaveData
libSystemService
libUserService
libPlayGo
libDialog + dialog.cpp
libShare
libWebBrowserDialog, libConvertKeycode
Save-data mounting, user profiles, chunk-based install progress (games query how much of themselves has "downloaded"), message and error dialogs (drawn by the imgui system overlay and kept open until dismissed), share features. The last two are the newest files in the directory — a status stub and one keyboard-type query — and show how small a "library" can be.
Data formats libJson2
libPngDec
libRtc
libCes
JSON parsing, PNG decode via stb, real-time clock conversions, and — the newest file in the directory — Ces, the console's character-encoding service, implementing the Shift-JIS/CP932 ↔ UTF-8 conversions Japanese titles call.
Input libPad.cpp
controller.cpp
DualSense state, customizable keyboard/mouse mapping, SDL rumble forwarding, and — since SDL's DualSense HIDAPI driver was enabled — the light bar, adaptive triggers and the touchpad (emulated from the mouse). A DualShock 4 takes the same pad path.
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:

src/libs/libs.cpp — InitAll()
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);
    ...
    InitAgcDriver_1(s);
    InitLibKernel_1(s);
    ...
    LibGen5::InitVideoOut_1(s);
    InitWebBrowserDialog_1(s);
}

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. Each LIB_DEFINE lives in its own namespace.

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:

src/kernel/eventQueue.h
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 pathHost backingPurpose
/app0the --game directoryThe game's own files, read-only in practice.
/hostappthe same directoryAlias 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.
src/emulator.cpp — MountSandboxDirs()
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.bin rather 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.

src/libs/libKernel.cpp — the single-request wrapper
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

FileProvides
kernel/semaphore.cppCounting semaphores with timeouts and cancellation.
kernel/eventFlag.cppEvent flags — a bitmask on which threads wait for any or all bits. Common in console middleware.
libs/libUlt.cppULT, Sony's user-level cooperative threading library — fibers with their own scheduler.
libs/libKernel.cppEverything else in libkernel: time, process info, dynamic library management, Posix:: aliases.
Watch

One system call, from the game's call instruction to your C++ and back. Every function in src/libs/ is reached exactly this way.

Try
Find your own gaps

Drop a log from one of your titles into the triage tool. The "Unimplemented calls" bucket is a list of functions that resolved but do nothing yet — each one is a candidate for a small, self-contained change.

Do

Read one library end to end. They are more approachable than their size suggests, because the pattern repeats.

Checkpoint