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.
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:
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
| Element | Why 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_ABI | The 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 logging | Almost every function logs its inputs. When reverse-engineering behaviour, the log is the documentation. |
LIB_DEFINE + LIB_FUNC | Registration. 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
#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.
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.
| Area | Files | Notes |
|---|---|---|
| Graphics driver (AGC) | agc.cpplibAgcDriver.cppagcRegisterDefaults.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.cppnetwork.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.cpplibAudio.cpplibAudio2.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.cppajm/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.cpplibVideoDec2.cppvideoDec2Decoder.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.cpplibFontFt.cpp |
Glyph layout, metrics and rendering. Large because the API surface is large. |
| System services | libSaveDatalibSystemServicelibUserServicelibPlayGolibDialog + dialog.cpplibSharelibWebBrowserDialog, 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 | libJson2libPngDeclibRtclibCes |
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.cppcontroller.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:
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:
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 path | Host backing | Purpose |
|---|---|---|
/app0 | the --game directory | The game's own files, read-only in practice. |
/hostapp | the same directory | Alias 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. |
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.binrather 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.
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
| File | Provides |
|---|---|
kernel/semaphore.cpp | Counting semaphores with timeouts and cancellation. |
kernel/eventFlag.cpp | Event flags — a bitmask on which threads wait for any or all bits. Common in console middleware. |
libs/libUlt.cpp | ULT, Sony's user-level cooperative threading library — fibers with their own scheduler. |
libs/libKernel.cpp | Everything else in libkernel: time, process info, dynamic library management, Posix:: aliases. |
One system call, from the game's call instruction to your C++ and back. Every function in src/libs/ is reached exactly this way.
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.
Read one library end to end. They are more approachable than their size suggests, because the pattern repeats.
Checkpoint