// 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 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 or Async. 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 min; std::optional 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> done; std::function>()> 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 invoke; std::uint64_t owner = 0; }; struct WatchEntry { std::string path; Value type; std::string help; std::function get; // Empty for a read-only watch. Returns an error message, or nothing. std::function(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 FindCommand(std::string_view name) const; // Sorted by name; `glob` filters ("*" any run, "?" one character). std::vector> Commands(std::string_view glob = "*") const; std::vector> Watches(std::string_view glob = "*") const; std::vector SamplePaths(std::string_view glob = "*") const; std::vector 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 why not (UnknownPath, ReadOnly or InvalidParams), or // nothing. Call on the Pump thread. std::optional 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 SuggestCommand(std::string_view name) const; std::optional SuggestPath(std::string_view path) const; private: mutable std::mutex mutex; std::map, std::less<>> commands; std::map, std::less<>> watches; struct Series { std::deque points; std::uint64_t owner = 0; }; std::map> samples; std::atomic 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 struct FunctionTraits; template struct FunctionTraits> { using Return = R; static constexpr std::size_t arity = sizeof...(A); template using Arg = std::tuple_element_t>; }; // The value type a handler's return produces: void, T, Result, Async. template struct Produced { using Type = R; }; template struct Produced> { using Type = T; }; template struct Produced> { using Type = T; }; template inline constexpr bool isAsync = false; template inline constexpr bool isAsync> = true; template inline constexpr bool isResult = false; template inline constexpr bool isResult> = true; inline CommandOutcome Fail(std::string code, std::string message) { return CommandOutcome{ .done = Result(Error{ std::move(code), std::move(message) }), .pending = {} }; } template Result ToValue(Result&& result) { if (!result.Ok()) return result.GetError(); if constexpr (std::is_void_v) { 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 CommandOutcome Run(Call&& call) { using R = std::invoke_result_t; try { if constexpr (std::is_void_v) { call(); return CommandOutcome{ .done = Result(Value()), .pending = {} }; } else if constexpr (isAsync) { using T = typename Produced::Type; return CommandOutcome{ .done = std::nullopt, .pending = [async = call()]() mutable -> std::optional> { std::optional> polled = async.Poll(); if (!polled) return std::nullopt; return ToValue(std::move(*polled)); }, }; } else if constexpr (isResult) { return CommandOutcome{ .done = ToValue(call()), .pending = {} }; } else { return CommandOutcome{ .done = Result(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 CommandEntry MakeCommand(std::string name, std::string_view description, F handler, std::uint64_t owner) { using Traits = FunctionTraits; static_assert(Traits::arity <= 1, "a command handler takes no parameters, or one argument struct"); using R = typename Traits::Return; using P = typename Produced::Type; CommandEntry entry; entry.name = std::move(name); entry.description = std::string(description); entry.owner = owner; if constexpr (!std::is_void_v

) entry.returns = Debug::Describe>(); 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>; entry.args = Debug::Describe(); entry.invoke = [handler = std::move(handler)](const Value& params) mutable -> CommandOutcome { Args args{}; DecodeContext context; if constexpr (Aggregate) { 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 std::optional CheckBounds(const T& value, const WatchOptions& options) { if constexpr (std::is_arithmetic_v && !std::is_same_v) { const double number = static_cast(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 Value WatchType(const WatchOptions& options) { Value type = Debug::Describe(); if (options.min) type.Set("minimum", Value(*options.min)); if (options.max) type.Set("maximum", Value(*options.max)); return type; } template WatchEntry MakeVariableWatch(std::string path, T& variable, const WatchOptions& options, std::uint64_t owner) { WatchEntry entry; entry.path = std::move(path); entry.type = WatchType(options); entry.help = options.help; entry.owner = owner; entry.get = [&variable] { return Debug::Encode(variable); }; if constexpr (!std::is_const_v) { if (!options.readOnly) { entry.set = [&variable, options](const Value& value) -> std::optional { 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 WatchEntry MakeComputedWatch(std::string path, G getter, std::optional setter, const WatchOptions& options, std::uint64_t owner) { using T = std::remove_cvref_t>; WatchEntry entry; entry.path = std::move(path); entry.type = WatchType(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 { 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; if constexpr (std::is_void_v) { setter(decoded); } else { static_assert(std::is_same_v>, "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 void operator()(const T&) const {} }; } export namespace Crafter::Debug { // Registers a command for the program's lifetime. See the comment at the top. template void Command(std::string_view name, std::string_view description, F&& handler) { Detail::Registry::Instance().AddCommand(Detail::MakeCommand(std::string(name), description, std::forward(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 requires (!std::invocable) 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 requires std::invocable void Watch(std::string_view path, G getter, WatchOptions options = {}) { Detail::Registry::Instance().AddWatch(Detail::MakeComputedWatch(std::string(path), std::move(getter), std::optional(), options, 0)); } // Watches a getter and writes through a setter taking the getter's type // and returning void or Result<>. template requires std::invocable 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(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 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 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(handler), owner)); } template requires (!std::invocable) void Watch(std::string_view path, T& variable, WatchOptions options = {}) { Detail::Registry::Instance().AddWatch(Detail::MakeVariableWatch(Detail::Joined(prefix, path), variable, options, owner)); } template requires std::invocable 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(), options, owner)); } template requires std::invocable 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(std::move(setter)), options, owner)); } template 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; }; }