debug.log handed `last` to the timeline query, where 0 means every entry, so `log --follow --last 0` streamed the whole ring of up to 8192 entries before the new ones, where an agent reads it as tail -n 0 -f: nothing old, only what comes next. 0 now means no backlog, followed or not, and the guide says that --follow starts with the last 50. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1024 lines
53 KiB
C++
1024 lines
53 KiB
C++
// 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 <name|id>] [--json] [--timeout <30s>] <command> ...
|
|
|
|
ls [--wait 60s] running instances
|
|
status frame, fps, pause state, failing checks
|
|
cmds [glob] the app's commands
|
|
help <command|watch> arguments, types and defaults
|
|
call <command> [k=v ...] call a command (or --json '{...}')
|
|
get <path|glob> ... read watches and samples
|
|
set <path> <value> write a watch, or one member of it
|
|
pause [--when <cond>] [--on-check] [--trap] [--clear]
|
|
resume
|
|
step [n]
|
|
wait <cond> | --frames <n> | --event <name> | --paused
|
|
trace <path> ... [--frames 60] [--history]
|
|
log [--last 50] [--level warn] [--cat <glob>] [--grep <text>] [--since-frame <n>] [--follow [--for 30m]]
|
|
screenshot [-o <file.png>]
|
|
quit [--force]
|
|
debugger how to attach lldb or gdb
|
|
guide the full reference
|
|
)";
|
|
|
|
constexpr std::array<std::string_view, 18> 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<std::string> rest;
|
|
};
|
|
|
|
Value Object(std::initializer_list<std::pair<std::string_view, Value>> 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<Value, std::string> Coerce(std::string_view raw, const Value* schema) {
|
|
const auto json = [&]() -> std::optional<Value> {
|
|
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<Value, std::string> BuildParams(std::span<const std::string> 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::seconds>(std::chrono::system_clock::now().time_since_epoch()).count();
|
|
const std::int64_t seconds = std::max<std::int64_t>(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<std::uint64_t> 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> instance;
|
|
std::unique_ptr<Connection> 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<Instance> 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 <name|id>:\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 <debug flag> -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<Value, std::int32_t> Request(std::string_view method, Value params, std::chrono::milliseconds timeout,
|
|
const std::function<bool(const Value& line)>& onLine = {}) {
|
|
Value request = Object({
|
|
{ "token", Value(instance->record.token) },
|
|
{ "method", Value(method) },
|
|
{ "params", std::move(params) },
|
|
{ "timeoutMs", Value(static_cast<std::int64_t>(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<std::chrono::milliseconds> 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<Instance> 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 <name>");
|
|
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<std::string> 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<std::string_view> 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 <command> [key=value ...]");
|
|
const std::string command = opts.rest[0];
|
|
std::vector<std::string> pairs;
|
|
std::optional<Value> 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 <n>, --event <name> 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<char>(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<std::int64_t>(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::milliseconds>(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<const char*>(png->data()), static_cast<std::streamsize>(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<Options, std::int32_t> Parse(std::span<const std::string_view> 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 may follow the command too, before its own.
|
|
if (arg == "--json") {
|
|
options.json = true;
|
|
continue;
|
|
}
|
|
options.rest.emplace_back(arg);
|
|
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 == "-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<std::chrono::milliseconds> 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<const std::string_view> args, Environment& environment) {
|
|
auto parsed = Parse(args, environment);
|
|
if (const std::int32_t* code = std::get_if<std::int32_t>(&parsed)) return *code;
|
|
Client client(environment, std::get<Options>(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 <command>` 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 <command|watch>
|
|
call <command> [key=value ...] values typed by the command's arguments:
|
|
slot=1 yaw=45 at.x=3 (dotted keys nest),
|
|
or --json '{"slot": 1}'
|
|
get <path|glob> ... 'fort1.*', 'bodies[3].pos.y'
|
|
set <path> <value> a JSON value, or text: set scene Game
|
|
pause pause now
|
|
pause --when '<condition>' 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 '<condition>' | --frames n | --event Name | --paused
|
|
trace <path> ... [--frames 60] the value every frame, with min/max/mean
|
|
trace <sample> --history a Debug::Sample's recent history
|
|
log [--last 50] [--level info|warn|error] [--cat <glob>] [--grep <text>]
|
|
[--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 <name|id> 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`.
|
|
)";
|
|
}
|