Step 12 · Project orientation · C3
Building, linking & debugging
Reading the code is one thing; making it build and telling a crash log apart from a feature is the other. This page is the bridge: how source becomes a binary in this project, what the linker is really doing, and how the emulator tells you what broke.
Before this page: Your first C++ program · The codebase map & the toolbox · C++ language, by example. View the learning route.
Start with the toolchain, configure/build commands and tests. You already compiled the standalone lessons; this page explains the additional dependencies and settings for the emulator. Keep the learning repository and emulator checkout separate. You can follow the next source traces without a game; record whether you have actually built or run the emulator.
The linker, failure macros and crash-log sections are reference material to revisit during diagnostics. Web-development comparisons at the end are optional.
The toolchain at a glance
The documented build uses Clang, Ninja and LLVM’s linker. CMake also accepts GCC on POSIX; use the upstream toolchain when reproducing a build:
| Piece | Choice | Why |
|---|---|---|
| Language | C++20 | set(CMAKE_CXX_STANDARD 20) in the root CMakeLists.txt — the consteval/requires/if constexpr from topic C1 need it. |
| Compiler | Clang in documented/CI builds; GCC accepted on POSIX; MSVC rejected | The project needs __attribute__((sysv_abi)). CMake accepts Clang or GCC, but Windows must use clang-cl and upstream build instructions/CI use Clang. |
| Build generator | Ninja | Fast, parallel, single command. Configure with -G Ninja. |
| Linker | lld (or lld-link on Windows) | LLVM's linker; chosen because the compiler is LLVM's. Emits the map file that turns crash addresses into symbols. |
| Shaders | glslangValidator | Found at configure time (find_program(... REQUIRED)) and run during the build to turn GLSL into embedded SPIR-V. |
The very first line of output when you run the emulator is the build string, and it is there to tell you exactly which of these you are running:
fmt::format("{}, {}, ver = {}, git = {}, date = {}",
type.c_str(), compiler.c_str(), // e.g. "Debug, clang-lld-64"
KYTY_VERSION, KYTY_GIT_VERSION, date.ToString().c_str());
When you see a bug report, that first line is the reproduction conditions in one line.
Configure and build — the three commands
The whole build is three commands, and understanding what each does is the point of this section:
git submodule update --init --recursive cmake -S . -B _Build/windows -G Ninja ^ -DCMAKE_BUILD_TYPE=Release ^ -DCMAKE_C_COMPILER=clang-cl -DCMAKE_CXX_COMPILER=clang-cl ^ -DCMAKE_PREFIX_PATH="C:/Qt/6.x.x/msvc2022_64" cmake --build _Build/windows --target launcher cmake --install _Build/windows --prefix _Build/windows/install
Step 1 — dependencies. Dependencies are configured from 3rdparty/ through a mix of pinned submodules, source downloads and prebuilt packages: SDL3 (windows/input), FFmpeg (audio/video decode), SPIRV-Tools, VulkanMemoryAllocator, fmt, gtest, magic_enum, imgui, Zydis (x86 disassembler), xbyak (x86 assembler), LibAtrac9. This is the equivalent of npm install — except the versions are pinned as commits, like a lockfile.
Step 2 — configure. CMake's configure pass does the detection work: verifies 64-bit (if(NOT CMAKE_SIZEOF_VOID_P EQUAL 8) message(FATAL_ERROR ...)), detects endianness (test_big_endian), checks the supported Clang/GCC toolchain, finds glslangValidator, and generates two headers — cmake_config.h (compiler/linker/platform macros) and kytyGitVersion.h (the git hash from generate_version.cmake). Nothing compiles yet.
Step 3 — build. Compile and link. Everything after this is the rest of the page.
Current build options
The root CMakeLists.txt requires CMake 3.19 or newer (the root declares compatibility through 4.3; dependencies may impose additional requirements). Use clang-cl on Windows and the documented Clang toolchain on Linux/macOS. glslangValidator must be on PATH. CMake automatically selects sccache, then ccache, unless a compiler launcher was already supplied.
Qt is optional: configure a separate directory with -DKYTY_BUILD_LAUNCHER=OFF to build kyty_emulator and kyty_tests without it. SDL3 provides window, input and audio services. FFmpeg comes from the pinned static FFmpeg-core release, including VP9/WebM; system FFmpeg packages are not required. Dependencies include both submodules and configure-time downloads, including ZArchive and zstd.
cmake -S . -B _Build/no-qt -G Ninja -DCMAKE_BUILD_TYPE=Release \ -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++ \ -DKYTY_BUILD_LAUNCHER=OFF cmake --build _Build/no-qt --target kyty_emulator kyty_tests --parallel ctest --test-dir _Build/no-qt --output-on-failure
On NixOS, enter nix develop or nix-shell first. The shell supplies the toolchain, Qt paths and SDL3 backend dependencies; configure still needs network access. See flake.nix and nix/devshell.nix.
What actually compiles
The source list is collected by explicit file(GLOB ... CONFIGURE_DEPENDS) entries in the root CMakeLists.txt. The shader entries now spell out frontend/cfg, frontend/decode, frontend/translate, ir, ir/opcodes, ir/passes and backend/spirv. Adding a new directory requires a new glob; adding a file to a listed directory triggers a CMake re-run.
Then the genuinely unusual part: the GPU's compute shaders are compiled during the build. A custom CMake command runs glslangValidator on each gpu_tiler_*.comp, and embed_spirv.cmake turns the resulting .spv file into a C header containing the bytes as an array:
COMMAND "${KYTY_GLSLANG_VALIDATOR}" -V --target-env vulkan1.3 -Os
"-I${gpu_tiler_shader_dir}" -o "${shader_spv}" "${shader_source}"
COMMAND ${CMAKE_COMMAND} -DINPUT=${shader_spv} -DOUTPUT=${shader_header}
-DSYMBOL=${shader_symbol} -P "${KYTY_SOURCE_DIR}/embed_spirv.cmake"
So the emulator's own GPU shaders ride inside the binary as byte arrays — no asset loading at runtime, no missing-file bugs. Same trick as the JIT machine code from topic C2: code becomes data at build time.
Linking — what your bundler wishes it was
Each .cpp is compiled into its own translation unit, producing an object file. The compiler sees exactly one file plus its includes. When you call a function declared in a header, the compiler just records "there will be a symbol called X somewhere". It is the linker that joins every object file (and the 3rdparty libraries) and resolves those references — the analogue of your bundler resolving imports, but with the whole program laid out into one binary.
Three facts make linker errors readable:
- Internal linkage — a function or global marked
staticat file scope (like every HLE implementation) exists only inside its own translation unit. The linker never sees it, which is why two files can both have astatichelper with the same name without colliding. - The One Definition Rule — a non-
staticfunction defined in a header is defined in every translation unit that includes it, so it must beinline. That is why you seeinlineon the small helpers in headers likevaContext.h. - Unresolved symbols — "unresolved external symbol" / "undefined reference" means the declaration had no definition in any linked object. Almost always a missing source file in a CMake target, or a function you added a call to but never wrote.
Two linker details worth knowing because they exist here. On Windows, the binary is linked with /lldmap: (Linux: -Wl,-Map=) which writes a map file — the full address→symbol table of the final binary. That, plus a PDB, is how a raw address from a crash becomes a function name. And CMAKE_WINDOWS_EXPORT_ALL_SYMBOLS ON tells the linker to export everything from the shared parts without hand-written __declspec(dllexport) annotations.
No exceptions, no RTTI — and what that means
The compiler flags in src/utils.cmake set the tone for the whole codebase:
set(KYTY_CPP_FLAGS "${KYTY_CPP_FLAGS}
-fno-rtti -fno-exceptions
-fno-strict-aliasing -fno-omit-frame-pointer -Wall
-fcolor-diagnostics ...")
Two consequences ripple through everything:
- No
try/catch, nodynamic_cast, notypeid. That is not an accident or a style choice — they would not compile. The project's error strategy (covered next) is explicitEXITs and return codes instead. Exceptions are also disabled because guest code crashes must not be recoverable as if they were host errors. -fno-omit-frame-pointeris the reason the crash logs can walk the stack. Every function keeps its frame pointer, so the debugger (and the emulator's own crash handler) can walk up the call stack without debug info.
How the project fails: EXIT and EXIT_IF
There is no exception to throw, so a fatal condition is spelled with a macro that prints a report and kills the process:
#define EXIT(...) \
do { \
((void)(Common::DbgExitHandler(__FILE__, __LINE__, \
::fmt::sprintf(__VA_ARGS__)) && \
(EXIT_HALT(), 1))); \
} while (0)
#define EXIT_IF(x) ((void)((x) && Common::DbgExitIfHandler(#x, __FILE__, __LINE__) != 0 \
&& (EXIT_HALT(), 1) != 0))
DbgExitHandler formats and logs the build label, error message and source file:line. The macro’s EXIT_HALT() then calls DbgExit(321), which performs emergency subsystem shutdown, flushes output and terminates. This report does not contain a host stack trace; the guest fault dump is a separate path. A few notes:
__FILE__and__LINE__are compiler-provided strings — the "where" is automatic. That is also why you see__func__inPRINT_NAME: the same family of built-ins.EXIT_IF(x)compiles to nothing when built withKYTY_FINAL— a release-with-asserts-off switch, exactly likeNDEBUGdisablingassert.- The
do { } while(0)wrapper is the canonical way to make a multi-statement macro safe to use in anifbody without braces — one of the reasons macros survive in this codebase.
Reading a crash log
Guest code runs natively, so when the game hits a bad pointer the host OS delivers an exception to the emulator process, and KytyExceptionHandler (in runtimeLinker.cpp) writes a forensic dump before dying. Read it in order, and every block has a job:
--- Guest fault context ---
thread: GameMainThread the guest pthread that faulted — or "(host thread)"
rax=0000000000000000 rbx=000000090009e000 ... every register, four per line, because the
rsi=... rdi=... rbp=... rsp=0000000900c7f800 instruction's operands live there
r8 =... r9 =... r10=... r11=...
r12=... r13=... r14=... r15=...
code (pc-48 .. pc+48, fault at byte 48): 96 bytes around the fault, 16 per line; byte 48
48 8b 5c 24 50 48 8b 03 48 8b 04 18 48 89 04 24 is the first byte of the instruction that died
...
stack: 32 qwords read upward from rsp — return
000000090009e000 0000000000000000 ... module-range values are only candidate callers
...
--- Error --- then the ordinary EXIT report:
Unhandled host exception: type=1 code=3221225477 pc=0x00000009000a3b4c access=2 address=0x0000000000000000
type 1 = access violation, 2 = illegal instruction;
access 1/2/3 = read/write/execute; address = what was touched
Three habits make this fast to read:
- The faulting address is usually a symptom.
address=is the accessed address — often a null, a stale pointer, or a mis-read argument. The cause may be earlier in the call chain. Stack words inside a loaded module’s range are only candidate return addresses, andRuntimeLinker::StackTrace()(Stack trace [thread = N]:followed by[i] address, off=…, modulelines) resolves them where the emulator prints it, such as on a guestabort(). Translate module+offset with the map file or a disassembler. - The code bytes are the instruction. Byte 48 of the 96-byte window is the first byte of the instruction that faulted. If you can decode it (objdump, a hex-to-instruction tool, Zydis is in-tree), you know exactly which machine instruction died and which register it needed.
- The dump is deliberately guest-only. Each probe checks
IsReadableRangebefore reading; this reduces the risk of a second fault but does not make concurrent memory changes impossible. On Windows, the--redzoneflag (from topic C2) is how you opt into protecting the guest's System V red zone; a fault that disappears with it enabled is evidence to investigate red-zone handling, not proof of the root cause.
This is the same output the bug-report template asks you to attach. When a game misbehaves, the log — not the screen — is the evidence.
Diagnostics flags
All of these come from --help in src/main.cpp. The three that matter for reading the project are covered in Debugging & diagnostics; here is the full useful set:
| Flag | What it produces |
|---|---|
--printf-direction File --printf-output-file _kyty.txt | Everything the emulator logs — unresolved imports, unimplemented calls, errors. The default starting point. |
--command-buffer-dump true | Raw PM4 command buffers to disk, feedable to the Playground decoder. |
--shader-log-direction File --shader-log-folder _Shaders | Input ISA, IR and emitted SPIR-V for every shader translated. |
--vulkan-validation true | The Vulkan validation layers — host-side correctness checking. |
--graphics-debug-dump true | Debug dumps of the graphics state at key points. |
--rd | RenderDoc capture hook for frame-level GPU debugging. |
--redzone (Windows) | Protect the guest SysV red zone — opt-in, see topic C2. |
The tests
Tests are gtest-based and registered with CTest. Fifteen executables, each deliberately small — the interesting bit is that each compiles only the sources it needs:
add_executable(bit_array_tests EXCLUDE_FROM_ALL
"${KYTY_TESTS_DIR}/BitArrayTests.cpp"
)
target_include_directories(bit_array_tests PRIVATE ${inc_headers})
add_executable(memory_tracker_tests EXCLUDE_FROM_ALL
"${KYTY_TESTS_DIR}/MemoryTrackerTests.cpp"
"${KYTY_SOURCE_DIR}/graphics/host_gpu/pageManager.cpp"
"${KYTY_SOURCE_DIR}/graphics/host_gpu/memoryTracker.cpp"
)
EXCLUDE_FROM_ALL means nothing builds until you ask — you build them all with one target, then run everything with ctest:
cmake --build _Build/windows --target kyty_tests ctest --test-dir _Build/windows --output-on-failure
Some tests are one binary with many modes — shader_recompiler_compute_tests takes --scheduler-only, --gpu-tiler-only, --texture-cache-image-views, and friends so one heavy build covers many subsystems. When you add a change (like an HLE function), the README asks for a test "where practical"; these focused executables are the pattern to copy.
Coming from TypeScript — the build mapping
| JS / TS world | Here |
|---|---|
package.json | CMakeLists.txt — the manifest that says what builds and what it needs |
npm install + lockfile | git submodule update --init --recursive — pinned commits in 3rdparty/ |
npm run build | cmake --build _Build/windows --target launcher |
TypeScript compiler (tsc) | Clang — one file at a time, emitting object files |
| Bundler (webpack/rollup) | the linker (lld) — joining all the objects into one binary and resolving every symbol |
| Source maps | the PDB / .map file — turning addresses back into names |
tsc --noEmit, ESLint | clang-tidy and include-what-you-use (opt-in CMake options) |
| Jest / Vitest | gtest + CTest (ctest --test-dir _Build/windows) |
| Babel / plugins | CMake custom commands — e.g. glslangValidator + embed_spirv.cmake running during the build |
| Pre-commit hooks | a pre-commit hook running clang-format on staged files |
In JS nothing checks whether the function you called actually exists until runtime. Here the linker checks it before the program ever runs, and static_assert checks facts about the types themselves during compilation. The price is that errors appear earlier and louder; the payoff is that "it builds" genuinely means something.
- ReferenceCMake documentationThe full reference for
add_executable,target_link_libraries,file(GLOB)and everything this project's CMake uses. Modern CMake is a friendlier in-depth guide. - BookLinkers & Loaders, John R. LevineThe classic on what actually happens between object files and a running binary — symbol resolution, relocations, and why the map file exists.
- ToolLLVM linker (lld) docsThe linker this project uses. The
-Map/-lldmapflag and what the output means are documented here. - GuideWinDbg / gdb stack walkingThe general skill of reading a register dump and walking a stack — the crash log this page walks through is that skill in miniature.
- Referencecppreference — the compilation modelTranslation units, ODR, internal vs external linkage — the rules behind the linker errors, in spec form.
The whole pipeline, from three commands to a running emulator — including the part where the build compiles GPU shaders into the binary.
The fastest way to learn a toolchain is to break it on purpose, once.
Take any file in src/libs/ and temporarily add EXIT_IF(true) to a function the game calls. Rebuild, run, and read the fatal report that comes out: the --- Build --- label, the error message, and the file:line of your edit. Then add LOGF("x %llu\n", (unsigned long long)1) somewhere reachable and watch it appear in _kyty.txt under --printf-direction File. Restore, rebuild, and you have done the whole debug loop end to end.
These checkpoints are the README's own contribution requirements, unpacked.
Checkpoint