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
314
implementations/Crafter.Debug-Condition.cpp
Normal file
314
implementations/Crafter.Debug-Condition.cpp
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module Crafter.Debug:Condition_impl;
|
||||
import std;
|
||||
import Crafter.Debug.Core;
|
||||
import :Condition;
|
||||
|
||||
using namespace Crafter::Debug;
|
||||
|
||||
struct Condition::Node {
|
||||
enum class Op : std::uint8_t {
|
||||
Or,
|
||||
And,
|
||||
Not,
|
||||
Compare,
|
||||
Truthy,
|
||||
};
|
||||
enum class Comparison : std::uint8_t {
|
||||
Equal,
|
||||
NotEqual,
|
||||
Less,
|
||||
LessEqual,
|
||||
Greater,
|
||||
GreaterEqual,
|
||||
};
|
||||
struct Operand {
|
||||
// A path to resolve, or else a literal.
|
||||
std::string path;
|
||||
Value literal;
|
||||
};
|
||||
|
||||
Op op = Op::Truthy;
|
||||
Comparison comparison = Comparison::Equal;
|
||||
std::shared_ptr<const Node> left;
|
||||
std::shared_ptr<const Node> right;
|
||||
Operand a;
|
||||
Operand b;
|
||||
// The text this node was parsed from, for errors.
|
||||
std::string source;
|
||||
};
|
||||
|
||||
namespace {
|
||||
using Node = Condition::Node;
|
||||
using Outcome = std::expected<bool, std::string>;
|
||||
|
||||
// Nesting deeper than this is refused rather than recursed into.
|
||||
constexpr std::uint32_t maxDepth = 64;
|
||||
|
||||
bool PathStart(char c) {
|
||||
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_';
|
||||
}
|
||||
|
||||
bool PathChar(char c) {
|
||||
return PathStart(c) || (c >= '0' && c <= '9') || c == '-';
|
||||
}
|
||||
|
||||
std::string_view ComparisonText(Node::Comparison comparison) {
|
||||
switch (comparison) {
|
||||
case Node::Comparison::Equal: return "==";
|
||||
case Node::Comparison::NotEqual: return "!=";
|
||||
case Node::Comparison::Less: return "<";
|
||||
case Node::Comparison::LessEqual: return "<=";
|
||||
case Node::Comparison::Greater: return ">";
|
||||
case Node::Comparison::GreaterEqual: return ">=";
|
||||
}
|
||||
return "?";
|
||||
}
|
||||
|
||||
class Parser {
|
||||
public:
|
||||
Parser(std::string_view text, std::vector<std::string>& paths) : text(text), paths(paths) {}
|
||||
|
||||
std::shared_ptr<const Node> Document() {
|
||||
auto node = Or(0);
|
||||
Skip();
|
||||
if (!AtEnd()) Fail("unexpected text; join conditions with && or ||");
|
||||
return node;
|
||||
}
|
||||
|
||||
private:
|
||||
std::string_view text;
|
||||
std::vector<std::string>& paths;
|
||||
std::size_t position = 0;
|
||||
|
||||
[[noreturn]] void Fail(std::string_view what) const {
|
||||
throw ConditionError(position, what);
|
||||
}
|
||||
|
||||
bool AtEnd() const {
|
||||
return position >= text.size();
|
||||
}
|
||||
|
||||
void Skip() {
|
||||
while (!AtEnd() && (text[position] == ' ' || text[position] == '\t')) ++position;
|
||||
}
|
||||
|
||||
bool Match(std::string_view token) {
|
||||
Skip();
|
||||
if (text.substr(position, token.size()) != token) return false;
|
||||
position += token.size();
|
||||
return true;
|
||||
}
|
||||
|
||||
std::shared_ptr<const Node> Combine(Node::Op op, std::shared_ptr<const Node> left, std::shared_ptr<const Node> right) {
|
||||
auto node = std::make_shared<Node>();
|
||||
node->op = op;
|
||||
node->left = std::move(left);
|
||||
node->right = std::move(right);
|
||||
return node;
|
||||
}
|
||||
|
||||
std::shared_ptr<const Node> Or(std::uint32_t depth) {
|
||||
auto left = And(depth);
|
||||
while (Match("||")) left = Combine(Node::Op::Or, std::move(left), And(depth));
|
||||
return left;
|
||||
}
|
||||
|
||||
std::shared_ptr<const Node> And(std::uint32_t depth) {
|
||||
auto left = Not(depth);
|
||||
while (Match("&&")) left = Combine(Node::Op::And, std::move(left), Not(depth));
|
||||
return left;
|
||||
}
|
||||
|
||||
std::shared_ptr<const Node> Not(std::uint32_t depth) {
|
||||
if (depth > maxDepth) Fail("nested too deeply");
|
||||
Skip();
|
||||
if (!AtEnd() && text[position] == '!' && text.substr(position, 2) != "!=") {
|
||||
++position;
|
||||
return Combine(Node::Op::Not, Not(depth + 1), nullptr);
|
||||
}
|
||||
return Primary(depth);
|
||||
}
|
||||
|
||||
std::shared_ptr<const Node> Primary(std::uint32_t depth) {
|
||||
if (Match("(")) {
|
||||
auto inner = Or(depth + 1);
|
||||
if (!Match(")")) Fail("expected ')'");
|
||||
return inner;
|
||||
}
|
||||
Skip();
|
||||
const std::size_t start = position;
|
||||
auto node = std::make_shared<Node>();
|
||||
node->a = Operand();
|
||||
if (const auto comparison = ComparisonOperator()) {
|
||||
node->op = Node::Op::Compare;
|
||||
node->comparison = *comparison;
|
||||
node->b = Operand();
|
||||
} else {
|
||||
node->op = Node::Op::Truthy;
|
||||
}
|
||||
node->source = std::string(text.substr(start, position - start));
|
||||
while (!node->source.empty() && node->source.back() == ' ') node->source.pop_back();
|
||||
return node;
|
||||
}
|
||||
|
||||
std::optional<Node::Comparison> ComparisonOperator() {
|
||||
Skip();
|
||||
if (Match("==")) return Node::Comparison::Equal;
|
||||
if (Match("!=")) return Node::Comparison::NotEqual;
|
||||
if (Match("<=")) return Node::Comparison::LessEqual;
|
||||
if (Match(">=")) return Node::Comparison::GreaterEqual;
|
||||
if (Match("<")) return Node::Comparison::Less;
|
||||
if (Match(">")) return Node::Comparison::Greater;
|
||||
if (!AtEnd() && text[position] == '=') Fail("use == to compare");
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
Node::Operand Operand() {
|
||||
Skip();
|
||||
if (AtEnd()) Fail("expected a path or a value");
|
||||
const char c = text[position];
|
||||
if (c == '"' || c == '\'') return { {}, Value(String(c)) };
|
||||
if ((c >= '0' && c <= '9') || c == '-') return { {}, Number() };
|
||||
if (!PathStart(c)) Fail("expected a path or a value");
|
||||
const std::size_t start = position;
|
||||
while (!AtEnd()) {
|
||||
const char next = text[position];
|
||||
if (PathChar(next) || next == '.') {
|
||||
++position;
|
||||
} else if (next == '[') {
|
||||
const std::size_t close = text.find(']', position);
|
||||
if (close == std::string_view::npos) Fail("expected ']'");
|
||||
position = close + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
const std::string_view word = text.substr(start, position - start);
|
||||
if (word == "true") return { {}, Value(true) };
|
||||
if (word == "false") return { {}, Value(false) };
|
||||
if (word == "null") return { {}, Value() };
|
||||
paths.emplace_back(word);
|
||||
return { std::string(word), Value() };
|
||||
}
|
||||
|
||||
std::string String(char quote) {
|
||||
++position;
|
||||
std::string out;
|
||||
while (true) {
|
||||
if (AtEnd()) Fail("unterminated string");
|
||||
const char c = text[position++];
|
||||
if (c == quote) return out;
|
||||
if (c == '\\' && !AtEnd()) {
|
||||
out += text[position++];
|
||||
continue;
|
||||
}
|
||||
out += c;
|
||||
}
|
||||
}
|
||||
|
||||
Value Number() {
|
||||
const std::size_t start = position;
|
||||
if (text[position] == '-') ++position;
|
||||
while (!AtEnd() && ((text[position] >= '0' && text[position] <= '9') || text[position] == '.' || text[position] == 'e' || text[position] == 'E'
|
||||
|| ((text[position] == '+' || text[position] == '-') && (text[position - 1] == 'e' || text[position - 1] == 'E')))) {
|
||||
++position;
|
||||
}
|
||||
try {
|
||||
return Value::Parse(text.substr(start, position - start));
|
||||
} catch (const ParseError&) {
|
||||
position = start;
|
||||
Fail("malformed number");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
std::expected<Value, std::string> Load(const Node::Operand& operand, const Condition::Resolver& resolve) {
|
||||
if (operand.path.empty()) return operand.literal;
|
||||
return resolve(operand.path);
|
||||
}
|
||||
|
||||
bool Truthy(const Value& value) {
|
||||
switch (value.Kind()) {
|
||||
case ValueKind::Null: return false;
|
||||
case ValueKind::Bool: return value.AsBool();
|
||||
case ValueKind::Integer: return value.AsInteger() != 0;
|
||||
case ValueKind::Number: return value.AsNumber() != 0;
|
||||
case ValueKind::String: return !value.AsString().empty();
|
||||
case ValueKind::Array:
|
||||
case ValueKind::Object: return value.Size() != 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
template<class T>
|
||||
bool Ordered(Node::Comparison comparison, const T& a, const T& b) {
|
||||
switch (comparison) {
|
||||
case Node::Comparison::Less: return a < b;
|
||||
case Node::Comparison::LessEqual: return a <= b;
|
||||
case Node::Comparison::Greater: return a > b;
|
||||
case Node::Comparison::GreaterEqual: return a >= b;
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
Outcome Compare(const Node& node, const Value& a, const Value& b) {
|
||||
if (node.comparison == Node::Comparison::Equal) return a == b;
|
||||
if (node.comparison == Node::Comparison::NotEqual) return !(a == b);
|
||||
if (a.IsInteger() && b.IsInteger()) return Ordered(node.comparison, a.AsInteger(), b.AsInteger());
|
||||
if (a.IsNumber() && b.IsNumber()) return Ordered(node.comparison, a.AsNumber(), b.AsNumber());
|
||||
if (a.IsString() && b.IsString()) return Ordered(node.comparison, a.AsString(), b.AsString());
|
||||
return std::unexpected(std::format("cannot order {} against {} in '{}': {} needs two numbers or two strings", a.Dump(), b.Dump(), node.source,
|
||||
ComparisonText(node.comparison)));
|
||||
}
|
||||
|
||||
Outcome Evaluate(const Node& node, const Condition::Resolver& resolve) {
|
||||
switch (node.op) {
|
||||
case Node::Op::Or: {
|
||||
const Outcome left = Evaluate(*node.left, resolve);
|
||||
if (!left || *left) return left;
|
||||
return Evaluate(*node.right, resolve);
|
||||
}
|
||||
case Node::Op::And: {
|
||||
const Outcome left = Evaluate(*node.left, resolve);
|
||||
if (!left || !*left) return left;
|
||||
return Evaluate(*node.right, resolve);
|
||||
}
|
||||
case Node::Op::Not: {
|
||||
const Outcome inner = Evaluate(*node.left, resolve);
|
||||
if (!inner) return inner;
|
||||
return !*inner;
|
||||
}
|
||||
case Node::Op::Truthy: {
|
||||
const auto value = Load(node.a, resolve);
|
||||
if (!value) return std::unexpected(value.error());
|
||||
return Truthy(*value);
|
||||
}
|
||||
case Node::Op::Compare: {
|
||||
const auto a = Load(node.a, resolve);
|
||||
if (!a) return std::unexpected(a.error());
|
||||
const auto b = Load(node.b, resolve);
|
||||
if (!b) return std::unexpected(b.error());
|
||||
return Compare(node, *a, *b);
|
||||
}
|
||||
}
|
||||
return std::unexpected("unknown node");
|
||||
}
|
||||
}
|
||||
|
||||
ConditionError::ConditionError(std::size_t offset, std::string_view what)
|
||||
: std::runtime_error(std::format("{} at offset {}", what, offset)), offset(offset) {}
|
||||
|
||||
Condition Condition::Parse(std::string_view text) {
|
||||
Condition condition;
|
||||
condition.text = std::string(text);
|
||||
Parser parser(condition.text, condition.paths);
|
||||
condition.root = parser.Document();
|
||||
return condition;
|
||||
}
|
||||
|
||||
std::expected<bool, std::string> Condition::Evaluate(const Resolver& resolve) const {
|
||||
return ::Evaluate(*root, resolve);
|
||||
}
|
||||
301
implementations/Crafter.Debug-Registry.cpp
Normal file
301
implementations/Crafter.Debug-Registry.cpp
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module Crafter.Debug:Registry_impl;
|
||||
import std;
|
||||
import Crafter.Debug.Core;
|
||||
import :Registry;
|
||||
|
||||
using namespace Crafter::Debug;
|
||||
using namespace Crafter::Debug::Detail;
|
||||
|
||||
namespace {
|
||||
std::atomic<std::uint64_t> frame{ 0 };
|
||||
|
||||
// Points kept per sample path, enough for `trace` over a few seconds of
|
||||
// frames without an app logging every frame growing without bound.
|
||||
constexpr std::size_t sampleHistory = 512;
|
||||
|
||||
bool ValidName(std::string_view name) {
|
||||
return !name.empty() && std::ranges::none_of(name, [](char c) { return c == ' ' || c == '\t' || c == '\n' || c == '\r'; });
|
||||
}
|
||||
|
||||
// The registered name that `path` starts with, longest first, ending at a
|
||||
// step boundary: "bodies" for "bodies[3].pos", not "bod".
|
||||
template<class Map>
|
||||
auto LongestPrefix(const Map& map, std::string_view path) -> decltype(map.end()) {
|
||||
auto best = map.end();
|
||||
std::size_t bestLength = 0;
|
||||
for (auto it = map.begin(); it != map.end(); ++it) {
|
||||
const std::string_view name = it->first;
|
||||
if (!path.starts_with(name)) continue;
|
||||
if (path.size() > name.size() && path[name.size()] != '.' && path[name.size()] != '[') continue;
|
||||
if (name.size() >= bestLength) {
|
||||
best = it;
|
||||
bestLength = name.size();
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
std::vector<std::string_view> MemberNames(const Value& object) {
|
||||
std::vector<std::string_view> names;
|
||||
for (const auto& [key, member] : object.Members()) names.push_back(key);
|
||||
return names;
|
||||
}
|
||||
|
||||
std::string JoinNames(std::span<const std::string_view> names) {
|
||||
std::string joined;
|
||||
for (const std::string_view name : names) {
|
||||
if (!joined.empty()) joined += ", ";
|
||||
joined += name;
|
||||
}
|
||||
return joined;
|
||||
}
|
||||
|
||||
// Follows `rest` (".name" and "[index]" steps) from `root`. `where` names
|
||||
// the root in errors. Returns nullptr and sets `error` when a step fails.
|
||||
template<class V>
|
||||
V* Walk(V& root, std::string_view rest, std::string where, std::string& error) {
|
||||
V* current = &root;
|
||||
while (!rest.empty()) {
|
||||
if (rest.front() == '.') {
|
||||
rest.remove_prefix(1);
|
||||
const std::size_t end = std::min(rest.find('.'), rest.find('['));
|
||||
const std::string_view name = rest.substr(0, end);
|
||||
rest.remove_prefix(name.size());
|
||||
if (!current->IsObject()) {
|
||||
error = std::format("'{}' is {}, so it has no member '{}'", where, KindName(current->Kind()), name);
|
||||
return nullptr;
|
||||
}
|
||||
V* next = current->Find(name);
|
||||
if (next == nullptr) {
|
||||
const std::vector<std::string_view> names = MemberNames(*current);
|
||||
const auto suggestion = Suggest(name, names);
|
||||
error = suggestion ? std::format("'{}' has no member '{}'; did you mean '{}'?", where, name, *suggestion)
|
||||
: std::format("'{}' has no member '{}' (members: {})", where, name, JoinNames(names));
|
||||
return nullptr;
|
||||
}
|
||||
where += '.';
|
||||
where += name;
|
||||
current = next;
|
||||
} else if (rest.front() == '[') {
|
||||
const std::size_t close = rest.find(']');
|
||||
std::size_t index = 0;
|
||||
const std::string_view digits = rest.substr(1, close == std::string_view::npos ? std::string_view::npos : close - 1);
|
||||
const auto [end, failure] = std::from_chars(digits.data(), digits.data() + digits.size(), index);
|
||||
if (close == std::string_view::npos || failure != std::errc() || end != digits.data() + digits.size()) {
|
||||
error = std::format("'{}' is followed by a malformed index: '{}'", where, rest);
|
||||
return nullptr;
|
||||
}
|
||||
rest.remove_prefix(close + 1);
|
||||
if (!current->IsArray()) {
|
||||
error = std::format("'{}' is {}, not an array", where, KindName(current->Kind()));
|
||||
return nullptr;
|
||||
}
|
||||
if (index >= current->Size()) {
|
||||
error = std::format("'{}' has {} items, so [{}] is out of range", where, current->Size(), index);
|
||||
return nullptr;
|
||||
}
|
||||
where += std::format("[{}]", index);
|
||||
current = ¤t->Items()[index];
|
||||
} else {
|
||||
error = std::format("cannot read '{}' after '{}'", rest, where);
|
||||
return nullptr;
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
}
|
||||
|
||||
std::uint64_t Crafter::Debug::Detail::CurrentFrame() {
|
||||
return frame.load(std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void Crafter::Debug::Detail::SetFrame(std::uint64_t value) {
|
||||
frame.store(value, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
bool Crafter::Debug::Detail::GlobMatch(std::string_view glob, std::string_view text) {
|
||||
std::size_t g = 0;
|
||||
std::size_t t = 0;
|
||||
std::size_t star = std::string_view::npos;
|
||||
std::size_t mark = 0;
|
||||
while (t < text.size()) {
|
||||
if (g < glob.size() && (glob[g] == '?' || glob[g] == text[t])) {
|
||||
++g;
|
||||
++t;
|
||||
} else if (g < glob.size() && glob[g] == '*') {
|
||||
star = g++;
|
||||
mark = t;
|
||||
} else if (star != std::string_view::npos) {
|
||||
g = star + 1;
|
||||
t = ++mark;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
while (g < glob.size() && glob[g] == '*') ++g;
|
||||
return g == glob.size();
|
||||
}
|
||||
|
||||
std::string Crafter::Debug::Detail::Joined(std::string_view prefix, std::string_view name) {
|
||||
if (prefix.empty()) return std::string(name);
|
||||
if (name.empty()) return std::string(prefix);
|
||||
return std::format("{}.{}", prefix, name);
|
||||
}
|
||||
|
||||
Registry& Registry::Instance() {
|
||||
static Registry registry;
|
||||
return registry;
|
||||
}
|
||||
|
||||
std::uint64_t Registry::NewOwner() {
|
||||
return nextOwner.fetch_add(1, std::memory_order_relaxed);
|
||||
}
|
||||
|
||||
void Registry::AddCommand(CommandEntry entry) {
|
||||
if (!ValidName(entry.name)) throw std::invalid_argument(std::format("Crafter.Debug: '{}' is not a command name: it must be non-empty, without whitespace", entry.name));
|
||||
std::string name = entry.name;
|
||||
std::lock_guard lock(mutex);
|
||||
commands.insert_or_assign(std::move(name), std::make_shared<const CommandEntry>(std::move(entry)));
|
||||
}
|
||||
|
||||
void Registry::AddWatch(WatchEntry entry) {
|
||||
if (!ValidName(entry.path)) throw std::invalid_argument(std::format("Crafter.Debug: '{}' is not a watch path: it must be non-empty, without whitespace", entry.path));
|
||||
std::string path = entry.path;
|
||||
std::lock_guard lock(mutex);
|
||||
watches.insert_or_assign(std::move(path), std::make_shared<const WatchEntry>(std::move(entry)));
|
||||
}
|
||||
|
||||
void Registry::AddSample(std::string_view path, Value value, std::uint64_t sampleFrame, std::uint64_t owner) {
|
||||
std::lock_guard lock(mutex);
|
||||
auto found = samples.find(path);
|
||||
if (found == samples.end()) found = samples.emplace(std::string(path), Series{}).first;
|
||||
Series& series = found->second;
|
||||
series.owner = owner;
|
||||
series.points.push_back({ sampleFrame, std::move(value) });
|
||||
if (series.points.size() > sampleHistory) series.points.pop_front();
|
||||
}
|
||||
|
||||
void Registry::RemoveOwner(std::uint64_t owner) {
|
||||
std::lock_guard lock(mutex);
|
||||
std::erase_if(commands, [&](const auto& item) { return item.second->owner == owner; });
|
||||
std::erase_if(watches, [&](const auto& item) { return item.second->owner == owner; });
|
||||
std::erase_if(samples, [&](const auto& item) { return item.second.owner == owner; });
|
||||
}
|
||||
|
||||
std::shared_ptr<const CommandEntry> Registry::FindCommand(std::string_view name) const {
|
||||
std::lock_guard lock(mutex);
|
||||
const auto found = commands.find(name);
|
||||
return found == commands.end() ? nullptr : found->second;
|
||||
}
|
||||
|
||||
std::vector<std::shared_ptr<const CommandEntry>> Registry::Commands(std::string_view glob) const {
|
||||
std::lock_guard lock(mutex);
|
||||
std::vector<std::shared_ptr<const CommandEntry>> matched;
|
||||
for (const auto& [name, entry] : commands) {
|
||||
if (GlobMatch(glob, name)) matched.push_back(entry);
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
std::vector<std::shared_ptr<const WatchEntry>> Registry::Watches(std::string_view glob) const {
|
||||
std::lock_guard lock(mutex);
|
||||
std::vector<std::shared_ptr<const WatchEntry>> matched;
|
||||
for (const auto& [path, entry] : watches) {
|
||||
if (GlobMatch(glob, path)) matched.push_back(entry);
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
std::vector<std::string> Registry::SamplePaths(std::string_view glob) const {
|
||||
std::lock_guard lock(mutex);
|
||||
std::vector<std::string> matched;
|
||||
for (const auto& [path, series] : samples) {
|
||||
if (GlobMatch(glob, path)) matched.push_back(path);
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
std::vector<SamplePoint> Registry::SampleHistory(std::string_view path) const {
|
||||
std::lock_guard lock(mutex);
|
||||
const auto found = samples.find(path);
|
||||
if (found == samples.end()) return {};
|
||||
return std::vector<SamplePoint>(found->second.points.begin(), found->second.points.end());
|
||||
}
|
||||
|
||||
Resolved Registry::Resolve(std::string_view path) const {
|
||||
std::shared_ptr<const WatchEntry> watch;
|
||||
std::optional<SamplePoint> latest;
|
||||
std::string base;
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
const auto foundWatch = LongestPrefix(watches, path);
|
||||
const auto foundSample = LongestPrefix(samples, path);
|
||||
const std::size_t watchLength = foundWatch == watches.end() ? 0 : foundWatch->first.size();
|
||||
const std::size_t sampleLength = foundSample == samples.end() ? 0 : foundSample->first.size();
|
||||
if (foundWatch != watches.end() && watchLength >= sampleLength) {
|
||||
watch = foundWatch->second;
|
||||
base = foundWatch->first;
|
||||
} else if (foundSample != samples.end() && !foundSample->second.points.empty()) {
|
||||
latest = foundSample->second.points.back();
|
||||
base = foundSample->first;
|
||||
}
|
||||
}
|
||||
if (!watch && !latest) {
|
||||
const auto suggestion = SuggestPath(path);
|
||||
return { Value(), suggestion ? std::format("no watch or sample '{}'; did you mean '{}'?", path, *suggestion)
|
||||
: std::format("no watch or sample '{}'; list them with: crafter-debug get '*'", path) };
|
||||
}
|
||||
Value root = watch ? watch->get() : std::move(latest->value);
|
||||
std::string error;
|
||||
const Value* reached = Walk(std::as_const(root), path.substr(base.size()), base, error);
|
||||
if (reached == nullptr) return { Value(), error };
|
||||
return { *reached, {} };
|
||||
}
|
||||
|
||||
std::optional<std::string> Registry::Set(std::string_view path, const Value& value) const {
|
||||
std::shared_ptr<const WatchEntry> watch;
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
const auto found = LongestPrefix(watches, path);
|
||||
if (found != watches.end()) watch = found->second;
|
||||
}
|
||||
if (!watch) {
|
||||
const auto suggestion = SuggestPath(path);
|
||||
return suggestion ? std::format("no watch '{}'; did you mean '{}'?", path, *suggestion) : std::format("no watch '{}'", path);
|
||||
}
|
||||
if (!watch->set) return std::format("'{}' is read-only", watch->path);
|
||||
const std::string_view rest = path.substr(watch->path.size());
|
||||
if (rest.empty()) return watch->set(value);
|
||||
Value whole = watch->get();
|
||||
std::string error;
|
||||
Value* part = Walk(whole, rest, watch->path, error);
|
||||
if (part == nullptr) return error;
|
||||
*part = value;
|
||||
return watch->set(whole);
|
||||
}
|
||||
|
||||
std::optional<std::string> Registry::SuggestCommand(std::string_view name) const {
|
||||
std::lock_guard lock(mutex);
|
||||
std::vector<std::string_view> names;
|
||||
for (const auto& [command, entry] : commands) names.push_back(command);
|
||||
if (const auto suggestion = Suggest(name, names)) return std::string(*suggestion);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<std::string> Registry::SuggestPath(std::string_view path) const {
|
||||
std::lock_guard lock(mutex);
|
||||
std::vector<std::string_view> names;
|
||||
for (const auto& [watch, entry] : watches) names.push_back(watch);
|
||||
for (const auto& [sample, series] : samples) names.push_back(sample);
|
||||
if (const auto suggestion = Suggest(path, names)) return std::string(*suggestion);
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
Group::Group(std::string prefix) : prefix(std::move(prefix)), owner(Registry::Instance().NewOwner()) {}
|
||||
|
||||
Group::~Group() {
|
||||
Registry::Instance().RemoveOwner(owner);
|
||||
}
|
||||
131
implementations/Crafter.Debug-Timeline.cpp
Normal file
131
implementations/Crafter.Debug-Timeline.cpp
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
module Crafter.Debug:Timeline_impl;
|
||||
import std;
|
||||
import Crafter.Debug.Core;
|
||||
import :Registry;
|
||||
import :Timeline;
|
||||
|
||||
using namespace Crafter::Debug;
|
||||
using namespace Crafter::Debug::Detail;
|
||||
|
||||
namespace {
|
||||
constexpr std::size_t capacity = 8192;
|
||||
|
||||
bool Stored(std::uint64_t failures) {
|
||||
return failures <= 10 || std::has_single_bit(failures);
|
||||
}
|
||||
}
|
||||
|
||||
std::string_view Crafter::Debug::EntryKindName(EntryKind kind) {
|
||||
switch (kind) {
|
||||
case EntryKind::Log: return "log";
|
||||
case EntryKind::Warn: return "warn";
|
||||
case EntryKind::Error: return "error";
|
||||
case EntryKind::Check: return "check";
|
||||
case EntryKind::Event: return "event";
|
||||
case EntryKind::Agent: return "agent";
|
||||
case EntryKind::Pause: return "pause";
|
||||
case EntryKind::Stdio: return "stdio";
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
std::uint8_t Crafter::Debug::Detail::Level(EntryKind kind) {
|
||||
switch (kind) {
|
||||
case EntryKind::Warn: return 1;
|
||||
case EntryKind::Error:
|
||||
case EntryKind::Check: return 2;
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
TimelineEntry Crafter::Debug::Detail::MakeEntry(EntryKind kind, std::string_view category, std::string message, const std::source_location& location, Value payload) {
|
||||
TimelineEntry entry;
|
||||
entry.kind = kind;
|
||||
entry.category = std::string(category);
|
||||
entry.message = std::move(message);
|
||||
entry.file = location.file_name();
|
||||
entry.line = location.line();
|
||||
entry.payload = std::move(payload);
|
||||
return entry;
|
||||
}
|
||||
|
||||
Timeline& Timeline::Instance() {
|
||||
static Timeline timeline;
|
||||
return timeline;
|
||||
}
|
||||
|
||||
std::uint64_t Timeline::Append(TimelineEntry entry) {
|
||||
entry.frame = CurrentFrame();
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
entry.seconds = std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
|
||||
entry.sequence = nextSequence++;
|
||||
entries.push_back(entry);
|
||||
if (entries.size() > capacity) entries.pop_front();
|
||||
}
|
||||
Notify(entry);
|
||||
return entry.sequence;
|
||||
}
|
||||
|
||||
void Timeline::RecordCheck(std::string_view category, std::string message, const std::source_location& location) {
|
||||
TimelineEntry entry = MakeEntry(EntryKind::Check, category, std::move(message), location);
|
||||
entry.frame = CurrentFrame();
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
entry.seconds = std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
|
||||
SiteCount& site = checks[{ entry.file, entry.line }];
|
||||
site.category = entry.category;
|
||||
entry.count = ++site.failures;
|
||||
if (Stored(entry.count)) {
|
||||
entry.sequence = nextSequence++;
|
||||
entries.push_back(entry);
|
||||
if (entries.size() > capacity) entries.pop_front();
|
||||
}
|
||||
}
|
||||
Notify(entry);
|
||||
}
|
||||
|
||||
std::vector<TimelineEntry> Timeline::Query(const TimelineQuery& query) const {
|
||||
std::vector<TimelineEntry> matched;
|
||||
std::lock_guard lock(mutex);
|
||||
for (const TimelineEntry& entry : entries) {
|
||||
if (entry.sequence <= query.afterSequence) continue;
|
||||
if (query.sinceFrame && entry.frame < *query.sinceFrame) continue;
|
||||
if (Level(entry.kind) < query.minLevel) continue;
|
||||
if (!query.kinds.empty() && std::ranges::find(query.kinds, entry.kind) == query.kinds.end()) continue;
|
||||
if (!query.category.empty() && !GlobMatch(query.category, entry.category)) continue;
|
||||
if (!query.grep.empty() && entry.message.find(query.grep) == std::string::npos && entry.category.find(query.grep) == std::string::npos) continue;
|
||||
matched.push_back(entry);
|
||||
}
|
||||
if (query.last != 0 && matched.size() > query.last) matched.erase(matched.begin(), matched.end() - static_cast<std::ptrdiff_t>(query.last));
|
||||
return matched;
|
||||
}
|
||||
|
||||
std::uint64_t Timeline::LastSequence() const {
|
||||
std::lock_guard lock(mutex);
|
||||
return nextSequence - 1;
|
||||
}
|
||||
|
||||
std::vector<CheckSite> Timeline::CheckSites() const {
|
||||
std::lock_guard lock(mutex);
|
||||
std::vector<CheckSite> sites;
|
||||
for (const auto& [site, count] : checks) sites.push_back({ site.first, site.second, count.category, count.failures });
|
||||
return sites;
|
||||
}
|
||||
|
||||
void Timeline::SetListener(std::function<void(const TimelineEntry&)> callback) {
|
||||
std::lock_guard lock(mutex);
|
||||
listener = callback ? std::make_shared<const std::function<void(const TimelineEntry&)>>(std::move(callback)) : nullptr;
|
||||
}
|
||||
|
||||
void Timeline::Notify(const TimelineEntry& entry) const {
|
||||
std::shared_ptr<const std::function<void(const TimelineEntry&)>> current;
|
||||
{
|
||||
std::lock_guard lock(mutex);
|
||||
current = listener;
|
||||
}
|
||||
if (current) (*current)(entry);
|
||||
}
|
||||
51
interfaces/Crafter.Debug-Condition.cppm
Normal file
51
interfaces/Crafter.Debug-Condition.cppm
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// The conditions `crafter-debug wait` and `pause --when` take: comparisons of
|
||||
// watch paths with literals and with each other, combined with && || ! and
|
||||
// parentheses.
|
||||
//
|
||||
// fort1.core.hp <= 0
|
||||
// scene == "Game" && !debug.paused
|
||||
// bodies[3].pos.y < -10 || (lives == 0 && score > highScore)
|
||||
//
|
||||
// A lone operand tests truthiness: true, a nonzero number, a non-empty
|
||||
// string, array or object. Numbers compare by value, strings in byte order;
|
||||
// == and != work on any two values, and ordering a string against a number
|
||||
// is an error rather than false, so a typo in a condition is reported
|
||||
// instead of waiting forever. Parsed once when it arrives, evaluated on the
|
||||
// Pump thread every frame.
|
||||
export module Crafter.Debug:Condition;
|
||||
import std;
|
||||
import Crafter.Debug.Core;
|
||||
|
||||
export namespace Crafter::Debug {
|
||||
class ConditionError : public std::runtime_error {
|
||||
public:
|
||||
std::size_t offset;
|
||||
ConditionError(std::size_t offset, std::string_view what);
|
||||
};
|
||||
|
||||
class Condition {
|
||||
public:
|
||||
// Throws ConditionError, with the offset of the problem.
|
||||
static Condition Parse(std::string_view text);
|
||||
|
||||
// Looks a path up: a value, or why there is none.
|
||||
using Resolver = std::function<std::expected<Value, std::string>(std::string_view path)>;
|
||||
// True or false, or why it cannot be decided (an unknown path, or
|
||||
// operands that cannot be ordered).
|
||||
std::expected<bool, std::string> Evaluate(const Resolver& resolve) const;
|
||||
|
||||
const std::string& Text() const { return text; }
|
||||
// The paths the condition reads, in order of appearance.
|
||||
std::span<const std::string> Paths() const { return paths; }
|
||||
|
||||
struct Node;
|
||||
|
||||
private:
|
||||
std::string text;
|
||||
std::shared_ptr<const Node> root;
|
||||
std::vector<std::string> paths;
|
||||
};
|
||||
}
|
||||
428
interfaces/Crafter.Debug-Registry.cppm
Normal file
428
interfaces/Crafter.Debug-Registry.cppm
Normal file
|
|
@ -0,0 +1,428 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// What an app offers the agent: commands it can call, watches it can read and
|
||||
// set, and samples it can trace.
|
||||
//
|
||||
// struct AimArgs { Debug::Required<std::int32_t> slot; float yaw = 0; float pitch = 0; };
|
||||
// Debug::Command("Player_SetAim", "Aim the slot's turret, in degrees.",
|
||||
// [](const AimArgs& a) -> Debug::Result<> { ... });
|
||||
// Debug::Watch("physics.gravity", Physics::gravity, { .min = -50, .max = 0, .help = "m/s^2" });
|
||||
// Debug::Sample("xpbd.residual", residual);
|
||||
//
|
||||
// A command handler takes nothing, or one argument type that has a Codec (an
|
||||
// aggregate, usually, or Value for raw JSON), and returns void, a value with
|
||||
// a Codec, Result<T> or Async<T>. Arguments are decoded before the handler
|
||||
// runs, so a mistyped call is answered with every mistake in it and never
|
||||
// reaches the handler. Free functions register for the program's lifetime;
|
||||
// a Group registers under a prefix and unregisters with its owner.
|
||||
//
|
||||
// Thread safety: registering, unregistering and listing are safe from any
|
||||
// thread. Handlers, getters and setters are only ever called by the session,
|
||||
// on the thread that calls Pump, so they read app state without locking.
|
||||
// Sample may be called from any thread.
|
||||
export module Crafter.Debug:Registry;
|
||||
import std;
|
||||
import Crafter.Debug.Core;
|
||||
|
||||
export namespace Crafter::Debug {
|
||||
struct WatchOptions {
|
||||
// Bounds a set must respect, for arithmetic watches.
|
||||
std::optional<double> min;
|
||||
std::optional<double> max;
|
||||
// Shown by `crafter-debug help` and `get`: units, meaning.
|
||||
std::string help;
|
||||
// A watch of a variable is writable unless this is set.
|
||||
bool readOnly = false;
|
||||
};
|
||||
}
|
||||
|
||||
// The type-erased registry. Not for app code: the templates below fill it.
|
||||
export namespace Crafter::Debug::Detail {
|
||||
// What calling a handler produced: a result now, or a poller that the
|
||||
// session calls once per Pump until it yields (the handler returned
|
||||
// an Async).
|
||||
struct CommandOutcome {
|
||||
std::optional<Result<Value>> done;
|
||||
std::function<std::optional<Result<Value>>()> pending;
|
||||
};
|
||||
|
||||
struct CommandEntry {
|
||||
std::string name;
|
||||
std::string description;
|
||||
// Describe() of the argument type, or null for a command that takes none.
|
||||
Value args;
|
||||
// Describe() of the result type, or null for one that returns nothing.
|
||||
Value returns;
|
||||
std::function<CommandOutcome(const Value& params)> invoke;
|
||||
std::uint64_t owner = 0;
|
||||
};
|
||||
|
||||
struct WatchEntry {
|
||||
std::string path;
|
||||
Value type;
|
||||
std::string help;
|
||||
std::function<Value()> get;
|
||||
// Empty for a read-only watch. Returns an error message, or nothing.
|
||||
std::function<std::optional<std::string>(const Value& value)> set;
|
||||
std::uint64_t owner = 0;
|
||||
};
|
||||
|
||||
struct SamplePoint {
|
||||
std::uint64_t frame;
|
||||
Value value;
|
||||
};
|
||||
|
||||
// A path resolved to a value: from a watch's getter or a sample's
|
||||
// latest point, then into it along whatever the path names past the
|
||||
// watch ("bodies[3].pos" against the watch "bodies").
|
||||
struct Resolved {
|
||||
Value value;
|
||||
std::string error;
|
||||
bool Ok() const { return error.empty(); }
|
||||
};
|
||||
|
||||
class Registry {
|
||||
public:
|
||||
static Registry& Instance();
|
||||
|
||||
std::uint64_t NewOwner();
|
||||
void AddCommand(CommandEntry entry);
|
||||
void AddWatch(WatchEntry entry);
|
||||
void AddSample(std::string_view path, Value value, std::uint64_t frame, std::uint64_t owner);
|
||||
void RemoveOwner(std::uint64_t owner);
|
||||
|
||||
std::shared_ptr<const CommandEntry> FindCommand(std::string_view name) const;
|
||||
// Sorted by name; `glob` filters ("*" any run, "?" one character).
|
||||
std::vector<std::shared_ptr<const CommandEntry>> Commands(std::string_view glob = "*") const;
|
||||
std::vector<std::shared_ptr<const WatchEntry>> Watches(std::string_view glob = "*") const;
|
||||
std::vector<std::string> SamplePaths(std::string_view glob = "*") const;
|
||||
std::vector<SamplePoint> SampleHistory(std::string_view path) const;
|
||||
|
||||
// Call on the thread that calls Pump: it runs getters.
|
||||
Resolved Resolve(std::string_view path) const;
|
||||
// Sets the watch the path names, or a part of its value: "fort1.core.pos.x"
|
||||
// reads the watch "fort1.core.pos", replaces x and writes it back.
|
||||
// Returns an error message, or nothing. Call on the Pump thread.
|
||||
std::optional<std::string> Set(std::string_view path, const Value& value) const;
|
||||
|
||||
// The closest known command, watch or sample name to `name`, for
|
||||
// "did you mean" in errors.
|
||||
std::optional<std::string> SuggestCommand(std::string_view name) const;
|
||||
std::optional<std::string> SuggestPath(std::string_view path) const;
|
||||
|
||||
private:
|
||||
mutable std::mutex mutex;
|
||||
std::map<std::string, std::shared_ptr<const CommandEntry>, std::less<>> commands;
|
||||
std::map<std::string, std::shared_ptr<const WatchEntry>, std::less<>> watches;
|
||||
struct Series {
|
||||
std::deque<SamplePoint> points;
|
||||
std::uint64_t owner = 0;
|
||||
};
|
||||
std::map<std::string, Series, std::less<>> samples;
|
||||
std::atomic<std::uint64_t> nextOwner{ 1 };
|
||||
};
|
||||
|
||||
// "*" matches any run of characters, "?" any one.
|
||||
bool GlobMatch(std::string_view glob, std::string_view text);
|
||||
|
||||
// The frame Sample and the timeline stamp what they record with; the
|
||||
// session sets it each Pump.
|
||||
std::uint64_t CurrentFrame();
|
||||
void SetFrame(std::uint64_t frame);
|
||||
|
||||
template<class>
|
||||
struct FunctionTraits;
|
||||
|
||||
template<class R, class... A>
|
||||
struct FunctionTraits<std::function<R(A...)>> {
|
||||
using Return = R;
|
||||
static constexpr std::size_t arity = sizeof...(A);
|
||||
template<std::size_t I>
|
||||
using Arg = std::tuple_element_t<I, std::tuple<A...>>;
|
||||
};
|
||||
|
||||
// The value type a handler's return produces: void, T, Result<T>, Async<T>.
|
||||
template<class R>
|
||||
struct Produced {
|
||||
using Type = R;
|
||||
};
|
||||
template<class T>
|
||||
struct Produced<Result<T>> {
|
||||
using Type = T;
|
||||
};
|
||||
template<class T>
|
||||
struct Produced<Async<T>> {
|
||||
using Type = T;
|
||||
};
|
||||
|
||||
template<class R>
|
||||
inline constexpr bool isAsync = false;
|
||||
template<class T>
|
||||
inline constexpr bool isAsync<Async<T>> = true;
|
||||
|
||||
template<class R>
|
||||
inline constexpr bool isResult = false;
|
||||
template<class T>
|
||||
inline constexpr bool isResult<Result<T>> = true;
|
||||
|
||||
inline CommandOutcome Fail(std::string code, std::string message) {
|
||||
return CommandOutcome{ .done = Result<Value>(Error{ std::move(code), std::move(message) }), .pending = {} };
|
||||
}
|
||||
|
||||
template<class T>
|
||||
Result<Value> ToValue(Result<T>&& result) {
|
||||
if (!result.Ok()) return result.GetError();
|
||||
if constexpr (std::is_void_v<T>) {
|
||||
return Value();
|
||||
} else {
|
||||
return Debug::Encode(result.Get());
|
||||
}
|
||||
}
|
||||
|
||||
// Calls the handler through `call`, turning whatever it returns, or
|
||||
// throws, into an outcome. A handler's exception must not unwind into
|
||||
// the app's frame loop.
|
||||
template<class Call>
|
||||
CommandOutcome Run(Call&& call) {
|
||||
using R = std::invoke_result_t<Call&>;
|
||||
try {
|
||||
if constexpr (std::is_void_v<R>) {
|
||||
call();
|
||||
return CommandOutcome{ .done = Result<Value>(Value()), .pending = {} };
|
||||
} else if constexpr (isAsync<R>) {
|
||||
using T = typename Produced<R>::Type;
|
||||
return CommandOutcome{
|
||||
.done = std::nullopt,
|
||||
.pending = [async = call()]() mutable -> std::optional<Result<Value>> {
|
||||
std::optional<Result<T>> polled = async.Poll();
|
||||
if (!polled) return std::nullopt;
|
||||
return ToValue(std::move(*polled));
|
||||
},
|
||||
};
|
||||
} else if constexpr (isResult<R>) {
|
||||
return CommandOutcome{ .done = ToValue(call()), .pending = {} };
|
||||
} else {
|
||||
return CommandOutcome{ .done = Result<Value>(Debug::Encode(call())), .pending = {} };
|
||||
}
|
||||
} catch (const std::exception& error) {
|
||||
return Fail("Exception", std::format("the handler threw: {}", error.what()));
|
||||
} catch (...) {
|
||||
return Fail("Exception", "the handler threw something that is not a std::exception");
|
||||
}
|
||||
}
|
||||
|
||||
template<class F>
|
||||
CommandEntry MakeCommand(std::string name, std::string_view description, F handler, std::uint64_t owner) {
|
||||
using Traits = FunctionTraits<decltype(std::function(handler))>;
|
||||
static_assert(Traits::arity <= 1, "a command handler takes no parameters, or one argument struct");
|
||||
using R = typename Traits::Return;
|
||||
using P = typename Produced<R>::Type;
|
||||
CommandEntry entry;
|
||||
entry.name = std::move(name);
|
||||
entry.description = std::string(description);
|
||||
entry.owner = owner;
|
||||
if constexpr (!std::is_void_v<P>) entry.returns = Debug::Describe<std::remove_cvref_t<P>>();
|
||||
if constexpr (Traits::arity == 0) {
|
||||
entry.invoke = [handler = std::move(handler)](const Value& params) mutable -> CommandOutcome {
|
||||
if (!params.IsNull() && !(params.IsObject() && params.Size() == 0)) {
|
||||
return Fail("InvalidParams", "this command takes no arguments");
|
||||
}
|
||||
return Run([&] { return handler(); });
|
||||
};
|
||||
} else {
|
||||
using Args = std::remove_cvref_t<typename Traits::template Arg<0>>;
|
||||
entry.args = Debug::Describe<Args>();
|
||||
entry.invoke = [handler = std::move(handler)](const Value& params) mutable -> CommandOutcome {
|
||||
Args args{};
|
||||
DecodeContext context;
|
||||
if constexpr (Aggregate<Args>) {
|
||||
Debug::Decode(params.IsNull() ? Value::MakeObject() : params, args, context);
|
||||
} else {
|
||||
Debug::Decode(params, args, context);
|
||||
}
|
||||
if (!context.Ok()) return Fail("InvalidParams", context.Report());
|
||||
return Run([&] { return handler(args); });
|
||||
};
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
template<class T>
|
||||
std::optional<std::string> CheckBounds(const T& value, const WatchOptions& options) {
|
||||
if constexpr (std::is_arithmetic_v<T> && !std::is_same_v<T, bool>) {
|
||||
const double number = static_cast<double>(value);
|
||||
if ((options.min && number < *options.min) || (options.max && number > *options.max)) {
|
||||
return std::format("{} is outside {}..{}", number, options.min ? std::format("{}", *options.min) : std::string(),
|
||||
options.max ? std::format("{}", *options.max) : std::string());
|
||||
}
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
template<class T>
|
||||
Value WatchType(const WatchOptions& options) {
|
||||
Value type = Debug::Describe<T>();
|
||||
if (options.min) type.Set("minimum", Value(*options.min));
|
||||
if (options.max) type.Set("maximum", Value(*options.max));
|
||||
return type;
|
||||
}
|
||||
|
||||
template<class T>
|
||||
WatchEntry MakeVariableWatch(std::string path, T& variable, const WatchOptions& options, std::uint64_t owner) {
|
||||
WatchEntry entry;
|
||||
entry.path = std::move(path);
|
||||
entry.type = WatchType<T>(options);
|
||||
entry.help = options.help;
|
||||
entry.owner = owner;
|
||||
entry.get = [&variable] { return Debug::Encode(variable); };
|
||||
if constexpr (!std::is_const_v<T>) {
|
||||
if (!options.readOnly) {
|
||||
entry.set = [&variable, options](const Value& value) -> std::optional<std::string> {
|
||||
T decoded = variable;
|
||||
DecodeContext context;
|
||||
Debug::Decode(value, decoded, context);
|
||||
if (!context.Ok()) return context.Report();
|
||||
if (auto bounds = CheckBounds(decoded, options)) return bounds;
|
||||
variable = std::move(decoded);
|
||||
return std::nullopt;
|
||||
};
|
||||
}
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
template<class G, class S>
|
||||
WatchEntry MakeComputedWatch(std::string path, G getter, std::optional<S> setter, const WatchOptions& options, std::uint64_t owner) {
|
||||
using T = std::remove_cvref_t<std::invoke_result_t<G&>>;
|
||||
WatchEntry entry;
|
||||
entry.path = std::move(path);
|
||||
entry.type = WatchType<T>(options);
|
||||
entry.help = options.help;
|
||||
entry.owner = owner;
|
||||
entry.get = [getter]() mutable { return Debug::Encode(getter()); };
|
||||
if (setter && !options.readOnly) {
|
||||
entry.set = [getter, setter = std::move(*setter), options](const Value& value) mutable -> std::optional<std::string> {
|
||||
T decoded = getter();
|
||||
DecodeContext context;
|
||||
Debug::Decode(value, decoded, context);
|
||||
if (!context.Ok()) return context.Report();
|
||||
if (auto bounds = CheckBounds(decoded, options)) return bounds;
|
||||
using SR = std::invoke_result_t<S&, const T&>;
|
||||
if constexpr (std::is_void_v<SR>) {
|
||||
setter(decoded);
|
||||
} else {
|
||||
static_assert(std::is_same_v<SR, Result<>>, "a watch setter returns void or Debug::Result<>");
|
||||
Result<> result = setter(decoded);
|
||||
if (!result.Ok()) return std::format("{}: {}", result.GetError().code, result.GetError().message);
|
||||
}
|
||||
return std::nullopt;
|
||||
};
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
std::string Joined(std::string_view prefix, std::string_view name);
|
||||
|
||||
struct NoSetter {
|
||||
template<class T>
|
||||
void operator()(const T&) const {}
|
||||
};
|
||||
}
|
||||
|
||||
export namespace Crafter::Debug {
|
||||
// Registers a command for the program's lifetime. See the comment at the top.
|
||||
template<class F>
|
||||
void Command(std::string_view name, std::string_view description, F&& handler) {
|
||||
Detail::Registry::Instance().AddCommand(Detail::MakeCommand(std::string(name), description, std::forward<F>(handler), 0));
|
||||
}
|
||||
|
||||
// Watches a variable: `get` reads it and `set` writes it, on the Pump
|
||||
// thread. The variable must outlive the program, or use a Group.
|
||||
template<class T>
|
||||
requires (!std::invocable<T&>)
|
||||
void Watch(std::string_view path, T& variable, WatchOptions options = {}) {
|
||||
Detail::Registry::Instance().AddWatch(Detail::MakeVariableWatch(std::string(path), variable, options, 0));
|
||||
}
|
||||
|
||||
// Watches what a getter returns; read-only.
|
||||
template<class G>
|
||||
requires std::invocable<G&>
|
||||
void Watch(std::string_view path, G getter, WatchOptions options = {}) {
|
||||
Detail::Registry::Instance().AddWatch(Detail::MakeComputedWatch(std::string(path), std::move(getter), std::optional<Detail::NoSetter>(), options, 0));
|
||||
}
|
||||
|
||||
// Watches a getter and writes through a setter taking the getter's type
|
||||
// and returning void or Result<>.
|
||||
template<class G, class S>
|
||||
requires std::invocable<G&>
|
||||
void Watch(std::string_view path, G getter, S setter, WatchOptions options = {}) {
|
||||
Detail::Registry::Instance().AddWatch(Detail::MakeComputedWatch(std::string(path), std::move(getter), std::optional<S>(std::move(setter)), options, 0));
|
||||
}
|
||||
|
||||
// Records a value under `path`, stamped with the current frame, from any
|
||||
// thread: for locals in hot code that no watch can reach. `get` reads the
|
||||
// latest, `trace` the history.
|
||||
template<class T>
|
||||
void Sample(std::string_view path, const T& value) {
|
||||
Detail::Registry::Instance().AddSample(path, Debug::Encode(value), Detail::CurrentFrame(), 0);
|
||||
}
|
||||
|
||||
// Registrations under a prefix that end with the Group: hold one as a
|
||||
// member of what the commands and watches reach into.
|
||||
//
|
||||
// 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);
|
||||
// }
|
||||
// };
|
||||
//
|
||||
// Neither copyable nor movable, and so neither is a class that holds one:
|
||||
// its watches refer to the members they were given, and a moved object
|
||||
// would leave them pointing at the old one. Keep such objects in place
|
||||
// (std::unique_ptr, std::deque, std::list) rather than in a std::vector.
|
||||
class Group {
|
||||
public:
|
||||
explicit Group(std::string prefix);
|
||||
~Group();
|
||||
Group(const Group&) = delete;
|
||||
Group& operator=(const Group&) = delete;
|
||||
|
||||
const std::string& Prefix() const { return prefix; }
|
||||
|
||||
template<class F>
|
||||
void Command(std::string_view name, std::string_view description, F&& handler) {
|
||||
Detail::Registry::Instance().AddCommand(Detail::MakeCommand(Detail::Joined(prefix, name), description, std::forward<F>(handler), owner));
|
||||
}
|
||||
|
||||
template<class T>
|
||||
requires (!std::invocable<T&>)
|
||||
void Watch(std::string_view path, T& variable, WatchOptions options = {}) {
|
||||
Detail::Registry::Instance().AddWatch(Detail::MakeVariableWatch(Detail::Joined(prefix, path), variable, options, owner));
|
||||
}
|
||||
|
||||
template<class G>
|
||||
requires std::invocable<G&>
|
||||
void Watch(std::string_view path, G getter, WatchOptions options = {}) {
|
||||
Detail::Registry::Instance().AddWatch(Detail::MakeComputedWatch(Detail::Joined(prefix, path), std::move(getter), std::optional<Detail::NoSetter>(), options, owner));
|
||||
}
|
||||
|
||||
template<class G, class S>
|
||||
requires std::invocable<G&>
|
||||
void Watch(std::string_view path, G getter, S setter, WatchOptions options = {}) {
|
||||
Detail::Registry::Instance().AddWatch(Detail::MakeComputedWatch(Detail::Joined(prefix, path), std::move(getter), std::optional<S>(std::move(setter)), options, owner));
|
||||
}
|
||||
|
||||
template<class T>
|
||||
void Sample(std::string_view path, const T& value) {
|
||||
Detail::Registry::Instance().AddSample(Detail::Joined(prefix, path), Debug::Encode(value), Detail::CurrentFrame(), owner);
|
||||
}
|
||||
|
||||
private:
|
||||
std::string prefix;
|
||||
std::uint64_t owner = 0;
|
||||
};
|
||||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,15 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// Runtime debugging for AI agents: what an app imports. The session, the
|
||||
// registry of commands and watches, the timeline and pause control follow in
|
||||
// later phases; for now this re-exports the shared core.
|
||||
// Runtime debugging for AI agents: what an app imports. Commands, watches
|
||||
// and samples (:Registry), the timeline with Log, Check and Emit
|
||||
// (:Timeline), and the conditions waits and pauses use (:Condition), over
|
||||
// the core shared with the client. The session follows in a later phase.
|
||||
export module Crafter.Debug;
|
||||
export import Crafter.Debug.Core;
|
||||
export import :Registry;
|
||||
export import :Timeline;
|
||||
export import :Condition;
|
||||
import std;
|
||||
|
||||
export namespace Crafter::Debug {
|
||||
|
|
|
|||
27
project.cpp
27
project.cpp
|
|
@ -27,12 +27,21 @@ namespace {
|
|||
return fs::path(std::source_location::current().file_name()).parent_path();
|
||||
}
|
||||
|
||||
constexpr std::array<std::string_view, 4> tests = {
|
||||
// Tests of the core, linked against it alone.
|
||||
constexpr std::array<std::string_view, 4> coreTests = {
|
||||
"ShouldRoundTripJson",
|
||||
"ShouldRoundTripAggregates",
|
||||
"ShouldReportDecodeErrorsWithPaths",
|
||||
"ShouldDescribeDefaultsAndRequired",
|
||||
};
|
||||
|
||||
// Tests of the app library.
|
||||
constexpr std::array<std::string_view, 4> libraryTests = {
|
||||
"ShouldInvokeCommands",
|
||||
"ShouldResolveWatchPaths",
|
||||
"ShouldRecordTimeline",
|
||||
"ShouldEvaluateConditions",
|
||||
};
|
||||
}
|
||||
|
||||
extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> args) {
|
||||
|
|
@ -85,15 +94,25 @@ extern "C" Configuration CrafterBuildProject(std::span<const std::string_view> a
|
|||
|
||||
cfg.dependencies = { &core, &client };
|
||||
{
|
||||
std::array<fs::path, 1> interfaces = { "interfaces/Crafter.Debug" };
|
||||
std::array<fs::path, 0> implementations = {};
|
||||
std::array<fs::path, 4> interfaces = {
|
||||
"interfaces/Crafter.Debug-Registry",
|
||||
"interfaces/Crafter.Debug-Timeline",
|
||||
"interfaces/Crafter.Debug-Condition",
|
||||
"interfaces/Crafter.Debug",
|
||||
};
|
||||
std::array<fs::path, 3> implementations = {
|
||||
"implementations/Crafter.Debug-Registry",
|
||||
"implementations/Crafter.Debug-Timeline",
|
||||
"implementations/Crafter.Debug-Condition",
|
||||
};
|
||||
cfg.GetInterfacesAndImplementations(interfaces, implementations);
|
||||
}
|
||||
|
||||
// Tests only when this is the project being built: a consumer's load of
|
||||
// this file has no use for them.
|
||||
if (IsTopLevelProject()) {
|
||||
for (const std::string_view test : tests) cfg.AddTest(test).Dependencies({ &core });
|
||||
for (const std::string_view test : coreTests) cfg.AddTest(test).Dependencies({ &core });
|
||||
for (const std::string_view test : libraryTests) cfg.AddTest(test).Dependencies({ &cfg });
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
|
|
|||
80
tests/ShouldEvaluateConditions/main.cpp
Normal file
80
tests/ShouldEvaluateConditions/main.cpp
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// The conditions `wait` and `pause --when` take: comparisons, && || ! and
|
||||
// parentheses with the usual precedence, truthiness of a lone operand,
|
||||
// strings in either quote, paths with indices, and short-circuiting. A
|
||||
// condition that cannot be decided says why instead of being false, since a
|
||||
// silently false condition makes `wait` hang: an unknown path, ordering a
|
||||
// string against a number. Malformed text is refused with its offset, and a
|
||||
// lone = is caught as the typo it is.
|
||||
import std;
|
||||
import Crafter.Debug;
|
||||
using namespace Crafter::Debug;
|
||||
|
||||
namespace {
|
||||
std::int32_t failures = 0;
|
||||
|
||||
void Check(bool ok, std::string_view what) {
|
||||
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||
if (!ok) ++failures;
|
||||
}
|
||||
|
||||
std::expected<Value, std::string> Resolve(std::string_view path) {
|
||||
static const Value state = Value::Parse(R"({"hp": 0, "scene": "Game", "paused": false, "bodies": [{"y": 2}, {"y": -12}], "name": "", "lives": 3})");
|
||||
if (path.starts_with("bodies[")) {
|
||||
const std::size_t index = static_cast<std::size_t>(path[7] - '0');
|
||||
return state.Find("bodies")->Items()[index].Find("y") ? *state.Find("bodies")->Items()[index].Find("y") : Value();
|
||||
}
|
||||
if (const Value* value = state.Find(path)) return *value;
|
||||
return std::unexpected(std::format("no watch '{}'", path));
|
||||
}
|
||||
|
||||
std::expected<bool, std::string> Evaluate(std::string_view text) {
|
||||
return Condition::Parse(text).Evaluate(Resolve);
|
||||
}
|
||||
|
||||
bool Is(std::string_view text, bool expected) {
|
||||
const auto result = Evaluate(text);
|
||||
return result.has_value() && *result == expected;
|
||||
}
|
||||
|
||||
std::size_t RefusedAt(std::string_view text) {
|
||||
try {
|
||||
(void)Condition::Parse(text);
|
||||
} catch (const ConditionError& error) {
|
||||
return error.offset;
|
||||
}
|
||||
return std::string_view::npos;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
Check(Is("hp <= 0", true), "a comparison with a literal");
|
||||
Check(Is("scene == \"Game\" && !paused", true), "&& and ! combine");
|
||||
Check(Is("scene == 'Menu' || lives >= 3", true), "|| and single-quoted strings");
|
||||
Check(Is("hp > 0 || lives > 5", false), "false || false is false");
|
||||
Check(Is("!(hp == 0 && lives == 3)", false), "parentheses group");
|
||||
Check(Is("hp == 1 || hp == 0 && lives == 4", false), "&& binds tighter than ||");
|
||||
Check(Is("bodies[1].y < -10", true), "a path with an index, against a negative number");
|
||||
Check(Is("lives", true) && Is("name", false) && Is("paused", false), "a lone operand tests truthiness");
|
||||
Check(Is("lives == 3.0", true), "an integer equals the same number");
|
||||
Check(Is("hp > 0 && missing == 1", false), "&& short-circuits past an unknown path");
|
||||
|
||||
const auto unknown = Evaluate("missing > 1");
|
||||
Check(!unknown && unknown.error() == "no watch 'missing'", "an unknown path is an error, not false");
|
||||
const auto ordering = Evaluate("scene < 3");
|
||||
Check(!ordering && ordering.error().find("cannot order") != std::string::npos && ordering.error().find("scene < 3") != std::string::npos,
|
||||
"ordering a string against a number is an error that quotes the comparison");
|
||||
Check(Is("scene == 3", false), "== between different kinds is false, not an error");
|
||||
|
||||
Check(RefusedAt("hp = 0") == 3, "a lone = is refused where it stands");
|
||||
Check(RefusedAt("(hp == 0") == 8, "an unclosed parenthesis is refused at the end");
|
||||
Check(RefusedAt("hp == 0 lives") == 8, "two conditions without && or || are refused");
|
||||
Check(RefusedAt("hp == ") == 6, "a missing operand is refused");
|
||||
|
||||
const Condition parsed = Condition::Parse("fort1.core.hp <= 0 && bodies[3].pos.y > 1");
|
||||
Check(parsed.Paths().size() == 2 && parsed.Paths()[0] == "fort1.core.hp" && parsed.Paths()[1] == "bodies[3].pos.y", "a condition lists the paths it reads");
|
||||
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
115
tests/ShouldInvokeCommands/main.cpp
Normal file
115
tests/ShouldInvokeCommands/main.cpp
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// Commands registered with Debug::Command are found by name and invoked with
|
||||
// JSON parameters decoded into their argument struct: defaults fill what the
|
||||
// call leaves out, a bad call is refused with InvalidParams before the
|
||||
// handler runs, a throwing handler becomes an Exception error instead of
|
||||
// unwinding into the app, Result errors pass through with their code, an
|
||||
// Async result is polled until it yields, and a Group's commands disappear
|
||||
// with the Group. Guards what replaces 3DForts' hand-written dispatch table.
|
||||
import std;
|
||||
import Crafter.Debug;
|
||||
using namespace Crafter::Debug;
|
||||
|
||||
namespace {
|
||||
std::int32_t failures = 0;
|
||||
|
||||
void Check(bool ok, std::string_view what) {
|
||||
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||
if (!ok) ++failures;
|
||||
}
|
||||
|
||||
struct AimArgs {
|
||||
Required<std::int32_t> slot;
|
||||
float yaw = 10;
|
||||
float pitch = 0;
|
||||
};
|
||||
|
||||
struct Aimed {
|
||||
std::int32_t slot = 0;
|
||||
float yaw = 0;
|
||||
};
|
||||
|
||||
Detail::CommandOutcome Call(std::string_view name, std::string_view params) {
|
||||
const auto command = Detail::Registry::Instance().FindCommand(name);
|
||||
if (!command) throw std::runtime_error(std::format("no command {}", name));
|
||||
return command->invoke(Value::Parse(params));
|
||||
}
|
||||
|
||||
bool Failed(const Detail::CommandOutcome& outcome, std::string_view code) {
|
||||
return outcome.done && !outcome.done->Ok() && outcome.done->GetError().code == code;
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
std::int32_t fired = 0;
|
||||
Command("Player_Fire", "Fire the weapon.", [&] { ++fired; });
|
||||
Command("Player_SetAim", "Aim the turret, in degrees.", [](const AimArgs& args) -> Result<Aimed> {
|
||||
if (*args.slot < 0) return Error{ "NoSlot", "slots start at 0" };
|
||||
return Aimed{ *args.slot, args.yaw };
|
||||
});
|
||||
Command("Game_Score", "The score.", [] { return 42; });
|
||||
Command("Game_Crash", "Throws.", []() -> std::int32_t { throw std::runtime_error("boom"); });
|
||||
std::int32_t polls = 0;
|
||||
Command("Sim_StateHash", "A hash that takes frames.", [&]() -> Async<std::string> {
|
||||
return Async<std::string>([&]() -> std::optional<std::string> {
|
||||
if (++polls < 3) return std::nullopt;
|
||||
return std::string("cafe");
|
||||
});
|
||||
});
|
||||
|
||||
const auto fire = Call("Player_Fire", "{}");
|
||||
Check(fire.done && fire.done->Ok() && fired == 1, "a void command runs and succeeds");
|
||||
Check(Failed(Call("Player_Fire", R"({"x":1})"), "InvalidParams"), "a command without arguments refuses some");
|
||||
|
||||
const auto aim = Call("Player_SetAim", R"({"slot": 2})");
|
||||
Check(aim.done && aim.done->Ok() && aim.done->Get().Find("yaw")->AsNumber() == 10 && aim.done->Get().Find("slot")->AsInteger() == 2,
|
||||
"a field left out takes its default, and the result is encoded");
|
||||
const auto mistyped = Call("Player_SetAim", R"({"yw": 1})");
|
||||
Check(Failed(mistyped, "InvalidParams") && mistyped.done->GetError().message.find("did you mean 'yaw'") != std::string::npos
|
||||
&& mistyped.done->GetError().message.find("slot: missing") != std::string::npos,
|
||||
"a mistyped call is refused with every mistake in it");
|
||||
const auto refused = Call("Player_SetAim", R"({"slot": -1})");
|
||||
Check(Failed(refused, "NoSlot") && refused.done->GetError().message == "slots start at 0", "a Result error keeps its code and message");
|
||||
|
||||
const auto score = Call("Game_Score", "null");
|
||||
Check(score.done && score.done->Ok() && score.done->Get().AsInteger() == 42, "a plain return value is encoded");
|
||||
|
||||
const auto crash = Call("Game_Crash", "{}");
|
||||
Check(Failed(crash, "Exception") && crash.done->GetError().message.find("boom") != std::string::npos, "an exception becomes an Exception error");
|
||||
|
||||
auto hash = Call("Sim_StateHash", "{}");
|
||||
Check(!hash.done && hash.pending, "an Async result is pending");
|
||||
std::optional<Result<Value>> polled;
|
||||
std::int32_t pumps = 0;
|
||||
while (!polled && pumps < 10) {
|
||||
polled = hash.pending();
|
||||
++pumps;
|
||||
}
|
||||
Check(polled && polled->Ok() && polled->Get().AsString() == "cafe" && pumps == 3, "an Async result is polled until it yields");
|
||||
|
||||
const auto described = Detail::Registry::Instance().FindCommand("Player_SetAim");
|
||||
Check(described->args.Find("fields")->Size() == 3 && described->returns.Find("fields")->Size() == 2 && described->description == "Aim the turret, in degrees.",
|
||||
"a command describes its arguments and its result");
|
||||
Check(Detail::Registry::Instance().FindCommand("Player_Fire")->args.IsNull(), "a command without arguments describes none");
|
||||
Check(Detail::Registry::Instance().SuggestCommand("Player_SetAm") == std::optional<std::string>("Player_SetAim"), "a mistyped command name gets a suggestion");
|
||||
|
||||
{
|
||||
Group fort("fort1");
|
||||
fort.Command("Explode", "Blow it up.", [] {});
|
||||
Check(Detail::Registry::Instance().FindCommand("fort1.Explode") != nullptr, "a Group prefixes its commands");
|
||||
}
|
||||
Check(Detail::Registry::Instance().FindCommand("fort1.Explode") == nullptr, "a Group's commands go with it");
|
||||
Check(Detail::Registry::Instance().Commands("Player_*").size() == 2, "commands can be listed by glob");
|
||||
|
||||
bool threw = false;
|
||||
try {
|
||||
Command("has space", "", [] {});
|
||||
} catch (const std::invalid_argument&) {
|
||||
threw = true;
|
||||
}
|
||||
Check(threw, "a name with whitespace is refused at registration");
|
||||
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
105
tests/ShouldRecordTimeline/main.cpp
Normal file
105
tests/ShouldRecordTimeline/main.cpp
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// The timeline records Log, LogWarn, LogError, Check and Emit with frame,
|
||||
// category and source location, and answers the queries `crafter-debug log`
|
||||
// makes: by level, category glob, substring, frame and sequence, newest N.
|
||||
// A check that fails every frame is counted every time but stored only
|
||||
// sparsely, so it cannot push everything else out, while the listener the
|
||||
// session pauses from still hears every failure. Appends from many threads
|
||||
// keep one increasing sequence.
|
||||
import std;
|
||||
import Crafter.Debug;
|
||||
using namespace Crafter::Debug;
|
||||
|
||||
namespace {
|
||||
std::int32_t failures = 0;
|
||||
|
||||
void Check(bool ok, std::string_view what) {
|
||||
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||
if (!ok) ++failures;
|
||||
}
|
||||
|
||||
struct MatchEnded {
|
||||
std::int32_t winnerSlot = 0;
|
||||
};
|
||||
|
||||
Detail::Timeline& timeline() {
|
||||
return Detail::Timeline::Instance();
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
std::atomic<std::int32_t> heard = 0;
|
||||
timeline().SetListener([&](const TimelineEntry&) { heard.fetch_add(1); });
|
||||
|
||||
Detail::SetFrame(10);
|
||||
Log("net", "peer {} joined slot {}", "b1", 2);
|
||||
Detail::SetFrame(11);
|
||||
LogWarn("physics", "brace {} strain {:.1f}", 17, 4.2);
|
||||
LogError("render", "lost device");
|
||||
Emit("MatchEnded", MatchEnded{ .winnerSlot = 1 });
|
||||
|
||||
const auto all = timeline().Query({});
|
||||
Check(all.size() == 4, std::format("four entries ({})", all.size()));
|
||||
Check(all[0].message == "peer b1 joined slot 2" && all[0].category == "net" && all[0].frame == 10 && all[0].kind == EntryKind::Log,
|
||||
"a log entry has its message, category and frame");
|
||||
Check(all[0].file.ends_with("main.cpp") && all[0].line > 0, "an entry carries the caller's source location");
|
||||
Check(all[1].message == "brace 17 strain 4.2" && all[1].kind == EntryKind::Warn && all[1].frame == 11, "a warning is formatted and stamped");
|
||||
Check(all[3].kind == EntryKind::Event && all[3].category == "MatchEnded" && all[3].payload.Find("winnerSlot")->AsInteger() == 1, "an event carries its payload");
|
||||
Check(all[1].sequence > all[0].sequence, "sequences increase");
|
||||
|
||||
Detail::TimelineQuery warnings;
|
||||
warnings.minLevel = 1;
|
||||
Check(timeline().Query(warnings).size() == 2, "--level warn keeps warnings and errors");
|
||||
Detail::TimelineQuery physics;
|
||||
physics.category = "phys*";
|
||||
Check(timeline().Query(physics).size() == 1, "a category glob filters");
|
||||
Detail::TimelineQuery grep;
|
||||
grep.grep = "device";
|
||||
Check(timeline().Query(grep).size() == 1, "a substring filters");
|
||||
Detail::TimelineQuery since;
|
||||
since.sinceFrame = 11;
|
||||
Check(timeline().Query(since).size() == 3, "--since-frame filters");
|
||||
Detail::TimelineQuery after;
|
||||
after.afterSequence = all[1].sequence;
|
||||
Check(timeline().Query(after).size() == 2, "afterSequence returns only newer entries");
|
||||
Detail::TimelineQuery last;
|
||||
last.last = 2;
|
||||
const auto newest = timeline().Query(last);
|
||||
Check(newest.size() == 2 && newest.back().kind == EntryKind::Event, "last returns the newest entries, in order");
|
||||
|
||||
heard = 0;
|
||||
const std::uint64_t before = timeline().LastSequence();
|
||||
std::int32_t passed = 0;
|
||||
for (std::int32_t i = 0; i < 100; ++i) {
|
||||
if (Crafter::Debug::Check(i % 2 == 1, "physics", "body {} has mass {}", i, -1)) ++passed;
|
||||
}
|
||||
Check(passed == 50, "Check returns its condition");
|
||||
Detail::TimelineQuery checks;
|
||||
checks.afterSequence = before;
|
||||
const auto stored = timeline().Query(checks);
|
||||
Check(stored.size() == 12, std::format("50 failures at one site store 12 entries ({})", stored.size()));
|
||||
Check(stored.back().count == 32 && stored.back().kind == EntryKind::Check, "a stored check carries its site's count");
|
||||
Check(heard == 50, std::format("the listener hears every failure ({})", heard.load()));
|
||||
const auto sites = timeline().CheckSites();
|
||||
Check(sites.size() == 1 && sites[0].failures == 50 && sites[0].category == "physics", "check sites are counted");
|
||||
|
||||
const std::uint64_t start = timeline().LastSequence();
|
||||
std::vector<std::thread> threads;
|
||||
for (std::int32_t t = 0; t < 8; ++t) {
|
||||
threads.emplace_back([t] {
|
||||
for (std::int32_t i = 0; i < 200; ++i) Log("threads", "thread {} line {}", t, i);
|
||||
});
|
||||
}
|
||||
for (std::thread& thread : threads) thread.join();
|
||||
Detail::TimelineQuery fromThreads;
|
||||
fromThreads.afterSequence = start;
|
||||
const auto lines = timeline().Query(fromThreads);
|
||||
bool increasing = lines.size() == 1600;
|
||||
for (std::size_t i = 1; i < lines.size(); ++i) increasing = increasing && lines[i].sequence == lines[i - 1].sequence + 1;
|
||||
Check(increasing, "appends from eight threads keep one increasing sequence");
|
||||
|
||||
timeline().SetListener(nullptr);
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
102
tests/ShouldResolveWatchPaths/main.cpp
Normal file
102
tests/ShouldResolveWatchPaths/main.cpp
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// SPDX-License-Identifier: LGPL-3.0-only
|
||||
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
|
||||
|
||||
// Watches and samples resolve by path, including into their values
|
||||
// ("bodies[1].pos.y" against the watch "bodies"), and set the same way:
|
||||
// writing one member of a watched struct writes the whole struct back. Sets
|
||||
// respect bounds and read-only watches, computed watches set through their
|
||||
// setter (which may refuse), samples keep a frame-stamped history, unknown
|
||||
// paths get a suggestion, and a Group's watches go with it.
|
||||
import std;
|
||||
import Crafter.Debug;
|
||||
using namespace Crafter::Debug;
|
||||
|
||||
namespace {
|
||||
std::int32_t failures = 0;
|
||||
|
||||
void Check(bool ok, std::string_view what) {
|
||||
std::println("{} {}", ok ? "PASS" : "FAIL", what);
|
||||
if (!ok) ++failures;
|
||||
}
|
||||
|
||||
struct Vec3 {
|
||||
float x = 0;
|
||||
float y = 0;
|
||||
float z = 0;
|
||||
};
|
||||
|
||||
struct Body {
|
||||
std::int32_t id = 0;
|
||||
Vec3 pos;
|
||||
};
|
||||
|
||||
float gravity = -9.81F;
|
||||
const std::int32_t build = 7;
|
||||
std::vector<Body> bodies = { { 1, { 0, 1, 0 } }, { 2, { 3, 4, 5 } } };
|
||||
std::int32_t lives = 3;
|
||||
|
||||
Detail::Registry& registry() {
|
||||
return Detail::Registry::Instance();
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
Watch("physics.gravity", gravity, { .min = -50, .max = 0, .help = "m/s^2" });
|
||||
Watch("build", build);
|
||||
Watch("bodies", bodies);
|
||||
Watch("lives", [] { return lives; }, [](const std::int32_t& value) -> Result<> {
|
||||
if (value < 0) return Error{ "Negative", "lives cannot be negative" };
|
||||
lives = value;
|
||||
return {};
|
||||
});
|
||||
Watch("score", [] { return 1234; });
|
||||
|
||||
Check(registry().Resolve("physics.gravity").value.AsNumber() == static_cast<double>(-9.81F), "a variable watch reads the variable");
|
||||
Check(registry().Resolve("bodies[1].pos.y").value.AsNumber() == 4, "a path reads into a watched value");
|
||||
Check(registry().Resolve("bodies[1]").value.Find("id")->AsInteger() == 2, "an index reads an item");
|
||||
|
||||
Check(!registry().Set("physics.gravity", Value(-3.5)) && gravity == -3.5F, "a set writes the variable");
|
||||
const auto bounded = registry().Set("physics.gravity", Value(10));
|
||||
Check(bounded && bounded->find("outside") != std::string::npos && gravity == -3.5F, "a set outside the bounds is refused");
|
||||
Check(registry().Set("build", Value(8)) == std::optional<std::string>("'build' is read-only") && build == 7, "a const variable's watch is read-only");
|
||||
Check(!registry().Set("bodies[0].pos.x", Value(9)) && bodies[0].pos.x == 9 && bodies[0].pos.y == 1, "a set into a member writes back the whole value");
|
||||
const auto wrongType = registry().Set("physics.gravity", Value("down"));
|
||||
Check(wrongType && wrongType->find("expected a number") != std::string::npos, "a set of the wrong type is refused");
|
||||
|
||||
Check(!registry().Set("lives", Value(5)) && lives == 5, "a computed watch sets through its setter");
|
||||
const auto negative = registry().Set("lives", Value(-1));
|
||||
Check(negative && negative->find("lives cannot be negative") != std::string::npos && lives == 5, "a setter can refuse");
|
||||
Check(registry().Set("score", Value(1)) == std::optional<std::string>("'score' is read-only"), "a getter-only watch is read-only");
|
||||
|
||||
const auto missing = registry().Resolve("physics.gravty");
|
||||
Check(!missing.Ok() && missing.error.find("did you mean 'physics.gravity'") != std::string::npos, "an unknown path gets a suggestion");
|
||||
const auto badMember = registry().Resolve("bodies[0].pos.w");
|
||||
Check(!badMember.Ok() && badMember.error.find("has no member 'w'") != std::string::npos, "an unknown member is named");
|
||||
const auto outOfRange = registry().Resolve("bodies[5]");
|
||||
Check(!outOfRange.Ok() && outOfRange.error.find("2 items") != std::string::npos, "an index out of range gives the size");
|
||||
|
||||
for (std::uint64_t frame = 1; frame <= 3; ++frame) {
|
||||
Detail::SetFrame(frame);
|
||||
Sample("xpbd.residual", static_cast<double>(frame) / 10);
|
||||
}
|
||||
Check(registry().Resolve("xpbd.residual").value.AsNumber() == 0.3, "a sample resolves to its latest value");
|
||||
const auto history = registry().SampleHistory("xpbd.residual");
|
||||
Check(history.size() == 3 && history.front().frame == 1 && history.back().frame == 3, "a sample keeps a frame-stamped history");
|
||||
|
||||
Check(registry().Watches("physics.*").size() == 1 && registry().Watches("*").size() == 5, "watches can be listed by glob");
|
||||
Check(registry().Watches("physics.gravity").front()->help == "m/s^2" && registry().Watches("physics.gravity").front()->type.Find("maximum") != nullptr,
|
||||
"a watch carries its help and bounds");
|
||||
|
||||
{
|
||||
struct Fort {
|
||||
std::int32_t hp = 100;
|
||||
Group debug{ "fort1" };
|
||||
Fort() { debug.Watch("core.hp", hp); }
|
||||
};
|
||||
Fort fort;
|
||||
Check(registry().Resolve("fort1.core.hp").value.AsInteger() == 100, "a Group prefixes its watches");
|
||||
}
|
||||
Check(!registry().Resolve("fort1.core.hp").Ok(), "a Group's watches go with it");
|
||||
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
Loading…
Reference in a new issue