feat(registry): commands, watches, samples, the timeline and conditions
The app side of Crafter.Debug, minus the session:
- Command(name, description, handler): the handler takes nothing or
one argument struct and returns void, a value, Result<T> or
Async<T>. Arguments are decoded before it runs, so a mistyped call
is answered with every mistake and never reaches it; a throwing
handler becomes an Exception error instead of unwinding into the
frame loop. This replaces 3DForts' three-file pattern per verb.
- Watch(path, variable | getter [, setter]) with bounds, help and
read-only, and Sample(path, value) from any thread with a
frame-stamped history. Paths resolve into values ("bodies[1].pos.y")
and set the same way, writing a member writes the whole value back.
Unknown paths and commands get a suggestion.
- Group: registrations under a prefix that end with their owner. Not
movable, so neither is its owner: its watches refer to the owner's
members, and a move would leave them pointing at the old object.
- Log, LogWarn, LogError, Check and Emit on one frame-stamped
timeline, with source locations captured without macros. A check
failing every frame is counted every time but stored sparsely, and
the session's listener hears every failure.
- Condition: what `wait` and `pause --when` evaluate each frame. A
condition that cannot be decided reports why instead of being false,
which would make a wait hang.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:40:59 +01:00
|
|
|
// SPDX-License-Identifier: LGPL-3.0-only
|
|
|
|
|
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
|
|
|
|
|
|
|
|
|
// The timeline: what the app logged, the checks that failed, the events it
|
|
|
|
|
// emitted, and what the agent did, in one sequence stamped with frame
|
|
|
|
|
// numbers and source locations, so `crafter-debug log` shows cause next to
|
|
|
|
|
// effect:
|
|
|
|
|
//
|
|
|
|
|
// 1790 AGENT call Player_Fire {}
|
|
|
|
|
// 1838 WARN physics brace 17 strain 4.2 > limit Forts3D-Brace.cpp:212
|
|
|
|
|
//
|
|
|
|
|
// Debug::Log("net", "peer {} joined slot {}", peerId, slot); // also LogWarn, LogError
|
|
|
|
|
// Debug::Emit("MatchEnded", MatchEnded{ .winnerSlot = 1 });
|
|
|
|
|
// if (!Debug::Check(mass > 0, "physics", "body {} has mass {}", id, mass)) return;
|
|
|
|
|
//
|
|
|
|
|
// The format string is checked at compile time, as std::format's is, and the
|
|
|
|
|
// caller's location is captured without a macro (macros don't cross
|
|
|
|
|
// `import`). Every function here is safe from any thread. The timeline
|
|
|
|
|
// keeps the last 8192 entries.
|
|
|
|
|
export module Crafter.Debug:Timeline;
|
|
|
|
|
import std;
|
|
|
|
|
import Crafter.Debug.Core;
|
|
|
|
|
import :Registry;
|
|
|
|
|
|
|
|
|
|
export namespace Crafter::Debug {
|
|
|
|
|
enum class EntryKind : std::uint8_t {
|
|
|
|
|
Log,
|
|
|
|
|
Warn,
|
|
|
|
|
Error,
|
|
|
|
|
Check,
|
|
|
|
|
Event,
|
|
|
|
|
Agent,
|
|
|
|
|
Pause,
|
|
|
|
|
Stdio,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// "log", "warn", "error", "check", "event", "agent", "pause", "stdio".
|
|
|
|
|
std::string_view EntryKindName(EntryKind kind);
|
|
|
|
|
|
|
|
|
|
struct TimelineEntry {
|
|
|
|
|
// 0 for a check failure that was counted but not stored (see Check).
|
|
|
|
|
std::uint64_t sequence = 0;
|
|
|
|
|
std::uint64_t frame = 0;
|
|
|
|
|
// Since the timeline started, on the steady clock.
|
|
|
|
|
double seconds = 0;
|
|
|
|
|
EntryKind kind = EntryKind::Log;
|
|
|
|
|
// The log category, or an event's name.
|
|
|
|
|
std::string category;
|
|
|
|
|
std::string message;
|
|
|
|
|
std::string file;
|
|
|
|
|
std::uint32_t line = 0;
|
|
|
|
|
// An event's payload.
|
|
|
|
|
Value payload;
|
|
|
|
|
// For a check: its site's failures so far, this one included.
|
|
|
|
|
std::uint64_t count = 1;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// A format string and where it was written, captured at the call site.
|
|
|
|
|
template<class... A>
|
|
|
|
|
struct LocFmt {
|
|
|
|
|
std::format_string<A...> format;
|
|
|
|
|
std::source_location location;
|
|
|
|
|
|
|
|
|
|
template<class S>
|
|
|
|
|
requires std::convertible_to<const S&, std::string_view>
|
|
|
|
|
consteval LocFmt(const S& text, std::source_location where = std::source_location::current()) : format(text), location(where) {}
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export namespace Crafter::Debug::Detail {
|
|
|
|
|
// 0 for information (log, event, agent, pause, stdio), 1 for warn, 2 for
|
|
|
|
|
// error and check: what `log --level` compares against.
|
|
|
|
|
std::uint8_t Level(EntryKind kind);
|
|
|
|
|
|
|
|
|
|
struct TimelineQuery {
|
|
|
|
|
// Only entries after this sequence number: what a follower has seen.
|
|
|
|
|
std::uint64_t afterSequence = 0;
|
|
|
|
|
std::optional<std::uint64_t> sinceFrame;
|
|
|
|
|
// The newest this many of the matches; 0 for all of them.
|
|
|
|
|
std::size_t last = 0;
|
|
|
|
|
std::uint8_t minLevel = 0;
|
|
|
|
|
// A glob on the category; empty for any.
|
|
|
|
|
std::string category;
|
|
|
|
|
// A substring of the message or the category; empty for any.
|
|
|
|
|
std::string grep;
|
|
|
|
|
// Empty for any kind.
|
|
|
|
|
std::vector<EntryKind> kinds;
|
|
|
|
|
};
|
|
|
|
|
|
feat(session): serve requests without blocking a thread, pause and step
The app's end of the connection, independent of the transport, which
reaches it through Detail::Exchange: one request's way back, safe from
any thread and never blocking.
- What needs only the session's own state (hello, status, list, log,
pause, resume) is answered on the transport thread. What touches the
app (calls, get, set, trace, screenshot) queues for the next Pump and
runs on the app's thread between frames. Waits and steps reply from
the Pump that sees them satisfied, events from the thread that emits
them. No request ever holds a thread while it waits.
- A request whose timeout passes before Pump is answered Timeout and
is guaranteed not to have run, so an agent never wonders whether a
command went through; an Async result arrives on the Pump it is
ready; requests left when the Session ends are answered ShuttingDown.
- Advance() for frame loops and Paused()/TakeSteps() for fixed-step
loops hold what the app gates on them; pausing an app that gates
nothing is refused instead of silently doing nothing. pause --when
stops the frame its condition holds; pause --on-check stops before
the next Advance in the frame a check fails, optionally raising
SIGTRAP for an attached debugger.
- A wait that times out says what its condition's paths were; a log
follower gets matching entries in order, from the backlog on, and is
dropped when it cannot keep up.
- Request parameters go through Codec, so a mistyped request gets the
same did-you-mean replies a mistyped command does. The session sets
PR_SET_PTRACER_ANY so a debugger the agent starts can attach.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:53:15 +01:00
|
|
|
// Whether one entry passes a query's filters (all but `last`), for a
|
|
|
|
|
// follower that sees entries one at a time.
|
|
|
|
|
bool Matches(const TimelineQuery& query, const TimelineEntry& entry);
|
|
|
|
|
|
feat(registry): commands, watches, samples, the timeline and conditions
The app side of Crafter.Debug, minus the session:
- Command(name, description, handler): the handler takes nothing or
one argument struct and returns void, a value, Result<T> or
Async<T>. Arguments are decoded before it runs, so a mistyped call
is answered with every mistake and never reaches it; a throwing
handler becomes an Exception error instead of unwinding into the
frame loop. This replaces 3DForts' three-file pattern per verb.
- Watch(path, variable | getter [, setter]) with bounds, help and
read-only, and Sample(path, value) from any thread with a
frame-stamped history. Paths resolve into values ("bodies[1].pos.y")
and set the same way, writing a member writes the whole value back.
Unknown paths and commands get a suggestion.
- Group: registrations under a prefix that end with their owner. Not
movable, so neither is its owner: its watches refer to the owner's
members, and a move would leave them pointing at the old object.
- Log, LogWarn, LogError, Check and Emit on one frame-stamped
timeline, with source locations captured without macros. A check
failing every frame is counted every time but stored sparsely, and
the session's listener hears every failure.
- Condition: what `wait` and `pause --when` evaluate each frame. A
condition that cannot be decided reports why instead of being false,
which would make a wait hang.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 01:40:59 +01:00
|
|
|
struct CheckSite {
|
|
|
|
|
std::string file;
|
|
|
|
|
std::uint32_t line = 0;
|
|
|
|
|
std::string category;
|
|
|
|
|
std::uint64_t failures = 0;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
class Timeline {
|
|
|
|
|
public:
|
|
|
|
|
static Timeline& Instance();
|
|
|
|
|
|
|
|
|
|
// Stamps the entry with its sequence, frame and time, stores it and
|
|
|
|
|
// tells the listener. Returns its sequence number.
|
|
|
|
|
std::uint64_t Append(TimelineEntry entry);
|
|
|
|
|
// A failed Check. Counted per site; stored while the site has failed
|
|
|
|
|
// at most ten times and then at every power of two, so a check that
|
|
|
|
|
// fails every frame cannot push everything else out. The listener
|
|
|
|
|
// hears every failure, stored or not.
|
|
|
|
|
void RecordCheck(std::string_view category, std::string message, const std::source_location& location);
|
|
|
|
|
|
|
|
|
|
std::vector<TimelineEntry> Query(const TimelineQuery& query) const;
|
|
|
|
|
std::uint64_t LastSequence() const;
|
|
|
|
|
std::vector<CheckSite> CheckSites() const;
|
|
|
|
|
|
|
|
|
|
// Called with every stored entry and every check failure, on the
|
|
|
|
|
// thread that recorded it, after the timeline's lock is released:
|
|
|
|
|
// the session uses it to pause on a check and to wake followers.
|
|
|
|
|
void SetListener(std::function<void(const TimelineEntry&)> listener);
|
|
|
|
|
|
|
|
|
|
private:
|
|
|
|
|
mutable std::mutex mutex;
|
|
|
|
|
std::deque<TimelineEntry> entries;
|
|
|
|
|
std::uint64_t nextSequence = 1;
|
|
|
|
|
struct SiteCount {
|
|
|
|
|
std::string category;
|
|
|
|
|
std::uint64_t failures = 0;
|
|
|
|
|
};
|
|
|
|
|
std::map<std::pair<std::string, std::uint32_t>, SiteCount> checks;
|
|
|
|
|
std::shared_ptr<const std::function<void(const TimelineEntry&)>> listener;
|
|
|
|
|
std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now();
|
|
|
|
|
|
|
|
|
|
void Notify(const TimelineEntry& entry) const;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
TimelineEntry MakeEntry(EntryKind kind, std::string_view category, std::string message, const std::source_location& location, Value payload = {});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export namespace Crafter::Debug {
|
|
|
|
|
template<class... A>
|
|
|
|
|
void Log(std::string_view category, LocFmt<std::type_identity_t<A>...> format, A&&... args) {
|
|
|
|
|
Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Log, category, std::format(format.format, std::forward<A>(args)...), format.location));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
template<class... A>
|
|
|
|
|
void LogWarn(std::string_view category, LocFmt<std::type_identity_t<A>...> format, A&&... args) {
|
|
|
|
|
Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Warn, category, std::format(format.format, std::forward<A>(args)...), format.location));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
template<class... A>
|
|
|
|
|
void LogError(std::string_view category, LocFmt<std::type_identity_t<A>...> format, A&&... args) {
|
|
|
|
|
Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Error, category, std::format(format.format, std::forward<A>(args)...), format.location));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A soft assert: returns `ok`, and when it is false records the failure
|
|
|
|
|
// (the message is only formatted then, so a Check in hot code is cheap).
|
|
|
|
|
// An agent can make a failure pause the app (`pause --on-check`) or stop
|
|
|
|
|
// it in an attached debugger (`--trap`).
|
|
|
|
|
template<class... A>
|
|
|
|
|
bool Check(bool ok, std::string_view category, LocFmt<std::type_identity_t<A>...> format, A&&... args) {
|
|
|
|
|
if (!ok) Detail::Timeline::Instance().RecordCheck(category, std::format(format.format, std::forward<A>(args)...), format.location);
|
|
|
|
|
return ok;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A structured event, which `crafter-debug wait --event <name>` waits for.
|
|
|
|
|
template<class T>
|
|
|
|
|
void Emit(std::string_view event, const T& payload, std::source_location location = std::source_location::current()) {
|
|
|
|
|
Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Event, event, {}, location, Debug::Encode(payload)));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
inline void Emit(std::string_view event, std::source_location location = std::source_location::current()) {
|
|
|
|
|
Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Event, event, {}, location));
|
|
|
|
|
}
|
|
|
|
|
}
|