428 lines
18 KiB
Text
428 lines
18 KiB
Text
|
|
// 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;
|
||
|
|
};
|
||
|
|
}
|