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.

Read

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:

PieceChoiceWhy
LanguageC++20set(CMAKE_CXX_STANDARD 20) in the root CMakeLists.txt — the consteval/requires/if constexpr from topic C1 need it.
CompilerClang in documented/CI builds; GCC accepted on POSIX; MSVC rejectedThe project needs __attribute__((sysv_abi)). CMake accepts Clang or GCC, but Windows must use clang-cl and upstream build instructions/CI use Clang.
Build generatorNinjaFast, parallel, single command. Configure with -G Ninja.
Linkerlld (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.
ShadersglslangValidatorFound 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:

src/main.cpp — GetBuildString
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:

first build, from the repository root
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:

CMakeLists.txt — one embedded shader
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 static at 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 a static helper with the same name without colliding.
  • The One Definition Rule — a non-static function defined in a header is defined in every translation unit that includes it, so it must be inline. That is why you see inline on the small helpers in headers like vaContext.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:

src/utils.cmake — the clang flags
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, no dynamic_cast, no typeid. That is not an accident or a style choice — they would not compile. The project's error strategy (covered next) is explicit EXITs 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-pointer is 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:

src/common/assert.h — abridged
#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__ in PRINT_NAME: the same family of built-ins.
  • EXIT_IF(x) compiles to nothing when built with KYTY_FINAL — a release-with-asserts-off switch, exactly like NDEBUG disabling assert.
  • The do { } while(0) wrapper is the canonical way to make a multi-statement macro safe to use in an if body 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:

the shape of the dump — annotated (format since September 2026)
--- 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, and RuntimeLinker::StackTrace() (Stack trace [thread = N]: followed by [i] address, off=…, module lines) resolves them where the emulator prints it, such as on a guest abort(). 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 IsReadableRange before reading; this reduces the risk of a second fault but does not make concurrent memory changes impossible. On Windows, the --redzone flag (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:

FlagWhat it produces
--printf-direction File --printf-output-file _kyty.txtEverything the emulator logs — unresolved imports, unimplemented calls, errors. The default starting point.
--command-buffer-dump trueRaw PM4 command buffers to disk, feedable to the Playground decoder.
--shader-log-direction File --shader-log-folder _ShadersInput ISA, IR and emitted SPIR-V for every shader translated.
--vulkan-validation trueThe Vulkan validation layers — host-side correctness checking.
--graphics-debug-dump trueDebug dumps of the graphics state at key points.
--rdRenderDoc 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:

CMakeLists.txt — one focused test
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:

build and run the whole suite
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 worldHere
package.jsonCMakeLists.txt — the manifest that says what builds and what it needs
npm install + lockfilegit submodule update --init --recursive — pinned commits in 3rdparty/
npm run buildcmake --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 mapsthe PDB / .map file — turning addresses back into names
tsc --noEmit, ESLintclang-tidy and include-what-you-use (opt-in CMake options)
Jest / Vitestgtest + CTest (ctest --test-dir _Build/windows)
Babel / pluginsCMake custom commands — e.g. glslangValidator + embed_spirv.cmake running during the build
Pre-commit hooksa pre-commit hook running clang-format on staged files
The one mental shift

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.

Learn more · building and debugging C++
  • Reference
    CMake 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.
  • Book
    Linkers & 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.
  • Tool
    LLVM linker (lld) docsThe linker this project uses. The -Map/-lldmap flag and what the output means are documented here.
  • Guide
    WinDbg / 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.
  • Reference
    cppreference — the compilation modelTranslation units, ODR, internal vs external linkage — the rules behind the linker errors, in spec form.
Watch

The whole pipeline, from three commands to a running emulator — including the part where the build compiles GPU shaders into the binary.

Try

The fastest way to learn a toolchain is to break it on purpose, once.

A ten-minute lab

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.

Do

These checkpoints are the README's own contribution requirements, unpacked.

Checkpoint