# Crafter.Debug Runtime debugging for AI agents, and for people. An app built with Crafter.Debug can be inspected and driven while it runs. From a shell, `crafter-debug` can: - call the commands the app registers; - read and set its watched state; - pause it, step it, and pause it when a condition holds; - wait for conditions, frames or events; - trace values over frames; - read its log; - take screenshots. Breakpoints and memory stay with a debugger; `crafter-debug debugger` prints how to attach one. ```console $ crafter-build --debug-api -r & # the app's debug build, in the background $ ./build/crafter-debug ls --wait 60s game id 4f1c09a2b7e3d851 pid 3 (sandbox 48211) up 4s port 41207 $ ./build/crafter-debug call Lobby_Start && ./build/crafter-debug wait --frames 30 frame 342 $ ./build/crafter-debug pause --when 'fort1.core.hp <= 0' && ./build/crafter-debug call Player_Fire $ ./build/crafter-debug wait --paused paused at frame 1840: 'fort1.core.hp <= 0' became true $ ./build/crafter-debug log --since-frame 1790 --level warn 1838 WARN physics brace 17 strain 4.2 > limit Forts3D-Brace.cpp:212 1839 CHECK physics body 9 has mass -0 (x3) Forts3D-Physics.cpp:88 $ ./build/crafter-debug screenshot /home/me/game/build/crafter-debug-shots/game-1793488099646.png (2215871 bytes) ``` ## How it fits together | Piece | What it is | |---|---| | **The library**, `Crafter.Debug` | Linked into the app's debug builds only. A `Debug::Session` serves requests on 127.0.0.1 over QUIC, with a random port and a token, and runs the ones that touch the app on the app's own thread, between frames, in `Debug::Pump()`. | | **The client**, `crafter-debug` | Built in the same pass as the app, from the same sources. The launcher `build/crafter-debug` runs it inside the CrafterBuild Flatpak sandbox it was built for. Nothing the Flatpak compiles runs on the host. | | **Discovery** | Each running app writes `$HOME/.cache/crafter-debug/instances/.json` (its user's only) and holds a lock while it lives. The client finds apps there, across Flatpak sandboxes, and forgets the ones that died. | | **The build helper**, `Crafter.Debug.Build` | Loaded into a consumer's `project.cpp` by `#pragma crafter dependency`. It adds the library under the consumer's own debug flag and writes the launcher. | ## Adding it to a project **1. In `project.cpp`**, load the helper and call `Debug::Use` with your own flag: ```cpp import std; import Crafter.Build; #pragma crafter dependency "https://forgejo.catcrafts.net/Catcrafts/Crafter.Debug.git" project "tooling/project.cpp" import Crafter.Debug.Build; using namespace Crafter; extern "C" Configuration CrafterBuildProject(std::span args) { Configuration cfg; // ... const ArgQuery opts = ApplyStandardArgs(cfg, args); std::vector depArgs; // your args minus your own flags for (const std::string_view arg : args) { if (arg != "--debug-api") depArgs.emplace_back(arg); } // ...give depArgs to every other dependency too... const bool debugApi = Debug::Use(cfg, { .enabled = opts.Has("--debug-api"), .flag = "--debug-api", .define = "MYAPP_DEBUG_API", .args = depArgs }); // ...your sources, plus the debug-only ones when debugApi... } ``` **What `Use` does with the flag:** - **Flag on, native Linux target:** it adds the define and the library, from the checkout the pragma names, writes `build/crafter-debug`, and returns true. - **Otherwise:** it adds nothing and returns false, and release builds never link Crafter.Debug. Until a debug build has run, `build/crafter-debug` is a stub that says to build one. Add your debug-only sources when `Use` returns true, not when the flag is on: a non-Linux target with the flag gets neither the library nor the define. A dependency that two paths in the build graph share must get the **same arg list on both**. Otherwise crafter-build refuses the graph with "module … built twice". Build one `depArgs` and give it to every dependency, as above. That includes Crafter.Network, which Crafter.Debug takes from `https://forgejo.catcrafts.net/Catcrafts/Crafter.Network.git`, unpinned (with `--local`, from `../Crafter.Network`). If your project depends on Crafter.Network too, name it the same way. A project that pins its own Crafter.Network to a commit can't add Crafter.Debug yet. **2. In `main`**, start a session and pump it once per frame. Gate the parts the agent should be able to pause: ```cpp import MyApp.DebugShim; // see "Without #ifdefs" below ThreadPool::Start(); // the session's transport needs a pool worker #ifdef MYAPP_DEBUG_API Debug::Session debug({ .name = "MyApp" }); MyApp::RegisterDebug(); // your commands and watches #endif while (running && !Debug::QuitRequested()) { Debug::Pump(); while (acc >= dt && Debug::Advance()) { sim.Tick(); acc -= dt; } render(); } ``` The `Session` must be destroyed before `main` returns and before `ThreadPool::Stop()`, because msquic waits for open connections at exit. A stack object in `main`, as above, does that. It needs the pool started with at least one worker (`Start(1)` starts none) and throws without one. **3. Register what the agent needs** in debug-only source files, which `project.cpp` adds only under the flag. See "The app side" below. **4. For agents**, Crafter.Build merges Crafter.Debug's notes into your `AGENTS.md`. Check three things: - **Your project needs an `AGENTS.template.md`.** Without one, crafter-build writes no `AGENTS.md`. - **Name your debug flag in it.** The merged notes say "build with the project's debug flag", and only you know which flag that is. - **If the repository has a `CLAUDE.md`, put `@AGENTS.md` in it.** Claude Code ignores `AGENTS.md` whenever a `CLAUDE.md` applies. Optionally, allow `Bash(./build/crafter-debug *)` in `.claude/settings.json`. **Working on Crafter.Debug itself next to a consumer:** point the pragma at your checkout: - **A local path,** `#pragma crafter dependency "../Crafter.Debug" project "tooling/project.cpp"`, uses the working tree. - **A file URL,** `"file:///…/Crafter.Debug"`, is a git source, so it uses the committed state. Pragmas are read as plain text, so neither `#if` nor a flag can switch them. ## The app side Everything is in `import Crafter.Debug;`, namespace `Crafter::Debug`. ### Commands ```cpp struct AimArgs { Debug::Required slot; // must be passed float yaw = 0; // optional; the default shows in `help` float pitch = 0; }; Debug::Command("Player_SetAim", "Aim the slot's turret, in degrees.", [](const AimArgs& a) -> Debug::Result<> { Game* g = CurrentGame(); if (!g) return Debug::Error{ "InvalidScene", "not in a game; call Lobby_Start first" }; g->Aim(*a.slot, a.yaw, a.pitch); return {}; }); ``` **Arguments:** - A handler takes nothing, or one argument type. That is usually an aggregate, whose fields are found by reflection, so no serialization code is written; `Debug::Value` gives raw JSON. - Fields a call leaves out keep their default member initializers. - A call with mistakes is answered with every one of them, each with its path and the likely intended name, and never reaches the handler: `slot: missing, and this field is required; yw: unknown field; did you mean 'yaw'?`. **Results:** a handler returns `void`, any value with a codec, `Debug::Result`, or `Debug::Async`. - `Debug::Result` is a value or a `Debug::Error{code, message}`. Write the message for the agent: what went wrong and the next step. - `Debug::Async` is for results that take frames, such as a GPU readback. Its poller runs once per `Pump` until it yields, and the caller simply waits: ```cpp Debug::Command("Sim_StateHash", "Hash of the GPU sim state.", []() -> Debug::Async { Physics::RequestCapture(); return Debug::Async([]() -> std::optional { if (!Physics::CaptureReady()) return std::nullopt; return HashCapture(); }); }); ``` A handler that throws is answered with an `Exception` error. The exception does not unwind into the frame loop. ### Watches and samples ```cpp Debug::Watch("physics.gravity", Physics::gravity, { .min = -50, .max = 0, .help = "m/s^2" }); // read and write Debug::Watch("scene", [] { return SceneName(); }); // read only Debug::Watch("lives", [] { return lives; }, [](const std::int32_t& v) -> Debug::Result<> { // through a setter if (v < 0) return Debug::Error{ "Negative", "lives cannot be negative" }; lives = v; return {}; }); Debug::Sample("xpbd.residual", residual); // from hot code, any thread; keeps a history for `trace --history` ``` **Paths read into values:** - `get bodies[3].pos.y` reads the watch `bodies`. - `set fort1.core.pos.x 5` reads `fort1.core.pos`, replaces `x`, and writes the whole value back. **Where they run:** getters, setters and handlers are only ever called on the thread that calls `Pump`, so they read app state without locking. Registering and unregistering are safe from any thread. ### Groups Registrations that end with their owner, under a prefix: ```cpp class Fort { Core core; Debug::Group debug; public: explicit Fort(std::int32_t slot) : debug(std::format("fort{}", slot)) { debug.Watch("core.hp", core.hp); debug.Command("Explode", "Blow it up.", [this] { core.Explode(); }); } }; ``` A `Group` can't be copied or moved, so neither can a class that holds one. Its watches refer to the members they were given, and a move would leave them pointing at the old object. Keep such objects in place: `std::unique_ptr`, `std::deque` or `std::list`, not `std::vector`. ### The timeline ```cpp Debug::Log("net", "peer {} joined slot {}", peerId, slot); // also LogWarn, LogError Debug::Emit("MatchEnded", MatchEnded{ .winnerSlot = 1 }); // `wait --event MatchEnded` if (!Debug::Check(mass > 0, "physics", "body {} has mass {}", id, mass)) return; // a soft assert ``` **One timeline:** every entry carries the frame and the source location, next to the agent's own actions. The format strings are checked at compile time. **`Check`:** - It returns its condition, and only formats the message when the condition fails. - A check that fails every frame is counted every time, but its entries are thinned out, so it cannot flood the log. - `pause --on-check` pauses the app within the frame of the next failure; `--trap` also stops an attached debugger there. **Without `#ifdef`s:** the frame-loop and timeline calls can be left in release code. Wrap them in a small module of your own that forwards to Crafter.Debug in debug builds and does nothing otherwise. It is one of your own sources, so your define reaches it: ```cpp export module MyApp.DebugShim; import std; #ifdef MYAPP_DEBUG_API export import Crafter.Debug; #else export namespace Crafter::Debug { inline void Pump() {} inline bool Advance() { return true; } inline bool Paused() { return false; } inline std::uint32_t TakeSteps() { return 0; } inline bool QuitRequested() { return false; } template void Log(A&&...) {} template void LogWarn(A&&...) {} template void LogError(A&&...) {} template bool Check(bool ok, A&&...) { return ok; } template void Emit(A&&...) {} template void Sample(std::string_view, const T&) {} } #endif ``` ### Pause and step Pausing holds only what the app gates. Rendering and `Pump` keep running, so the agent can still read state, take screenshots and step. ```cpp // A frame loop: while (acc >= dt && Debug::Advance()) { sim.Tick(); acc -= dt; } // false while paused, true once per step // A fixed-step loop that must not spiral when resumed: if (Debug::Paused()) { for (std::uint32_t n = Debug::TakeSteps(); n > 0; --n) sim.Tick(); acc = 0; } else { while (acc >= dt) { sim.Tick(); acc -= dt; } } ``` Pausing an app that never calls `Advance` or `Paused` is refused, rather than silently doing nothing. ### Screenshots `Debug::SetCapture` tells the session where PNGs come from. It is called on the `Pump` thread and returns the bytes when they are ready. With Crafter.Graphics: ```cpp Debug::SetCapture([&window] { const fs::path file = fs::temp_directory_path() / "crafter-debug-capture.png"; window.CaptureNextFrame(file); return Debug::Async>([&window, file]() -> std::optional> { if (window.pendingCapture) return std::nullopt; // the next Render writes it std::vector png(fs::file_size(file)); std::ifstream(file, std::ios::binary).read(reinterpret_cast(png.data()), static_cast(png.size())); return png; }); }); ``` **Pumping:** with Crafter.Graphics, call `Debug::Pump()` from `window.onBeforeUpdate`. **Hidden windows:** a window that is hidden or minimized stops updating, and with it `Pump`. Give such windows `window.updateInterval` with `PresentMode::Mailbox`. The PNG travels over the connection, and the client writes it where the agent asked. Paths aren't passed, because the app and the client are in different sandboxes, each with a private `/tmp`. ### Your own types A field can be any type with a codec. Codecs exist for: - `bool`, integers, floating point, `std::string`; - scoped enums, by name; - `std::optional`, `std::vector`, `std::array`, string-keyed maps; - `Debug::Value`, `Debug::Required`, and aggregates. For anything else, specialize `Debug::Codec` with `Encode`, `Decode` and `Describe`; `Crafter.Debug.Core-Codec.cppm` shows the shape. Reflection works without C++26 ``, so it has limits: - **Aggregates only:** public members, no bases with members, no reference or const members, default-constructible fields. - **Enums:** scoped enums get names for values in -128..127, and `EnumRange` widens that. Other enums travel as integers. ## The agent side From the consumer's directory: ``` ./build/crafter-debug ls [--wait 60s] ./build/crafter-debug wait '' | --frames n | --event E | --paused ./build/crafter-debug status ./build/crafter-debug trace [--frames 60] [--history] ./build/crafter-debug cmds [glob] ./build/crafter-debug log [--level warn] [--cat c] [--grep s] [--follow] ./build/crafter-debug help ./build/crafter-debug screenshot [-o file.png] ./build/crafter-debug call k=v ... ./build/crafter-debug pause [--when c] [--on-check [--trap]] [--clear] ./build/crafter-debug get ... ./build/crafter-debug resume | step [n] | quit [--force] ./build/crafter-debug set ./build/crafter-debug debugger | guide ``` `./build/crafter-debug guide` is the full reference. **Exit codes:** 0 done, 2 usage, 3 no instance, 4 app and client speak different protocols, 5 timed out, 6 the app answered an error. **Timeouts:** a request that times out before the app's next `Pump` is guaranteed not to have run. A request with no reply is diagnosed and the next step printed. The cases are: - the app has exited; - it is stopped in a debugger; - it no longer calls `Pump`. **Stopping an app:** - `quit` ends it cleanly. `quit --force` ends it even when its main loop is stuck. - `kill -KILL ` ends the whole `-r` sandbox from the host; `ls` shows that pid. - With `--die-with-parent` in the crafter-build alias, stopping the background `-r` task stops the app too. **Debuggers:** `crafter-debug debugger` prints `flatpak enter lldb -p `, or `gdb`, which runs the debugger inside the app's own sandbox. A debugger started in another `flatpak run` cannot see the app's pid namespace. The session allows any process of the user to attach, which Yama's ptrace_scope 1 requires. **Version skew:** the client is built with the app. If an app from an older build is still running, the client compares the protocol and source id recorded at discovery: - **Different protocol:** it refuses with exit 4, and prints the command that runs the app's own client. - **Different sources:** it only warns. ## Developing Crafter.Debug ```bash crafter-build test # every test crafter-build test 'Should*Client*' cd examples/Basic && crafter-build --debug-api -r ``` - **Library and core tests** run in-process, against a `Session` built with `listen = false` and a fake transport. - **`ShouldRunClientCommands`** runs the real client against a real session that way. - **`ShouldServeOverQuic`** runs a listening session and drives it over loopback with the client's own QUIC transport. It covers discovery, calls, `log --follow`, wrong tokens, oversized requests, parallel requests, and shutdown with a client connected. - **Crafter.Network** comes from forgejo, or from `../Crafter.Network` with `--local`. To build against another checkout, a worktree say, set `CRAFTER_DEBUG_NETWORK` to its `project.cpp`. - **`AGENTS.template.md`** has the conventions. Licensed LGPL-3.0-only; the examples are MIT.