- README: how the pieces fit, adding Crafter.Debug to a project (Debug::Use, the shared-args rule, Session and Pump, the AGENTS.md checklist), the app-side API with examples, the fixed-step and Crafter.Graphics patterns, a shim module for calls without #ifdefs, and the agent side: commands, exit codes, stopping apps, attaching a debugger, version skew. - AGENTS.template.md: the configurations and partitions, commands, test conventions (including the dangling Value::Find pointer two tests hit), style, design rules, security and gotchas. - tooling/AGENTS.consumer.md: what every consumer's agents read in each session, kept short: the loop, exit codes and the gotchas, pointing at `crafter-debug guide` for the rest. The QUIC transport the README describes lands next. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
16 KiB
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.
$ 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/<id>.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:
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<const std::string_view> args) {
Configuration cfg;
// ...
const ArgQuery opts = ApplyStandardArgs(cfg, args);
std::vector<std::string> 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...
Debug::Use(cfg, { .enabled = opts.Has("--debug-api"), .flag = "--debug-api",
.define = "MYAPP_DEBUG_API", .args = depArgs });
// ...
}
What Use does with the flag:
- Flag on, native Linux target: it adds the define and the library, from the checkout the pragma names, and writes
build/crafter-debug. - Otherwise: it adds nothing, and release builds never link Crafter.Debug. Until a debug build has run,
build/crafter-debugis a stub that says to build one.
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.
2. In main, start a session and pump it once per frame. Gate the parts the agent should be able to pause:
import MyApp.DebugShim; // see "Without #ifdefs" below
ThreadPool::Start(); // the session's transport runs on the pool
#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.
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 noAGENTS.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.mdin it. Claude Code ignoresAGENTS.mdwhenever aCLAUDE.mdapplies.
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
struct AimArgs {
Debug::Required<std::int32_t> 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::Valuegives 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<T>, or Debug::Async<T>.
Debug::Result<T>is a value or aDebug::Error{code, message}. Write the message for the agent: what went wrong and the next step.Debug::Async<T>is for results that take frames, such as a GPU readback. Its poller runs once perPumpuntil it yields, and the caller simply waits:
Debug::Command("Sim_StateHash", "Hash of the GPU sim state.", []() -> Debug::Async<StateHash> {
Physics::RequestCapture();
return Debug::Async<StateHash>([]() -> std::optional<StateHash> {
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
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.yreads the watchbodies.set fort1.core.pos.x 5readsfort1.core.pos, replacesx, 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:
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
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-checkpauses the app within the frame of the next failure;--trapalso stops an attached debugger there.
Without #ifdefs: 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:
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<class... A> void Log(A&&...) {}
template<class... A> void LogWarn(A&&...) {}
template<class... A> void LogError(A&&...) {}
template<class... A> bool Check(bool ok, A&&...) { return ok; }
template<class... A> void Emit(A&&...) {}
template<class T> 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.
// 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:
Debug::SetCapture([&window] {
const fs::path file = fs::temp_directory_path() / "crafter-debug-capture.png";
window.CaptureNextFrame(file);
return Debug::Async<std::vector<std::byte>>([&window, file]() -> std::optional<std::vector<std::byte>> {
if (window.pendingCapture) return std::nullopt; // the next Render writes it
std::vector<std::byte> png(fs::file_size(file));
std::ifstream(file, std::ios::binary).read(reinterpret_cast<char*>(png.data()), static_cast<std::streamsize>(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<T>, and aggregates.
For anything else, specialize Debug::Codec<T> with Encode, Decode and Describe; Crafter.Debug.Core-Codec.cppm shows the shape.
Reflection works without C++26 <meta>, 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
EnumRangewidens that. Other enums travel as integers.
The agent side
From the consumer's directory:
./build/crafter-debug ls [--wait 60s] ./build/crafter-debug wait '<cond>' | --frames n | --event E | --paused
./build/crafter-debug status ./build/crafter-debug trace <path> [--frames 60] [--history]
./build/crafter-debug cmds [glob] ./build/crafter-debug log [--level warn] [--cat c] [--grep s] [--follow]
./build/crafter-debug help <name> ./build/crafter-debug screenshot [-o file.png]
./build/crafter-debug call <cmd> k=v ... ./build/crafter-debug pause [--when c] [--on-check [--trap]] [--clear]
./build/crafter-debug get <path|glob> ... ./build/crafter-debug resume | step [n] | quit [--force]
./build/crafter-debug set <path> <value> ./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:
quitends it cleanly.quit --forceends it even when its main loop is stuck.kill -KILL <sandbox pid>ends the whole-rsandbox from the host;lsshows that pid.- With
--die-with-parentin the crafter-build alias, stopping the background-rtask stops the app too.
Debuggers: crafter-debug debugger prints flatpak enter <instance> lldb -p <pid>, 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
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
Sessionbuilt withlisten = falseand a fake transport. ShouldRunClientCommandsruns the real client against a real session that way.AGENTS.template.mdhas the conventions.
Licensed LGPL-3.0-only; the examples are MIT.