// SPDX-License-Identifier: LGPL-3.0-only // SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts® module Crafter.Debug.Cli:Client_impl; import std; import Crafter.Debug.Core; import :Client; using namespace Crafter::Debug; using namespace Crafter::Debug::Cli; namespace fs = std::filesystem; using namespace std::chrono_literals; namespace { constexpr std::string_view usage = R"(usage: crafter-debug [-i ] [--json] [--timeout <30s>] ... ls [--wait 60s] running instances status frame, fps, pause state, failing checks cmds [glob] the app's commands help arguments, types and defaults call [k=v ...] call a command (or --json '{...}') get ... read watches and samples set write a watch, or one member of it pause [--when ] [--on-check] [--trap] [--clear] resume step [n] wait | --frames | --event | --paused trace ... [--frames 60] [--history] log [--last 50] [--level warn] [--cat ] [--grep ] [--since-frame ] [--follow [--for 30m]] screenshot [-o ] quit [--force] debugger how to attach lldb or gdb guide the full reference )"; constexpr std::array commandNames = { "ls", "status", "cmds", "help", "call", "get", "set", "pause", "resume", "step", "wait", "trace", "log", "screenshot", "quit", "debugger", "guide", "version", }; struct Options { std::string instance; bool json = false; std::chrono::milliseconds timeout = 30s; std::string command; std::vector rest; }; Value Object(std::initializer_list> members) { Value object = Value::MakeObject(); for (const auto& [key, value] : members) object.Set(key, value); return object; } std::string Short(std::string_view name) { const std::size_t colon = name.rfind("::"); return std::string(colon == std::string_view::npos ? name : name.substr(colon + 2)); } std::string FileName(std::string_view path) { const std::size_t slash = path.rfind('/'); return std::string(slash == std::string_view::npos ? path : path.substr(slash + 1)); } // A value as an agent reads it in a terminal: arrays cut to 20 items // with a note saying how many more, deeply; --json shows everything. Value Truncated(const Value& value) { if (value.IsArray()) { Value cut = Value::MakeArray(); const auto items = value.Items(); for (std::size_t i = 0; i < items.size() && i < 20; ++i) cut.Push(Truncated(items[i])); if (items.size() > 20) cut.Push(Value(std::format("... {} more (--json for all)", items.size() - 20))); return cut; } if (value.IsObject()) { Value cut = Value::MakeObject(); for (const auto& [key, member] : value.Members()) cut.Set(key, Truncated(member)); return cut; } return value; } std::string Display(const Value& value) { if (value.IsString()) return value.AsString(); return Truncated(value).Dump(); } std::string TypeText(const Value& schema) { const Value* type = schema.Find("type"); if (type == nullptr || !type->IsString()) return "?"; const std::string& kind = type->AsString(); std::string text; if (kind == "integer") { text = "integer"; const Value* low = schema.Find("minimum"); const Value* high = schema.Find("maximum"); if (low != nullptr && high != nullptr) text += std::format(" {}..{}", low->Dump(), high->Dump()); } else if (kind == "enum") { for (const Value& name : schema.Find("values")->Items()) text += text.empty() ? name.AsString() : "|" + name.AsString(); } else if (kind == "array") { text = std::format("array of {}", schema.Find("items") ? TypeText(*schema.Find("items")) : "?"); if (const Value* length = schema.Find("length")) text += std::format(" [{}]", length->Dump()); } else if (kind == "object") { if (const Value* name = schema.Find("name")) { text = Short(name->AsString()); } else if (const Value* values = schema.Find("values")) { text = std::format("map of {}", TypeText(*values)); } else { text = "object"; } } else if (kind == "any") { text = "json"; } else { text = kind; } if (schema.Find("nullable") != nullptr) text += "?"; return text; } // "slot: integer 0..255 (required), yaw: number = 10" std::string ArgsText(const Value& args) { const Value* fields = args.Find("fields"); if (fields == nullptr) return args.IsNull() ? std::string() : TypeText(args); std::string text; for (const Value& field : fields->Items()) { if (!text.empty()) text += ", "; text += std::format("{}: {}", field.Find("name")->AsString(), TypeText(*field.Find("type"))); if (field.Find("required") != nullptr) { text += " (required)"; } else if (const Value* fallback = field.Find("default")) { text += std::format(" = {}", fallback->Dump()); } } return text; } // A k=v value typed by the field's schema: numbers and booleans parsed, // strings and enum names taken as written, anything else read as JSON. std::expected Coerce(std::string_view raw, const Value* schema) { const auto json = [&]() -> std::optional { try { return Value::Parse(raw); } catch (const ParseError&) { return std::nullopt; } }; const Value* type = schema != nullptr ? schema->Find("type") : nullptr; const std::string kind = type != nullptr && type->IsString() ? type->AsString() : std::string(); if (kind == "string" || kind == "enum") return Value(raw); if (kind == "boolean") { if (raw == "true" || raw == "1" || raw == "yes") return Value(true); if (raw == "false" || raw == "0" || raw == "no") return Value(false); return std::unexpected(std::format("'{}' is not a boolean (true or false)", raw)); } if (kind == "integer" || kind == "number") { const auto parsed = json(); if (parsed && parsed->IsNumber()) return *parsed; return std::unexpected(std::format("'{}' is not a number", raw)); } if (raw == "null" && schema != nullptr && schema->Find("nullable") != nullptr) return Value(); if (const auto parsed = json()) return *parsed; if (kind == "array" || kind == "object") return std::unexpected(std::format("'{}' is not JSON; an {} is written as JSON", raw, kind)); return Value(raw); } const Value* FieldSchema(const Value* objectSchema, std::string_view name) { if (objectSchema == nullptr) return nullptr; const Value* fields = objectSchema->Find("fields"); if (fields == nullptr) return nullptr; for (const Value& field : fields->Items()) { if (field.Find("name")->AsString() == name) return field.Find("type"); } return nullptr; } // key=value pairs into an object; dotted keys nest ("at.x=1"). std::expected BuildParams(std::span pairs, const Value* schema) { Value params = Value::MakeObject(); for (const std::string& pair : pairs) { const std::size_t equals = pair.find('='); if (equals == std::string::npos || equals == 0) return std::unexpected(std::format("'{}' is not key=value; values are passed as key=value, or all at once with --json '{{...}}'", pair)); const std::string_view key(pair.data(), equals); const std::string_view raw = std::string_view(pair).substr(equals + 1); Value* target = ¶ms; const Value* targetSchema = schema; std::string_view rest = key; while (true) { const std::size_t dot = rest.find('.'); const std::string_view name = rest.substr(0, dot); const Value* fieldSchema = FieldSchema(targetSchema, name); if (dot == std::string_view::npos) { auto value = Coerce(raw, fieldSchema); if (!value) return std::unexpected(std::format("{}: {}", key, value.error())); target->Set(name, std::move(*value)); break; } Value* nested = target->Find(name); if (nested == nullptr) nested = &target->Set(name, Value::MakeObject()); target = nested; targetSchema = fieldSchema; rest = rest.substr(dot + 1); } } return params; } // A value given on the command line: JSON when it parses, else a string. Value Literal(std::string_view raw) { try { return Value::Parse(raw); } catch (const ParseError&) { return Value(raw); } } std::string Age(std::int64_t startedAt) { const std::int64_t now = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); const std::int64_t seconds = std::max(0, now - startedAt); if (seconds < 120) return std::format("{}s", seconds); if (seconds < 7200) return std::format("{}m", seconds / 60); return std::format("{}h", seconds / 3600); } std::optional Number(std::string_view text) { std::uint64_t number = 0; const auto [end, error] = std::from_chars(text.data(), text.data() + text.size(), number); if (error != std::errc() || end != text.data() + text.size()) return std::nullopt; return number; } class Client { public: Client(Environment& environment, Options options) : env(environment), opts(std::move(options)) {} std::int32_t Execute() { const std::string& c = opts.command; if (c == "guide") { Out() << Guide(); return exitOk; } if (c == "version") { Out() << std::format("crafter-debug {}, protocol {}, sources {}\n", libraryVersion, protocolVersion, Detail::SourceId()); return exitOk; } if (c == "ls") return Ls(); if (const std::int32_t code = Select(); code != exitOk) return code; if (c == "debugger") return Debugger(); if (c == "status") return Status(); if (c == "cmds") return Cmds(); if (c == "help") return Help(); if (c == "call") return Call(); if (c == "get") return Get(); if (c == "set") return Set(); if (c == "pause") return Pause(); if (c == "resume") return Resume(); if (c == "step") return Step(); if (c == "wait") return Wait(); if (c == "trace") return Trace(); if (c == "log") return Log(); if (c == "screenshot") return Screenshot(); if (c == "quit") return Quit(); return exitUsage; } private: Environment& env; Options opts; std::optional instance; std::unique_ptr connection; std::ostream& Out() { return *env.out; } std::ostream& Err() { return *env.err; } std::int32_t Usage(std::string_view problem) { Err() << std::format("crafter-debug {}: {}\n", opts.command, problem); return exitUsage; } // ---- choosing the instance ---- std::int32_t Select() { std::vector live = env.list(); if (!opts.instance.empty()) { std::erase_if(live, [&](const Instance& candidate) { return candidate.record.name != opts.instance && !candidate.record.instanceId.starts_with(opts.instance); }); } if (live.empty()) { if (!opts.instance.empty()) { std::string running; for (const Instance& other : env.list()) running += std::format("{}{} ({})", running.empty() ? "" : ", ", other.record.name, other.record.instanceId); Err() << std::format("crafter-debug: no running instance named '{}'{}\n", opts.instance, running.empty() ? std::string() : std::format("; running: {}", running)); } else { Err() << "crafter-debug: no running instance. Start the app from its debug build in the background (e.g. crafter-build --debug-api -r), then: crafter-debug ls --wait 60s\n"; } return exitNoInstance; } if (live.size() > 1) { Err() << "crafter-debug: several instances run; pick one with -i :\n"; for (const Instance& candidate : live) Err() << std::format(" {} id {} up {}\n", candidate.record.name, candidate.record.instanceId, Age(candidate.record.startedAt)); return exitUsage; } instance = live.front(); const InstanceRecord& record = instance->record; if (record.protocol != protocolVersion) { const bool appNewer = record.protocol > protocolVersion; Err() << std::format("crafter-debug: {} speaks protocol {} and this client {}: the {} is newer.\n", record.name, record.protocol, protocolVersion, appNewer ? "app" : "client"); if (appNewer) { Err() << " This launcher points at an older build. Rebuild with the project's debug flag, or run the app's own client:\n"; } else { Err() << " Restart the app from the current build (crafter-build -r), or talk to it with its own client:\n"; } Err() << std::format(" flatpak run --die-with-parent --command={} net.catcrafts.CrafterBuild {}\n", record.client, opts.command); return exitVersion; } if (!record.sourceId.empty() && record.sourceId != Detail::SourceId()) { Err() << std::format("note: {} was built from other Crafter.Debug sources ({} here, {} there); restart it from the current build if something misbehaves\n", record.name, Detail::SourceId(), record.sourceId); } connection = env.connect(record); return exitOk; } // ---- talking to it ---- // The result, or the exit code once the problem has been reported. std::expected Request(std::string_view method, Value params, std::chrono::milliseconds timeout, const std::function& onLine = {}) { Value request = Object({ { "token", Value(instance->record.token) }, { "method", Value(method) }, { "params", std::move(params) }, { "timeoutMs", Value(static_cast(timeout.count())) }, }); if (!connection) return std::unexpected(Unreachable("no connection")); auto reply = connection->Call(request, timeout + 2s, onLine); if (!reply) { if (reply.error().kind == CallError::Kind::TimedOut) return std::unexpected(TimedOut(method, timeout)); if (reply.error().kind == CallError::Kind::Broken) { Err() << std::format("crafter-debug: a malformed reply: {}\n", reply.error().message); return std::unexpected(exitError); } return std::unexpected(Unreachable(reply.error().message)); } if (const Value* error = reply->Find("error")) { const std::string code = error->Find("code") ? error->Find("code")->AsString() : std::string("Error"); const std::string message = error->Find("message") ? error->Find("message")->AsString() : std::string(); if (opts.json) { Err() << reply->Dump() << '\n'; } else { Err() << std::format("error {}: {}\n", code, message); } return std::unexpected(code == Codes::timeout ? exitTimeout : exitError); } const Value* result = reply->Find("result"); return result != nullptr ? *result : Value(); } std::int32_t Unreachable(std::string_view why) { const bool alive = std::ranges::any_of(env.list(), [&](const Instance& candidate) { return candidate.record.instanceId == instance->record.instanceId; }); if (!alive) { Err() << std::format("crafter-debug: {} has exited\n", instance->record.name); } else { Err() << std::format("crafter-debug: cannot reach {} on port {}: {}\n", instance->record.name, instance->record.port, why); } return exitNoInstance; } // A request that got no reply. The app may be gone, stopped in a // debugger, or no longer calling Pump; a short status call tells // which, and what to do next. std::int32_t TimedOut(std::string_view method, std::chrono::milliseconds timeout) { const bool alive = std::ranges::any_of(env.list(), [&](const Instance& candidate) { return candidate.record.instanceId == instance->record.instanceId; }); if (!alive) { Err() << std::format("crafter-debug: {} exited while {} waited\n", instance->record.name, method); return exitNoInstance; } const auto probe = env.connect(instance->record); const Value status = Object({ { "token", Value(instance->record.token) }, { "method", Value("debug.status") }, { "timeoutMs", Value(1000) } }); const auto reply = probe ? probe->Call(status, 2s, {}) : std::unexpected(CallError{}); Err() << std::format("crafter-debug: {} got no reply within {} ms. ", method, timeout.count()); if (!reply || reply->Find("result") == nullptr) { Err() << std::format("{} is alive but not answering at all: stopped in a debugger, or hung.\n", instance->record.name); PrintAttach(true); return exitTimeout; } const Value* last = reply->Find("result")->Find("lastPumpAgoMs"); if (last != nullptr && last->IsInteger() && last->AsInteger() > 1000) { Err() << std::format("{} answers, but has not called Debug::Pump() for {} ms: its main loop is stalled, or its window hidden " "(set Window::updateInterval to keep it pumping).\n", instance->record.name, last->AsInteger()); } else { Err() << "The app is pumping; the request took longer than its timeout (raise it with --timeout).\n"; } return exitTimeout; } void PrintAttach(bool toErr) { std::ostream& stream = toErr ? Err() : Out(); const InstanceRecord& record = instance->record; const std::string debugger = env.haveLldb && env.haveLldb() ? "lldb" : "gdb"; if (!record.flatpakInstance.empty()) { stream << std::format(" attach (on the host): flatpak enter {} {} -p {}\n", record.flatpakInstance, debugger, record.pid); stream << std::format(" check whether a debugger holds it: flatpak enter {} grep State /proc/{}/status (t = stopped by a tracer)\n", record.flatpakInstance, record.pid); if (record.hostInitPid > 0) stream << std::format(" end it, sandbox and all (on the host): kill -KILL {}\n", record.hostInitPid); } else { stream << std::format(" attach: {} -p {}\n", debugger, record.pid); stream << std::format(" check whether a debugger holds it: grep State /proc/{}/status (t = stopped by a tracer)\n", record.pid); } } std::int32_t Print(const Value& result) { if (opts.json) { Out() << result.Dump() << '\n'; return exitOk; } Out() << (result.IsNull() ? std::string("ok") : Display(result)) << '\n'; return exitOk; } // ---- commands ---- std::int32_t Ls() { std::optional wait; for (std::size_t i = 0; i < opts.rest.size(); ++i) { if (opts.rest[i] == "--wait" && i + 1 < opts.rest.size()) { wait = ParseDuration(opts.rest[++i]); if (!wait) return Usage(std::format("'{}' is not a duration (30s, 500ms, 2m)", opts.rest[i])); } else { return Usage(std::format("unknown argument '{}'", opts.rest[i])); } } const auto deadline = std::chrono::steady_clock::now() + wait.value_or(0ms); std::vector live = env.list(); while (live.empty() && wait && std::chrono::steady_clock::now() < deadline) { std::this_thread::sleep_for(200ms); live = env.list(); } if (opts.json) { Value list = Value::MakeArray(); for (const Instance& entry : live) { Value record = Encode(entry.record); record.Erase("token"); list.Push(std::move(record)); } Out() << list.Dump() << '\n'; return live.empty() ? exitNoInstance : exitOk; } if (live.empty()) { Err() << "no running instances. Start the app from its debug build in the background (e.g. crafter-build --debug-api -r)" << (wait ? ", and wait longer if it is still building\n" : ", then: crafter-debug ls --wait 60s\n"); return exitNoInstance; } for (const Instance& entry : live) { const InstanceRecord& r = entry.record; Out() << std::format("{} id {} pid {}{} up {} port {}{}\n", r.name, r.instanceId, r.pid, r.hostInitPid > 0 ? std::format(" (sandbox {})", r.hostInitPid) : std::string(), Age(r.startedAt), r.port, r.protocol != protocolVersion ? std::format(" protocol {} (this client {})", r.protocol, protocolVersion) : std::string()); } return exitOk; } std::int32_t Debugger() { Out() << std::format("{}: the app stops while a debugger holds it, and crafter-debug calls then report it as not answering.\n", instance->record.name); PrintAttach(false); return exitOk; } std::int32_t Status() { if (!opts.rest.empty()) return Usage("takes no arguments"); const auto result = Request("debug.status", Value(), opts.timeout); if (!result) return result.error(); if (opts.json) return Print(*result); const Value& s = *result; const bool paused = s.Find("paused")->AsBool(); const std::string reason = s.Find("pauseReason") ? s.Find("pauseReason")->AsString() : std::string(); Out() << std::format("{} frame {} {:.1f} fps {}\n", s.Find("name")->AsString(), s.Find("frame")->Dump(), s.Find("fps")->AsNumber(), paused ? std::format("PAUSED ({})", reason) : std::string("running")); std::string armed; if (const Value* when = s.Find("armed")->Find("when")) armed += std::format(" pause armed: when '{}'", when->AsString()); if (s.Find("armed")->Find("onCheck") != nullptr) armed += " pause armed: on the next failing check"; Out() << std::format("pause: {}{}\n", s.Find("pauseSupported")->AsBool() ? "supported" : "unsupported (the app never calls Debug::Advance or Debug::Paused)", armed); const std::int64_t failures = s.Find("checkFailures")->AsInteger(); if (failures > 0) { std::string sites; for (const Value& site : s.Find("checkSites")->Items()) { sites += std::format("{}{}:{} x{} ({})", sites.empty() ? "" : ", ", site.Find("file")->AsString(), site.Find("line")->Dump(), site.Find("failures")->Dump(), site.Find("category")->AsString()); } Out() << std::format("checks: {} failures: {}\n", failures, sites); } Out() << std::format("{} commands, {} watches; requests: {} queued, {} waiting, {} following\n", s.Find("commands")->Dump(), s.Find("watches")->Dump(), s.Find("queued")->Dump(), s.Find("waiting")->Dump(), s.Find("following")->Dump()); const Value* last = s.Find("lastPumpAgoMs"); Out() << (last == nullptr || last->IsNull() ? std::string("Pump: never called: the app does not call Debug::Pump(), so calls, gets and waits will not run\n") : std::format("last Pump {} ms ago\n", last->Dump())); return exitOk; } std::int32_t Cmds() { if (opts.rest.size() > 1) return Usage("takes at most one glob"); const std::string glob = opts.rest.empty() ? "*" : opts.rest[0]; const auto result = Request("debug.list", Object({ { "glob", Value(glob) } }), opts.timeout); if (!result) return result.error(); if (opts.json) return Print(*result->Find("commands")); const auto commands = result->Find("commands")->Items(); if (commands.empty()) { Out() << std::format("no commands match '{}'\n", glob); return exitOk; } for (const Value& command : commands) { const std::string returns = command.Find("returns")->IsNull() ? std::string() : std::format(" -> {}", TypeText(*command.Find("returns"))); const std::string description = command.Find("description")->AsString(); Out() << std::format("{}({}){}{}\n", command.Find("name")->AsString(), ArgsText(*command.Find("args")), returns, description.empty() ? std::string() : std::format(" -- {}", description)); } return exitOk; } std::int32_t Help() { if (opts.rest.size() != 1) return Usage("names one command or watch: crafter-debug help "); const std::string& name = opts.rest[0]; const auto result = Request("debug.list", Object({ { "glob", Value("*") } }), opts.timeout); if (!result) return result.error(); for (const Value& command : result->Find("commands")->Items()) { if (command.Find("name")->AsString() != name) continue; if (opts.json) return Print(command); Out() << std::format("{} -- {}\n", name, command.Find("description")->AsString()); const Value& args = *command.Find("args"); if (const Value* fields = args.Find("fields")) { for (const Value& field : fields->Items()) { const std::string detail = field.Find("required") != nullptr ? "required" : std::format("default {}", field.Find("default")->Dump()); Out() << std::format(" {:<16} {:<24} {}\n", field.Find("name")->AsString(), TypeText(*field.Find("type")), detail); } } else if (!args.IsNull()) { Out() << std::format(" takes {}\n", TypeText(args)); } else { Out() << " takes no arguments\n"; } if (!command.Find("returns")->IsNull()) Out() << std::format("returns {}\n", TypeText(*command.Find("returns"))); Out() << std::format("call: crafter-debug call {}{}\n", name, args.Find("fields") ? " key=value ..." : ""); return exitOk; } for (const Value& watch : result->Find("watches")->Items()) { if (watch.Find("path")->AsString() != name) continue; if (opts.json) return Print(watch); Out() << std::format("{} {}{} {}\n", name, TypeText(*watch.Find("type")), watch.Find("readOnly")->AsBool() ? " read-only" : "", watch.Find("help")->AsString()); return exitOk; } std::vector names; for (const Value& command : result->Find("commands")->Items()) names.push_back(command.Find("name")->AsString()); for (const Value& watch : result->Find("watches")->Items()) names.push_back(watch.Find("path")->AsString()); std::vector views(names.begin(), names.end()); const auto suggestion = Suggest(name, views); Err() << std::format("crafter-debug: no command or watch '{}'{}\n", name, suggestion ? std::format("; did you mean '{}'?", *suggestion) : std::string("; crafter-debug cmds lists them")); return exitError; } std::int32_t Call() { if (opts.rest.empty()) return Usage("names a command: crafter-debug call [key=value ...]"); const std::string command = opts.rest[0]; std::vector pairs; std::optional json; for (std::size_t i = 1; i < opts.rest.size(); ++i) { if (opts.rest[i] == "--json" && i + 1 < opts.rest.size()) { try { json = Value::Parse(opts.rest[++i]); } catch (const ParseError& error) { return Usage(std::format("--json: {}", error.what())); } } else { pairs.push_back(opts.rest[i]); } } Value params = json.value_or(Value()); if (!pairs.empty()) { // The argument schema types each value: slot=1 is a number, // name=1 a string, if that is what the fields are. const auto listed = Request("debug.list", Object({ { "glob", Value(command) } }), opts.timeout); if (!listed) return listed.error(); const Value* schema = nullptr; for (const Value& entry : listed->Find("commands")->Items()) { if (entry.Find("name")->AsString() == command) schema = entry.Find("args"); } auto built = BuildParams(pairs, schema); if (!built) return Usage(built.error()); if (json && json->IsObject()) { for (const auto& [key, value] : built->Members()) json->Set(key, value); params = *json; } else { params = std::move(*built); } } const auto result = Request(command, std::move(params), opts.timeout); if (!result) return result.error(); return Print(*result); } std::int32_t Get() { if (opts.rest.empty()) return Usage("names one or more paths or globs: crafter-debug get 'fort1.*'"); Value paths = Value::MakeArray(); for (const std::string& path : opts.rest) paths.Push(Value(path)); const auto result = Request("debug.get", Object({ { "paths", std::move(paths) } }), opts.timeout); if (!result) return result.error(); if (opts.json) return Print(*result); for (const auto& [path, value] : result->Find("values")->Members()) Out() << std::format("{} = {}\n", path, Display(value)); if (const Value* errors = result->Find("errors")) { for (const auto& [path, error] : errors->Members()) Err() << std::format("{}: {}\n", path, error.AsString()); } return exitOk; } std::int32_t Set() { if (opts.rest.size() != 2) return Usage("takes a path and a value: crafter-debug set physics.gravity -9.81"); const auto result = Request("debug.set", Object({ { "path", Value(opts.rest[0]) }, { "value", Literal(opts.rest[1]) } }), opts.timeout); if (!result) return result.error(); if (opts.json) return Print(*result); Out() << std::format("{} = {}\n", result->Find("path")->AsString(), Display(*result->Find("value"))); return exitOk; } std::int32_t Pause() { Value params = Value::MakeObject(); for (std::size_t i = 0; i < opts.rest.size(); ++i) { const std::string& arg = opts.rest[i]; if (arg == "--when" && i + 1 < opts.rest.size()) { params.Set("when", Value(opts.rest[++i])); } else if (arg == "--on-check") { params.Set("onCheck", Value(true)); } else if (arg == "--trap") { params.Set("trap", Value(true)); } else if (arg == "--clear") { params.Set("clear", Value(true)); } else { return Usage(std::format("unknown argument '{}'", arg)); } } const auto result = Request("debug.pause", std::move(params), opts.timeout); if (!result) return result.error(); if (opts.json) return Print(*result); if (const Value* armed = result->Find("armed")) { if (armed->IsBool()) { Out() << "disarmed\n"; } else if (armed->AsString() == "when") { Out() << std::format("armed: pause when '{}'; then: crafter-debug wait --paused\n", result->Find("condition")->AsString()); } else { Out() << "armed: pause on the next failing Debug::Check; then: crafter-debug wait --paused\n"; } return exitOk; } Out() << std::format("paused at frame {}\n", result->Find("frame")->Dump()); return exitOk; } std::int32_t Resume() { if (!opts.rest.empty()) return Usage("takes no arguments"); const auto result = Request("debug.resume", Value(), opts.timeout); if (!result) return result.error(); if (opts.json) return Print(*result); Out() << std::format("running from frame {}\n", result->Find("frame")->Dump()); return exitOk; } std::int32_t Step() { std::uint64_t count = 1; if (opts.rest.size() > 1) return Usage("takes at most a count: crafter-debug step 3"); if (opts.rest.size() == 1) { const auto parsed = Number(opts.rest[0]); if (!parsed || *parsed == 0) return Usage(std::format("'{}' is not a positive count", opts.rest[0])); count = *parsed; } const auto result = Request("debug.step", Object({ { "count", Value(count) } }), opts.timeout); if (!result) return result.error(); if (opts.json) return Print(*result); Out() << std::format("stepped {} to frame {}, paused\n", count, result->Find("frame")->Dump()); return exitOk; } std::int32_t Wait() { Value params = Value::MakeObject(); for (std::size_t i = 0; i < opts.rest.size(); ++i) { const std::string& arg = opts.rest[i]; if (arg == "--frames" && i + 1 < opts.rest.size()) { const auto frames = Number(opts.rest[++i]); if (!frames) return Usage(std::format("'{}' is not a number of frames", opts.rest[i])); params.Set("frames", Value(*frames)); } else if (arg == "--event" && i + 1 < opts.rest.size()) { params.Set("event", Value(opts.rest[++i])); } else if (arg == "--paused") { params.Set("paused", Value(true)); } else if (!arg.starts_with("--") && params.Find("condition") == nullptr) { params.Set("condition", Value(arg)); } else { return Usage(std::format("unknown argument '{}'", arg)); } } if (params.Size() == 0) return Usage("waits for something: a condition, --frames , --event or --paused"); const auto result = Request("debug.wait", std::move(params), opts.timeout); if (!result) return result.error(); if (opts.json) return Print(*result); const Value& r = *result; if (const Value* condition = r.Find("condition")) { Out() << std::format("'{}' held at frame {}\n", condition->AsString(), r.Find("frame")->Dump()); } else if (const Value* event = r.Find("event")) { Out() << std::format("{} at frame {}: {}\n", event->AsString(), r.Find("frame")->Dump(), Display(*r.Find("payload"))); } else if (const Value* reason = r.Find("reason")) { Out() << std::format("paused at frame {}: {}\n", r.Find("frame")->Dump(), reason->AsString()); } else { Out() << std::format("frame {}\n", r.Find("frame")->Dump()); } return exitOk; } std::int32_t Trace() { Value paths = Value::MakeArray(); Value params = Value::MakeObject(); for (std::size_t i = 0; i < opts.rest.size(); ++i) { const std::string& arg = opts.rest[i]; if (arg == "--frames" && i + 1 < opts.rest.size()) { const auto frames = Number(opts.rest[++i]); if (!frames || *frames == 0) return Usage(std::format("'{}' is not a positive number of frames", opts.rest[i])); params.Set("frames", Value(*frames)); } else if (arg == "--history") { params.Set("history", Value(true)); } else if (arg.starts_with("--")) { return Usage(std::format("unknown argument '{}'", arg)); } else { paths.Push(Value(arg)); } } if (paths.Size() == 0) return Usage("names one or more paths: crafter-debug trace score --frames 120"); params.Set("paths", std::move(paths)); const auto result = Request("debug.trace", std::move(params), opts.timeout); if (!result) return result.error(); if (opts.json) return Print(*result); const Value* frames = result->Find("frames"); for (const auto& [path, series] : result->Find("series")->Members()) { const Value& framesOf = series.Find("frames") ? *series.Find("frames") : *frames; const auto values = series.Find("values")->Items(); Out() << std::format("{} {} values, frames {}..{}\n", path, values.size(), framesOf.Items().front().Dump(), framesOf.Items().back().Dump()); Out() << std::format(" {}\n", Display(*series.Find("values"))); const Value& stats = *series.Find("stats"); std::string line; for (const std::string_view key : { "min", "max", "mean" }) { if (const Value* number = stats.Find(key)) line += std::format("{} {} ", key, number->Dump()); } line += std::format("changes {}", stats.Find("changes")->Dump()); if (const Value* first = stats.Find("firstChangeFrame")) line += std::format(" (first at frame {})", first->Dump()); Out() << std::format(" {}\n", line); } if (result->Find("truncated") != nullptr) Out() << "(cut short by the timeout; raise --timeout for more frames)\n"; return exitOk; } void PrintEntry(const Value& entry) { if (opts.json) { Out() << entry.Dump() << '\n'; return; } std::string kind = entry.Find("kind")->AsString(); std::ranges::transform(kind, kind.begin(), [](char c) { return static_cast(c >= 'a' && c <= 'z' ? c - 32 : c); }); std::string text = entry.Find("message")->AsString(); if (const Value* payload = entry.Find("payload")) text = Display(*payload); if (const Value* count = entry.Find("count"); count != nullptr && count->AsInteger() > 1) text += std::format(" (x{})", count->Dump()); const std::string where = entry.Find("kind")->AsString() == "agent" ? std::string() : std::format(" {}:{}", FileName(entry.Find("file")->AsString()), entry.Find("line")->Dump()); Out() << std::format("{:>7} {:<6} {:<10} {}{}\n", entry.Find("frame")->Dump(), kind, entry.Find("category")->AsString(), text, where) << std::flush; } std::int32_t Log() { Value params = Value::MakeObject(); std::chrono::milliseconds timeout = opts.timeout; bool follow = false; for (std::size_t i = 0; i < opts.rest.size(); ++i) { const std::string& arg = opts.rest[i]; const bool hasValue = i + 1 < opts.rest.size(); if (arg == "--last" && hasValue) { const auto last = Number(opts.rest[++i]); if (!last) return Usage(std::format("'{}' is not a number", opts.rest[i])); params.Set("last", Value(*last)); } else if (arg == "--level" && hasValue) { params.Set("level", Value(opts.rest[++i])); } else if (arg == "--cat" && hasValue) { params.Set("category", Value(opts.rest[++i])); } else if (arg == "--grep" && hasValue) { params.Set("grep", Value(opts.rest[++i])); } else if (arg == "--since-frame" && hasValue) { const auto frame = Number(opts.rest[++i]); if (!frame) return Usage(std::format("'{}' is not a frame number", opts.rest[i])); params.Set("sinceFrame", Value(*frame)); } else if (arg == "--kinds" && hasValue) { Value kinds = Value::MakeArray(); for (const auto part : std::views::split(std::string_view(opts.rest[++i]), ',')) kinds.Push(Value(std::string_view(std::ranges::data(part), std::ranges::size(part)))); params.Set("kinds", std::move(kinds)); } else if (arg == "--follow") { follow = true; params.Set("follow", Value(true)); } else if (arg == "--for" && hasValue) { const auto duration = ParseDuration(opts.rest[++i]); if (!duration) return Usage(std::format("'{}' is not a duration", opts.rest[i])); params.Set("forMs", Value(static_cast(duration->count()))); } else { return Usage(std::format("unknown argument '{}'", arg)); } } if (follow) { const Value* forMs = params.Find("forMs"); timeout = std::chrono::milliseconds(forMs != nullptr ? forMs->AsInteger() : 30 * 60 * 1000) + 5s; const auto result = Request("debug.log", std::move(params), timeout, [&](const Value& line) { if (const Value* entry = line.Find("entry")) PrintEntry(*entry); return true; }); if (!result) return result.error(); if (!opts.json) Err() << "(follow ended)\n"; return exitOk; } const auto result = Request("debug.log", std::move(params), timeout); if (!result) return result.error(); for (const Value& entry : result->Find("entries")->Items()) PrintEntry(entry); return exitOk; } std::int32_t Screenshot() { fs::path file; for (std::size_t i = 0; i < opts.rest.size(); ++i) { if ((opts.rest[i] == "-o" || opts.rest[i] == "--output") && i + 1 < opts.rest.size()) { file = opts.rest[++i]; } else { return Usage(std::format("unknown argument '{}'", opts.rest[i])); } } const auto result = Request("debug.screenshot", Value(), opts.timeout); if (!result) return result.error(); const auto png = Base64Decode(result->Find("png")->AsString()); if (!png) { Err() << "crafter-debug: the screenshot was not valid base64\n"; return exitError; } if (file.empty()) { const std::int64_t now = std::chrono::duration_cast(std::chrono::system_clock::now().time_since_epoch()).count(); file = fs::path("build") / "crafter-debug-shots" / std::format("{}-{}.png", instance->record.name, now); } if (file.is_relative()) file = env.cwd / file; std::error_code ec; fs::create_directories(file.parent_path(), ec); std::ofstream out(file, std::ios::binary | std::ios::trunc); out.write(reinterpret_cast(png->data()), static_cast(png->size())); if (!out) { Err() << std::format("crafter-debug: could not write {}\n", file.string()); return exitError; } if (opts.json) return Print(Object({ { "file", Value(file.string()) }, { "bytes", Value(png->size()) } })); Out() << std::format("{} ({} bytes)\n", file.string(), png->size()); return exitOk; } std::int32_t Quit() { bool force = false; for (const std::string& arg : opts.rest) { if (arg == "--force") { force = true; } else { return Usage(std::format("unknown argument '{}'", arg)); } } const auto result = Request("debug.quit", Object({ { "force", Value(force) } }), opts.timeout); if (!result) return result.error(); if (opts.json) return Print(*result); if (force) { Out() << std::format("ended {}\n", instance->record.name); } else { Out() << std::format("asked {} to quit\n", instance->record.name); } return exitOk; } }; std::variant Parse(std::span args, Environment& env) { Options options; for (std::size_t i = 0; i < args.size(); ++i) { const std::string_view arg = args[i]; if (!options.command.empty()) { // Global flags go anywhere, as agents write them: `wait // --paused --timeout 60s`. No command has options of these // names, but for `call --json '{...}'`, its arguments as JSON. const bool callArguments = options.command == "call" && arg == "--json" && i + 1 < args.size() && args[i + 1].starts_with('{'); if (arg == "--json" && !callArguments) { options.json = true; continue; } if (arg != "--timeout" && arg != "-i" && arg != "--instance") { options.rest.emplace_back(arg); if (callArguments) options.rest.emplace_back(args[++i]); continue; } } if ((arg == "-i" || arg == "--instance") && i + 1 < args.size()) { options.instance = args[++i]; } else if (arg == "--json") { options.json = true; } else if (arg == "--timeout" && i + 1 < args.size()) { const auto timeout = ParseDuration(args[++i]); if (!timeout) { *env.err << std::format("crafter-debug: '{}' is not a duration (30s, 500ms, 2m)\n", args[i]); return exitUsage; } options.timeout = *timeout; } else if (arg == "--timeout" || arg == "-i" || arg == "--instance") { *env.err << std::format("crafter-debug: {} needs a value\n", arg); return exitUsage; } else if (arg == "-h" || arg == "--help") { *env.out << usage; return exitOk; } else if (arg.starts_with("-")) { *env.err << std::format("crafter-debug: unknown option '{}'\n{}", arg, usage); return exitUsage; } else { options.command = std::string(arg); } } if (options.command.empty()) { *env.err << usage; return exitUsage; } if (std::ranges::find(commandNames, options.command) == commandNames.end()) { const auto suggestion = Suggest(options.command, commandNames); *env.err << std::format("crafter-debug: no command '{}'{}\n{}", options.command, suggestion ? std::format("; did you mean '{}'?", *suggestion) : std::string(), usage); return exitUsage; } return options; } } std::optional Crafter::Debug::Cli::ParseDuration(std::string_view text) { std::size_t digits = 0; while (digits < text.size() && text[digits] >= '0' && text[digits] <= '9') ++digits; if (digits == 0) return std::nullopt; std::int64_t number = 0; std::from_chars(text.data(), text.data() + digits, number); const std::string_view unit = text.substr(digits); if (unit.empty() || unit == "s") return std::chrono::seconds(number); if (unit == "ms") return std::chrono::milliseconds(number); if (unit == "m") return std::chrono::minutes(number); if (unit == "h") return std::chrono::hours(number); return std::nullopt; } std::int32_t Crafter::Debug::Cli::Run(std::span args, Environment& environment) { auto parsed = Parse(args, environment); if (const std::int32_t* code = std::get_if(&parsed)) return *code; Client client(environment, std::get(std::move(parsed))); return client.Execute(); } std::string_view Crafter::Debug::Cli::Guide() { return R"(# crafter-debug: driving a running app crafter-debug talks to an app built with Crafter.Debug while it runs: call its commands, read and set its state, pause and step it, wait for conditions, trace values over frames, read its log, take screenshots. Breakpoints and memory are a debugger's job; `crafter-debug debugger` prints how to attach one. ## The loop 1. Build and start the app from its debug build, in the background (it does not exit): `crafter-build --debug-api -r` (use the project's own flag). 2. `./build/crafter-debug ls --wait 60s` until it is listed. 3. `cmds` and `help ` to see what it offers, `get '*'` for its state. 4. Act and observe: `call`, `set`, `pause --when`, `wait`, `step`, `trace`, `log --level warn`, `screenshot` (open the PNG it prints with Read). 5. `quit` when done. Killing the background task may leave the app running if crafter-build's alias lacks --die-with-parent; `ls` shows leftovers. ## Commands ls [--wait 60s] status cmds [glob] name(args = defaults) -> result -- description help call [key=value ...] values typed by the command's arguments: slot=1 yaw=45 at.x=3 (dotted keys nest), or --json '{"slot": 1}' get ... 'fort1.*', 'bodies[3].pos.y' set a JSON value, or text: set scene Game pause pause now pause --when '' pause the frame it holds pause --on-check [--trap] pause on the next failing Debug::Check; --trap also stops an attached debugger there pause --clear disarm resume step [n] replies once the n steps are taken wait '' | --frames n | --event Name | --paused trace ... [--frames 60] the value every frame, with min/max/mean trace --history a Debug::Sample's recent history log [--last 50] [--level info|warn|error] [--cat ] [--grep ] [--since-frame n] [--kinds log,warn,error,check,event,agent,pause,stdio] log --follow [--for 30m] the last 50 (--last; 0 for none), then new entries as they come; run it under Monitor screenshot [-o shot.png] default ./build/crafter-debug-shots/ quit [--force] --force exits even if the loop is stuck debugger how to attach lldb or gdb version Global: -i picks an instance when several run; --json prints raw JSON (never truncated); --timeout 30s bounds the request (waits included). ## Conditions fort1.core.hp <= 0 scene == "Game" && !debug.paused bodies[3].pos.y < -10 || (lives == 0 && score > highScore) Compare watch paths with literals or each other; && || ! and parentheses; a lone path tests truthiness. debug.frame, debug.paused and debug.fps always exist. A condition that cannot be decided (unknown path, ordering a string against a number) is an error, not false, so a typo never waits forever. ## Exit codes 0 done, 2 usage, 3 no instance, 4 app and client speak different protocols, 5 timed out, 6 the app answered an error. Errors name the next step. ## Gotchas - Calls, gets, sets and waits run when the app calls Debug::Pump(), once per frame. If `status` says Pump was never called or is long ago, the main loop is stalled, stopped in a debugger, or its window is hidden. - pause and step only hold what the app gates on Debug::Advance() or Debug::Paused(); rendering and Pump keep running while paused. - A request that timed out before Pump is guaranteed not to have run. - Run long waits and `log --follow` in the background, and chain short steps with &&: `call Lobby_Start && wait --frames 30 && screenshot`. )"; }