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>
This commit is contained in:
parent
89a12bcd61
commit
3500b73ecf
12 changed files with 1829 additions and 7 deletions
172
interfaces/Crafter.Debug-Timeline.cppm
Normal file
172
interfaces/Crafter.Debug-Timeline.cppm
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
// 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;
|
||||
};
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue