Files
EVEngine/src/engine/devtools/Debugger.cpp
T
西风逍遥游 072c454150 feat(mcp): add RenderVision — vision-model description of rendered frames
Adds a vision-model bridge (devtools/RenderVision) that captures the
current frame (Graphics readback -> PNG data URL) and sends it with
engine render parameters to an OpenAI-compatible vision model over
plain HTTP, returning prose that describes the frame and explains the
render-parameter <-> visual-result correspondence for other LLM agents.

- New MCP tools:
  - eve_render_describe (fresh/reason; cached otherwise)
  - eve_render_vision_config (set/read config, key masked)
- Breakpoint hit and runtime-error pause auto-flag a pending dump that
  McpServer::poll performs on the main (render) thread.
- Config via env (EVE_VISION_BASE_URL/API_KEY/MODEL/PATH/TIMEOUT_MS)
  or the config tool.
- No TLS/OpenSSL is linked, so the endpoint must be plain HTTP (works
  with local Ollama/vLLM/LM Studio).
- Compiles cleanly on MSVC (C++20); tests extended for new tools.
2026-08-16 16:10:26 +08:00

570 lines
17 KiB
C++

#include "devtools/Debugger.hpp"
#include "devtools/RenderVision.hpp"
#include <simplesquirrel/simplesquirrel.hpp>
#include <squirrel.h>
#include <algorithm>
#include <chrono>
#include <thread>
namespace eve::dev {
namespace {
std::string describeSqValue(HSQUIRRELVM vm, SQInteger idx) {
const SQObjectType t = sq_gettype(vm, idx);
switch (t) {
case OT_NULL:
return "null";
case OT_INTEGER: {
SQInteger v = 0;
sq_getinteger(vm, idx, &v);
return std::to_string(static_cast<long long>(v));
}
case OT_FLOAT: {
SQFloat v = 0;
sq_getfloat(vm, idx, &v);
return std::to_string(static_cast<double>(v));
}
case OT_BOOL: {
SQBool v = SQFalse;
sq_getbool(vm, idx, &v);
return v ? "true" : "false";
}
case OT_STRING: {
const SQChar* s = nullptr;
sq_getstring(vm, idx, &s);
return s ? std::string("\"") + s + "\"" : "\"\"";
}
case OT_TABLE:
return "<table>";
case OT_ARRAY:
return "<array>";
case OT_USERDATA:
return "<userdata>";
case OT_CLOSURE:
return "<closure>";
case OT_NATIVECLOSURE:
return "<native>";
case OT_INSTANCE:
return "<instance>";
case OT_CLASS:
return "<class>";
case OT_THREAD:
return "<thread>";
default:
return "<other>";
}
}
std::string typeName(HSQUIRRELVM vm, SQInteger idx) {
switch (sq_gettype(vm, idx)) {
case OT_NULL:
return "null";
case OT_INTEGER:
return "integer";
case OT_FLOAT:
return "float";
case OT_BOOL:
return "bool";
case OT_STRING:
return "string";
case OT_TABLE:
return "table";
case OT_ARRAY:
return "array";
case OT_USERDATA:
return "userdata";
case OT_CLOSURE:
return "closure";
case OT_NATIVECLOSURE:
return "native";
case OT_INSTANCE:
return "instance";
case OT_CLASS:
return "class";
case OT_THREAD:
return "thread";
default:
return "other";
}
}
} // namespace
Debugger& Debugger::instance() {
static Debugger inst;
return inst;
}
std::string Debugger::normalizeSource(std::string source) {
if (source.empty()) return source;
// Strip common URI prefixes VS Code may send.
if (source.rfind("file://", 0) == 0) {
source = source.substr(7);
// file://localhost/Users/... → /Users/...
if (source.rfind("localhost/", 0) == 0) source = source.substr(9);
}
// Unify separators.
for (char& c : source) {
if (c == '\\') c = '/';
}
// Drop leading ./
while (source.size() >= 2 && source[0] == '.' && source[1] == '/') source = source.substr(2);
return source;
}
std::string Debugger::sourceBasename(const std::string& source) {
const std::string norm = normalizeSource(source);
const auto slash = norm.find_last_of('/');
return (slash == std::string::npos) ? norm : norm.substr(slash + 1);
}
bool Debugger::sourcesMatch(const std::string& a, const std::string& b) {
const std::string na = normalizeSource(a);
const std::string nb = normalizeSource(b);
if (na.empty() || nb.empty()) return false;
if (na == nb) return true;
const std::string ba = sourceBasename(na);
const std::string bb = sourceBasename(nb);
if (!ba.empty() && ba == bb) return true;
// Suffix match: ".../scripts/main.nut" vs "scripts/main.nut"
const std::string& longer = na.size() >= nb.size() ? na : nb;
const std::string& shorter = na.size() >= nb.size() ? nb : na;
if (longer.size() > shorter.size() &&
longer.compare(longer.size() - shorter.size(), shorter.size(), shorter) == 0) {
const auto idx = longer.size() - shorter.size();
return idx == 0 || longer[idx - 1] == '/';
}
return false;
}
void Debugger::attach(HSQUIRRELVM vm) {
std::lock_guard<std::mutex> lock(mu_);
vm_ = vm;
mode_ = RunMode::Running;
reason_ = PauseReason::None;
pauseLoc_ = {};
stepFrameArmed_ = false;
}
void Debugger::detach() {
std::lock_guard<std::mutex> lock(mu_);
vm_ = nullptr;
mode_ = RunMode::Running;
reason_ = PauseReason::None;
stepFrameArmed_ = false;
}
void Debugger::pause(PauseReason reason) {
reason_.store(reason);
mode_.store(RunMode::Paused);
// Frame-level Pause has no script site; drop a stale hook location so
// smart step() does not treat this as mid-script.
if (reason == PauseReason::PauseKey) pauseLoc_ = {};
}
void Debugger::resume() {
reason_.store(PauseReason::None);
mode_.store(RunMode::Running);
stepFrameArmed_ = false;
stepStartDepth_ = 0;
stepSkipLoc_ = {};
pauseLoc_ = {};
}
void Debugger::stepFrame() {
reason_.store(PauseReason::Step);
mode_.store(RunMode::StepFrame);
stepFrameArmed_ = true;
}
int Debugger::scriptStackDepth() const {
HSQUIRRELVM vm = vm_;
if (!vm) return 0;
int depth = 0;
for (int level = 0;; ++level) {
SQStackInfos si;
if (SQ_FAILED(sq_stackinfos(vm, level, &si))) break;
++depth;
}
return depth;
}
void Debugger::beginScriptStep(RunMode stepMode) {
reason_.store(PauseReason::Step);
stepStartDepth_ = scriptStackDepth();
// Squirrel can emit several _OP_LINE for one source line; skip the line we
// are currently paused on until the location changes.
stepSkipLoc_ = pauseLoc_;
// Not currently inside a script frame (frame-level pause): stop on the
// first line we see — treat like stepInto with an open depth gate.
if (stepStartDepth_ <= 0) {
mode_.store(RunMode::StepInto);
stepStartDepth_ = 0;
return;
}
mode_.store(stepMode);
}
void Debugger::stepInto() { beginScriptStep(RunMode::StepInto); }
void Debugger::stepOver() { beginScriptStep(RunMode::StepOver); }
void Debugger::stepOut() {
// No caller to return to → just step over the current line.
if (scriptStackDepth() <= 1) {
beginScriptStep(RunMode::StepOver);
return;
}
beginScriptStep(RunMode::StepOut);
}
void Debugger::step() {
// Prefer script step-over when we have a script pause site; else one frame.
const PauseReason r = reason_.load();
if (!pauseLoc_.empty() &&
(r == PauseReason::Breakpoint || r == PauseReason::Step || r == PauseReason::Exception)) {
stepOver();
return;
}
stepFrame();
}
bool Debugger::shouldRunUpdate() {
const RunMode m = mode_.load();
if (m == RunMode::Running) return true;
if (m == RunMode::StepFrame) return true;
// Allow the game loop to enter eve_update so script steps can begin after
// a frame-level pause.
if (m == RunMode::StepInto || m == RunMode::StepOver || m == RunMode::StepOut) return true;
return false; // Paused
}
void Debugger::notifyFrameDone() {
if (mode_.load() == RunMode::StepFrame || stepFrameArmed_) {
stepFrameArmed_ = false;
reason_.store(PauseReason::Step);
mode_.store(RunMode::Paused);
// Frame step finished outside the line hook — next smart step is frame.
pauseLoc_ = {};
}
}
bool Debugger::matchBreakpoint(const std::string& source, int line) const {
for (const auto& bp : bps_) {
if (!bp.enabled || bp.line != line) continue;
if (sourcesMatch(source, bp.source)) return true;
}
return false;
}
bool Debugger::onScriptLine(const SourceLoc& loc) {
const RunMode m = mode_.load();
if (m == RunMode::Paused) {
// Already paused mid-script (nested?) — keep blocking.
return true;
}
const bool stepping =
m == RunMode::StepInto || m == RunMode::StepOver || m == RunMode::StepOut;
// While leaving the paused line, ignore further events for that exact
// source+line (extra _OP_LINE and re-armed breakpoints on the same site).
if (stepping && !stepSkipLoc_.empty() && stepSkipLoc_.line == loc.line &&
sourcesMatch(stepSkipLoc_.source, loc.source)) {
return false;
}
// Breakpoints win over step filters (hit inside a skipped call).
bool hit = false;
{
std::lock_guard<std::mutex> lock(mu_);
hit = matchBreakpoint(loc.source, loc.line);
}
if (hit) {
stepSkipLoc_ = {};
pauseLoc_ = loc;
reason_.store(PauseReason::Breakpoint);
mode_.store(RunMode::Paused);
RenderVision::instance().notifyPending("breakpoint", loc.source, loc.line);
return true;
}
if (stepping) {
const int depth = scriptStackDepth();
bool stop = false;
if (m == RunMode::StepInto) {
stop = true;
} else if (m == RunMode::StepOver) {
// Same frame or outer: stop. Deeper (inside a call): keep going.
stop = depth <= stepStartDepth_;
} else { // StepOut
stop = depth < stepStartDepth_;
}
if (stop) {
stepSkipLoc_ = {};
pauseLoc_ = loc;
reason_.store(PauseReason::Step);
mode_.store(RunMode::Paused);
return true;
}
return false;
}
return false;
}
void Debugger::waitWhilePaused(const std::function<void()>& pump) {
while (mode_.load() == RunMode::Paused) {
if (pump) pump();
else if (pump_) pump_();
std::this_thread::sleep_for(std::chrono::milliseconds(5));
if (!vm_) break;
}
}
int Debugger::setBreakpoint(std::string source, int line, bool enabled) {
source = normalizeSource(std::move(source));
if (source.empty() || line <= 0) return 0;
std::lock_guard<std::mutex> lock(mu_);
for (auto& bp : bps_) {
if (bp.line == line && normalizeSource(bp.source) == source) {
bp.enabled = enabled;
return bp.id;
}
}
Breakpoint bp;
bp.source = std::move(source);
bp.line = line;
bp.enabled = enabled;
bp.id = nextBpId_++;
bps_.push_back(bp);
return bp.id;
}
bool Debugger::clearBreakpoint(std::string source, int line) {
std::lock_guard<std::mutex> lock(mu_);
const auto before = bps_.size();
bps_.erase(std::remove_if(bps_.begin(), bps_.end(),
[&](const Breakpoint& bp) {
return bp.line == line && sourcesMatch(source, bp.source);
}),
bps_.end());
return bps_.size() != before;
}
void Debugger::clearBreakpoints(const std::string& source) {
std::lock_guard<std::mutex> lock(mu_);
if (source.empty()) {
bps_.clear();
return;
}
bps_.erase(std::remove_if(bps_.begin(), bps_.end(),
[&](const Breakpoint& bp) { return sourcesMatch(source, bp.source); }),
bps_.end());
}
std::vector<Breakpoint> Debugger::breakpoints() const {
std::lock_guard<std::mutex> lock(mu_);
return bps_;
}
bool Debugger::hasBreakpoint(const std::string& source, int line) const {
std::lock_guard<std::mutex> lock(mu_);
return matchBreakpoint(source, line);
}
void Debugger::addWatch(std::string expression) {
if (expression.empty()) return;
std::lock_guard<std::mutex> lock(mu_);
for (const auto& e : watchExprs_) {
if (e == expression) return;
}
watchExprs_.push_back(std::move(expression));
}
bool Debugger::removeWatch(const std::string& expression) {
std::lock_guard<std::mutex> lock(mu_);
const auto before = watchExprs_.size();
watchExprs_.erase(std::remove(watchExprs_.begin(), watchExprs_.end(), expression),
watchExprs_.end());
return watchExprs_.size() != before;
}
void Debugger::clearWatches() {
std::lock_guard<std::mutex> lock(mu_);
watchExprs_.clear();
watchCache_.clear();
}
std::vector<WatchEntry> Debugger::watches() const {
std::lock_guard<std::mutex> lock(mu_);
return watchCache_;
}
void Debugger::refreshWatches() {
std::vector<std::string> exprs;
{
std::lock_guard<std::mutex> lock(mu_);
exprs = watchExprs_;
}
std::vector<WatchEntry> cache;
cache.reserve(exprs.size());
for (const auto& e : exprs) {
WatchEntry w;
w.expression = e;
auto info = evaluate(e);
w.ok = !info.type.empty() && info.type != "error";
w.value = info.value;
cache.push_back(std::move(w));
}
std::lock_guard<std::mutex> lock(mu_);
watchCache_ = std::move(cache);
}
VariableInfo Debugger::readLocal(HSQUIRRELVM vm, unsigned level, const std::string& name) const {
VariableInfo info;
info.name = name;
if (!vm) {
info.type = "error";
info.value = "no vm";
return info;
}
for (SQUnsignedInteger n = 0;; ++n) {
const SQInteger top = sq_gettop(vm);
const SQChar* lname = sq_getlocal(vm, level, n);
if (!lname) {
sq_settop(vm, top);
break;
}
if (name == lname) {
info.value = describeSqValue(vm, -1);
info.type = typeName(vm, -1);
sq_settop(vm, top);
return info;
}
sq_settop(vm, top);
}
info.type = "error";
info.value = "not found";
return info;
}
VariableInfo Debugger::readRoot(HSQUIRRELVM vm, const std::string& name) const {
VariableInfo info;
info.name = name;
if (!vm) {
info.type = "error";
info.value = "no vm";
return info;
}
const SQInteger top = sq_gettop(vm);
sq_pushroottable(vm);
sq_pushstring(vm, name.c_str(), -1);
if (SQ_SUCCEEDED(sq_get(vm, -2))) {
info.value = describeSqValue(vm, -1);
info.type = typeName(vm, -1);
} else {
info.type = "error";
info.value = "not found";
}
sq_settop(vm, top);
return info;
}
VariableInfo Debugger::evaluate(const std::string& expression) const {
HSQUIRRELVM vm = vm_;
if (!vm || expression.empty()) {
VariableInfo info;
info.name = expression;
info.type = "error";
info.value = "unavailable";
return info;
}
// Prefer local, then roottable slot. Full expression eval is intentionally
// limited (safe for watches of variable names / dotted root paths).
auto local = readLocal(vm, 0, expression);
if (local.type != "error") return local;
// Support a.b root path (tables only).
if (expression.find('.') != std::string::npos) {
VariableInfo info;
info.name = expression;
const SQInteger top = sq_gettop(vm);
sq_pushroottable(vm);
bool ok = true;
size_t start = 0;
while (start < expression.size()) {
size_t dot = expression.find('.', start);
if (dot == std::string::npos) dot = expression.size();
const std::string part = expression.substr(start, dot - start);
sq_pushstring(vm, part.c_str(), -1);
if (SQ_FAILED(sq_get(vm, -2))) {
ok = false;
break;
}
// Replace parent with child: [root, parent, child] → keep child under root.
sq_remove(vm, -2);
start = dot + 1;
}
if (ok) {
info.value = describeSqValue(vm, -1);
info.type = typeName(vm, -1);
} else {
info.type = "error";
info.value = "not found";
}
sq_settop(vm, top);
return info;
}
return readRoot(vm, expression);
}
std::vector<VariableInfo> Debugger::locals(int stackLevel) const {
std::vector<VariableInfo> out;
HSQUIRRELVM vm = vm_;
if (!vm) return out;
const SQUnsignedInteger level = static_cast<SQUnsignedInteger>(stackLevel < 0 ? 0 : stackLevel);
for (SQUnsignedInteger n = 0;; ++n) {
const SQInteger top = sq_gettop(vm);
const SQChar* name = sq_getlocal(vm, level, n);
if (!name) {
sq_settop(vm, top);
break;
}
VariableInfo info;
info.name = name;
info.value = describeSqValue(vm, -1);
info.type = typeName(vm, -1);
sq_settop(vm, top);
out.push_back(std::move(info));
}
return out;
}
std::vector<StackFrameInfo> Debugger::stackTrace(int maxFrames) const {
std::vector<StackFrameInfo> out;
HSQUIRRELVM vm = vm_;
if (!vm) return out;
if (maxFrames <= 0) maxFrames = 32;
for (int level = 0; level <= maxFrames; ++level) {
SQStackInfos si;
if (SQ_FAILED(sq_stackinfos(vm, level, &si))) break;
StackFrameInfo f;
f.id = level;
if (si.source) f.loc.source = si.source;
f.loc.line = static_cast<int>(si.line);
if (si.funcname) {
f.loc.function = si.funcname;
f.name = si.funcname;
} else {
f.name = "?";
}
out.push_back(std::move(f));
}
return out;
}
} // namespace eve::dev