From 7b9b1408231a9a5af7b3dd7e1a89c344d4add727 Mon Sep 17 00:00:00 2001 From: noita-player <56001276+noita-player@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:00:50 -0700 Subject: [PATCH 1/3] Fix MCP use-after-free, scanner chunk overlap, build scripts - MCP bridge: guard against use-after-free when client disconnects during sendJson flush by re-checking m_client after write - Scanner engine: fix chunk overlap advancing past region end on final chunk; fix fallback region flags for providers without enumerateRegions - Build scripts: prefer GCC MinGW over LLVM-MinGW in PATH detection --- scripts/build.ps1 | 5 +++-- scripts/build_qscintilla.ps1 | 6 +++--- src/mcp/mcp_bridge.cpp | 22 +++++++++++++++++----- src/scanner.cpp | 11 +++++++---- tests/test_scanner_ui.cpp | 5 +++++ 5 files changed, 35 insertions(+), 14 deletions(-) diff --git a/scripts/build.ps1 b/scripts/build.ps1 index cd6872b..9861e84 100644 --- a/scripts/build.ps1 +++ b/scripts/build.ps1 @@ -283,9 +283,10 @@ function Find-MinGWDirectory { $toolsDir = Join-Path $qtRoot "Tools" if (Test-Path $toolsDir) { + # Prefer GCC-based MinGW (has g++.exe); exclude llvm-mingw. Prefer 64-bit, then newest. $mingwToolDirs = Get-ChildItem -Path $toolsDir -Directory -ErrorAction SilentlyContinue | Where-Object { - $_.Name -match 'mingw' - } + $_.Name -match '^mingw\d+_\d+$' + } | Sort-Object -Property @{ Expression = { if ($_.Name -match '_64$') { 1 } else { 0 } }; Descending = $true }, Name -Descending foreach ($dir in $mingwToolDirs) { $testBin = Join-Path $dir.FullName "bin\g++.exe" diff --git a/scripts/build_qscintilla.ps1 b/scripts/build_qscintilla.ps1 index 38dc4e3..f6efc18 100644 --- a/scripts/build_qscintilla.ps1 +++ b/scripts/build_qscintilla.ps1 @@ -318,10 +318,10 @@ $qtRoot = Split-Path (Split-Path $selectedQtDir -Parent) -Parent $toolsDir = Join-Path $qtRoot "Tools" if (Test-Path $toolsDir) { - # Look for MinGW tools directory + # Prefer GCC-based MinGW (has g++.exe); exclude llvm-mingw. Prefer 64-bit, then newest. $mingwToolDirs = Get-ChildItem -Path $toolsDir -Directory -ErrorAction SilentlyContinue | Where-Object { - $_.Name -match 'mingw' - } + $_.Name -match '^mingw\d+_\d+$' + } | Sort-Object -Property @{ Expression = { if ($_.Name -match '_64$') { 1 } else { 0 } }; Descending = $true }, Name -Descending foreach ($dir in $mingwToolDirs) { $testBin = Join-Path $dir.FullName "bin\g++.exe" diff --git a/src/mcp/mcp_bridge.cpp b/src/mcp/mcp_bridge.cpp index f8f0fc6..e22def0 100644 --- a/src/mcp/mcp_bridge.cpp +++ b/src/mcp/mcp_bridge.cpp @@ -45,9 +45,13 @@ void McpBridge::start() { void McpBridge::stop() { if (m_client) { + m_client->disconnect(this); m_client->disconnectFromServer(); + m_client->deleteLater(); m_client = nullptr; } + m_readBuffer.clear(); + m_initialized = false; if (m_server) { m_server->close(); delete m_server; @@ -65,8 +69,10 @@ void McpBridge::onNewConnection() { // Single client — disconnect previous if (m_client) { + m_client->disconnect(this); m_client->disconnectFromServer(); m_client->deleteLater(); + m_client = nullptr; } m_client = pending; @@ -82,10 +88,13 @@ void McpBridge::onNewConnection() { } void McpBridge::onReadyRead() { + if (!m_client) return; m_readBuffer.append(m_client->readAll()); // Newline-delimited JSON framing - while (true) { + // Guard: processLine→sendJson→flush can re-enter the event loop + // and trigger onDisconnected, nulling m_client mid-loop. + while (m_client) { int idx = m_readBuffer.indexOf('\n'); if (idx < 0) break; QByteArray line = m_readBuffer.left(idx).trimmed(); @@ -97,7 +106,12 @@ void McpBridge::onReadyRead() { void McpBridge::onDisconnected() { qDebug() << "[MCP] Client disconnected"; - m_client = nullptr; + if (m_client) { + m_client->disconnect(this); + m_client->deleteLater(); + m_client = nullptr; + } + m_readBuffer.clear(); m_initialized = false; } @@ -127,7 +141,7 @@ void McpBridge::sendJson(const QJsonObject& obj) { qDebug() << "[MCP] >>" << data.left(200); data.append('\n'); m_client->write(data); - m_client->flush(); + if (m_client) m_client->flush(); } void McpBridge::sendNotification(const QString& method, const QJsonObject& params) { @@ -172,12 +186,10 @@ void McpBridge::processLine(const QByteArray& line) { if (method == "initialize") { m_mainWindow->setMcpStatus(QStringLiteral("MCP: client connected")); - QCoreApplication::processEvents(); sendJson(handleInitialize(id, req.value("params").toObject())); m_mainWindow->clearMcpStatus(); } else if (method == "tools/list") { m_mainWindow->setMcpStatus(QStringLiteral("MCP: tools/list")); - QCoreApplication::processEvents(); sendJson(handleToolsList(id)); m_mainWindow->clearMcpStatus(); } else if (method == "tools/call") { diff --git a/src/scanner.cpp b/src/scanner.cpp index 950e0c3..03837cc 100644 --- a/src/scanner.cpp +++ b/src/scanner.cpp @@ -473,14 +473,14 @@ QVector ScanEngine::runScan(std::shared_ptr prov, << " filterExec:" << req.filterExecutable << " filterWrite:" << req.filterWritable; - // Fallback for providers that don't enumerate regions (file/buffer) + // Fallback for providers that don't enumerate regions (file/buffer/syscall without modules) if (regions.isEmpty()) { MemoryRegion fallback; fallback.base = 0; fallback.size = (uint64_t)prov->size(); fallback.readable = true; fallback.writable = true; - fallback.executable = false; + fallback.executable = true; // unknown; include so filters don't exclude the only region regions.append(fallback); } @@ -515,7 +515,8 @@ QVector ScanEngine::runScan(std::shared_ptr prov, constexpr int kChunk = 256 * 1024; - for (const auto& region : regions) { + for (int regionIndex = 0; regionIndex < regions.size(); ++regionIndex) { + const auto& region = regions[regionIndex]; if (m_abort.load()) break; if (req.filterExecutable && !region.executable) continue; @@ -540,7 +541,7 @@ QVector ScanEngine::runScan(std::shared_ptr prov, continue; } - const int overlap = patternLen - 1; + const int overlap = patternLen; // need full patternLen overlap so pattern at chunk end is found QByteArray chunk(qMin((uint64_t)kChunk, regSize), Qt::Uninitialized); uint64_t regOffset = regStart - region.base; // offset within provider region @@ -552,6 +553,8 @@ QVector ScanEngine::runScan(std::shared_ptr prov, if (!prov->read(regStart + off, chunk.data(), readLen)) { // Skip unreadable chunk + qDebug() << "[scan] read failed region" << regionIndex << "addr" << Qt::showbase << Qt::hex + << (region.base + off) << "base" << region.base << "off" << off << "len" << readLen << Qt::dec; off += readLen; scannedBytes += readLen; continue; diff --git a/tests/test_scanner_ui.cpp b/tests/test_scanner_ui.cpp index 638bec3..ebf4373 100644 --- a/tests/test_scanner_ui.cpp +++ b/tests/test_scanner_ui.cpp @@ -790,6 +790,7 @@ private slots: QByteArray newBytes(4, '\0'); std::memcpy(newBytes.data(), &newVal, 4); prov->writeBytes(8, newBytes); + m_panel->valueEdit()->setText("99"); // Click update — runs async QSignalSpy rescanSpy(m_panel->engine(), &ScanEngine::rescanFinished); @@ -839,6 +840,7 @@ private slots: std::memcpy(nb.data(), &newVal, 4); prov->writeBytes(i * 4, nb); } + m_panel->valueEdit()->setText("21"); // Click Re-scan — runs async QSignalSpy rescanSpy(m_panel->engine(), &ScanEngine::rescanFinished); @@ -930,6 +932,7 @@ private slots: QByteArray nb2(4, '\0'); std::memcpy(nb2.data(), &v2, 4); prov->writeBytes(4, nb2); + m_panel->valueEdit()->setText("20"); { QSignalSpy rescanSpy(m_panel->engine(), &ScanEngine::rescanFinished); QTest::mouseClick(m_panel->updateButton(), Qt::LeftButton); @@ -944,6 +947,7 @@ private slots: QByteArray nb3(4, '\0'); std::memcpy(nb3.data(), &v3, 4); prov->writeBytes(4, nb3); + m_panel->valueEdit()->setText("30"); { QSignalSpy rescanSpy(m_panel->engine(), &ScanEngine::rescanFinished); QTest::mouseClick(m_panel->updateButton(), Qt::LeftButton); @@ -1009,6 +1013,7 @@ private slots: int32_t newVal = kVal + iter; for (int off = 0; off + 4 <= kBufSize; off += kStride) std::memcpy(prov->data().data() + off, &newVal, 4); + m_panel->valueEdit()->setText(QString::number(newVal)); QElapsedTimer iterTimer; iterTimer.start(); From 51de48a6ed3e605677036e8fc69f30a1237b13fc Mon Sep 17 00:00:00 2001 From: noita-player <56001276+noita-player@users.noreply.github.com> Date: Wed, 4 Mar 2026 18:55:09 -0800 Subject: [PATCH 2/3] Add MCP scanner tools, source.modules, reconnect, and constraint regions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scanner engine: - Add constrainRegions to ScanRequest — callers pass address ranges that are intersected with provider regions before scanning - Merge overlapping/adjacent constraints to prevent duplicate results - Fix final-chunk overlap: skip overlap advance on last chunk to avoid re-scanning the tail of a region MCP tools: - scanner.scan: value scans (int/float types) with optional region constraints, returns first 15 addresses - scanner.scan_pattern: pattern/signature scans with wildcards - source.modules: list loaded modules with base address and size - mcp.reconnect: graceful client disconnect for IDE reconnection - parseInteger() helper for hex string args (avoids JSON double precision loss on 64-bit addresses) - Fix baseRelative semantics in hex.read/hex.write (was inverted) - Auto-set tree.baseAddress from provider after process attach Scanner panel: - runValueScanAndWait() and runPatternScanAndWait() for blocking scan execution from MCP/automation code Tests: 41 new test cases for constrainRegions covering gaps, partial overlap, adjacent regions, writable filter, degenerate ranges, overlapping constraints, boundary patterns, alignment, and value types at region start/end positions. --- src/mcp/mcp_bridge.cpp | 385 +++++++++++++++++--- src/mcp/mcp_bridge.h | 5 +- src/scanner.cpp | 44 ++- src/scanner.h | 8 + src/scannerpanel.cpp | 93 +++++ src/scannerpanel.h | 15 + tests/test_scanner.cpp | 807 +++++++++++++++++++++++++++++++++++++++++ 7 files changed, 1305 insertions(+), 52 deletions(-) diff --git a/src/mcp/mcp_bridge.cpp b/src/mcp/mcp_bridge.cpp index e22def0..71eece6 100644 --- a/src/mcp/mcp_bridge.cpp +++ b/src/mcp/mcp_bridge.cpp @@ -1,15 +1,40 @@ #include "mcp_bridge.h" +#include "addressparser.h" #include "core.h" #include "controller.h" #include "generator.h" #include "mainwindow.h" +#include "scanner.h" #include #include +#include #include #include +#include namespace rcx { +// Parse a number from JSON; accepts string (hex "0x..." or decimal) or number. +// Use for offset, length, pid, limit, tabIndex, etc. to avoid double precision loss +// and to allow clients to send exact values as decimal/hex strings. +static int64_t parseInteger(const QJsonValue& v, int64_t defaultVal = 0) { + if (v.isUndefined() || v.isNull()) + return defaultVal; + if (v.isString()) { + QString s = v.toString().trimmed(); + if (s.isEmpty()) + return defaultVal; + bool ok; + qint64 val = s.startsWith(QLatin1String("0x"), Qt::CaseInsensitive) + ? s.mid(2).toLongLong(&ok, 16) + : s.toLongLong(&ok, 10); + return ok ? val : defaultVal; + } + if (v.isDouble()) + return static_cast(v.toDouble()); + return defaultVal; +} + // ════════════════════════════════════════════════════════════════════ // Construction / lifecycle // ════════════════════════════════════════════════════════════════════ @@ -337,6 +362,21 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) { }} }); + // 3b. source.modules + tools.append(QJsonObject{ + {"name", "source.modules"}, + {"description", "List modules for the current data source. Returns name, base (hex), and size for each module. " + "Only available when the provider reports module info (e.g. after attaching to a process). " + "Use these names in baseAddressFormula for tree base, e.g. ' + 0x1000'."}, + {"inputSchema", QJsonObject{ + {"type", "object"}, + {"properties", QJsonObject{ + {"tabIndex", QJsonObject{{"type", "integer"}, + {"description", "MDI tab index (0-based). Omit for active tab."}}} + }} + }} + }); + // 4. hex.read tools.append(QJsonObject{ {"name", "hex.read"}, @@ -459,22 +499,73 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) { }} }); - - // process.info + // 10. scanner.scan tools.append(QJsonObject{ - {"name", "process.info"}, - {"description", "Returns PEB address and enumerates all Thread Environment Blocks (TEBs) for the attached process. " - "TEBs are discovered via NtQuerySystemInformation and NtQueryInformationThread. " - "Each TEB entry includes: address, threadId. " - "Requires a live process provider with PEB support."}, + {"name", "scanner.scan"}, + {"description", "Run a value scan on the active tab's provider and wait for completion. " + "Use after source.switch (e.g. attach to process). Value type: int8, int16, int32, int64, " + "uint8, uint16, uint32, uint64, float, double. Results appear in the Scanner panel. " + "For value scans (e.g. float 120) prefer scanning readable/writable (data) regions, not executable: " + "set filterWritable: true and filterExecutable: false. " + "Use 'regions' to restrict scan to specific address ranges (intersected with provider regions)."}, {"inputSchema", QJsonObject{ {"type", "object"}, {"properties", QJsonObject{ {"tabIndex", QJsonObject{{"type", "integer"}, - {"description", "MDI tab index (0-based). Omit for active tab."}}} - }} + {"description", "MDI tab index (0-based). Omit for active tab."}}}, + {"valueType", QJsonObject{{"type", "string"}, + {"description", "Value type: float, double, int32, uint32, int64, uint64, int16, uint16, int8, uint8."}}}, + {"value", QJsonObject{{"type", "string"}, + {"description", "Value to search for (e.g. \"120\" for float 120)."}}}, + {"filterExecutable", QJsonObject{{"type", "boolean"}, + {"description", "Only scan executable regions (default false). For value scans use false; use writable instead."}}}, + {"filterWritable", QJsonObject{{"type", "boolean"}, + {"description", "Only scan writable regions (default false). Recommended true for value scans to hit data/heap, not code."}}}, + {"regions", QJsonObject{{"type", "array"}, + {"description", "Restrict scan to these address ranges. Each element is [startHex, endHex], e.g. [[\"0x10000\",\"0x20000\"],[\"0x50000\",\"0x60000\"]]. Ranges are intersected with the provider's real memory regions."}, + {"items", QJsonObject{{"type", "array"}, {"items", QJsonObject{{"type", "string"}}}}}}} + }}, + {"required", QJsonArray{"valueType", "value"}} }} }); + + // 10. scanner.scan_pattern + tools.append(QJsonObject{ + {"name", "scanner.scan_pattern"}, + {"description", "Run a pattern/signature scan on the active tab's provider and wait for completion. " + "Pattern is space-separated hex bytes, e.g. '00 00 20 42 00 00 20 42'. Use ?? for wildcards. " + "Results appear in the Scanner panel. Uses the same region list as value scans. " + "Use 'regions' to restrict scan to specific address ranges (intersected with provider regions)."}, + {"inputSchema", QJsonObject{ + {"type", "object"}, + {"properties", QJsonObject{ + {"tabIndex", QJsonObject{{"type", "integer"}, + {"description", "MDI tab index (0-based). Omit for active tab."}}}, + {"pattern", QJsonObject{{"type", "string"}, + {"description", "Hex pattern, e.g. '00 00 20 42 00 00 20 42 00 00 00 00 00 00 00 00'. Use ?? for wildcard bytes."}}}, + {"filterExecutable", QJsonObject{{"type", "boolean"}, + {"description", "Only scan executable regions (default false)."}}}, + {"filterWritable", QJsonObject{{"type", "boolean"}, + {"description", "Only scan writable regions (default false)."}}}, + {"regions", QJsonObject{{"type", "array"}, + {"description", "Restrict scan to these address ranges. Each element is [startHex, endHex], e.g. [[\"0x10000\",\"0x20000\"],[\"0x50000\",\"0x60000\"]]. Ranges are intersected with the provider's real memory regions."}, + {"items", QJsonObject{{"type", "array"}, {"items", QJsonObject{{"type", "string"}}}}}}} + }}, + {"required", QJsonArray{"pattern"}} + }} + }); + + // 11. mcp.reconnect + tools.append(QJsonObject{ + {"name", "mcp.reconnect"}, + {"description", "Disconnect the current MCP client so it can reconnect to Reclass (e.g. after Reclass was restarted or to reset connection state). " + "The client process will exit; your IDE may restart it automatically, reconnecting to Reclass like at startup."}, + {"inputSchema", QJsonObject{ + {"type", "object"}, + {"properties", QJsonObject{}} + }} + }); + return okReply(id, QJsonObject{{"tools", tools}}); } @@ -494,13 +585,16 @@ QJsonObject McpBridge::handleToolsCall(const QJsonValue& id, const QJsonObject& if (toolName == "project.state") result = toolProjectState(args); else if (toolName == "tree.apply") result = toolTreeApply(args); else if (toolName == "source.switch") result = toolSourceSwitch(args); + else if (toolName == "source.modules") result = toolSourceModules(args); else if (toolName == "hex.read") result = toolHexRead(args); else if (toolName == "hex.write") result = toolHexWrite(args); else if (toolName == "status.set") result = toolStatusSet(args); else if (toolName == "ui.action") result = toolUiAction(args); else if (toolName == "tree.search") result = toolTreeSearch(args); else if (toolName == "node.history") result = toolNodeHistory(args); - else if (toolName == "process.info") result = toolProcessInfo(args); + else if (toolName == "scanner.scan") result = toolScannerScan(args); + else if (toolName == "scanner.scan_pattern") result = toolScannerScanPattern(args); + else if (toolName == "mcp.reconnect") result = toolReconnect(args); else return errReply(id, -32601, "Unknown tool: " + toolName); m_mainWindow->clearMcpStatus(); @@ -529,7 +623,7 @@ QString McpBridge::resolvePlaceholder(const QString& ref, MainWindow::TabState* McpBridge::resolveTab(const QJsonObject& args) { // 1) Explicit tab index from args if (args.contains("tabIndex")) { - int idx = args.value("tabIndex").toInt(); + int idx = (int)parseInteger(args.value("tabIndex")); auto* t = m_mainWindow->tabByIndex(idx); if (t) return t; } @@ -561,16 +655,18 @@ QJsonObject McpBridge::toolProjectState(const QJsonObject& args) { auto* ctrl = tab->ctrl; const auto& tree = doc->tree; - int maxDepth = args.value("depth").toInt(1); + int maxDepth = (int)parseInteger(args.value("depth"), 1); bool includeTree = args.contains("includeTree") ? args.value("includeTree").toBool() : true; bool includeMembers = args.value("includeMembers").toBool(false); - int limit = qBound(1, args.value("limit").toInt(50), 500); - int offset = qMax(0, args.value("offset").toInt(0)); + int limit = qBound(1, (int)parseInteger(args.value("limit"), 50), 500); + int offset = qMax(0, (int)parseInteger(args.value("offset"), 0)); QString parentIdStr = args.value("parentId").toString(); uint64_t filterParentId = parentIdStr.isEmpty() ? 0 : parentIdStr.toULongLong(); QJsonObject state; state["baseAddress"] = "0x" + QString::number(tree.baseAddress, 16).toUpper(); + if (!tree.baseAddressFormula.isEmpty()) + state["baseAddressFormula"] = tree.baseAddressFormula; state["viewRootId"] = QString::number(ctrl->viewRootId()); state["nodeCount"] = tree.nodes.size(); @@ -686,6 +782,8 @@ QJsonObject McpBridge::toolProjectState(const QJsonObject& args) { QJsonObject treeObj; treeObj["baseAddress"] = QString::number(tree.baseAddress, 16); + if (!tree.baseAddressFormula.isEmpty()) + treeObj["baseAddressFormula"] = tree.baseAddressFormula; treeObj["nextId"] = QString::number(tree.m_nextId); treeObj["nodes"] = nodeArr; treeObj["returned"] = emitted; @@ -750,12 +848,12 @@ QJsonObject McpBridge::toolTreeApply(const QJsonObject& args) { n.name = op.value("name").toString(); QString pid = resolvePlaceholder(op.value("parentId").toString("0"), placeholders); n.parentId = pid.toULongLong(); - n.offset = op.value("offset").toInt(0); + n.offset = (int)parseInteger(op.value("offset"), 0); n.structTypeName = op.value("structTypeName").toString(); n.classKeyword = op.value("classKeyword").toString(); - n.strLen = op.value("strLen").toInt(64); + n.strLen = (int)parseInteger(op.value("strLen"), 64); n.elementKind = kindFromString(op.value("elementKind").toString("UInt8")); - n.arrayLen = op.value("arrayLen").toInt(1); + n.arrayLen = (int)parseInteger(op.value("arrayLen"), 1); QString refStr = resolvePlaceholder(op.value("refId").toString("0"), placeholders); n.refId = refStr.toULongLong(); @@ -822,7 +920,7 @@ QJsonObject McpBridge::toolTreeApply(const QJsonObject& args) { QString nid = resolvePlaceholder(op.value("nodeId").toString(), placeholders); int idx = tree.indexOfId(nid.toULongLong()); if (idx >= 0) { - int newOff = op.value("offset").toInt(); + int newOff = (int)parseInteger(op.value("offset")); doc->undoStack.push(new RcxCommand(ctrl, cmd::ChangeOffset{tree.nodes[idx].id, tree.nodes[idx].offset, newOff})); applied++; @@ -882,7 +980,7 @@ QJsonObject McpBridge::toolTreeApply(const QJsonObject& args) { int idx = tree.indexOfId(nid.toULongLong()); if (idx >= 0) { NodeKind newElemKind = kindFromString(op.value("elementKind").toString()); - int newLen = op.value("arrayLen").toInt(1); + int newLen = (int)parseInteger(op.value("arrayLen"), 1); doc->undoStack.push(new RcxCommand(ctrl, cmd::ChangeArrayMeta{tree.nodes[idx].id, tree.nodes[idx].elementKind, newElemKind, @@ -951,7 +1049,7 @@ QJsonObject McpBridge::toolSourceSwitch(const QJsonObject& args) { auto* doc = tab->doc; if (args.contains("sourceIndex")) { - int idx = args.value("sourceIndex").toInt(); + int idx = (int)parseInteger(args.value("sourceIndex")); const auto& sources = ctrl->savedSources(); if (idx < 0 || idx >= sources.size()) return makeTextResult("Source index out of range: " + QString::number(idx), true); @@ -968,11 +1066,17 @@ QJsonObject McpBridge::toolSourceSwitch(const QJsonObject& args) { } if (args.contains("pid")) { - uint32_t pid = (uint32_t)args.value("pid").toInt(); + uint32_t pid = (uint32_t)parseInteger(args.value("pid")); QString name = args.value("processName").toString(); if (name.isEmpty()) name = QString("PID %1").arg(pid); QString target = QString("%1:%2").arg(pid).arg(name); ctrl->attachViaPlugin(QStringLiteral("processmemory"), target); + // attachViaPlugin does not set tree.baseAddress; set it from the new provider (like selectSource does). + if (doc->provider && doc->provider->base() != 0) { + doc->tree.baseAddress = doc->provider->base(); + doc->tree.baseAddressFormula.clear(); + ctrl->refresh(); + } return makeTextResult("Attached to process " + name + " (PID " + QString::number(pid) + ")"); } @@ -986,6 +1090,54 @@ QJsonObject McpBridge::toolSourceSwitch(const QJsonObject& args) { return makeTextResult("Provide sourceIndex, filePath, or pid", true); } +// ════════════════════════════════════════════════════════════════════ +// TOOL: source.modules +// ════════════════════════════════════════════════════════════════════ + +QJsonObject McpBridge::toolSourceModules(const QJsonObject& args) { + auto* tab = resolveTab(args); + if (!tab) return makeTextResult("No active tab", true); + + auto* prov = tab->doc->provider.get(); + if (!prov) return makeTextResult("No data source attached", true); + + QVector regions = prov->enumerateRegions(); + // Build unique modules: name -> { minBase, maxEnd } + QHash> moduleMap; + for (const auto& r : regions) { + if (r.moduleName.isEmpty()) continue; + uint64_t end = r.base + r.size; + auto it = moduleMap.find(r.moduleName); + if (it == moduleMap.end()) { + moduleMap[r.moduleName] = qMakePair(r.base, end); + } else { + it->first = qMin(it->first, r.base); + it->second = qMax(it->second, end); + } + } + + QJsonArray arr; + QStringList names = moduleMap.keys(); + std::sort(names.begin(), names.end(), [](const QString& a, const QString& b) { + return QString::compare(a, b, Qt::CaseInsensitive) < 0; + }); + for (const QString& name : names) { + const auto& p = moduleMap[name]; + uint64_t base = p.first; + uint64_t size = p.second - p.first; + arr.append(QJsonObject{ + {"name", name}, + {"base", "0x" + QString::number(base, 16).toUpper()}, + {"size", QJsonValue(static_cast(size))} + }); + } + + QJsonObject out; + out["modules"] = arr; + out["count"] = arr.size(); + return makeTextResult(QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Indented))); +} + // ════════════════════════════════════════════════════════════════════ // TOOL: hex.read // ════════════════════════════════════════════════════════════════════ @@ -997,12 +1149,19 @@ QJsonObject McpBridge::toolHexRead(const QJsonObject& args) { auto* prov = tab->doc->provider.get(); if (!prov) return makeTextResult("No provider", true); - int64_t offset = static_cast(args.value("offset").toDouble()); - int length = qMin(args.value("length").toInt(64), 4096); + int64_t offset = parseInteger(args.value("offset")); + int length = qBound(1, (int)parseInteger(args.value("length"), 64), 4096); + bool baseRel = args.value("baseRelative").toBool(); - if (!args.value("baseRelative").toBool()) + if (baseRel) offset += (int64_t)tab->doc->tree.baseAddress; + qDebug() << "[hex_read] arg offset" << (args.value("offset").isString() ? "str" : "num") + << (args.value("offset").isString() ? args.value("offset").toString() : QString()) + << Qt::showbase << Qt::hex << (quint64)offset + << "baseRelative" << baseRel << "tree.base" << (quint64)tab->doc->tree.baseAddress + << "final addr" << (quint64)offset << Qt::dec; + if (offset < 0 || !prov->isReadable((uint64_t)offset, length)) return makeTextResult("Cannot read at offset " + QString::number(offset), true); @@ -1079,10 +1238,10 @@ QJsonObject McpBridge::toolHexWrite(const QJsonObject& args) { auto* doc = tab->doc; auto* prov = doc->provider.get(); - int64_t offset = static_cast(args.value("offset").toDouble()); + int64_t offset = parseInteger(args.value("offset")); QString hexStr = args.value("hexBytes").toString().remove(' '); - if (!args.value("baseRelative").toBool()) + if (args.value("baseRelative").toBool()) offset += (int64_t)doc->tree.baseAddress; if (hexStr.size() % 2 != 0) @@ -1266,7 +1425,7 @@ QJsonObject McpBridge::toolTreeSearch(const QJsonObject& args) { const auto& tree = tab->doc->tree; QString query = args.value("query").toString(); QString kindFilter = args.value("kindFilter").toString(); - int limit = qBound(1, args.value("limit").toInt(20), 100); + int limit = qBound(1, (int)parseInteger(args.value("limit"), 20), 100); if (query.isEmpty() && kindFilter.isEmpty()) return makeTextResult("Provide 'query' (name substring) and/or 'kindFilter' (e.g. 'Struct')", true); @@ -1356,37 +1515,167 @@ QJsonObject McpBridge::toolNodeHistory(const QJsonObject& args) { QJsonDocument(result).toJson(QJsonDocument::Compact))); } - -// ════════════════════════════════════════════════════════════════════ -// TOOL: process.info — PEB address + TEB enumeration +// TOOL: scanner.scan // ════════════════════════════════════════════════════════════════════ -QJsonObject McpBridge::toolProcessInfo(const QJsonObject& args) { +static ValueType valueTypeFromString(const QString& s) { + QString lower = s.trimmed().toLower(); + if (lower == QStringLiteral("int8")) return ValueType::Int8; + if (lower == QStringLiteral("int16")) return ValueType::Int16; + if (lower == QStringLiteral("int32")) return ValueType::Int32; + if (lower == QStringLiteral("int64")) return ValueType::Int64; + if (lower == QStringLiteral("uint8")) return ValueType::UInt8; + if (lower == QStringLiteral("uint16")) return ValueType::UInt16; + if (lower == QStringLiteral("uint32")) return ValueType::UInt32; + if (lower == QStringLiteral("uint64")) return ValueType::UInt64; + if (lower == QStringLiteral("float")) return ValueType::Float; + if (lower == QStringLiteral("double")) return ValueType::Double; + return ValueType::Float; // default +} + +static QVector parseRegionsArg(const QJsonObject& args, QString* errOut = nullptr) { + QVector out; + QJsonArray arr = args.value("regions").toArray(); + if (arr.isEmpty()) return out; + out.reserve(arr.size()); + for (int i = 0; i < arr.size(); i++) { + QJsonArray pair = arr[i].toArray(); + if (pair.size() != 2) { + if (errOut) *errOut = QStringLiteral("regions[%1]: expected [startHex, endHex]").arg(i); + return {}; + } + bool ok1 = false, ok2 = false; + uint64_t start = pair[0].toString().toULongLong(&ok1, 0); + uint64_t end = pair[1].toString().toULongLong(&ok2, 0); + if (!ok1 || !ok2) { + if (errOut) *errOut = QStringLiteral("regions[%1]: invalid hex address").arg(i); + return {}; + } + if (end <= start) { + if (errOut) *errOut = QStringLiteral("regions[%1]: end must be > start").arg(i); + return {}; + } + out.append({start, end}); + } + return out; +} + +QJsonObject McpBridge::toolScannerScan(const QJsonObject& args) { auto* tab = resolveTab(args); if (!tab) return makeTextResult("No active tab", true); - auto* prov = tab->doc->provider.get(); - if (!prov) return makeTextResult("No data source attached", true); - if (!prov->isLive()) return makeTextResult("Not a live provider", true); + ScannerPanel* panel = m_mainWindow->m_scannerPanel; + if (!panel) return makeTextResult("Scanner panel not available", true); - uint64_t pebAddr = prov->peb(); - if (!pebAddr) return makeTextResult("PEB not available for this provider", true); + QString valueTypeStr = args.value("valueType").toString(); + QString value = args.value("value").toString(); + bool filterExec = args.value("filterExecutable").toBool(); + bool filterWrite = args.value("filterWritable").toBool(); - QJsonObject out; - out["peb"] = "0x" + QString::number(pebAddr, 16).toUpper(); + if (value.isEmpty()) + return makeTextResult("Missing 'value' (e.g. \"120\")", true); - auto tebList = prov->tebs(); - QJsonArray tebArr; - for (const auto& t : tebList) { - tebArr.append(QJsonObject{ - {"address", "0x" + QString::number(t.tebAddress, 16).toUpper()}, - {"threadId", (qint64)t.threadId} - }); + QString regErr; + auto constrainRegions = parseRegionsArg(args, ®Err); + if (!regErr.isEmpty()) + return makeTextResult(regErr, true); + + ValueType vt = valueTypeFromString(valueTypeStr); + QVector results = panel->runValueScanAndWait(vt, value, filterExec, filterWrite, constrainRegions); + + QString msg = QStringLiteral("Scan (%1 = %2): %3 result(s).") + .arg(valueTypeStr.isEmpty() ? QStringLiteral("float") : valueTypeStr) + .arg(value) + .arg(results.size()); + if (!constrainRegions.isEmpty()) { + uint64_t totalConstrained = 0; + for (const auto& r : constrainRegions) totalConstrained += r.end - r.start; + msg += QStringLiteral("\nRegion constraint: %1 range(s), %2 bytes total requested.") + .arg(constrainRegions.size()).arg(totalConstrained); + } + const int showAddrs = 15; + if (!results.isEmpty()) { + msg += QStringLiteral("\nFirst addresses:"); + for (int i = 0; i < qMin(results.size(), showAddrs); i++) { + msg += QStringLiteral("\n 0x%1").arg(results[i].address, 16, 16, QChar('0')); + if (!results[i].regionModule.isEmpty()) + msg += QStringLiteral(" (%1)").arg(results[i].regionModule); + } + if (results.size() > showAddrs) + msg += QStringLiteral("\n ... and %1 more").arg(results.size() - showAddrs); + } + return makeTextResult(msg); +} + +// ════════════════════════════════════════════════════════════════════ +// TOOL: scanner.scan_pattern +// ════════════════════════════════════════════════════════════════════ + +QJsonObject McpBridge::toolScannerScanPattern(const QJsonObject& args) { + auto* tab = resolveTab(args); + if (!tab) return makeTextResult("No active tab", true); + + ScannerPanel* panel = m_mainWindow->m_scannerPanel; + if (!panel) return makeTextResult("Scanner panel not available", true); + + QString pattern = args.value("pattern").toString().trimmed(); + bool filterExec = args.value("filterExecutable").toBool(); + bool filterWrite = args.value("filterWritable").toBool(); + + if (pattern.isEmpty()) + return makeTextResult("Missing 'pattern' (e.g. \"00 00 20 42 00 00 20 42\")", true); + + QString regErr; + auto constrainRegions = parseRegionsArg(args, ®Err); + if (!regErr.isEmpty()) + return makeTextResult(regErr, true); + + // Use the resolved tab's provider so the scan runs on the same tab we attached to (source_switch). + // If we used the panel's default getter we'd get the *active* tab's provider, which may be different. + std::shared_ptr provider = (tab->doc && tab->doc->provider) ? tab->doc->provider : nullptr; + if (!provider) { + return makeTextResult("No provider on this tab — the scan did not run. Use source_switch to attach to a process (or open a file), then run the pattern scan again. If you already ran source_switch, ensure the tab that was switched is the one used (e.g. pass tabIndex: 0 for the first tab).", true); } - out["tebs"] = tebArr; - out["tebCount"] = tebArr.size(); - return makeTextResult(QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Indented))); + QVector results = panel->runPatternScanAndWait(provider, pattern, filterExec, filterWrite, constrainRegions); + + QString msg = QStringLiteral("Pattern scan (%1): %2 result(s).") + .arg(pattern) + .arg(results.size()); + if (!constrainRegions.isEmpty()) { + uint64_t totalConstrained = 0; + for (const auto& r : constrainRegions) totalConstrained += r.end - r.start; + msg += QStringLiteral("\nRegion constraint: %1 range(s), %2 bytes total requested.") + .arg(constrainRegions.size()).arg(totalConstrained); + } + const int showAddrs = 15; + if (!results.isEmpty()) { + msg += QStringLiteral("\nFirst addresses:"); + for (int i = 0; i < qMin(results.size(), showAddrs); i++) { + msg += QStringLiteral("\n 0x%1").arg(results[i].address, 16, 16, QChar('0')); + if (!results[i].regionModule.isEmpty()) + msg += QStringLiteral(" (%1)").arg(results[i].regionModule); + } + if (results.size() > showAddrs) + msg += QStringLiteral("\n ... and %1 more").arg(results.size() - showAddrs); + } + return makeTextResult(msg); +} + +// ════════════════════════════════════════════════════════════════════ +// TOOL: mcp.reconnect +// ════════════════════════════════════════════════════════════════════ + +QJsonObject McpBridge::toolReconnect(const QJsonObject&) { + if (!m_client) + return makeTextResult("No client connected.", true); + // Disconnect after this response is sent so the client receives the result + QTimer::singleShot(0, this, [this]() { + if (m_client) { + m_client->disconnectFromServer(); + } + }); + return makeTextResult("Disconnected. The MCP client will exit; your IDE may restart it and reconnect to Reclass."); } // ════════════════════════════════════════════════════════════════════ diff --git a/src/mcp/mcp_bridge.h b/src/mcp/mcp_bridge.h index 8c5f1c5..c4e77ee 100644 --- a/src/mcp/mcp_bridge.h +++ b/src/mcp/mcp_bridge.h @@ -54,13 +54,16 @@ private: QJsonObject toolProjectState(const QJsonObject& args); QJsonObject toolTreeApply(const QJsonObject& args); QJsonObject toolSourceSwitch(const QJsonObject& args); + QJsonObject toolSourceModules(const QJsonObject& args); QJsonObject toolHexRead(const QJsonObject& args); QJsonObject toolHexWrite(const QJsonObject& args); QJsonObject toolStatusSet(const QJsonObject& args); QJsonObject toolUiAction(const QJsonObject& args); QJsonObject toolTreeSearch(const QJsonObject& args); QJsonObject toolNodeHistory(const QJsonObject& args); - QJsonObject toolProcessInfo(const QJsonObject& args); + QJsonObject toolScannerScan(const QJsonObject& args); + QJsonObject toolScannerScanPattern(const QJsonObject& args); + QJsonObject toolReconnect(const QJsonObject& args); // Helpers QJsonObject makeTextResult(const QString& text, bool isError = false); diff --git a/src/scanner.cpp b/src/scanner.cpp index 03837cc..2cc7bfd 100644 --- a/src/scanner.cpp +++ b/src/scanner.cpp @@ -492,6 +492,41 @@ QVector ScanEngine::runScan(std::shared_ptr prov, const bool hasRange = (req.startAddress != 0 || req.endAddress != 0) && req.endAddress > req.startAddress; + // If constrainRegions specified, intersect with provider regions + if (!req.constrainRegions.isEmpty()) { + // Sort and merge overlapping/adjacent constraints to avoid duplicate sub-regions + auto constraints = req.constrainRegions; + std::sort(constraints.begin(), constraints.end(), + [](const AddressRange& a, const AddressRange& b) { return a.start < b.start; }); + QVector merged; + for (const auto& c : constraints) { + if (c.end <= c.start) continue; // skip degenerate ranges + if (!merged.isEmpty() && c.start <= merged.last().end) + merged.last().end = qMax(merged.last().end, c.end); + else + merged.append(c); + } + + QVector clipped; + for (const auto& region : regions) { + uint64_t rEnd = region.base + region.size; + for (const auto& c : merged) { + if (c.end <= region.base || c.start >= rEnd) continue; + uint64_t iStart = qMax(region.base, c.start); + uint64_t iEnd = qMin(rEnd, c.end); + if (iEnd <= iStart) continue; + MemoryRegion sub = region; + sub.base = iStart; + sub.size = iEnd - iStart; + clipped.append(sub); + } + } + regions = std::move(clipped); + qDebug() << "[scan] constrained to" << regions.size() << "sub-regions from" + << req.constrainRegions.size() << "address ranges (" + << merged.size() << "after merge)"; + } + // Pre-compute total bytes for progress uint64_t totalBytes = 0; for (const auto& r : regions) { @@ -541,7 +576,7 @@ QVector ScanEngine::runScan(std::shared_ptr prov, continue; } - const int overlap = patternLen; // need full patternLen overlap so pattern at chunk end is found + const int overlap = patternLen - 1; QByteArray chunk(qMin((uint64_t)kChunk, regSize), Qt::Uninitialized); uint64_t regOffset = regStart - region.base; // offset within provider region @@ -597,9 +632,12 @@ QVector ScanEngine::runScan(std::shared_ptr prov, } } - // Advance with overlap to catch patterns that straddle chunks + // Advance with overlap to catch patterns that straddle chunks. + // Skip overlap on the final chunk -- nothing follows to overlap into. uint64_t advance; - if (readLen > overlap) + if ((uint64_t)readLen >= remaining) + advance = remaining; // last chunk, no overlap needed + else if (readLen > overlap) advance = (uint64_t)(readLen - overlap); else advance = 1; // prevent infinite loop on tiny regions diff --git a/src/scanner.h b/src/scanner.h index 491a8a3..d87ac2e 100644 --- a/src/scanner.h +++ b/src/scanner.h @@ -34,6 +34,11 @@ enum class ScanCondition { // ── Scan request / result ── +struct AddressRange { + uint64_t start = 0; + uint64_t end = 0; // exclusive +}; + struct ScanRequest { QByteArray pattern; // literal bytes to match (empty for UnknownValue) QByteArray mask; // 0xFF = must match, 0x00 = wildcard @@ -49,6 +54,9 @@ struct ScanRequest { uint64_t startAddress = 0; // 0 = no limit (scan all regions) uint64_t endAddress = 0; // 0 = no limit (scan all regions) + + // If non-empty, only scan within these address ranges (intersected with provider regions). + QVector constrainRegions; }; struct ScanResult { diff --git a/src/scannerpanel.cpp b/src/scannerpanel.cpp index fa0eb84..c97b7f7 100644 --- a/src/scannerpanel.cpp +++ b/src/scannerpanel.cpp @@ -10,6 +10,7 @@ #include #include #include +#include namespace rcx { @@ -418,6 +419,98 @@ ScanRequest ScannerPanel::buildRequest() { return req; } +QVector ScannerPanel::runValueScanAndWait(ValueType valueType, const QString& value, + bool filterExecutable, bool filterWritable, + const QVector& constrainRegions) { + QVector results; + QString err; + ScanRequest req; + if (!serializeValue(valueType, value, req.pattern, req.mask, &err)) { + m_statusLabel->setText(QStringLiteral("Value error: %1").arg(err)); + return results; + } + req.alignment = naturalAlignment(valueType); + req.filterExecutable = filterExecutable; + req.filterWritable = filterWritable; + req.constrainRegions = constrainRegions; + + auto provider = m_providerGetter ? m_providerGetter() : nullptr; + if (!provider) { + m_statusLabel->setText(QStringLiteral("No provider (attach to a process or open a file first)")); + return results; + } + if (m_engine->isRunning()) { + m_statusLabel->setText(QStringLiteral("Scan already in progress")); + return results; + } + + m_lastScanMode = 1; + m_lastValueType = valueType; + m_lastPattern = req.pattern; + m_progressBar->setValue(0); + m_progressBar->show(); + m_statusLabel->setText(QStringLiteral("Scanning...")); + + QEventLoop loop; + connect(m_engine, &ScanEngine::finished, this, [&results, &loop](const QVector& r) { + results = r; + loop.quit(); + }, Qt::SingleShotConnection); + m_engine->start(provider, req); + loop.exec(); + + return results; +} + +QVector ScannerPanel::runPatternScanAndWait(const QString& pattern, + bool filterExecutable, bool filterWritable, + const QVector& constrainRegions) { + auto provider = m_providerGetter ? m_providerGetter() : nullptr; + return runPatternScanAndWait(provider, pattern, filterExecutable, filterWritable, constrainRegions); +} + +QVector ScannerPanel::runPatternScanAndWait(std::shared_ptr provider, + const QString& pattern, + bool filterExecutable, bool filterWritable, + const QVector& constrainRegions) { + QVector results; + QString err; + ScanRequest req; + if (!parseSignature(pattern, req.pattern, req.mask, &err)) { + m_statusLabel->setText(QStringLiteral("Pattern error: %1").arg(err)); + return results; + } + req.alignment = 1; + req.filterExecutable = filterExecutable; + req.filterWritable = filterWritable; + req.constrainRegions = constrainRegions; + + if (!provider) { + m_statusLabel->setText(QStringLiteral("No provider (attach to a process or open a file first)")); + return results; + } + if (m_engine->isRunning()) { + m_statusLabel->setText(QStringLiteral("Scan already in progress")); + return results; + } + + m_lastScanMode = 0; + m_lastPattern = req.pattern; + m_progressBar->setValue(0); + m_progressBar->show(); + m_statusLabel->setText(QStringLiteral("Scanning...")); + + QEventLoop loop; + connect(m_engine, &ScanEngine::finished, this, [&results, &loop](const QVector& r) { + results = r; + loop.quit(); + }, Qt::SingleShotConnection); + m_engine->start(provider, req); + loop.exec(); + + return results; +} + void ScannerPanel::onScanFinished(QVector results) { m_scanBtn->setText(QStringLiteral("Scan")); m_progressBar->hide(); diff --git a/src/scannerpanel.h b/src/scannerpanel.h index c7e40b5..ef8edba 100644 --- a/src/scannerpanel.h +++ b/src/scannerpanel.h @@ -60,6 +60,21 @@ public: QLabel* condLabel() const { return m_condLabel; } QCheckBox* structOnlyCheck() const { return m_structOnlyCheck; } + /** Run a value scan and block until done. For MCP / automation. Returns results; updates panel table. */ + QVector runValueScanAndWait(ValueType valueType, const QString& value, + bool filterExecutable = false, bool filterWritable = false, + const QVector& constrainRegions = {}); + + /** Run a pattern/signature scan and block until done. Pattern: space-separated hex bytes, e.g. "00 00 20 42 ?? ??". */ + QVector runPatternScanAndWait(const QString& pattern, + bool filterExecutable = false, bool filterWritable = false, + const QVector& constrainRegions = {}); + + /** Run pattern scan using the given provider (for MCP: use tab's provider so scan runs on the right tab). */ + QVector runPatternScanAndWait(std::shared_ptr provider, const QString& pattern, + bool filterExecutable = false, bool filterWritable = false, + const QVector& constrainRegions = {}); + signals: void goToAddress(uint64_t address); diff --git a/tests/test_scanner.cpp b/tests/test_scanner.cpp index 09da93e..e327a59 100644 --- a/tests/test_scanner.cpp +++ b/tests/test_scanner.cpp @@ -1186,6 +1186,813 @@ private slots: QCOMPARE(results[0].address, (uint64_t)8); QCOMPARE(results[3].address, (uint64_t)20); } + + // -- constrainRegions (multi-range intersection) -- + + void scan_constrainRegions_multipleRanges() { + QByteArray data(32, 0); + data[4] = char(0xBB); + data[12] = char(0xBB); + data[20] = char(0xBB); + data[28] = char(0xBB); + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray("\xBB", 1); + req.mask = QByteArray("\xFF", 1); + req.constrainRegions = {{0, 8}, {16, 24}}; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 2); + QCOMPARE(results[0].address, (uint64_t)4); + QCOMPARE(results[1].address, (uint64_t)20); + } + + void scan_constrainRegions_intersectsProviderRegions() { + QByteArray data(256, 0); + data[160] = char(0xCC); + data[210] = char(0xCC); + QVector regions; + regions.append({100, 100, true, false, false, {}}); + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray("\xCC", 1); + req.mask = QByteArray("\xFF", 1); + req.constrainRegions = {{150, 250}}; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)160); + } + + void scan_constrainRegions_noOverlap() { + QByteArray data(32, char(0xEE)); + QVector regions; + regions.append({0, 16, true, false, false, {}}); + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray("\xEE", 1); + req.mask = QByteArray("\xFF", 1); + req.constrainRegions = {{100, 200}}; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 0); + } + + // -- constrainRegions edge cases -- + + void scan_constrainRegions_gapBetweenRegions() { + // Provider has two regions with a gap: [0,16) and [32,48). + // Constraint spans the gap: [8, 40). Should find matches in both. + QByteArray data(64, 0); + data[10] = char(0xDD); + data[35] = char(0xDD); + QVector regions; + regions.append({0, 16, true, true, false, {}}); + regions.append({32, 16, true, true, false, {}}); + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray(1, char(0xDD)); + req.mask = QByteArray(1, char(0xFF)); + req.constrainRegions = {{8, 40}}; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 2); + QCOMPARE(results[0].address, (uint64_t)10); + QCOMPARE(results[1].address, (uint64_t)35); + } + + void scan_constrainRegions_partialRegionOverlap() { + // Provider region [100, 200). Constraint [150, 250) clips to [150, 200). + QByteArray data(256, 0); + data[120] = char(0xAB); + data[160] = char(0xAB); + QVector regions; + regions.append({100, 100, true, true, false, {}}); + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray(1, char(0xAB)); + req.mask = QByteArray(1, char(0xFF)); + req.constrainRegions = {{150, 250}}; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)160); + } + + void scan_constrainRegions_mixedModuleAndAnonymous() { + // Module region + anonymous heap region. Constraint covers both. + QByteArray data(0x10000, 0); + data[0x1500] = char(0xCC); + data[0x5500] = char(0xCC); + QVector regions; + regions.append({0x1000, 0x1000, true, false, true, QString("game.exe")}); + regions.append({0x5000, 0x1000, true, true, false, {}}); + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray(1, char(0xCC)); + req.mask = QByteArray(1, char(0xFF)); + req.constrainRegions = {{0x0, 0x10000}}; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 2); + QCOMPARE(results[0].address, (uint64_t)0x1500); + QCOMPARE(results[1].address, (uint64_t)0x5500); + } + + void scan_constrainRegions_fallbackProvider() { + // BufferProvider returns no regions -> fallback [0, size). + // constrainRegions should still work against the fallback. + QByteArray data(64, 0); + data[10] = char(0xAA); + data[30] = char(0xAA); + data[50] = char(0xAA); + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray(1, char(0xAA)); + req.mask = QByteArray(1, char(0xFF)); + req.constrainRegions = {{5, 35}}; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 2); + QCOMPARE(results[0].address, (uint64_t)10); + QCOMPARE(results[1].address, (uint64_t)30); + } + + void scan_constrainRegions_adjacentRegions() { + // Two adjacent regions [0,16) and [16,32). Constraint [8,24) spans both. + QByteArray data(32, 0); + data[12] = char(0xEF); + data[20] = char(0xEF); + QVector regions; + regions.append({0, 16, true, true, false, {}}); + regions.append({16, 16, true, true, false, {}}); + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray(1, char(0xEF)); + req.mask = QByteArray(1, char(0xFF)); + req.constrainRegions = {{8, 24}}; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 2); + QCOMPARE(results[0].address, (uint64_t)12); + QCOMPARE(results[1].address, (uint64_t)20); + } + + void scan_constrainRegions_writableFilterPreserved() { + // filterWritable=true should still exclude non-writable clipped regions. + QByteArray data(0x4000, 0); + data[0x1100] = char(0xBB); + data[0x2100] = char(0xBB); + QVector regions; + regions.append({0x1000, 0x1000, true, false, true, {}}); + regions.append({0x2000, 0x1000, true, true, false, {}}); + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray(1, char(0xBB)); + req.mask = QByteArray(1, char(0xFF)); + req.filterWritable = true; + req.constrainRegions = {{0x1000, 0x3000}}; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)0x2100); + } + + + void scan_constrainRegions_constraintExtendsBeforeAndAfter() { + // Region [10, 20). Constraint [0, 30) extends before and after. + // Should only scan [10, 20) — the intersection. + QByteArray data(32, 0); + data[5] = char(0xAA); // outside region, should NOT be found + data[15] = char(0xAA); // inside region, should be found + data[25] = char(0xAA); // outside region, should NOT be found + QVector regions; + regions.append({10, 10, true, true, false, {}}); + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray(1, char(0xAA)); + req.mask = QByteArray(1, char(0xFF)); + req.constrainRegions = {{0, 30}}; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)15); + } + + void scan_constrainRegions_emptyConstraintScansAll() { + // Empty constrainRegions should scan everything (no restriction). + QByteArray data(32, 0); + data[5] = char(0xBB); + data[15] = char(0xBB); + QVector regions; + regions.append({0, 32, true, true, false, {}}); + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray(1, char(0xBB)); + req.mask = QByteArray(1, char(0xFF)); + // constrainRegions left empty + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 2); + } + + void scan_constrainRegions_singleAddressRange() { + // Equivalent to startAddress/endAddress: single constraint range. + QByteArray data(32, 0); + data[8] = char(0xAA); + data[16] = char(0xAA); + data[24] = char(0xAA); + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray(1, char(0xAA)); + req.mask = QByteArray(1, char(0xFF)); + req.constrainRegions = {{8, 20}}; // same as startAddress=8, endAddress=20 + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 2); + QCOMPARE(results[0].address, (uint64_t)8); + QCOMPARE(results[1].address, (uint64_t)16); + } + + + void scan_constrainRegions_withStartEndAddress() { + // Both constrainRegions and startAddress/endAddress set. + // constrainRegions: [0, 16) and [24, 32). startAddress/endAddress: [8, 28). + // Effective scan should be intersection of both: [8, 16) and [24, 28). + // Match at 4 (outside both), 12 (in both), 20 (in startEnd but not constrain), + // 26 (in both), 30 (in constrain but not startEnd). + QByteArray data(32, 0); + data[4] = char(0xDD); + data[12] = char(0xDD); + data[20] = char(0xDD); + data[26] = char(0xDD); + data[30] = char(0xDD); + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray(1, char(0xDD)); + req.mask = QByteArray(1, char(0xFF)); + req.constrainRegions = {{0, 16}, {24, 32}}; + req.startAddress = 8; + req.endAddress = 28; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 2); // only 12 and 26 + QCOMPARE(results[0].address, (uint64_t)12); + QCOMPARE(results[1].address, (uint64_t)26); + } + + void scan_constrainRegions_unknownValueScan() { + // Unknown value scan with constrainRegions should only capture within ranges. + QByteArray data(32, char(0x42)); + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.condition = ScanCondition::UnknownValue; + req.valueSize = 4; + req.alignment = 4; + req.constrainRegions = {{8, 24}}; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + // Range [8, 24) = 16 bytes, alignment 4, valueSize 4 -> offsets 8, 12, 16, 20 = 4 results + QCOMPARE(results.size(), 4); + QCOMPARE(results[0].address, (uint64_t)8); + QCOMPARE(results[3].address, (uint64_t)20); + } + + + void scan_constrainRegions_nonZeroBase() { + // Region with non-zero base; constraint matches exactly. + QByteArray data(0x10000, 0); + data[0x8100] = char(0xFF); + QVector regions; + regions.append({0x8000, 0x1000, true, true, false, {}}); + auto prov = std::make_shared(data2, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray(1, char(0xFF)); + req.mask = QByteArray(1, char(0xFF)); + req.constrainRegions = {{0x8000, 0x9000}}; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)0x8100); + } + + void scan_constrainRegions_zeroSizeConstraint() { + // Degenerate: constraint with start == end (zero size). Should scan nothing. + QByteArray data(32, char(0xAA)); + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray(1, char(0xAA)); + req.mask = QByteArray(1, char(0xFF)); + req.constrainRegions = {{10, 10}}; // zero-size + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 0); + } + + void scan_constrainRegions_invertedRange() { + // Degenerate: constraint with start > end. Should be treated as empty/invalid. + QByteArray data(32, char(0xAA)); + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray(1, char(0xAA)); + req.mask = QByteArray(1, char(0xFF)); + req.constrainRegions = {{20, 10}}; // inverted + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 0); + } + + void scan_constrainRegions_overlappingConstraints() { + // Two overlapping constraints: [4, 20) and [12, 28). + // Should NOT double-count matches in the overlap [12, 20). + QByteArray data(32, 0); + data[8] = char(0xCC); + data[16] = char(0xCC); + data[24] = char(0xCC); + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray(1, char(0xCC)); + req.mask = QByteArray(1, char(0xFF)); + req.constrainRegions = {{4, 20}, {12, 28}}; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + // After merge: [4, 28). All three matches are in range, no duplicates. + QCOMPARE(results.size(), 3); + } + + + void scan_constrainRegions_patternAtFirstByte() { + // Pattern at the very first byte of a clipped sub-region. + // Region [0, 64). Constraint [20, 40). Match at offset 20. + QByteArray data(64, 0); + data[20] = char(0xFE); + QVector regions; + regions.append({0, 64, true, true, false, {}}); + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray(1, char(0xFE)); + req.mask = QByteArray(1, char(0xFF)); + req.constrainRegions = {{20, 40}}; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)20); + } + + void scan_constrainRegions_patternAtLastByte() { + // Pattern at the very last valid position of a clipped sub-region. + // Region [0, 64). Constraint [20, 40). 4-byte pattern at offset 36 (last valid: 40-4=36). + QByteArray data(64, 0); + data[36] = char(0xDE); data[37] = char(0xAD); data[38] = char(0xBE); data[39] = char(0xEF); + QVector regions; + regions.append({0, 64, true, true, false, {}}); + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray("\xDE\xAD\xBE\xEF", 4); + req.mask = QByteArray("\xFF\xFF\xFF\xFF", 4); + req.constrainRegions = {{20, 40}}; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)36); + } + + void scan_constrainRegions_patternOneByteAfterEnd() { + // Pattern starts 1 byte before constraint end — only 3 of 4 bytes are in range. + // Should NOT match because the full pattern doesn't fit. + // Region [0, 64). Constraint [20, 39). 4-byte pattern at offset 36 (needs 36..39, but 39 is excluded). + QByteArray data(64, 0); + data[36] = char(0xDE); data[37] = char(0xAD); data[38] = char(0xBE); data[39] = char(0xEF); + QVector regions; + regions.append({0, 64, true, true, false, {}}); + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray("\xDE\xAD\xBE\xEF", 4); + req.mask = QByteArray("\xFF\xFF\xFF\xFF", 4); + req.constrainRegions = {{20, 39}}; // ends at 39, pattern needs 36..39 inclusive + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 0); // pattern doesn't fit + } + + void scan_constrainRegions_regionSmallerThanPattern() { + // Clipped sub-region is smaller than the pattern. Should scan nothing, not crash. + // Region [0, 64). Constraint [30, 32). 4-byte pattern can't fit in 2 bytes. + QByteArray data(64, char(0xAA)); + QVector regions; + regions.append({0, 64, true, true, false, {}}); + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray("\xAA\xAA\xAA\xAA", 4); + req.mask = QByteArray("\xFF\xFF\xFF\xFF", 4); + req.constrainRegions = {{30, 32}}; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 0); + } + + void scan_constrainRegions_patternExactlyFitsRegion() { + // Clipped sub-region is exactly pattern size. Should find match if bytes match. + // Region [0, 64). Constraint [30, 34). 4-byte pattern, 4-byte region. + QByteArray data(64, 0); + data[30] = char(0x11); data[31] = char(0x22); data[32] = char(0x33); data[33] = char(0x44); + QVector regions; + regions.append({0, 64, true, true, false, {}}); + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray("\x11\x22\x33\x44", 4); + req.mask = QByteArray("\xFF\xFF\xFF\xFF", 4); + req.constrainRegions = {{30, 34}}; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)30); + } + + void scan_constrainRegions_matchAtRegionBoundaries() { + // Two adjacent clipped sub-regions. Matches at the last byte of the first + // and first byte of the second. Both should be found. + // Regions: [0, 16) and [16, 32). Constraint [0, 32) (full coverage). + QByteArray data(32, 0); + data[15] = char(0x77); // last byte of first region + data[16] = char(0x77); // first byte of second region + QVector regions; + regions.append({0, 16, true, true, false, {}}); + regions.append({16, 16, true, true, false, {}}); + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray(1, char(0x77)); + req.mask = QByteArray(1, char(0xFF)); + req.constrainRegions = {{0, 32}}; + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 2); + QCOMPARE(results[0].address, (uint64_t)15); + QCOMPARE(results[1].address, (uint64_t)16); + } + + void scan_constrainRegions_multibyteAtClipBoundary() { + // 4-byte pattern that straddles the constraint boundary — should NOT be found + // because the clipped region doesn't contain the full pattern. + // Region [0, 64). Constraint [10, 13). Pattern at offset 10 is 4 bytes (10..13), + // but constraint end is 13 (exclusive), so only 3 bytes [10,13) are in range. + QByteArray data(64, 0); + data[10] = char(0xAA); data[11] = char(0xBB); data[12] = char(0xCC); data[13] = char(0xDD); + QVector regions; + regions.append({0, 64, true, true, false, {}}); + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = QByteArray("\xAA\xBB\xCC\xDD", 4); + req.mask = QByteArray("\xFF\xFF\xFF\xFF", 4); + req.constrainRegions = {{10, 13}}; // only 3 bytes, pattern needs 4 + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 0); + } + + + // ── Value type + pattern scans at every position in a constrained region ── + + // Helper: run a scan with the given pattern/mask/alignment in a constrained region, + // return the result addresses. + QVector scanConstrained(const QByteArray& data, + const QByteArray& pat, const QByteArray& mask, + int alignment, uint64_t cStart, uint64_t cEnd) { + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + ScanRequest req; + req.pattern = pat; + req.mask = mask; + req.alignment = alignment; + req.constrainRegions = {{cStart, cEnd}}; + engine.start(prov, req); + if (!finSpy.wait(5000)) return {}; + auto results = finSpy.first().first().value>(); + QVector addrs; + for (const auto& r : results) addrs.append(r.address); + return addrs; + } + + void scan_int32_atRegionStart() { + QByteArray data(128, 0); + int32_t v = 0x12345678; + std::memcpy(data.data() + 32, &v, 4); + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Int32, "305419896", pat, mask)); // 0x12345678 + auto addrs = scanConstrained(data, pat, mask, 4, 32, 96); + QCOMPARE(addrs.size(), 1); + QCOMPARE(addrs[0], (uint64_t)32); + } + + void scan_int32_atRegionEnd() { + QByteArray data(128, 0); + int32_t v = 0x12345678; + // Last aligned 4-byte position in [32, 96) is 92 + std::memcpy(data.data() + 92, &v, 4); + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Int32, "305419896", pat, mask)); + auto addrs = scanConstrained(data, pat, mask, 4, 32, 96); + QCOMPARE(addrs.size(), 1); + QCOMPARE(addrs[0], (uint64_t)92); + } + + void scan_float_atRegionStart() { + QByteArray data(128, 0); + float v = 3.14f; + std::memcpy(data.data() + 16, &v, 4); + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Float, "3.14", pat, mask)); + auto addrs = scanConstrained(data, pat, mask, 4, 16, 80); + QCOMPARE(addrs.size(), 1); + QCOMPARE(addrs[0], (uint64_t)16); + } + + void scan_float_atRegionEnd() { + QByteArray data(128, 0); + float v = 3.14f; + // Last aligned 4-byte position in [16, 80) is 76 + std::memcpy(data.data() + 76, &v, 4); + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Float, "3.14", pat, mask)); + auto addrs = scanConstrained(data, pat, mask, 4, 16, 80); + QCOMPARE(addrs.size(), 1); + QCOMPARE(addrs[0], (uint64_t)76); + } + + void scan_double_atRegionEnd() { + QByteArray data(128, 0); + double v = 2.71828; + // Last aligned 8-byte position in [0, 128) is 120 + std::memcpy(data.data() + 120, &v, 8); + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Double, "2.71828", pat, mask)); + auto addrs = scanConstrained(data, pat, mask, 8, 0, 128); + QCOMPARE(addrs.size(), 1); + QCOMPARE(addrs[0], (uint64_t)120); + } + + void scan_int64_atRegionEnd() { + QByteArray data(128, 0); + int64_t v = 0x0BADC0DEDEADBEEFLL; + // Last aligned 8-byte position in [8, 72) is 64 + std::memcpy(data.data() + 64, &v, 8); + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Int64, "841540768839352047", pat, mask)); + auto addrs = scanConstrained(data, pat, mask, 8, 8, 72); + QCOMPARE(addrs.size(), 1); + QCOMPARE(addrs[0], (uint64_t)64); + } + + void scan_utf16_atRegionEnd() { + QByteArray data(128, 0); + // "AB" in UTF-16LE = 4 bytes + uint16_t chars[] = { 'A', 'B' }; + // Last aligned 2-byte position where 4 bytes fit in [0, 128) is 124 + std::memcpy(data.data() + 124, chars, 4); + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::UTF16, "AB", pat, mask)); + auto addrs = scanConstrained(data, pat, mask, 2, 0, 128); + QCOMPARE(addrs.size(), 1); + QCOMPARE(addrs[0], (uint64_t)124); + } + + void scan_vec3_atRegionEnd() { + QByteArray data(128, 0); + float v[] = { 1.0f, 2.0f, 3.0f }; // 12 bytes + // Last aligned 4-byte position where 12 bytes fit in [0, 128) is 116 + std::memcpy(data.data() + 116, v, 12); + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Vec3, "1.0 2.0 3.0", pat, mask)); + auto addrs = scanConstrained(data, pat, mask, 4, 0, 128); + QCOMPARE(addrs.size(), 1); + QCOMPARE(addrs[0], (uint64_t)116); + } + + void scan_pattern_atRegionStart() { + QByteArray data(128, 0); + data[20] = char(0x48); data[21] = char(0x8B); data[22] = char(0x05); + QByteArray pat, mask; + QVERIFY(parseSignature("48 8B 05", pat, mask)); + auto addrs = scanConstrained(data, pat, mask, 1, 20, 100); + QCOMPARE(addrs.size(), 1); + QCOMPARE(addrs[0], (uint64_t)20); + } + + void scan_pattern_atRegionEnd() { + QByteArray data(128, 0); + // 3-byte pattern, last position in [20, 100) is 97 + data[97] = char(0x48); data[98] = char(0x8B); data[99] = char(0x05); + QByteArray pat, mask; + QVERIFY(parseSignature("48 8B 05", pat, mask)); + auto addrs = scanConstrained(data, pat, mask, 1, 20, 100); + QCOMPARE(addrs.size(), 1); + QCOMPARE(addrs[0], (uint64_t)97); + } + + void scan_pattern_withWildcard_atRegionEnd() { + QByteArray data(128, 0); + // "48 ?? 05" at last position 97 in [20, 100) + data[97] = char(0x48); data[98] = char(0xFF); data[99] = char(0x05); + QByteArray pat, mask; + QVERIFY(parseSignature("48 ?? 05", pat, mask)); + auto addrs = scanConstrained(data, pat, mask, 1, 20, 100); + QCOMPARE(addrs.size(), 1); + QCOMPARE(addrs[0], (uint64_t)97); + } + + void scan_int32_multiplePositions_inConstrainedRegion() { + // Place int32 at first, middle, and last aligned positions in [32, 96). + // Aligned positions: 32, 36, 40, ..., 88, 92. First=32, last=92, mid=60. + QByteArray data(128, 0); + int32_t v = 0xCAFEBABE; + std::memcpy(data.data() + 32, &v, 4); + std::memcpy(data.data() + 60, &v, 4); + std::memcpy(data.data() + 92, &v, 4); + // Also place one outside the constraint to verify it's excluded + std::memcpy(data.data() + 8, &v, 4); + std::memcpy(data.data() + 100, &v, 4); + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::UInt32, "0xCAFEBABE", pat, mask)); + auto addrs = scanConstrained(data, pat, mask, 4, 32, 96); + QCOMPARE(addrs.size(), 3); + QCOMPARE(addrs[0], (uint64_t)32); + QCOMPARE(addrs[1], (uint64_t)60); + QCOMPARE(addrs[2], (uint64_t)92); + } + + void scan_pattern_multiplePositions_inConstrainedRegion() { + // IDA-style pattern at first, last, and middle of [16, 80). + // Pattern "AA BB" (2 bytes), alignment 1. First=16, last=78, mid=50. + QByteArray data(128, 0); + data[16] = char(0xAA); data[17] = char(0xBB); + data[50] = char(0xAA); data[51] = char(0xBB); + data[78] = char(0xAA); data[79] = char(0xBB); + // Outside constraint + data[10] = char(0xAA); data[11] = char(0xBB); + data[90] = char(0xAA); data[91] = char(0xBB); + QByteArray pat, mask; + QVERIFY(parseSignature("AA BB", pat, mask)); + auto addrs = scanConstrained(data, pat, mask, 1, 16, 80); + QCOMPARE(addrs.size(), 3); + QCOMPARE(addrs[0], (uint64_t)16); + QCOMPARE(addrs[1], (uint64_t)50); + QCOMPARE(addrs[2], (uint64_t)78); + } + + + void scan_int8_alignment1_atRegionEnd() { + // 1-byte value at last byte of constrained region [10, 50). + QByteArray data(64, 0); + data[49] = char(0x7F); + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Int8, "127", pat, mask)); + auto addrs = scanConstrained(data, pat, mask, 1, 10, 50); + QCOMPARE(addrs.size(), 1); + QCOMPARE(addrs[0], (uint64_t)49); + } + + void scan_uint16_alignment2_atRegionEnd() { + // 2-byte value at last aligned-2 position in [10, 50) = offset 48. + QByteArray data(64, 0); + uint16_t v = 0xBEEF; + std::memcpy(data.data() + 48, &v, 2); + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::UInt16, "0xBEEF", pat, mask)); + auto addrs = scanConstrained(data, pat, mask, 2, 10, 50); + QCOMPARE(addrs.size(), 1); + QCOMPARE(addrs[0], (uint64_t)48); + } + + void scan_alignment4_skipsUnaligned() { + // int32 placed at unaligned offset 18 inside [16, 48). Alignment 4. + // Aligned positions from 16: 16, 20, 24, 28, 32, 36, 40, 44. + // Offset 18 is not aligned to 4 from the region start, so should be skipped. + QByteArray data(64, 0); + int32_t v = 0xDEADBEEF; + std::memcpy(data.data() + 18, &v, 4); // unaligned + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::UInt32, "0xDEADBEEF", pat, mask)); + auto addrs = scanConstrained(data, pat, mask, 4, 16, 48); + QCOMPARE(addrs.size(), 0); + } + + void scan_alignment8_skipsUnaligned() { + // double placed at offset 12 inside [0, 64). Alignment 8. + // Aligned positions: 0, 8, 16, 24, 32, 40, 48, 56. + // Offset 12 is not 8-aligned, so should be skipped. + QByteArray data(64, 0); + double v = 99.99; + std::memcpy(data.data() + 12, &v, 8); // unaligned + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Double, "99.99", pat, mask)); + auto addrs = scanConstrained(data, pat, mask, 8, 0, 64); + QCOMPARE(addrs.size(), 0); + } + + void scan_alignment2_findsAligned_skipsUnaligned() { + // utf16 "Hi" (4 bytes) at aligned offset 20 and unaligned offset 33. + // Constraint [16, 48), alignment 2. Should find only offset 20. + QByteArray data(64, 0); + uint16_t chars[] = { 'H', 'i' }; + std::memcpy(data.data() + 20, chars, 4); // aligned to 2 + std::memcpy(data.data() + 33, chars, 4); // unaligned to 2 + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::UTF16, "Hi", pat, mask)); + auto addrs = scanConstrained(data, pat, mask, 2, 16, 48); + QCOMPARE(addrs.size(), 1); + QCOMPARE(addrs[0], (uint64_t)20); + } + + void scan_alignment1_overlappingWrites() { + // Pattern "AA BB" written at 20, then overwritten at 21, plus 25. + // Second write clobbers offset 20's pattern; only 21 and 25 match. + QByteArray data(48, 0); + data[20] = char(0xAA); data[21] = char(0xBB); + data[21] = char(0xAA); data[22] = char(0xBB); // overlapping at 21 + data[25] = char(0xAA); data[26] = char(0xBB); + QByteArray pat, mask; + QVERIFY(parseSignature("AA BB", pat, mask)); + auto addrs = scanConstrained(data, pat, mask, 1, 16, 32); + QCOMPARE(addrs.size(), 2); // 21 and 25 (20 was overwritten) + QCOMPARE(addrs[0], (uint64_t)21); + QCOMPARE(addrs[1], (uint64_t)25); + } }; QTEST_MAIN(TestScanner) From 4d0782db68a1aa6089afa57fe64feb4922bab96d Mon Sep 17 00:00:00 2001 From: noita-player <56001276+noita-player@users.noreply.github.com> Date: Sun, 8 Mar 2026 20:49:59 -0700 Subject: [PATCH 3/3] MCP bridge: support multiple concurrent clients Replace single-client model (m_client/m_readBuffer/m_initialized) with a ClientState vector. Each client gets its own read buffer and initialized flag. Responses route to m_currentSender (set during request processing); notifications broadcast to all initialized clients. Re-entrancy guard in onReadyRead: re-resolve ClientState after each processLine() call since sendJson flush can re-enter the event loop and trigger onDisconnected, removing the client mid-iteration. Tests: 378-line test_mcp exercising connect, initialize, tools/list, disconnect one client, notification broadcast, and serial requests against a MockMcpServer with the same multi-client architecture. --- CMakeLists.txt | 5 + src/mcp/mcp_bridge.cpp | 201 +++++++++++++++------- src/mcp/mcp_bridge.h | 25 ++- tests/test_mcp.cpp | 378 +++++++++++++++++++++++++++++++++++++++++ tests/test_scanner.cpp | 2 +- 5 files changed, 549 insertions(+), 62 deletions(-) create mode 100644 tests/test_mcp.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3f27c0e..f3f3531 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -559,6 +559,11 @@ if(BUILD_TESTING) ${QT}::Widgets ${QT}::Concurrent ${QT}::Test) add_test(NAME test_scanner_ui COMMAND test_scanner_ui) + add_executable(test_mcp tests/test_mcp.cpp) + target_include_directories(test_mcp PRIVATE src) + target_link_libraries(test_mcp PRIVATE ${QT}::Core ${QT}::Network ${QT}::Test) + add_test(NAME test_mcp COMMAND test_mcp) + if(WIN32) add_executable(test_windbg_provider tests/test_windbg_provider.cpp plugins/WinDbgMemory/WinDbgMemoryPlugin.cpp diff --git a/src/mcp/mcp_bridge.cpp b/src/mcp/mcp_bridge.cpp index 71eece6..67f8f59 100644 --- a/src/mcp/mcp_bridge.cpp +++ b/src/mcp/mcp_bridge.cpp @@ -69,14 +69,15 @@ void McpBridge::start() { } void McpBridge::stop() { - if (m_client) { - m_client->disconnect(this); - m_client->disconnectFromServer(); - m_client->deleteLater(); - m_client = nullptr; + for (auto& c : m_clients) { + c.socket->disconnect(this); + c.socket->disconnectFromServer(); + c.socket->deleteLater(); } - m_readBuffer.clear(); - m_initialized = false; + m_clients.clear(); + m_currentSender = nullptr; + m_processing = false; + m_pendingRequests.clear(); if (m_server) { m_server->close(); delete m_server; @@ -88,56 +89,89 @@ void McpBridge::stop() { // Connection handling // ════════════════════════════════════════════════════════════════════ +McpBridge::ClientState* McpBridge::findClient(QLocalSocket* sock) { + for (auto& c : m_clients) + if (c.socket == sock) return &c; + return nullptr; +} + +void McpBridge::removeClient(QLocalSocket* sock) { + for (int i = 0; i < m_clients.size(); ++i) { + if (m_clients[i].socket == sock) { + sock->disconnect(this); + sock->deleteLater(); + m_clients.removeAt(i); + return; + } + } +} + void McpBridge::onNewConnection() { auto* pending = m_server->nextPendingConnection(); if (!pending) return; - // Single client — disconnect previous - if (m_client) { - m_client->disconnect(this); - m_client->disconnectFromServer(); - m_client->deleteLater(); - m_client = nullptr; - } + m_clients.append({pending, {}, false}); - m_client = pending; - m_readBuffer.clear(); - m_initialized = false; - - connect(m_client, &QLocalSocket::readyRead, + connect(pending, &QLocalSocket::readyRead, this, &McpBridge::onReadyRead); - connect(m_client, &QLocalSocket::disconnected, + connect(pending, &QLocalSocket::disconnected, this, &McpBridge::onDisconnected); - qDebug() << "[MCP] Client connected"; + qDebug() << "[MCP] Client connected (" << m_clients.size() << "total)"; } void McpBridge::onReadyRead() { - if (!m_client) return; - m_readBuffer.append(m_client->readAll()); + auto* sock = qobject_cast(sender()); + auto* cs = findClient(sock); + if (!cs) return; + cs->readBuffer.append(sock->readAll()); - // Newline-delimited JSON framing - // Guard: processLine→sendJson→flush can re-enter the event loop - // and trigger onDisconnected, nulling m_client mid-loop. - while (m_client) { - int idx = m_readBuffer.indexOf('\n'); + // Extract complete lines from this client's buffer. + // If a request is already in flight (m_processing), queue the line + // instead of processing it -- nested event loops in scanner/tree.apply + // would otherwise let interleaved requests clobber m_currentSender. + while (findClient(sock)) { + cs = findClient(sock); + int idx = cs->readBuffer.indexOf('\n'); if (idx < 0) break; - QByteArray line = m_readBuffer.left(idx).trimmed(); - m_readBuffer.remove(0, idx + 1); - if (!line.isEmpty()) - processLine(line); + QByteArray line = cs->readBuffer.left(idx).trimmed(); + cs->readBuffer.remove(0, idx + 1); + if (line.isEmpty()) continue; + + if (m_processing) { + m_pendingRequests.append({sock, line}); + continue; + } + m_processing = true; + m_currentSender = sock; + processLine(line); + m_currentSender = nullptr; + m_processing = false; + drainPendingRequests(); + } +} + +void McpBridge::drainPendingRequests() { + while (!m_pendingRequests.isEmpty()) { + auto req = m_pendingRequests.takeFirst(); + if (!findClient(req.socket)) continue; // client disconnected while queued + m_processing = true; + m_currentSender = req.socket; + processLine(req.line); + m_currentSender = nullptr; + m_processing = false; } } void McpBridge::onDisconnected() { - qDebug() << "[MCP] Client disconnected"; - if (m_client) { - m_client->disconnect(this); - m_client->deleteLater(); - m_client = nullptr; - } - m_readBuffer.clear(); - m_initialized = false; + auto* sock = qobject_cast(sender()); + qDebug() << "[MCP] Client disconnected (" << m_clients.size() - 1 << "remaining)"; + // Purge any queued requests from this client + m_pendingRequests.erase( + std::remove_if(m_pendingRequests.begin(), m_pendingRequests.end(), + [sock](const PendingRequest& r) { return r.socket == sock; }), + m_pendingRequests.end()); + removeClient(sock); } // ════════════════════════════════════════════════════════════════════ @@ -161,18 +195,26 @@ QJsonObject McpBridge::errReply(const QJsonValue& id, int code, const QString& m } void McpBridge::sendJson(const QJsonObject& obj) { - if (!m_client) return; + QLocalSocket* target = m_currentSender; + if (!target || !findClient(target)) return; QByteArray data = QJsonDocument(obj).toJson(QJsonDocument::Compact); qDebug() << "[MCP] >>" << data.left(200); data.append('\n'); - m_client->write(data); - if (m_client) m_client->flush(); + target->write(data); + target->flush(); } void McpBridge::sendNotification(const QString& method, const QJsonObject& params) { QJsonObject n{{"jsonrpc", "2.0"}, {"method", method}}; if (!params.isEmpty()) n["params"] = params; - sendJson(n); + QByteArray data = QJsonDocument(n).toJson(QJsonDocument::Compact); + data.append('\n'); + for (auto& c : m_clients) { + if (c.initialized) { + c.socket->write(data); + c.socket->flush(); + } + } } QJsonObject McpBridge::makeTextResult(const QString& text, bool isError) { @@ -229,7 +271,7 @@ void McpBridge::processLine(const QByteArray& line) { // ════════════════════════════════════════════════════════════════════ QJsonObject McpBridge::handleInitialize(const QJsonValue& id, const QJsonObject&) { - m_initialized = true; + if (auto* cs = findClient(m_currentSender)) cs->initialized = true; QJsonObject caps; caps["tools"] = QJsonObject{{"listChanged", false}}; @@ -566,6 +608,22 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) { }} }); + + // process.info + tools.append(QJsonObject{ + {"name", "process.info"}, + {"description", "Returns PEB address and enumerates all Thread Environment Blocks (TEBs) for the attached process. " + "TEBs are discovered via NtQuerySystemInformation and NtQueryInformationThread. " + "Each TEB entry includes: address, threadId. " + "Requires a live process provider with PEB support."}, + {"inputSchema", QJsonObject{ + {"type", "object"}, + {"properties", QJsonObject{ + {"tabIndex", QJsonObject{{"type", "integer"}, + {"description", "MDI tab index (0-based). Omit for active tab."}}} + }} + }} + }); return okReply(id, QJsonObject{{"tools", tools}}); } @@ -595,6 +653,7 @@ QJsonObject McpBridge::handleToolsCall(const QJsonValue& id, const QJsonObject& else if (toolName == "scanner.scan") result = toolScannerScan(args); else if (toolName == "scanner.scan_pattern") result = toolScannerScanPattern(args); else if (toolName == "mcp.reconnect") result = toolReconnect(args); + else if (toolName == "process.info") result = toolProcessInfo(args); else return errReply(id, -32601, "Unknown tool: " + toolName); m_mainWindow->clearMcpStatus(); @@ -1156,12 +1215,6 @@ QJsonObject McpBridge::toolHexRead(const QJsonObject& args) { if (baseRel) offset += (int64_t)tab->doc->tree.baseAddress; - qDebug() << "[hex_read] arg offset" << (args.value("offset").isString() ? "str" : "num") - << (args.value("offset").isString() ? args.value("offset").toString() : QString()) - << Qt::showbase << Qt::hex << (quint64)offset - << "baseRelative" << baseRel << "tree.base" << (quint64)tab->doc->tree.baseAddress - << "final addr" << (quint64)offset << Qt::dec; - if (offset < 0 || !prov->isReadable((uint64_t)offset, length)) return makeTextResult("Cannot read at offset " + QString::number(offset), true); @@ -1667,29 +1720,61 @@ QJsonObject McpBridge::toolScannerScanPattern(const QJsonObject& args) { // ════════════════════════════════════════════════════════════════════ QJsonObject McpBridge::toolReconnect(const QJsonObject&) { - if (!m_client) + QLocalSocket* sock = m_currentSender; + if (!sock) return makeTextResult("No client connected.", true); // Disconnect after this response is sent so the client receives the result - QTimer::singleShot(0, this, [this]() { - if (m_client) { - m_client->disconnectFromServer(); - } + QTimer::singleShot(0, this, [this, sock]() { + if (findClient(sock)) + sock->disconnectFromServer(); }); return makeTextResult("Disconnected. The MCP client will exit; your IDE may restart it and reconnect to Reclass."); } +// ════════════════════════════════════════════════════════════════════ +// TOOL: process.info — PEB address + TEB enumeration +// ════════════════════════════════════════════════════════════════════ + +QJsonObject McpBridge::toolProcessInfo(const QJsonObject& args) { + auto* tab = resolveTab(args); + if (!tab) return makeTextResult("No active tab", true); + + auto* prov = tab->doc->provider.get(); + if (!prov) return makeTextResult("No data source attached", true); + if (!prov->isLive()) return makeTextResult("Not a live provider", true); + + uint64_t pebAddr = prov->peb(); + if (!pebAddr) return makeTextResult("PEB not available for this provider", true); + + QJsonObject out; + out["peb"] = "0x" + QString::number(pebAddr, 16).toUpper(); + + auto tebList = prov->tebs(); + QJsonArray tebArr; + for (const auto& t : tebList) { + tebArr.append(QJsonObject{ + {"address", "0x" + QString::number(t.tebAddress, 16).toUpper()}, + {"threadId", (qint64)t.threadId} + }); + } + + out["tebs"] = tebArr; + out["tebCount"] = tebArr.size(); + return makeTextResult(QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Indented))); +} + // ════════════════════════════════════════════════════════════════════ // Notifications (call from MainWindow/Controller hooks) // ════════════════════════════════════════════════════════════════════ void McpBridge::notifyTreeChanged() { - if (!m_client || !m_initialized) return; + if (m_clients.isEmpty()) return; sendNotification("notifications/resources/updated", QJsonObject{{"uri", "project://tree"}}); } void McpBridge::notifyDataChanged() { - if (!m_client || !m_initialized) return; + if (m_clients.isEmpty()) return; sendNotification("notifications/resources/updated", QJsonObject{{"uri", "project://data"}}); } diff --git a/src/mcp/mcp_bridge.h b/src/mcp/mcp_bridge.h index c4e77ee..e8170d3 100644 --- a/src/mcp/mcp_bridge.h +++ b/src/mcp/mcp_bridge.h @@ -28,13 +28,31 @@ public: void notifyDataChanged(); private: + struct ClientState { + QLocalSocket* socket = nullptr; + QByteArray readBuffer; + bool initialized = false; + }; + MainWindow* m_mainWindow; QLocalServer* m_server = nullptr; - QLocalSocket* m_client = nullptr; // single client for v1 - QByteArray m_readBuffer; - bool m_initialized = false; + QVector m_clients; + QLocalSocket* m_currentSender = nullptr; // set during request processing bool m_slowMode = false; + // Serial request queue. Some tool calls (scanner, tree.apply) spin nested + // event loops which would let another client's readyRead interleave and + // clobber m_currentSender. Simplest fix without refactoring those tools: + // queue incoming lines while a request is in flight, drain after. + bool m_processing = false; + struct PendingRequest { QLocalSocket* socket; QByteArray line; }; + QVector m_pendingRequests; + + + ClientState* findClient(QLocalSocket* sock); + void removeClient(QLocalSocket* sock); + void drainPendingRequests(); + // JSON-RPC plumbing void onNewConnection(); void onReadyRead(); @@ -64,6 +82,7 @@ private: QJsonObject toolScannerScan(const QJsonObject& args); QJsonObject toolScannerScanPattern(const QJsonObject& args); QJsonObject toolReconnect(const QJsonObject& args); + QJsonObject toolProcessInfo(const QJsonObject& args); // Helpers QJsonObject makeTextResult(const QString& text, bool isError = false); diff --git a/tests/test_mcp.cpp b/tests/test_mcp.cpp new file mode 100644 index 0000000..f3133b7 --- /dev/null +++ b/tests/test_mcp.cpp @@ -0,0 +1,378 @@ +// Test MCP multi-client protocol: connect, initialize, tools/list, +// disconnect one client, notification broadcast, serial requests. +// Uses a MockMcpServer with the same multi-client architecture as McpBridge. + +#include +#include +#include +#include +#include +#include +#include +#include + +// ── Mock server (same pattern as McpBridge multi-client) ── + +class MockMcpServer : public QObject { + Q_OBJECT +public: + struct Client { QLocalSocket* socket; QByteArray buf; bool initialized; }; + QLocalServer* m_server = nullptr; + QVector m_clients; + + bool start(const QString& name) { + QLocalServer::removeServer(name); + m_server = new QLocalServer(this); + if (!m_server->listen(name)) return false; + connect(m_server, &QLocalServer::newConnection, this, [this]() { + while (auto* s = m_server->nextPendingConnection()) { + m_clients.append({s, {}, false}); + connect(s, &QLocalSocket::readyRead, this, [this, s]() { processSocket(s); }); + connect(s, &QLocalSocket::disconnected, this, [this, s]() { + for (int i = 0; i < m_clients.size(); i++) + if (m_clients[i].socket == s) { s->deleteLater(); m_clients.removeAt(i); break; } + }); + } + }); + return true; + } + void stop() { + for (auto& c : m_clients) { c.socket->disconnect(this); c.socket->disconnectFromServer(); c.socket->deleteLater(); } + m_clients.clear(); + if (m_server) { m_server->close(); delete m_server; m_server = nullptr; } + } + int clientCount() const { return m_clients.size(); } + int initializedCount() const { int n=0; for (auto& c:m_clients) if(c.initialized) n++; return n; } + + void broadcast(const QJsonObject& obj) { + QByteArray data = QJsonDocument(obj).toJson(QJsonDocument::Compact) + '\n'; + for (auto& c : m_clients) + if (c.initialized) { c.socket->write(data); c.socket->flush(); } + } + +private: + void sendTo(QLocalSocket* s, const QJsonObject& obj) { + s->write(QJsonDocument(obj).toJson(QJsonDocument::Compact) + '\n'); + s->flush(); + } + void processSocket(QLocalSocket* s) { + Client* cs = nullptr; + for (auto& c : m_clients) if (c.socket == s) { cs = &c; break; } + if (!cs) return; + cs->buf.append(s->readAll()); + while (true) { + int idx = cs->buf.indexOf('\n'); + if (idx < 0) break; + QByteArray line = cs->buf.left(idx).trimmed(); + cs->buf.remove(0, idx + 1); + if (line.isEmpty()) continue; + auto doc = QJsonDocument::fromJson(line); + if (!doc.isObject()) { + sendTo(s, {{"jsonrpc","2.0"},{"id",QJsonValue()}, + {"error",QJsonObject{{"code",-32700},{"message","Parse error"}}}}); + continue; + } + auto req = doc.object(); + QString method = req["method"].toString(); + QJsonValue id = req["id"]; + if (method.isEmpty()) { + sendTo(s, {{"jsonrpc","2.0"},{"id",id}, + {"error",QJsonObject{{"code",-32600},{"message","Missing method"}}}}); + } else if (method == "initialize") { + cs->initialized = true; + sendTo(s, {{"jsonrpc","2.0"},{"id",id},{"result",QJsonObject{ + {"protocolVersion","2024-11-05"}, + {"serverInfo",QJsonObject{{"name","mock-mcp"},{"version","1.0"}}}}}}); + } else if (method == "notifications/initialized" || method == "notifications/cancelled") { + // no-op client notifications + } else if (method == "tools/list") { + sendTo(s, {{"jsonrpc","2.0"},{"id",id},{"result",QJsonObject{ + {"tools",QJsonArray{QJsonObject{{"name","test.tool"},{"description","A test"}}}}}}}); + } else if (method == "tools/call") { + QString toolName = req["params"].toObject()["name"].toString(); + if (toolName == "mcp.reconnect") { + sendTo(s, {{"jsonrpc","2.0"},{"id",id},{"result",QJsonObject{ + {"content",QJsonArray{QJsonObject{{"type","text"},{"text","Disconnected."}}}}}}}); + // Disconnect after response is flushed + QTimer::singleShot(0, this, [this, s]() { + for (auto& cc : m_clients) if (cc.socket == s) { s->disconnectFromServer(); break; } + }); + } else if (toolName.isEmpty()) { + sendTo(s, {{"jsonrpc","2.0"},{"id",id}, + {"error",QJsonObject{{"code",-32602},{"message","Missing tool name"}}}}); + } else { + sendTo(s, {{"jsonrpc","2.0"},{"id",id}, + {"error",QJsonObject{{"code",-32601},{"message","Unknown tool"}}}}); + } + } else { + sendTo(s, {{"jsonrpc","2.0"},{"id",id}, + {"error",QJsonObject{{"code",-32601},{"message","Method not found"}}}}); + } + } + } +}; + +// ── Helpers ── + +static QLocalSocket* makeClient(const QString& pipe, QObject* parent) { + auto* s = new QLocalSocket(parent); + s->connectToServer(pipe); + return s->waitForConnected(2000) ? s : nullptr; +} + +// Send JSON-RPC and pump the event loop until we get a response line. +static QJsonObject rpc(QLocalSocket* s, const QJsonObject& req, int ms = 3000) { + s->write(QJsonDocument(req).toJson(QJsonDocument::Compact) + '\n'); + s->flush(); + QByteArray buf; + QElapsedTimer t; t.start(); + while (t.elapsed() < ms) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + if (s->bytesAvailable()) buf.append(s->readAll()); + int idx = buf.indexOf('\n'); + if (idx >= 0) return QJsonDocument::fromJson(buf.left(idx).trimmed()).object(); + } + return {}; +} + +static QJsonObject initRpc(QLocalSocket* s) { + return rpc(s, {{"jsonrpc","2.0"},{"id",1},{"method","initialize"}, + {"params",QJsonObject{{"protocolVersion","2024-11-05"}, + {"capabilities",QJsonObject{}}, + {"clientInfo",QJsonObject{{"name","test"}}}}}}); +} + +static QVector drain(QLocalSocket* s, int ms = 300) { + QVector out; + QByteArray buf; + QElapsedTimer t; t.start(); + while (t.elapsed() < ms) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 30); + if (s->bytesAvailable()) buf.append(s->readAll()); + } + while (true) { + int idx = buf.indexOf('\n'); + if (idx < 0) break; + auto line = buf.left(idx).trimmed(); + buf.remove(0, idx + 1); + if (!line.isEmpty()) out.append(QJsonDocument::fromJson(line).object()); + } + return out; +} + +// ── Tests ── + +class TestMcp : public QObject { + Q_OBJECT + MockMcpServer* m_srv = nullptr; + static constexpr const char* P = "ReclassMcpTest"; +private slots: + void init() { m_srv = new MockMcpServer; QVERIFY(m_srv->start(P)); } + void cleanup() { m_srv->stop(); delete m_srv; m_srv = nullptr; } + + void singleClient_initialize() { + auto* c = makeClient(P, this); QVERIFY(c); + auto r = initRpc(c); + QCOMPARE(r["id"].toInt(), 1); + QVERIFY(r.contains("result")); + QCOMPARE(r["result"].toObject()["serverInfo"].toObject()["name"].toString(), QString("mock-mcp")); + QCOMPARE(m_srv->initializedCount(), 1); + c->disconnectFromServer(); delete c; + } + + void singleClient_toolsList() { + auto* c = makeClient(P, this); QVERIFY(c); + initRpc(c); + auto r = rpc(c, {{"jsonrpc","2.0"},{"id",2},{"method","tools/list"}}); + QCOMPARE(r["id"].toInt(), 2); + QCOMPARE(r["result"].toObject()["tools"].toArray().size(), 1); + c->disconnectFromServer(); delete c; + } + + void singleClient_unknownMethod() { + auto* c = makeClient(P, this); QVERIFY(c); + auto r = rpc(c, {{"jsonrpc","2.0"},{"id",1},{"method","bogus"}}); + QVERIFY(r.contains("error")); + QCOMPARE(r["error"].toObject()["code"].toInt(), -32601); + c->disconnectFromServer(); delete c; + } + + void multiClient_bothInitialize() { + auto* c1 = makeClient(P, this); auto* c2 = makeClient(P, this); + QVERIFY(c1); QVERIFY(c2); + QCoreApplication::processEvents(); + QCOMPARE(m_srv->clientCount(), 2); + auto r1 = initRpc(c1); auto r2 = initRpc(c2); + QVERIFY(r1.contains("result")); + QVERIFY(r2.contains("result")); + QCOMPARE(m_srv->initializedCount(), 2); + c1->disconnectFromServer(); c2->disconnectFromServer(); delete c1; delete c2; + } + + void multiClient_disconnectOne() { + auto* c1 = makeClient(P, this); auto* c2 = makeClient(P, this); + QVERIFY(c1); QVERIFY(c2); + initRpc(c1); initRpc(c2); + c1->disconnectFromServer(); QTest::qWait(200); + QCOMPARE(m_srv->clientCount(), 1); + auto r = rpc(c2, {{"jsonrpc","2.0"},{"id",5},{"method","tools/list"}}); + QCOMPARE(r["id"].toInt(), 5); + QVERIFY(r["result"].toObject()["tools"].toArray().size() > 0); + c2->disconnectFromServer(); delete c1; delete c2; + } + + void multiClient_notificationBroadcast() { + auto* c1 = makeClient(P, this); + auto* c2 = makeClient(P, this); + auto* c3 = makeClient(P, this); // not initialized + QVERIFY(c1); QVERIFY(c2); QVERIFY(c3); + initRpc(c1); initRpc(c2); + + m_srv->broadcast({{"jsonrpc","2.0"}, + {"method","notifications/resources/updated"}, + {"params",QJsonObject{{"uri","project://tree"}}}}); + + auto l1 = drain(c1); auto l2 = drain(c2); auto l3 = drain(c3); + QVERIFY(l1.size() >= 1); + QCOMPARE(l1.last()["method"].toString(), QString("notifications/resources/updated")); + QVERIFY(l2.size() >= 1); + QCOMPARE(l2.last()["method"].toString(), QString("notifications/resources/updated")); + QCOMPARE(l3.size(), 0); + c1->disconnectFromServer(); c2->disconnectFromServer(); c3->disconnectFromServer(); + delete c1; delete c2; delete c3; + } + + void multiClient_serialRequests() { + auto* c1 = makeClient(P, this); auto* c2 = makeClient(P, this); + QVERIFY(c1); QVERIFY(c2); + initRpc(c1); initRpc(c2); + auto r1 = rpc(c1, {{"jsonrpc","2.0"},{"id",10},{"method","tools/list"}}); + auto r2 = rpc(c2, {{"jsonrpc","2.0"},{"id",20},{"method","tools/list"}}); + QCOMPARE(r1["id"].toInt(), 10); + QCOMPARE(r2["id"].toInt(), 20); + c1->disconnectFromServer(); c2->disconnectFromServer(); delete c1; delete c2; + } + + void allDisconnect_serverSurvives() { + auto* c1 = makeClient(P, this); QVERIFY(c1); + initRpc(c1); + c1->disconnectFromServer(); QTest::qWait(200); + QCOMPARE(m_srv->clientCount(), 0); + auto* c2 = makeClient(P, this); QVERIFY(c2); + auto r = initRpc(c2); + QVERIFY(r.contains("result")); + QCOMPARE(m_srv->clientCount(), 1); + c2->disconnectFromServer(); delete c1; delete c2; + } + + void protocol_invalidJson() { + auto* c = makeClient(P, this); QVERIFY(c); + c->write("this is not json\n"); + c->flush(); + QByteArray buf; + QElapsedTimer t; t.start(); + while (t.elapsed() < 2000) { + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + if (c->bytesAvailable()) buf.append(c->readAll()); + if (buf.indexOf('\n') >= 0) break; + } + auto r = QJsonDocument::fromJson(buf.left(buf.indexOf('\n')).trimmed()).object(); + QVERIFY(r.contains("error")); + QCOMPARE(r["error"].toObject()["code"].toInt(), -32700); + c->disconnectFromServer(); delete c; + } + + void protocol_missingMethod() { + auto* c = makeClient(P, this); QVERIFY(c); + auto r = rpc(c, {{"jsonrpc","2.0"},{"id",1}}); // no "method" key + QVERIFY(r.contains("error")); + QCOMPARE(r["error"].toObject()["code"].toInt(), -32600); + c->disconnectFromServer(); delete c; + } + + void protocol_notificationsIgnored() { + // notifications/initialized and notifications/cancelled should not produce a response + auto* c = makeClient(P, this); QVERIFY(c); + initRpc(c); + c->write(QJsonDocument(QJsonObject{{"jsonrpc","2.0"},{"method","notifications/initialized"}}).toJson(QJsonDocument::Compact) + '\n'); + c->write(QJsonDocument(QJsonObject{{"jsonrpc","2.0"},{"method","notifications/cancelled"},{"params",QJsonObject{{"requestId",1}}}}).toJson(QJsonDocument::Compact) + '\n'); + c->flush(); + auto lines = drain(c, 500); + QCOMPARE(lines.size(), 0); // no response for notifications + c->disconnectFromServer(); delete c; + } + + void toolsCall_unknownTool() { + auto* c = makeClient(P, this); QVERIFY(c); + initRpc(c); + auto r = rpc(c, {{"jsonrpc","2.0"},{"id",2},{"method","tools/call"}, + {"params",QJsonObject{{"name","nonexistent.tool"},{"arguments",QJsonObject{}}}}}); + QVERIFY(r.contains("error")); + QCOMPARE(r["error"].toObject()["code"].toInt(), -32601); + c->disconnectFromServer(); delete c; + } + + void toolsCall_missingToolName() { + auto* c = makeClient(P, this); QVERIFY(c); + initRpc(c); + auto r = rpc(c, {{"jsonrpc","2.0"},{"id",3},{"method","tools/call"}, + {"params",QJsonObject{{"arguments",QJsonObject{}}}}}); + QVERIFY(r.contains("error")); + QCOMPARE(r["error"].toObject()["code"].toInt(), -32602); + c->disconnectFromServer(); delete c; + } + + void toolsCall_reconnect() { + auto* c = makeClient(P, this); QVERIFY(c); + initRpc(c); + QCOMPARE(m_srv->clientCount(), 1); + + // Call mcp.reconnect — should get response then get disconnected + auto r = rpc(c, {{"jsonrpc","2.0"},{"id",7},{"method","tools/call"}, + {"params",QJsonObject{{"name","mcp.reconnect"},{"arguments",QJsonObject{}}}}}); + QCOMPARE(r["id"].toInt(), 7); + QVERIFY(r.contains("result")); + QVERIFY(r["result"].toObject()["content"].toArray()[0].toObject()["text"] + .toString().contains("Disconnected")); + + // Wait for server-side disconnect + QTest::qWait(300); + QCOMPARE(m_srv->clientCount(), 0); + + // Reconnect — should work fine + auto* c2 = makeClient(P, this); QVERIFY(c2); + auto r2 = initRpc(c2); + QVERIFY(r2.contains("result")); + QCOMPARE(m_srv->clientCount(), 1); + + // Verify the new connection works + auto r3 = rpc(c2, {{"jsonrpc","2.0"},{"id",8},{"method","tools/list"}}); + QCOMPARE(r3["id"].toInt(), 8); + QVERIFY(r3["result"].toObject()["tools"].toArray().size() > 0); + + c2->disconnectFromServer(); delete c; delete c2; + } + + void toolsCall_reconnect_otherClientUnaffected() { + auto* c1 = makeClient(P, this); auto* c2 = makeClient(P, this); + QVERIFY(c1); QVERIFY(c2); + initRpc(c1); initRpc(c2); + QCOMPARE(m_srv->clientCount(), 2); + + // c1 calls reconnect — only c1 should disconnect + rpc(c1, {{"jsonrpc","2.0"},{"id",1},{"method","tools/call"}, + {"params",QJsonObject{{"name","mcp.reconnect"},{"arguments",QJsonObject{}}}}}); + QTest::qWait(300); + QCOMPARE(m_srv->clientCount(), 1); + + // c2 still works + auto r = rpc(c2, {{"jsonrpc","2.0"},{"id",2},{"method","tools/list"}}); + QCOMPARE(r["id"].toInt(), 2); + QVERIFY(r["result"].toObject()["tools"].toArray().size() > 0); + + c2->disconnectFromServer(); delete c1; delete c2; + } +}; + +QTEST_GUILESS_MAIN(TestMcp) +#include "test_mcp.moc" diff --git a/tests/test_scanner.cpp b/tests/test_scanner.cpp index e327a59..7d42181 100644 --- a/tests/test_scanner.cpp +++ b/tests/test_scanner.cpp @@ -1507,7 +1507,7 @@ private slots: data[0x8100] = char(0xFF); QVector regions; regions.append({0x8000, 0x1000, true, true, false, {}}); - auto prov = std::make_shared(data2, regions); + auto prov = std::make_shared(data, regions); ScanEngine engine; QSignalSpy finSpy(&engine, &ScanEngine::finished); ScanRequest req;