docs: the manual, notes for agents, and the notes consumers get
- 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>
This commit is contained in:
parent
34c60dcd34
commit
8b995cd76a
3 changed files with 460 additions and 1 deletions
115
AGENTS.template.md
Normal file
115
AGENTS.template.md
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Notes for agents working on Crafter.Debug itself. The README is the user manual. `tooling/AGENTS.consumer.md` is what consumers' agents get.
|
||||||
|
|
||||||
|
## Project overview
|
||||||
|
|
||||||
|
Crafter.Debug lets an agent inspect and drive an app while it runs. `project.cpp` makes four configurations:
|
||||||
|
|
||||||
|
| Configuration | Holds |
|
||||||
|
|---|---|
|
||||||
|
| `Crafter.Debug.Core` (static) | What the app and the client share. Partitions:<br>• `:Value`: JSON value with its own parser and printer<br>• `:Reflect`: aggregate members and scoped-enum names, without `<meta>`<br>• `:Codec`: encode, decode, describe<br>• `:Result`: Error, Result, Async<br>• `:Protocol`: envelope, codes, versions, base64<br>• `:BuildInfo`: source id and client path, generated<br>• `:Discovery`: instance files and the lifetime lock |
|
||||||
|
| `Crafter.Debug.Cli` (static) | The client's logic: `Cli::Run`, with the transport injected. |
|
||||||
|
| `crafter.debug-cli` (executable) | `crafter-debug`: `main.cpp` plus the transport. |
|
||||||
|
| `Crafter.Debug` (static, returned) | What an app links. Partitions:<br>• `:Registry`: commands, watches, samples, Group<br>• `:Timeline`: Log, Check, Emit<br>• `:Condition`: what wait and pause --when evaluate<br>• `:Session`: serving requests, Pump, Advance |
|
||||||
|
|
||||||
|
The client is one of the library's dependencies only so that a consumer's debug build compiles it in the same pass.
|
||||||
|
|
||||||
|
`tooling/` is a separate project, `Crafter.Debug.Build`. Consumers load it with `#pragma crafter dependency ... project "tooling/project.cpp"`. Its `Debug::Use` adds the library under the consumer's flag and writes the consumer's `build/crafter-debug` launcher at load time.
|
||||||
|
|
||||||
|
`examples/Basic` is a consumer that uses this checkout through a local pragma path.
|
||||||
|
|
||||||
|
## Dev environment
|
||||||
|
|
||||||
|
crafter-build comes from its Flatpak. Spell it out, since the Bash tool may not expand aliases: `flatpak run --die-with-parent net.catcrafts.CrafterBuild`. The host is Linux x86_64.
|
||||||
|
|
||||||
|
Tests run inside the Flatpak sandbox, where:
|
||||||
|
- `/tmp` is private;
|
||||||
|
- the toolchain is clang 23 with libc++;
|
||||||
|
- `/.flatpak-info` exists.
|
||||||
|
|
||||||
|
Every `flatpak run` is its own sandbox with its own pid namespace.
|
||||||
|
|
||||||
|
## Build and test commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
flatpak run --die-with-parent net.catcrafts.CrafterBuild # the library, core, client
|
||||||
|
flatpak run --die-with-parent net.catcrafts.CrafterBuild test # every test
|
||||||
|
flatpak run --die-with-parent net.catcrafts.CrafterBuild test 'Should*Client*'
|
||||||
|
cd examples/Basic && flatpak run --die-with-parent net.catcrafts.CrafterBuild --debug-api # a consumer's debug build
|
||||||
|
```
|
||||||
|
|
||||||
|
A test's own output appears only when it fails. To see its PASS lines, run the binary from `bin/<Test>-*/` with `flatpak run --die-with-parent --command=<path> net.catcrafts.CrafterBuild`.
|
||||||
|
|
||||||
|
## Testing instructions
|
||||||
|
|
||||||
|
**Registration:** tests are declared in `project.cpp`, and only when this is the top-level project:
|
||||||
|
- `coreTests` link the core;
|
||||||
|
- `libraryTests` link the library;
|
||||||
|
- `ShouldRunClientCommands` links the library and the client.
|
||||||
|
|
||||||
|
**Shape of a test:**
|
||||||
|
- Name it `Should<Verb><What>`.
|
||||||
|
- Open `main.cpp` with a comment on the behaviour and the failure it guards against.
|
||||||
|
- Use a `Check(ok, what)` helper that prints PASS or FAIL, and return nonzero at the end.
|
||||||
|
|
||||||
|
**Session tests** use `Session({ .listen = false })` and hand requests to `Session::Handle` through a fake `Detail::Exchange`. Pump from the test's own thread, or from one thread playing the app.
|
||||||
|
|
||||||
|
**Files and discovery:**
|
||||||
|
- Temporary files go under `fs::temp_directory_path() / "crafter-debug-<Test>"`.
|
||||||
|
- Discovery tests set `CRAFTER_DEBUG_DIR` there.
|
||||||
|
|
||||||
|
**Hangs:** anything that can hang gets a watchdog that calls `std::_Exit(1)` before the runner's 60 s.
|
||||||
|
|
||||||
|
**Pointers into temporaries:** `Value::Find` returns a pointer into its value. Never keep one from a temporary such as `ResultOf(...).Find(...)` past the full expression; copy the value into a local first. Two tests failed on exactly this.
|
||||||
|
|
||||||
|
## Code style
|
||||||
|
|
||||||
|
**Nothing enforces it.** No lint rules are registered: a `#pragma crafter dependency` on Crafter.LintRules would be built on every load of every consumer.
|
||||||
|
|
||||||
|
**Headers and imports:**
|
||||||
|
- `// SPDX-License-Identifier: LGPL-3.0-only`, with a space after `//`, then the copyright line, then a blank line. Examples are MIT.
|
||||||
|
- `import std;`. C headers go only in a global module fragment, or before the imports in a test.
|
||||||
|
|
||||||
|
**Formatting and naming:**
|
||||||
|
- Four-space indentation.
|
||||||
|
- `PascalCase` for types and functions, `camelCase` for variables, members and constants.
|
||||||
|
- `std::println` and `std::format`.
|
||||||
|
- Fixed-width integers.
|
||||||
|
- `enum class`.
|
||||||
|
|
||||||
|
**Comments** say why: the failure mode, the race, the rejected alternative. Long comments are deliberate.
|
||||||
|
|
||||||
|
## Design rules
|
||||||
|
|
||||||
|
1. **Release builds never link Crafter.Debug.** Consumers gate it through `Debug::Use` and their own define. The library has no no-op mode.
|
||||||
|
2. **No thread of Crafter.Debug's own, and nothing ever blocks one.** Transport threads only queue work or answer from the session's own state; the Pump thread runs app code; `Detail::Exchange::Reply` and `Stream` never block. The thread pool belongs to the host app.
|
||||||
|
3. **App code runs only on the Pump thread:** handlers, getters, setters, and condition evaluation. Never hold the session mutex while calling it, since app code logs, and logging re-enters the session.
|
||||||
|
4. **Nothing the Flatpak compiles runs on the host.** The launcher is POSIX sh and starts the client with `flatpak run`. On older hosts, the bundled libc++ and the runtime's glibc do not load.
|
||||||
|
5. **The client is built with the app.** `debug.hello`, the ALPN `crafter-debug/1` and the discovery record's `protocol` and `sourceId` are frozen. Bump `protocolVersion` on any wire change.
|
||||||
|
6. **Errors name the next step.** Decoding never throws. A request that timed out before `Pump` never runs.
|
||||||
|
7. **Discovery names instances by random ids and tells liveness by flock, never by pid.** Pids collide across `flatpak run` sandboxes.
|
||||||
|
|
||||||
|
## Commit and PR guidelines
|
||||||
|
|
||||||
|
- **Branch:** off `master`.
|
||||||
|
- **Subject:** `type(scope): imperative summary`. Types: `feat`, `fix`, `docs`, `test`. Scopes in use: `core`, `registry`, `session`, `discovery`, `cli`, `tooling`, `transport`.
|
||||||
|
- **Body:** what, why, and why this shape, wrapped at about 75 columns. Add a `Co-Authored-By:` trailer when an agent wrote it.
|
||||||
|
- **Never commit** `bin/`, `build/` or `AGENTS.md`.
|
||||||
|
- **Push and open PRs only when asked.**
|
||||||
|
|
||||||
|
## Security
|
||||||
|
|
||||||
|
- **Debug builds only.** The session listens on 127.0.0.1, and every request must carry the instance's 128-bit token.
|
||||||
|
- **Discovery files** are 0600 in a 0700 directory.
|
||||||
|
- **Debuggers:** the session calls `prctl(PR_SET_PTRACER, PR_SET_PTRACER_ANY)`, so a debugger the agent starts can attach. That is another reason it never ships in a release build.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- **`Group` is neither copyable nor movable,** and neither is its owner. Its watches refer to the owner's members.
|
||||||
|
- **Inside a `Codec<T>` specialization, call the free functions qualified** (`Debug::Encode`): the member `Encode` hides them.
|
||||||
|
- **`std::format` needs a compile-time format string.** A ternary between two literals does not compile.
|
||||||
|
- **Tests don't inherit defines.** Add them with `.Define()`.
|
||||||
|
- **A consumer's `#ifdef`'d `import Crafter.Debug;`** is harmless in its plain builds.
|
||||||
|
- **`project.cpp` regenerates `build/generated/<core variant>/Crafter.Debug.Core-BuildInfo.cpp`** when the sources or the client's path change. `crafter-build clean` removes it, and the next load writes it again.
|
||||||
|
- **The launcher is written by `Debug::Use` while crafter-build loads the consumer,** not by the build. A plain build keeps an existing launcher; a fresh plain build writes the stub.
|
||||||
324
README.md
Normal file
324
README.md
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
# 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/<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:
|
||||||
|
|
||||||
|
```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<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-debug` is 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:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
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 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<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::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<T>`, or `Debug::Async<T>`.
|
||||||
|
- `Debug::Result<T>` is a value or a `Debug::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 per `Pump` until it yields, and the caller simply waits:
|
||||||
|
|
||||||
|
```cpp
|
||||||
|
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
|
||||||
|
|
||||||
|
```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<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.
|
||||||
|
|
||||||
|
```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<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 `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 '<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:**
|
||||||
|
- `quit` ends it cleanly. `quit --force` ends it even when its main loop is stuck.
|
||||||
|
- `kill -KILL <sandbox pid>` 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 <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
|
||||||
|
|
||||||
|
```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.
|
||||||
|
- **`AGENTS.template.md`** has the conventions.
|
||||||
|
|
||||||
|
Licensed LGPL-3.0-only; the examples are MIT.
|
||||||
|
|
@ -1,3 +1,23 @@
|
||||||
## Crafter.Debug
|
## Crafter.Debug
|
||||||
|
|
||||||
Spike placeholder: the real notes land with the client. This project can be inspected and driven at runtime: build it with the project's debug flag, then run `./build/crafter-debug`.
|
This app can be driven while it runs: call its commands, read and set its state, pause and step it, wait for conditions, read its log, take screenshots.
|
||||||
|
|
||||||
|
- Start its debug build in the background, since it does not exit: `crafter-build <debug flag> -r`. This project's notes above name the flag, usually `--debug-api`.
|
||||||
|
- Drive it with `./build/crafter-debug` from the project directory:
|
||||||
|
- `ls --wait 60s` until it is listed; `status`, `cmds`, `help <command>`, `get '*'`
|
||||||
|
- `call <command> key=value ...`, `set <path> <value>`
|
||||||
|
- `pause --when '<condition>'` then `wait --paused`; `step 3`, `resume`
|
||||||
|
- `wait '<condition>'`, `wait --frames 30`, `wait --event <Name>`
|
||||||
|
- `trace <path> --frames 120`, `log --level warn`, `log --follow` (under Monitor)
|
||||||
|
- `screenshot`: open the PNG it prints with Read
|
||||||
|
- `quit` when done
|
||||||
|
- `./build/crafter-debug guide` is the full reference. Errors name the next step.
|
||||||
|
- Exit codes: 2 usage, 3 no instance, 4 app and client from different versions, 5 timed out, 6 the app answered an error.
|
||||||
|
|
||||||
|
Gotchas:
|
||||||
|
- Requests run when the app calls `Debug::Pump()`, once per frame. `status` shows when it last did.
|
||||||
|
- `pause` and `step` only hold what the app gates on `Debug::Advance()` or `Debug::Paused()`.
|
||||||
|
- Stopping the background `-r` task can leave the app running. Stop it with `./build/crafter-debug quit`, or `quit --force` if its loop is stuck.
|
||||||
|
- Run long `wait`s in the background, and chain short steps with `&&`.
|
||||||
|
- For breakpoints and memory, `./build/crafter-debug debugger` prints how to attach lldb or gdb inside the app's sandbox.
|
||||||
|
- To give the agent more to work with, register commands and watches in the app's debug-only code (`Debug::Command`, `Debug::Watch`, `Debug::Group`). Crafter.Debug's README shows how.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue