feat(core): Value, reflection, codecs and results, shared by app and client

Crafter.Debug.Core is what the app side and the crafter-debug client
both need, without the session:

- Value: a JSON value with its own parser and printer, so no consumer
  interface sees a JSON library. Integers stay integers and whole
  doubles stay doubles; NaN and the infinities, which JSON cannot
  spell, are written as strings rather than hidden, because a debugger
  must not lose a NaN; invalid UTF-8 is replaced so output always
  parses. Objects keep insertion order, the order an agent reads.
- Reflect: aggregate members and names through structured-binding
  packs and source_location, taken from Crafter.DB, plus scoped-enum
  names by probing a range of values. Self-tested with static_asserts
  so a compiler that changes function_name()'s shape fails the build.
- Codec<T>: encode, decode and describe for bool, integers, floating
  point, strings, enums, optional, vector, array, string-keyed maps,
  Value, Required<T> and every aggregate. Decode reports every
  problem in a call with its path and, for a typo, the likely intended
  name, and never throws: a bad call cannot take an app down, which a
  wrongly typed parameter could in 3DForts' hand-written from_json.
- Result, Error and Async: handler results, including ones that take
  frames to arrive, so callers need no ready:false polling.

project.cpp now builds Crafter.Debug.Core as its own library, used by
both the app library and the client.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-11-01 01:31:58 +01:00
commit 89a12bcd61
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
13 changed files with 1951 additions and 11 deletions

View file

@ -0,0 +1,512 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// How C++ values become Values and back, and how their types are described
// to an agent. Codec<T> is the customization point: one specialization per
// type, with three static functions,
//
// static Value Encode(const T& value);
// static void Decode(const Value& value, T& out, DecodeContext& context);
// static Value Describe();
//
// provided here for bool, integers, floating point, std::string, enums,
// std::optional, std::vector, std::array, string-keyed maps, Value itself,
// Required<T> and every aggregate, whose members are found by :Reflect.
// Specialize it for a type that is none of those.
//
// Decode writes into an existing object, so a field the input leaves out
// keeps the value it had: for a command's arguments, the struct's default
// member initializer. It reports through the DecodeContext instead of
// throwing, and keeps going after a problem, so one reply names every
// mistake in a call, each with its path ("target.points[2].x") and, for an
// unknown field, the field the caller most likely meant. Describe gives a
// JSON-Schema-like Value that `crafter-debug help` renders.
export module Crafter.Debug.Core:Codec;
import std;
import :Value;
import :Reflect;
export namespace Crafter::Debug {
// An argument field the caller must pass. Decoding fails when it is
// missing, and help lists it as required instead of showing a default.
template<class T>
struct Required {
T value{};
constexpr T& operator*() noexcept { return value; }
constexpr const T& operator*() const noexcept { return value; }
constexpr T* operator->() noexcept { return &value; }
constexpr const T* operator->() const noexcept { return &value; }
};
struct DecodeIssue {
// Where, as "target.points[2].x"; empty for the value itself.
std::string path;
std::string message;
};
class DecodeContext {
public:
// Records a problem at the current path.
void Fail(std::string message);
bool Ok() const { return issues.empty(); }
std::span<const DecodeIssue> Issues() const { return issues; }
// Every problem, "path: message", separated by "; ".
std::string Report() const;
// Extends the path while it lives: a member, or an array item.
class Scope {
public:
~Scope();
Scope(const Scope&) = delete;
Scope& operator=(const Scope&) = delete;
private:
friend class DecodeContext;
explicit Scope(DecodeContext& context) : context(context) {}
DecodeContext& context;
};
[[nodiscard]] Scope Field(std::string_view name);
[[nodiscard]] Scope Index(std::size_t index);
private:
std::vector<std::string> segments;
std::vector<DecodeIssue> issues;
std::string Path() const;
};
// The candidate a mistyped `word` most likely meant, if one is close
// enough to suggest: Levenshtein distance 1 for words of up to four
// characters, 2 for longer ones.
std::optional<std::string_view> Suggest(std::string_view word, std::span<const std::string_view> candidates);
template<class T>
struct Codec;
template<class T>
concept Codable = requires(const T& in, const Value& value, T& out, DecodeContext& context) {
{ Codec<T>::Encode(in) } -> std::same_as<Value>;
Codec<T>::Decode(value, out, context);
{ Codec<T>::Describe() } -> std::same_as<Value>;
};
template<class T>
Value Encode(const T& value) {
static_assert(Codable<T>, "Crafter::Debug has no Codec for this type: make it an aggregate, or specialize Crafter::Debug::Codec<T>");
return Codec<T>::Encode(value);
}
template<class T>
void Decode(const Value& value, T& out, DecodeContext& context) {
static_assert(Codable<T>, "Crafter::Debug has no Codec for this type: make it an aggregate, or specialize Crafter::Debug::Codec<T>");
Codec<T>::Decode(value, out, context);
}
template<class T>
Value Describe() {
static_assert(Codable<T>, "Crafter::Debug has no Codec for this type: make it an aggregate, or specialize Crafter::Debug::Codec<T>");
return Codec<T>::Describe();
}
}
// Helpers the codecs below share. Module linkage, not an anonymous
// namespace: the exported templates that call them are instantiated in the
// consumer's translation units.
namespace Crafter::Debug::Detail {
// "expected an integer, got a string"
std::string Expected(std::string_view wanted, const Value& got);
// {"type": type}
Value Type(std::string_view type);
// The message for a field an aggregate does not have.
std::string UnknownField(std::string_view key, std::span<const std::string_view> fields);
// The message for a name a scoped enum does not have.
std::string UnknownName(std::string_view name, std::span<const std::string_view> names);
// The integer types that are numbers: not bool, and none of the
// character types, which std::in_range refuses and which read as text.
template<class T>
concept Number = std::integral<T> && !std::same_as<T, bool> && !std::same_as<T, char> && !std::same_as<T, wchar_t>
&& !std::same_as<T, char8_t> && !std::same_as<T, char16_t> && !std::same_as<T, char32_t>;
template<class T>
inline constexpr bool isRequired = false;
template<class T>
inline constexpr bool isRequired<Required<T>> = true;
// Describe recursion depth, so a type that contains itself (through a
// vector, say) is described a few levels deep instead of forever.
inline thread_local std::uint32_t describeDepth = 0;
}
export namespace Crafter::Debug {
template<>
struct Codec<bool> {
static Value Encode(bool value) { return Value(value); }
static void Decode(const Value& value, bool& out, DecodeContext& context) {
if (!value.IsBool()) {
context.Fail(Detail::Expected("a boolean", value));
return;
}
out = value.AsBool();
}
static Value Describe() { return Detail::Type("boolean"); }
};
template<Detail::Number T>
struct Codec<T> {
static Value Encode(T value) { return Value(value); }
static void Decode(const Value& value, T& out, DecodeContext& context) {
if (value.IsInteger()) {
const std::int64_t integer = value.AsInteger();
if (std::in_range<T>(integer)) {
out = static_cast<T>(integer);
return;
}
context.Fail(std::format("{} is out of range ({}..{})", integer, std::numeric_limits<T>::min(), std::numeric_limits<T>::max()));
return;
}
if (value.IsNumber()) {
// A double: an unsigned 64-bit value above the int64 range
// arrives as one, and so does 3.0 from a lenient client.
const double number = value.AsNumber();
if (number == std::trunc(number) && number >= static_cast<double>(std::numeric_limits<T>::min()) && number <= static_cast<double>(std::numeric_limits<T>::max())) {
out = static_cast<T>(number);
return;
}
context.Fail(std::format("expected an integer in {}..{}, got {}", std::numeric_limits<T>::min(), std::numeric_limits<T>::max(), value.Dump()));
return;
}
context.Fail(Detail::Expected("an integer", value));
}
static Value Describe() {
Value description = Detail::Type("integer");
if constexpr (sizeof(T) < sizeof(std::int64_t)) {
description.Set("minimum", Value(std::numeric_limits<T>::min()));
description.Set("maximum", Value(std::numeric_limits<T>::max()));
}
return description;
}
};
template<std::floating_point T>
struct Codec<T> {
static Value Encode(T value) { return Value(static_cast<double>(value)); }
static void Decode(const Value& value, T& out, DecodeContext& context) {
if (value.IsNumber()) {
out = static_cast<T>(value.AsNumber());
return;
}
// The spellings Value::Dump uses for what JSON cannot write.
if (value.IsString()) {
const std::string& text = value.AsString();
if (text == "NaN") {
out = std::numeric_limits<T>::quiet_NaN();
return;
}
if (text == "Infinity" || text == "-Infinity") {
out = text.starts_with('-') ? -std::numeric_limits<T>::infinity() : std::numeric_limits<T>::infinity();
return;
}
}
context.Fail(Detail::Expected("a number", value));
}
static Value Describe() { return Detail::Type("number"); }
};
template<>
struct Codec<std::string> {
static Value Encode(const std::string& value) { return Value(value); }
static void Decode(const Value& value, std::string& out, DecodeContext& context) {
if (!value.IsString()) {
context.Fail(Detail::Expected("a string", value));
return;
}
out = value.AsString();
}
static Value Describe() { return Detail::Type("string"); }
};
// For results and watches that hand out views. Nothing decodes into
// one: it would point into a request that is gone by the time it is read.
template<>
struct Codec<std::string_view> {
static Value Encode(std::string_view value) { return Value(value); }
static void Decode(const Value&, std::string_view&, DecodeContext& context) {
context.Fail("cannot be set: a std::string_view field is read-only, use std::string");
}
static Value Describe() { return Detail::Type("string"); }
};
// Scoped enums travel by name, and also accept the integer; unscoped ones
// by value only (see EnumRange in :Reflect).
template<class E>
requires std::is_enum_v<E>
struct Codec<E> {
static Value Encode(E value) {
if constexpr (std::is_scoped_enum_v<E>) {
if (const auto name = EnumName(value)) return Value(*name);
}
return Value(std::to_underlying(value));
}
static void Decode(const Value& value, E& out, DecodeContext& context) {
if constexpr (std::is_scoped_enum_v<E>) {
if (value.IsString()) {
if (const auto parsed = EnumFromName<E>(value.AsString())) {
out = *parsed;
return;
}
context.Fail(Detail::UnknownName(value.AsString(), Names()));
return;
}
}
if (value.IsInteger()) {
const std::int64_t integer = value.AsInteger();
if (std::in_range<std::underlying_type_t<E>>(integer)) {
out = static_cast<E>(integer);
return;
}
context.Fail(std::format("{} is out of range for this enum", integer));
return;
}
context.Fail(Detail::Expected(std::is_scoped_enum_v<E> ? "a name or an integer" : "an integer", value));
}
static Value Describe() {
if constexpr (std::is_scoped_enum_v<E>) {
Value description = Detail::Type("enum");
Value values = Value::MakeArray();
for (const std::string_view name : Names()) values.Push(Value(name));
description.Set("values", std::move(values));
return description;
} else {
return Detail::Type("integer");
}
}
private:
static std::vector<std::string_view> Names()
requires std::is_scoped_enum_v<E>
{
std::vector<std::string_view> names;
for (const Enumerator<E>& e : enumerators<E>) names.push_back(e.name);
return names;
}
};
template<class T>
struct Codec<std::optional<T>> {
static Value Encode(const std::optional<T>& value) {
return value ? Debug::Encode(*value) : Value();
}
static void Decode(const Value& value, std::optional<T>& out, DecodeContext& context) {
if (value.IsNull()) {
out.reset();
return;
}
if (!out) out.emplace();
Debug::Decode(value, *out, context);
}
static Value Describe() {
Value description = Debug::Describe<T>();
description.Set("nullable", true);
return description;
}
};
template<class T, class A>
struct Codec<std::vector<T, A>> {
static Value Encode(const std::vector<T, A>& value) {
Value array = Value::MakeArray();
for (const T& item : value) array.Push(Debug::Encode(item));
return array;
}
static void Decode(const Value& value, std::vector<T, A>& out, DecodeContext& context) {
if (!value.IsArray()) {
context.Fail(Detail::Expected("an array", value));
return;
}
std::vector<T, A> decoded;
decoded.reserve(value.Size());
const std::span<const Value> items = value.Items();
for (std::size_t i = 0; i < items.size(); ++i) {
const auto scope = context.Index(i);
// Through a local, so std::vector<bool> works too.
T item{};
Debug::Decode(items[i], item, context);
decoded.push_back(std::move(item));
}
out = std::move(decoded);
}
static Value Describe() {
Value description = Detail::Type("array");
description.Set("items", Debug::Describe<T>());
return description;
}
};
template<class T, std::size_t N>
struct Codec<std::array<T, N>> {
static Value Encode(const std::array<T, N>& value) {
Value array = Value::MakeArray();
for (const T& item : value) array.Push(Debug::Encode(item));
return array;
}
static void Decode(const Value& value, std::array<T, N>& out, DecodeContext& context) {
if (!value.IsArray()) {
context.Fail(Detail::Expected("an array", value));
return;
}
if (value.Size() != N) {
context.Fail(std::format("expected {} items, got {}", N, value.Size()));
return;
}
const std::span<const Value> items = value.Items();
for (std::size_t i = 0; i < N; ++i) {
const auto scope = context.Index(i);
Debug::Decode(items[i], out[i], context);
}
}
static Value Describe() {
Value description = Detail::Type("array");
description.Set("items", Debug::Describe<T>());
description.Set("length", Value(N));
return description;
}
};
// Maps keyed by string: std::map and std::unordered_map alike.
template<class M>
struct MapCodec {
using Mapped = typename M::mapped_type;
static Value Encode(const M& value) {
Value object = Value::MakeObject();
for (const auto& [key, item] : value) object.Set(key, Debug::Encode(item));
return object;
}
static void Decode(const Value& value, M& out, DecodeContext& context) {
if (!value.IsObject()) {
context.Fail(Detail::Expected("an object", value));
return;
}
M decoded;
for (const auto& [key, item] : value.Members()) {
const auto scope = context.Field(key);
Mapped mapped{};
Debug::Decode(item, mapped, context);
decoded.emplace(key, std::move(mapped));
}
out = std::move(decoded);
}
static Value Describe() {
Value description = Detail::Type("object");
description.Set("values", Debug::Describe<Mapped>());
return description;
}
};
template<class T, class C, class A>
struct Codec<std::map<std::string, T, C, A>> : MapCodec<std::map<std::string, T, C, A>> {};
template<class T, class H, class E, class A>
struct Codec<std::unordered_map<std::string, T, H, E, A>> : MapCodec<std::unordered_map<std::string, T, H, E, A>> {};
template<>
struct Codec<Value> {
static Value Encode(const Value& value) { return value; }
static void Decode(const Value& value, Value& out, DecodeContext&) { out = value; }
static Value Describe() { return Detail::Type("any"); }
};
template<class T>
struct Codec<Required<T>> {
static Value Encode(const Required<T>& value) { return Debug::Encode(value.value); }
static void Decode(const Value& value, Required<T>& out, DecodeContext& context) { Debug::Decode(value, out.value, context); }
static Value Describe() { return Debug::Describe<T>(); }
};
// Every aggregate: an object with one member per field, by name.
template<class T>
requires Aggregate<T>
struct Codec<T> {
static Value Encode(const T& value) {
Value object = Value::MakeObject();
[&]<std::size_t... I>(std::index_sequence<I...>) {
(object.Set(MemberName<I, T>(), Debug::Encode(Member<I>(value))), ...);
}(std::make_index_sequence<memberCount<T>>{});
return object;
}
static void Decode(const Value& value, T& out, DecodeContext& context) {
if (!value.IsObject()) {
context.Fail(Detail::Expected("an object", value));
return;
}
[&]<std::size_t... I>(std::index_sequence<I...>) {
([&] {
constexpr std::string_view name = MemberName<I, T>();
const auto scope = context.Field(name);
if (const Value* field = value.Find(name)) {
Debug::Decode(*field, Member<I>(out), context);
} else if constexpr (Detail::isRequired<MemberType<I, T>>) {
context.Fail("missing, and this field is required");
}
}(), ...);
}(std::make_index_sequence<memberCount<T>>{});
static constexpr std::array<std::string_view, memberCount<T>> names = Names();
for (const auto& [key, item] : value.Members()) {
if (std::ranges::find(names, std::string_view(key)) != names.end()) continue;
const auto scope = context.Field(key);
context.Fail(Detail::UnknownField(key, names));
}
}
static Value Describe() {
Value description = Detail::Type("object");
description.Set("name", Value(TypeName<T>()));
if (Detail::describeDepth >= 8) {
description.Set("truncated", true);
return description;
}
++Detail::describeDepth;
Value fields = Value::MakeArray();
const T defaults{};
[&]<std::size_t... I>(std::index_sequence<I...>) {
([&] {
using M = MemberType<I, T>;
Value field = Value::MakeObject();
field.Set("name", Value(MemberName<I, T>()));
field.Set("type", Debug::Describe<M>());
if constexpr (Detail::isRequired<M>) {
field.Set("required", true);
} else {
field.Set("default", Debug::Encode(Member<I>(defaults)));
}
fields.Push(std::move(field));
}(), ...);
}(std::make_index_sequence<memberCount<T>>{});
--Detail::describeDepth;
description.Set("fields", std::move(fields));
return description;
}
private:
static consteval std::array<std::string_view, memberCount<T>> Names() {
return [&]<std::size_t... I>(std::index_sequence<I...>) {
return std::array<std::string_view, memberCount<T>>{ MemberName<I, T>()... };
}(std::make_index_sequence<memberCount<T>>{});
}
};
}

View file

@ -0,0 +1,205 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Compile-time introspection without P2996 (clang 23 has no <meta>), taken
// from Crafter.DB:Reflect. For aggregates: structured-binding packs give the
// member count and per-member access, and std::source_location's
// function_name() spells a member's name when a pointer to a member of an
// extern object is a template argument. For scoped enums: function_name()
// spells a template argument that is an enumerator by its name and any other
// value as a cast, so probing a range of values finds every enumerator.
// When a clang ships <meta>, this partition is the only thing to replace.
export module Crafter.Debug.Core:Reflect;
import std;
namespace Crafter::Debug::Reflect {
template<class T>
struct CountHelper {
template<class U>
static consteval auto Count(U& u) {
auto&& [...m] = u;
return std::integral_constant<std::size_t, sizeof...(m)>{};
}
static constexpr std::size_t value = decltype(Count(std::declval<T&>()))::value;
};
// An instance whose member addresses are turned into text. Only its
// address is used, in constant expressions; no_destroy keeps the compiler
// from registering a destructor for it.
template<class T>
[[clang::no_destroy]] inline const T external{};
template<auto Probe>
consteval std::string_view RawName() {
return std::source_location::current().function_name();
}
template<class T>
consteval std::string_view RawTypeName() {
return std::source_location::current().function_name();
}
// The part of RawName<P>()'s text that spells P: what follows "Probe = "
// up to the closing bracket.
consteval std::string_view ProbeText(std::string_view raw) {
const std::size_t end = raw.rfind(']');
const std::size_t start = raw.rfind("Probe = ", end);
return raw.substr(start + 8, end - start - 8);
}
// The enumerator name for V, or "" when V is not an enumerator: clang
// spells those as a cast, "(Ns::Color)5".
template<auto V>
consteval std::string_view EnumeratorName() {
const std::string_view text = ProbeText(RawName<V>());
if (text.starts_with('(')) return {};
const std::size_t colon = text.rfind("::");
return colon == std::string_view::npos ? text : text.substr(colon + 2);
}
}
export namespace Crafter::Debug {
// True for class types whose non-static data members a structured
// binding can bind: public members, no bases carrying members, no
// user-declared constructors.
template<class T>
concept Aggregate = std::is_class_v<T> && std::is_aggregate_v<T> && requires { Reflect::CountHelper<T>::value; };
template<Aggregate T>
inline constexpr std::size_t memberCount = Reflect::CountHelper<T>::value;
// The I-th member of an aggregate lvalue.
template<std::size_t I, class T>
constexpr decltype(auto) Member(T&& t) noexcept {
auto&& [...m] = t;
return (m...[I]);
}
template<std::size_t I, class T>
using MemberType = std::remove_cvref_t<decltype(Member<I>(std::declval<T&>()))>;
// The declared name of the I-th member: "yaw". Parsed out of
// "std::string_view ...::RawName() [Probe = &external.yaw]".
template<std::size_t I, Aggregate T>
consteval std::string_view MemberName() {
constexpr std::string_view text = Reflect::ProbeText(Reflect::RawName<&Member<I>(Reflect::external<T>)>());
return text.substr(text.rfind('.') + 1);
}
// The type's name as the compiler spells it ("Game::AimArgs"), for help
// and errors; the spelling is not load-bearing.
template<class T>
consteval std::string_view TypeName() {
constexpr std::string_view raw = Reflect::RawTypeName<T>();
const std::size_t end = raw.rfind(']');
const std::size_t start = raw.rfind("T = ", end);
return raw.substr(start + 4, end - start - 4);
}
// Calls f.template operator()<I>(member) for every member, in order.
template<Aggregate T, class F>
constexpr void ForEachMember(T& t, F&& f) {
[&]<std::size_t... I>(std::index_sequence<I...>) {
(f.template operator()<I>(Member<I>(t)), ...);
}(std::make_index_sequence<memberCount<T>>{});
}
// The values probed for enumerator names, a half-open range clamped to
// the enum's underlying type. Specialize for an enum with values outside
// the default range; every probe is one template instantiation.
template<class E>
struct EnumRange {
static constexpr std::int64_t min = -128;
static constexpr std::int64_t max = 128;
};
template<class E>
struct Enumerator {
E value;
std::string_view name;
};
}
namespace Crafter::Debug::Reflect {
template<class E>
consteval std::int64_t ProbeMin() {
using U = std::underlying_type_t<E>;
return std::max<std::int64_t>(EnumRange<E>::min, static_cast<std::int64_t>(std::numeric_limits<U>::min()));
}
template<class E>
consteval std::int64_t ProbeMax() {
using U = std::underlying_type_t<E>;
constexpr std::int64_t limit = static_cast<std::uint64_t>(std::numeric_limits<U>::max()) >= static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max())
? std::numeric_limits<std::int64_t>::max()
: static_cast<std::int64_t>(std::numeric_limits<U>::max()) + 1;
return std::min<std::int64_t>(EnumRange<E>::max, limit);
}
template<class E, std::size_t... I>
consteval auto EnumTable(std::index_sequence<I...>) {
constexpr std::int64_t first = ProbeMin<E>();
constexpr std::array<std::string_view, sizeof...(I)> names = {
EnumeratorName<static_cast<E>(first + static_cast<std::int64_t>(I))>()...
};
constexpr std::size_t count = static_cast<std::size_t>(std::ranges::count_if(names, [](std::string_view name) { return !name.empty(); }));
std::array<Enumerator<E>, count> table{};
std::size_t next = 0;
for (std::size_t i = 0; i < names.size(); ++i) {
if (!names[i].empty()) table[next++] = { static_cast<E>(first + static_cast<std::int64_t>(i)), names[i] };
}
return table;
}
}
export namespace Crafter::Debug {
// Every named value of a scoped enum within EnumRange, in value order.
// Scoped only: clang rejects casting an out-of-range value to an unscoped
// enum without a fixed underlying type, even in a template argument.
template<class E>
requires std::is_scoped_enum_v<E>
inline constexpr auto enumerators = Reflect::EnumTable<E>(std::make_index_sequence<static_cast<std::size_t>(Reflect::ProbeMax<E>() - Reflect::ProbeMin<E>())>{});
template<class E>
requires std::is_scoped_enum_v<E>
constexpr std::optional<std::string_view> EnumName(E value) {
for (const Enumerator<E>& e : enumerators<E>) {
if (e.value == value) return e.name;
}
return std::nullopt;
}
template<class E>
requires std::is_scoped_enum_v<E>
constexpr std::optional<E> EnumFromName(std::string_view name) {
for (const Enumerator<E>& e : enumerators<E>) {
if (e.name == name) return e.value;
}
return std::nullopt;
}
}
// Self-tests: a compiler whose function_name() text changes shape fails
// here, at the library's build, instead of producing garbage names.
namespace Crafter::Debug::Reflect {
struct ProbeStruct {
std::int32_t alpha;
double beta;
std::string gamma;
};
enum class ProbeEnum : std::uint8_t { Zero, One, Seven = 7 };
enum class ProbeSigned : std::int16_t { Minus = -3, Big = 100 };
static_assert(Aggregate<ProbeStruct>);
static_assert(memberCount<ProbeStruct> == 3);
static_assert(MemberName<0, ProbeStruct>() == "alpha");
static_assert(MemberName<2, ProbeStruct>() == "gamma");
static_assert(std::is_same_v<MemberType<1, ProbeStruct>, double>);
static_assert(TypeName<ProbeStruct>().ends_with("ProbeStruct"));
static_assert(enumerators<ProbeEnum>.size() == 3);
static_assert(EnumName(ProbeEnum::Seven) == "Seven");
static_assert(!EnumName(static_cast<ProbeEnum>(5)).has_value());
static_assert(EnumFromName<ProbeEnum>("One") == ProbeEnum::One);
static_assert(EnumName(ProbeSigned::Minus) == "Minus");
static_assert(enumerators<ProbeSigned>.size() == 2);
}

View file

@ -0,0 +1,93 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// What a command handler returns besides a plain value: an Error with a
// stable code and a message written for the agent reading it, a Result that
// is either, and an Async for a result that takes frames to produce.
export module Crafter.Debug.Core:Result;
import std;
export namespace Crafter::Debug {
struct Error {
// Stable and machine-readable, "InvalidScene": scripts branch on it.
std::string code;
// For whoever reads the reply: what went wrong, and the next step
// ("not in a game; run Lobby_Start first").
std::string message;
};
template<class T = void>
class Result {
public:
// Anything T can be made from, so `return "text";` works for a
// Result<std::string>.
template<class U = T>
requires (std::constructible_from<T, U &&> && !std::same_as<std::remove_cvref_t<U>, Error> && !std::same_as<std::remove_cvref_t<U>, Result>)
Result(U&& value) : state(std::in_place_index<0>, std::forward<U>(value)) {}
Result(Error error) : state(std::in_place_index<1>, std::move(error)) {}
bool Ok() const { return state.index() == 0; }
explicit operator bool() const { return Ok(); }
T& Get() { return std::get<0>(state); }
const T& Get() const { return std::get<0>(state); }
const Error& GetError() const { return std::get<1>(state); }
private:
std::variant<T, Error> state;
};
template<>
class Result<void> {
public:
Result() = default;
Result(Error error) : error(std::move(error)) {}
bool Ok() const { return !error.has_value(); }
explicit operator bool() const { return Ok(); }
const Error& GetError() const { return *error; }
private:
std::optional<Error> error;
};
// A result that takes frames to arrive, such as a GPU readback. The
// session polls it once per Pump, on the app's thread, until it yields;
// the caller simply waits, with no ready:false loop of its own. The
// poller returns std::optional<T> (empty while not ready), or
// std::optional<Result<T>> to fail; for Async<void>, a bool or a
// std::optional<Result<>>.
template<class T = void>
class Async {
public:
template<class F>
requires std::invocable<F&>
Async(F poller) : poll(Wrap(std::move(poller))) {}
// Empty while not ready.
std::optional<Result<T>> Poll() { return poll(); }
private:
std::function<std::optional<Result<T>>()> poll;
template<class F>
static std::function<std::optional<Result<T>>()> Wrap(F poller) {
using R = std::invoke_result_t<F&>;
if constexpr (std::is_same_v<R, std::optional<Result<T>>>) {
return poller;
} else if constexpr (std::is_void_v<T>) {
static_assert(std::is_same_v<R, bool>, "an Async<void> poller returns bool or std::optional<Result<>>");
return [poller = std::move(poller)]() mutable -> std::optional<Result<T>> {
if (!poller()) return std::nullopt;
return Result<T>();
};
} else {
static_assert(std::is_same_v<R, std::optional<T>>, "an Async<T> poller returns std::optional<T> or std::optional<Result<T>>");
return [poller = std::move(poller)]() mutable -> std::optional<Result<T>> {
std::optional<T> value = poller();
if (!value) return std::nullopt;
return Result<T>(std::move(*value));
};
}
}
};
}

View file

@ -0,0 +1,130 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// A JSON value: what travels between an app and crafter-debug, and what
// command arguments, results, watches and events are encoded to. Its own type
// rather than a JSON library's, so no consumer interface ever sees one, and
// small enough to read in one sitting. Objects keep their members in the
// order they were set, which is the order an agent reads them in.
//
// Numbers keep the distinction JSON text does not: an Integer is a whole
// number that fits an int64, anything else is a Number (a double). Integer 1
// and Number 1.0 still compare equal. Non-finite doubles have no JSON
// spelling, and a debugger must not hide a NaN, so Dump writes them as the
// strings "NaN", "Infinity" and "-Infinity", which Codec reads back.
export module Crafter.Debug.Core:Value;
import std;
export namespace Crafter::Debug {
enum class ValueKind : std::uint8_t {
Null,
Bool,
Integer,
Number,
String,
Array,
Object,
};
// "null", "boolean", "integer", "number", "string", "array" or "object":
// how errors name a kind.
std::string_view KindName(ValueKind kind);
// Thrown by Value::Parse: what is wrong, and the byte offset in the text.
class ParseError : public std::runtime_error {
public:
std::size_t offset;
ParseError(std::size_t offset, std::string_view what);
};
class Value {
public:
Value() = default;
Value(std::nullptr_t) {}
Value(bool value) : kind(ValueKind::Bool), boolean(value) {}
// Any integer type but bool and char. An unsigned value above the
// int64 range becomes a Number, which is exact up to 2^53.
template<std::integral T>
requires (!std::same_as<T, bool> && !std::same_as<T, char>)
Value(T value) {
if constexpr (std::is_unsigned_v<T> && sizeof(T) >= sizeof(std::int64_t)) {
if (value > static_cast<std::uint64_t>(std::numeric_limits<std::int64_t>::max())) {
kind = ValueKind::Number;
number = static_cast<double>(value);
return;
}
}
kind = ValueKind::Integer;
integer = static_cast<std::int64_t>(value);
}
Value(double value) : kind(ValueKind::Number), number(value) {}
Value(float value) : Value(static_cast<double>(value)) {}
Value(std::string value) : kind(ValueKind::String), text(std::move(value)) {}
Value(std::string_view value) : Value(std::string(value)) {}
// String literals; without this, `Value("x")` would pick the bool
// constructor, a standard conversion beating a user-defined one.
Value(const char* value) : Value(std::string(value)) {}
static Value MakeArray(std::vector<Value> items = {});
static Value MakeObject();
ValueKind Kind() const { return kind; }
bool IsNull() const { return kind == ValueKind::Null; }
bool IsBool() const { return kind == ValueKind::Bool; }
bool IsInteger() const { return kind == ValueKind::Integer; }
// Integer or Number.
bool IsNumber() const { return kind == ValueKind::Integer || kind == ValueKind::Number; }
bool IsString() const { return kind == ValueKind::String; }
bool IsArray() const { return kind == ValueKind::Array; }
bool IsObject() const { return kind == ValueKind::Object; }
// Each throws std::logic_error for a value of another kind; check the
// kind first. AsNumber accepts an Integer too.
bool AsBool() const;
std::int64_t AsInteger() const;
double AsNumber() const;
const std::string& AsString() const;
// Array items. Push turns a null value into an array first.
std::span<const Value> Items() const;
std::span<Value> Items();
void Push(Value item);
// Object members, in the order they were first set. Set replaces the
// value of an existing key in place, and turns a null value into an
// object first, as operator[] does when the key is missing.
const Value* Find(std::string_view key) const;
Value* Find(std::string_view key);
Value& Set(std::string_view key, Value value);
Value& operator[](std::string_view key);
bool Erase(std::string_view key);
auto Members() const { return std::views::zip(keys, values); }
// Array items or object members; 0 for anything else.
std::size_t Size() const;
// JSON text. Parse throws ParseError; a duplicate key keeps the last
// value. Dump writes compact text, DumpPretty indents.
static Value Parse(std::string_view json);
std::string Dump() const;
std::string DumpPretty(std::uint32_t indent = 2) const;
// Numbers compare by value across Integer and Number; objects compare
// their members whatever their order.
friend bool operator==(const Value& a, const Value& b);
private:
ValueKind kind = ValueKind::Null;
bool boolean = false;
std::int64_t integer = 0;
double number = 0;
std::string text;
std::vector<Value> items;
// Object members as two parallel lists: a member type holding a Value
// could not be complete inside Value's own definition.
std::vector<std::string> keys;
std::vector<Value> values;
void DumpTo(std::string& out, std::uint32_t indent, std::uint32_t depth) const;
};
}

View file

@ -0,0 +1,11 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// What the app side and the crafter-debug client share: Value and its text
// form, reflection, the codecs, and the result types. The app imports it
// through Crafter.Debug; the client imports it directly, without the session.
export module Crafter.Debug.Core;
export import :Value;
export import :Reflect;
export import :Codec;
export import :Result;

View file

@ -1,12 +1,15 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Spike placeholder: the real API replaces this in Phase 1.
// 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.
export module Crafter.Debug;
export import Crafter.Debug.Core;
import std;
export namespace Crafter::Debug {
std::string_view Version() {
return "0.0.0-spike";
return "0.0.0-dev";
}
}