mirror of
https://github.com/NohamR/Reclass.git
synced 2026-05-10 19:59:21 +00:00
Compare commits
11 Commits
snapshot-0
...
snapshot-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f7bbdcc45 | ||
|
|
79b5125229 | ||
|
|
3aeb1a80d5 | ||
|
|
3b7ed682ac | ||
|
|
0582cb286b | ||
|
|
ea85b7a621 | ||
|
|
6c8b7d3d97 | ||
|
|
d1321b5165 | ||
|
|
4d0782db68 | ||
|
|
51de48a6ed | ||
|
|
7b9b140823 |
@@ -531,6 +531,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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -2006,6 +2006,19 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
|
||||
if (addedQuickConvert)
|
||||
menu.addSeparator();
|
||||
|
||||
// ── Hex byte / ASCII inline editing ──
|
||||
if (isHexNode(node.kind) && m_doc->provider->isWritable()) {
|
||||
menu.addAction(icon("edit.svg"), "Edit He&x Bytes", [editor, line]() {
|
||||
editor->setHexEditPending(true);
|
||||
editor->beginInlineEdit(EditTarget::Value, line);
|
||||
});
|
||||
menu.addAction(icon("edit.svg"), "Edit &ASCII", [editor, line]() {
|
||||
editor->setHexEditPending(true);
|
||||
editor->beginInlineEdit(EditTarget::Name, line);
|
||||
});
|
||||
menu.addSeparator();
|
||||
}
|
||||
|
||||
// ── Edit Value / Rename / Change Type ──
|
||||
bool isEditable = node.kind != NodeKind::Struct && node.kind != NodeKind::Array
|
||||
&& !isHexNode(node.kind)
|
||||
@@ -2016,9 +2029,11 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
|
||||
});
|
||||
}
|
||||
|
||||
menu.addAction(icon("rename.svg"), "Re&name\tF2", [editor, line]() {
|
||||
editor->beginInlineEdit(EditTarget::Name, line);
|
||||
});
|
||||
if (!isHexNode(node.kind)) {
|
||||
menu.addAction(icon("rename.svg"), "Re&name\tF2", [editor, line]() {
|
||||
editor->beginInlineEdit(EditTarget::Name, line);
|
||||
});
|
||||
}
|
||||
|
||||
menu.addAction("Change &Type\tT", [editor, line]() {
|
||||
editor->beginInlineEdit(EditTarget::Type, line);
|
||||
|
||||
316
src/editor.cpp
316
src/editor.cpp
@@ -515,7 +515,7 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
|
||||
connect(m_sci, &QsciScintilla::textChanged, this, [this]() {
|
||||
if (!m_editState.active) return;
|
||||
if (m_updatingComment) return; // Skip queuing during comment update
|
||||
if (m_editState.target == EditTarget::Value)
|
||||
if (m_editState.target == EditTarget::Value && !m_editState.hexOverwrite)
|
||||
QTimer::singleShot(0, this, &RcxEditor::validateEditLive);
|
||||
|
||||
// Autocomplete for static field expressions — show field names as user types
|
||||
@@ -1605,7 +1605,8 @@ RcxEditor::EndEditInfo RcxEditor::endInlineEdit() {
|
||||
// Dismiss any open user list / autocomplete popup
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_AUTOCCANCEL);
|
||||
// Clear edit comment and error marker before deactivating
|
||||
if (m_editState.target == EditTarget::Value) {
|
||||
if (m_editState.target == EditTarget::Value
|
||||
|| (m_editState.hexOverwrite && m_editState.target == EditTarget::Name)) {
|
||||
setEditComment({}); // Clear to spaces
|
||||
m_sci->markerDelete(m_editState.line, M_ERR);
|
||||
}
|
||||
@@ -2341,6 +2342,10 @@ bool RcxEditor::handleNormalKey(QKeyEvent* ke) {
|
||||
// ── Edit mode key handling ──
|
||||
|
||||
bool RcxEditor::handleEditKey(QKeyEvent* ke) {
|
||||
// Hex/ASCII overwrite mode: fully custom key handling
|
||||
if (m_editState.hexOverwrite)
|
||||
return handleHexEditKey(ke);
|
||||
|
||||
// User list is handled via userListActivated signal, not here
|
||||
// SCI_AUTOCACTIVE is for autocomplete, not user lists
|
||||
|
||||
@@ -2440,6 +2445,219 @@ bool RcxEditor::handleEditKey(QKeyEvent* ke) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Hex/ASCII overwrite-mode key handling ──
|
||||
|
||||
bool RcxEditor::handleHexEditKey(QKeyEvent* ke) {
|
||||
const bool isHexMode = (m_editState.target == EditTarget::Value);
|
||||
// isHexMode = true: editing "00 00 00 00 00 00 00 00" (hex bytes)
|
||||
// isHexMode = false: editing "........" (ASCII preview)
|
||||
|
||||
int line, col;
|
||||
m_sci->getCursorPosition(&line, &col);
|
||||
const int spanStart = m_editState.spanStart;
|
||||
const int spanEnd = spanStart + m_editState.original.size();
|
||||
|
||||
// Helper: replace a single character and re-apply hex dimming indicator
|
||||
// (SCI_REPLACETARGET can clear indicators at the replacement position)
|
||||
auto replaceCharAt = [this](long pos, char ch) {
|
||||
QByteArray buf(1, ch);
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART, pos);
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND, pos + 1);
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACETARGET,
|
||||
(uintptr_t)1, buf.constData());
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, IND_HEX_DIM);
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORFILLRANGE, pos, 1);
|
||||
};
|
||||
|
||||
switch (ke->key()) {
|
||||
case Qt::Key_Return:
|
||||
case Qt::Key_Enter:
|
||||
commitInlineEdit();
|
||||
return true;
|
||||
case Qt::Key_Escape:
|
||||
cancelInlineEdit();
|
||||
return true;
|
||||
case Qt::Key_Tab:
|
||||
case Qt::Key_Up:
|
||||
case Qt::Key_Down:
|
||||
case Qt::Key_PageUp:
|
||||
case Qt::Key_PageDown:
|
||||
return true; // block
|
||||
|
||||
case Qt::Key_Home:
|
||||
m_sci->setCursorPosition(line, spanStart);
|
||||
return true;
|
||||
case Qt::Key_End: {
|
||||
// Last data position (last char of span)
|
||||
int endCol = spanEnd - 1;
|
||||
if (endCol < spanStart) endCol = spanStart;
|
||||
m_sci->setCursorPosition(line, endCol);
|
||||
return true;
|
||||
}
|
||||
|
||||
case Qt::Key_Left: {
|
||||
if (col <= spanStart) return true;
|
||||
int newCol = col - 1;
|
||||
// In hex mode, skip over space separators
|
||||
if (isHexMode) {
|
||||
QString lineText = getLineText(m_sci, line);
|
||||
if (newCol >= spanStart && newCol < lineText.size() && lineText[newCol] == ' ')
|
||||
newCol--;
|
||||
}
|
||||
if (newCol < spanStart) newCol = spanStart;
|
||||
m_sci->setCursorPosition(line, newCol);
|
||||
return true;
|
||||
}
|
||||
|
||||
case Qt::Key_Right: {
|
||||
if (col >= spanEnd - 1) return true;
|
||||
int newCol = col + 1;
|
||||
if (isHexMode) {
|
||||
QString lineText = getLineText(m_sci, line);
|
||||
if (newCol < spanEnd && newCol < lineText.size() && lineText[newCol] == ' ')
|
||||
newCol++;
|
||||
}
|
||||
if (newCol >= spanEnd) newCol = spanEnd - 1;
|
||||
m_sci->setCursorPosition(line, newCol);
|
||||
return true;
|
||||
}
|
||||
|
||||
case Qt::Key_Backspace: {
|
||||
if (col <= spanStart) return true;
|
||||
int prevCol = col - 1;
|
||||
if (isHexMode) {
|
||||
QString lineText = getLineText(m_sci, line);
|
||||
if (prevCol >= spanStart && prevCol < lineText.size() && lineText[prevCol] == ' ')
|
||||
prevCol--;
|
||||
}
|
||||
if (prevCol < spanStart) return true;
|
||||
// Replace previous char with reset value
|
||||
long pos = posFromCol(m_sci, line, prevCol);
|
||||
replaceCharAt(pos, isHexMode ? '0' : '.');
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS, pos);
|
||||
return true;
|
||||
}
|
||||
|
||||
case Qt::Key_Delete: {
|
||||
if (col >= spanEnd) return true;
|
||||
// Skip space separators in hex mode
|
||||
if (isHexMode) {
|
||||
QString lineText = getLineText(m_sci, line);
|
||||
if (col < lineText.size() && lineText[col] == ' ') return true;
|
||||
}
|
||||
// Reset current char
|
||||
long pos = posFromCol(m_sci, line, col);
|
||||
replaceCharAt(pos, isHexMode ? '0' : '.');
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS, pos);
|
||||
return true;
|
||||
}
|
||||
|
||||
case Qt::Key_Z:
|
||||
if (ke->modifiers() & Qt::ControlModifier)
|
||||
return true; // block Ctrl+Z during hex overwrite
|
||||
break;
|
||||
|
||||
case Qt::Key_V:
|
||||
if (ke->modifiers() & Qt::ControlModifier) {
|
||||
QString clip = QApplication::clipboard()->text();
|
||||
clip.remove('\n');
|
||||
clip.remove('\r');
|
||||
if (!clip.isEmpty()) {
|
||||
QString lineText = getLineText(m_sci, line);
|
||||
int writeCol = col;
|
||||
for (int i = 0; i < clip.size() && writeCol < spanEnd; i++) {
|
||||
QChar ch = clip[i];
|
||||
if (isHexMode) {
|
||||
// Skip spaces in paste content
|
||||
if (ch == ' ') continue;
|
||||
// Skip over space separators in the target
|
||||
if (writeCol < lineText.size() && lineText[writeCol] == ' ')
|
||||
writeCol++;
|
||||
if (writeCol >= spanEnd) break;
|
||||
// Only accept hex digits
|
||||
if (!ch.isDigit() && !(ch >= 'a' && ch <= 'f') && !(ch >= 'A' && ch <= 'F'))
|
||||
continue;
|
||||
ch = ch.toUpper();
|
||||
} else {
|
||||
// Only accept printable ASCII
|
||||
if (ch.unicode() < 0x20 || ch.unicode() > 0x7E) continue;
|
||||
}
|
||||
long pos = posFromCol(m_sci, line, writeCol);
|
||||
replaceCharAt(pos, (char)ch.toLatin1());
|
||||
writeCol++;
|
||||
// Re-read after each replace for hex space skip
|
||||
if (isHexMode) lineText = getLineText(m_sci, line);
|
||||
}
|
||||
int finalCol = qMin(writeCol, spanEnd - 1);
|
||||
// In hex mode, if we landed on a space, advance past it
|
||||
if (isHexMode) {
|
||||
lineText = getLineText(m_sci, line);
|
||||
if (finalCol < spanEnd && finalCol < lineText.size() && lineText[finalCol] == ' ')
|
||||
finalCol++;
|
||||
if (finalCol >= spanEnd) finalCol = spanEnd - 1;
|
||||
}
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS,
|
||||
posFromCol(m_sci, line, finalCol));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Character input: overwrite current position and advance
|
||||
QString text = ke->text();
|
||||
if (text.isEmpty() || text[0].unicode() < 0x20)
|
||||
return true; // consume non-printable (block default Scintilla handling)
|
||||
|
||||
QChar ch = text[0];
|
||||
|
||||
if (isHexMode) {
|
||||
// Only accept hex digits
|
||||
if (!ch.isDigit() && !(ch >= 'a' && ch <= 'f') && !(ch >= 'A' && ch <= 'F'))
|
||||
return true;
|
||||
ch = ch.toUpper();
|
||||
|
||||
// If cursor is on a space, skip to next byte
|
||||
QString lineText = getLineText(m_sci, line);
|
||||
int writeCol = col;
|
||||
if (writeCol < lineText.size() && lineText[writeCol] == ' ')
|
||||
writeCol++;
|
||||
if (writeCol >= spanEnd) return true;
|
||||
|
||||
// Overwrite current digit
|
||||
long pos = posFromCol(m_sci, line, writeCol);
|
||||
replaceCharAt(pos, (char)ch.toLatin1());
|
||||
|
||||
// Advance cursor, skip over spaces
|
||||
int nextCol = writeCol + 1;
|
||||
lineText = getLineText(m_sci, line);
|
||||
if (nextCol < spanEnd && nextCol < lineText.size() && lineText[nextCol] == ' ')
|
||||
nextCol++;
|
||||
if (nextCol >= spanEnd) nextCol = spanEnd - 1;
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS,
|
||||
posFromCol(m_sci, line, nextCol));
|
||||
} else {
|
||||
// ASCII mode: only printable ASCII
|
||||
if (ch.unicode() < 0x20 || ch.unicode() > 0x7E)
|
||||
return true;
|
||||
if (col >= spanEnd) return true;
|
||||
|
||||
// Overwrite current char
|
||||
long pos = posFromCol(m_sci, line, col);
|
||||
replaceCharAt(pos, (char)ch.toLatin1());
|
||||
|
||||
// Advance cursor
|
||||
int nextCol = col + 1;
|
||||
if (nextCol >= spanEnd) nextCol = spanEnd - 1;
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS,
|
||||
posFromCol(m_sci, line, nextCol));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Begin inline edit ──
|
||||
|
||||
bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
|
||||
@@ -2490,14 +2708,39 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
|
||||
(target == EditTarget::BaseAddress || target == EditTarget::Source
|
||||
|| target == EditTarget::RootClassType || target == EditTarget::RootClassName)))
|
||||
return false;
|
||||
// Hex nodes: only Type is editable (ASCII preview + hex bytes are display-only)
|
||||
// Exception: static field names are always editable (they're function names, not hex labels)
|
||||
if ((target == EditTarget::Name || target == EditTarget::Value) && isHexNode(lm->nodeKind) && !lm->isStaticLine)
|
||||
// Hex nodes: only Type is editable via normal flow (double-click, F2, Enter)
|
||||
// Exception: context-menu-initiated hex/ASCII edits bypass this via m_hexEditPending
|
||||
bool isHexEdit = m_hexEditPending && isHexNode(lm->nodeKind) && !lm->isStaticLine
|
||||
&& (target == EditTarget::Name || target == EditTarget::Value);
|
||||
m_hexEditPending = false;
|
||||
if ((target == EditTarget::Name || target == EditTarget::Value)
|
||||
&& isHexNode(lm->nodeKind) && !lm->isStaticLine && !isHexEdit)
|
||||
return false;
|
||||
|
||||
QString lineText;
|
||||
NormalizedSpan norm;
|
||||
if (!resolvedSpanFor(line, target, norm, &lineText)) return false;
|
||||
|
||||
if (isHexEdit) {
|
||||
// Compute hex spans directly (bypasses resolvedSpanFor which also blocks hex)
|
||||
lineText = getLineText(m_sci, line);
|
||||
int typeW = lm->effectiveTypeW;
|
||||
int nameW = lm->effectiveNameW;
|
||||
int byteCount = sizeForKind(lm->nodeKind);
|
||||
if (target == EditTarget::Name) {
|
||||
// ASCII preview: exactly byteCount chars (no trailing-space trim)
|
||||
ColumnSpan s = nameSpanFor(*lm, typeW, nameW);
|
||||
if (!s.valid) return false;
|
||||
norm = {s.start, s.start + byteCount, true};
|
||||
} else {
|
||||
// Hex bytes: "XX XX XX..." = byteCount*3-1 chars
|
||||
ColumnSpan s = valueSpanFor(*lm, lineText.size(), typeW, nameW);
|
||||
if (!s.valid) return false;
|
||||
int hexWidth = byteCount * 3 - 1;
|
||||
norm = {s.start, s.start + hexWidth, true};
|
||||
}
|
||||
} else {
|
||||
if (!resolvedSpanFor(line, target, norm, &lineText)) return false;
|
||||
}
|
||||
|
||||
QString trimmed = lineText.mid(norm.start, norm.end - norm.start);
|
||||
|
||||
@@ -2558,6 +2801,7 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
|
||||
m_editState.original = trimmed;
|
||||
m_editState.linelenAfterReplace = lineText.size();
|
||||
m_editState.editKind = lm->nodeKind;
|
||||
m_editState.hexOverwrite = isHexEdit;
|
||||
if (isVectorKind(lm->nodeKind)) {
|
||||
m_editState.subLine = vecComponent;
|
||||
m_editState.editKind = NodeKind::Float;
|
||||
@@ -2567,14 +2811,14 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
|
||||
m_editState.editKind = NodeKind::Float;
|
||||
}
|
||||
|
||||
// Store fixed comment column position for value editing
|
||||
// Store fixed comment column position for value editing (and hex ASCII edits)
|
||||
// Use large lineLength so commentCol is always computed (padding added dynamically)
|
||||
if (target == EditTarget::Value) {
|
||||
if (target == EditTarget::Value || (isHexEdit && target == EditTarget::Name)) {
|
||||
ColumnSpan cs = commentSpanFor(*lm, 9999, lm->effectiveTypeW, lm->effectiveNameW);
|
||||
m_editState.commentCol = cs.valid ? cs.start : -1;
|
||||
m_editState.lastValidationOk = true; // original value is always valid
|
||||
} else if (target == EditTarget::BaseAddress) {
|
||||
m_editState.commentCol = norm.end + 2; // command row has no column layout
|
||||
m_editState.commentCol = (int)lineText.size() + 2; // after full command row content
|
||||
} else {
|
||||
m_editState.commentCol = -1;
|
||||
}
|
||||
@@ -2586,12 +2830,14 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_SETCARETWIDTH, 1);
|
||||
m_sci->setReadOnly(false);
|
||||
|
||||
// For value editing: extend line with trailing spaces for the edit comment area
|
||||
// For value/hex editing: extend line with trailing spaces for the edit comment area
|
||||
// (comment padding is no longer baked into every line to avoid unnecessary scroll width)
|
||||
if ((target == EditTarget::Value || target == EditTarget::BaseAddress)
|
||||
if ((target == EditTarget::Value || target == EditTarget::BaseAddress
|
||||
|| (isHexEdit && target == EditTarget::Name))
|
||||
&& m_editState.commentCol >= 0) {
|
||||
int commentStart = norm.end + 2;
|
||||
int neededLen = commentStart + kColComment;
|
||||
int commentStart = m_editState.commentCol;
|
||||
int commentWidth = (target == EditTarget::BaseAddress) ? 60 : kColComment;
|
||||
int neededLen = commentStart + commentWidth;
|
||||
int currentLen = (int)lineText.size();
|
||||
if (currentLen < neededLen) {
|
||||
int extend = neededLen - currentLen;
|
||||
@@ -2635,10 +2881,19 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
|
||||
}
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSEL, selStart, m_editState.posEnd);
|
||||
|
||||
// Hex overwrite: place cursor at start, no selection
|
||||
if (m_editState.hexOverwrite)
|
||||
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS, m_editState.posStart);
|
||||
|
||||
// Show initial edit hint in comment column
|
||||
if (target == EditTarget::Value)
|
||||
setEditComment(QStringLiteral("Enter=Save Esc=Cancel"));
|
||||
else if (target == EditTarget::BaseAddress)
|
||||
if (target == EditTarget::Value) {
|
||||
if (m_editState.hexOverwrite)
|
||||
setEditComment(QStringLiteral("Hex edit: Enter=Save Esc=Cancel"));
|
||||
else
|
||||
setEditComment(QStringLiteral("Enter=Save Esc=Cancel"));
|
||||
} else if (target == EditTarget::Name && m_editState.hexOverwrite) {
|
||||
setEditComment(QStringLiteral("ASCII edit: Enter=Save Esc=Cancel"));
|
||||
} else if (target == EditTarget::BaseAddress)
|
||||
setEditComment(QStringLiteral("e.g. <mod.exe> + 0xFF | [0x1000 + 0x10] | 7ff6`1234ABCD"));
|
||||
|
||||
// Note: Type, ArrayElementType, PointerTarget are handled by TypeSelectorPopup
|
||||
@@ -2664,6 +2919,19 @@ void RcxEditor::clampEditSelection() {
|
||||
if (m_clampingSelection) return;
|
||||
m_clampingSelection = true;
|
||||
|
||||
// Hex overwrite: collapse any selection to cursor (no selection allowed)
|
||||
if (m_editState.hexOverwrite) {
|
||||
int sL, sC, eL, eC;
|
||||
m_sci->getSelection(&sL, &sC, &eL, &eC);
|
||||
if (sL != eL || sC != eC) {
|
||||
int curLine, curCol;
|
||||
m_sci->getCursorPosition(&curLine, &curCol);
|
||||
m_sci->setCursorPosition(m_editState.line, curCol);
|
||||
}
|
||||
m_clampingSelection = false;
|
||||
return;
|
||||
}
|
||||
|
||||
int selStartLine, selStartCol, selEndLine, selEndCol;
|
||||
m_sci->getSelection(&selStartLine, &selStartCol, &selEndLine, &selEndCol);
|
||||
|
||||
@@ -2709,8 +2977,11 @@ void RcxEditor::commitInlineEdit() {
|
||||
int editedLen = m_editState.original.size() + delta;
|
||||
|
||||
QString editedText;
|
||||
if (editedLen > 0)
|
||||
editedText = lineText.mid(m_editState.spanStart, editedLen).trimmed();
|
||||
if (editedLen > 0) {
|
||||
editedText = lineText.mid(m_editState.spanStart, editedLen);
|
||||
if (!m_editState.hexOverwrite)
|
||||
editedText = editedText.trimmed();
|
||||
}
|
||||
|
||||
// For Type edits: if nothing changed, commit original
|
||||
if (m_editState.target == EditTarget::Type && editedText.isEmpty())
|
||||
@@ -3540,7 +3811,7 @@ void RcxEditor::setEditComment(const QString& comment) {
|
||||
|
||||
// Place comment 2 spaces after current value, prefixed with //
|
||||
int valueEnd = editEndCol();
|
||||
int startCol = valueEnd + 2; // 2 spaces after value
|
||||
int startCol = qMax(valueEnd + 2, m_editState.commentCol);
|
||||
int endCol = lineText.size();
|
||||
int availWidth = endCol - startCol;
|
||||
if (availWidth <= 0) { m_updatingComment = false; return; }
|
||||
@@ -3589,7 +3860,12 @@ void RcxEditor::validateEditLive() {
|
||||
if (isValid) {
|
||||
m_sci->markerDelete(m_editState.line, M_ERR);
|
||||
if (isSelected) m_sci->markerAdd(m_editState.line, M_SELECTED);
|
||||
if (stateChanged) setEditComment("Enter=Save Esc=Cancel");
|
||||
if (stateChanged) {
|
||||
if (m_editState.target == EditTarget::BaseAddress)
|
||||
setEditComment(QStringLiteral("e.g. <mod.exe> + 0xFF | [0x1000 + 0x10] | 7ff6`1234ABCD"));
|
||||
else
|
||||
setEditComment("Enter=Save Esc=Cancel");
|
||||
}
|
||||
} else {
|
||||
if (isSelected) m_sci->markerDelete(m_editState.line, M_SELECTED);
|
||||
m_sci->markerAdd(m_editState.line, M_ERR);
|
||||
|
||||
@@ -51,6 +51,7 @@ public:
|
||||
bool isEditing() const { return m_editState.active; }
|
||||
bool beginInlineEdit(EditTarget target, int line = -1, int col = -1);
|
||||
void cancelInlineEdit();
|
||||
void setHexEditPending(bool v) { m_hexEditPending = v; }
|
||||
void setStaticCompletions(const QStringList& words) { m_staticCompletions = words; }
|
||||
|
||||
void applySelectionOverlay(const QSet<uint64_t>& selIds);
|
||||
@@ -143,6 +144,7 @@ private:
|
||||
NodeKind editKind = NodeKind::Int32;
|
||||
int commentCol = -1; // fixed comment column (stored at edit start)
|
||||
bool lastValidationOk = true; // track state to avoid redundant updates
|
||||
bool hexOverwrite = false; // true for hex-byte / ASCII-preview fixed-length editing
|
||||
};
|
||||
InlineEditState m_editState;
|
||||
QStringList m_staticCompletions; // autocomplete words for StaticExpr editing
|
||||
@@ -171,6 +173,9 @@ private:
|
||||
long m_findPos = 0;
|
||||
void hideFindBar();
|
||||
|
||||
// ── Hex inline edit ──
|
||||
bool m_hexEditPending = false; // set by context menu before calling beginInlineEdit
|
||||
|
||||
// ── Reentrancy guards ──
|
||||
bool m_applyingDocument = false;
|
||||
bool m_clampingSelection = false;
|
||||
@@ -195,6 +200,7 @@ private:
|
||||
int editEndCol() const;
|
||||
bool handleNormalKey(QKeyEvent* ke);
|
||||
bool handleEditKey(QKeyEvent* ke);
|
||||
bool handleHexEditKey(QKeyEvent* ke);
|
||||
void showTypeAutocomplete();
|
||||
void showSourcePicker();
|
||||
void showTypeListFiltered(const QString& filter);
|
||||
|
||||
1176
src/generator.cpp
1176
src/generator.cpp
File diff suppressed because it is too large
Load Diff
@@ -6,17 +6,83 @@
|
||||
|
||||
namespace rcx {
|
||||
|
||||
// Generate C++ struct definitions for a single root struct and all
|
||||
// nested/referenced types reachable from it.
|
||||
// ── Code output format ──
|
||||
|
||||
enum class CodeFormat : int {
|
||||
CppHeader = 0, // C/C++ struct definitions
|
||||
RustStruct, // Rust #[repr(C)] struct definitions
|
||||
DefineOffsets, // #define ClassName_FieldName 0xNN
|
||||
CSharpStruct, // C# [StructLayout] with [FieldOffset]
|
||||
PythonCtypes, // Python ctypes.Structure
|
||||
_Count
|
||||
};
|
||||
|
||||
enum class CodeScope : int {
|
||||
Current = 0, // Just the selected struct
|
||||
WithChildren, // Selected struct + all referenced types
|
||||
FullSdk, // All root-level structs
|
||||
_Count
|
||||
};
|
||||
|
||||
const char* codeFormatName(CodeFormat fmt);
|
||||
const char* codeFormatFileFilter(CodeFormat fmt);
|
||||
const char* codeScopeName(CodeScope scope);
|
||||
|
||||
// ── Format-aware dispatch (calls the appropriate backend) ──
|
||||
|
||||
QString renderCode(CodeFormat fmt, const NodeTree& tree, uint64_t rootStructId,
|
||||
const QHash<NodeKind, QString>* typeAliases = nullptr,
|
||||
bool emitAsserts = false);
|
||||
|
||||
// Render rootStructId + all struct types reachable from it
|
||||
QString renderCodeTree(CodeFormat fmt, const NodeTree& tree, uint64_t rootStructId,
|
||||
const QHash<NodeKind, QString>* typeAliases = nullptr,
|
||||
bool emitAsserts = false);
|
||||
|
||||
QString renderCodeAll(CodeFormat fmt, const NodeTree& tree,
|
||||
const QHash<NodeKind, QString>* typeAliases = nullptr,
|
||||
bool emitAsserts = false);
|
||||
|
||||
// ── Individual backends ──
|
||||
|
||||
QString renderCpp(const NodeTree& tree, uint64_t rootStructId,
|
||||
const QHash<NodeKind, QString>* typeAliases = nullptr,
|
||||
bool emitAsserts = false);
|
||||
|
||||
// Generate C++ struct definitions for every root-level struct (full SDK).
|
||||
QString renderCppTree(const NodeTree& tree, uint64_t rootStructId,
|
||||
const QHash<NodeKind, QString>* typeAliases = nullptr,
|
||||
bool emitAsserts = false);
|
||||
QString renderCppAll(const NodeTree& tree,
|
||||
const QHash<NodeKind, QString>* typeAliases = nullptr,
|
||||
bool emitAsserts = false);
|
||||
|
||||
QString renderRust(const NodeTree& tree, uint64_t rootStructId,
|
||||
const QHash<NodeKind, QString>* typeAliases = nullptr,
|
||||
bool emitAsserts = false);
|
||||
QString renderRustTree(const NodeTree& tree, uint64_t rootStructId,
|
||||
const QHash<NodeKind, QString>* typeAliases = nullptr,
|
||||
bool emitAsserts = false);
|
||||
QString renderRustAll(const NodeTree& tree,
|
||||
const QHash<NodeKind, QString>* typeAliases = nullptr,
|
||||
bool emitAsserts = false);
|
||||
|
||||
QString renderDefines(const NodeTree& tree, uint64_t rootStructId);
|
||||
QString renderDefinesTree(const NodeTree& tree, uint64_t rootStructId);
|
||||
QString renderDefinesAll(const NodeTree& tree);
|
||||
|
||||
QString renderCSharp(const NodeTree& tree, uint64_t rootStructId,
|
||||
const QHash<NodeKind, QString>* typeAliases = nullptr,
|
||||
bool emitAsserts = false);
|
||||
QString renderCSharpTree(const NodeTree& tree, uint64_t rootStructId,
|
||||
const QHash<NodeKind, QString>* typeAliases = nullptr,
|
||||
bool emitAsserts = false);
|
||||
QString renderCSharpAll(const NodeTree& tree,
|
||||
const QHash<NodeKind, QString>* typeAliases = nullptr,
|
||||
bool emitAsserts = false);
|
||||
|
||||
QString renderPython(const NodeTree& tree, uint64_t rootStructId);
|
||||
QString renderPythonTree(const NodeTree& tree, uint64_t rootStructId);
|
||||
QString renderPythonAll(const NodeTree& tree);
|
||||
|
||||
// Null generator placeholder (returns empty string).
|
||||
QString renderNull(const NodeTree& tree, uint64_t rootStructId);
|
||||
|
||||
|
||||
361
src/main.cpp
361
src/main.cpp
@@ -605,24 +605,6 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
|
||||
createWorkspaceDock();
|
||||
createScannerDock();
|
||||
|
||||
// Hidden sentinel dock — never visible, only used to force Qt to create a
|
||||
// QTabBar when the first document dock is added (Qt only creates tab bars
|
||||
// via tabifyDockWidget). Immediately hidden after tabification so it takes
|
||||
// zero layout space. An event filter on the QTabBar keeps it visible.
|
||||
{
|
||||
m_sentinelDock = new QDockWidget(this);
|
||||
m_sentinelDock->setObjectName(QStringLiteral("_sentinel"));
|
||||
m_sentinelDock->setFeatures(QDockWidget::NoDockWidgetFeatures);
|
||||
auto* sw = new QWidget(m_sentinelDock);
|
||||
sw->setFixedSize(0, 0);
|
||||
m_sentinelDock->setWidget(sw);
|
||||
auto* stb = new QWidget(m_sentinelDock);
|
||||
stb->setFixedHeight(0);
|
||||
m_sentinelDock->setTitleBarWidget(stb);
|
||||
addDockWidget(Qt::TopDockWidgetArea, m_sentinelDock);
|
||||
m_sentinelDock->hide(); // hidden = zero layout space
|
||||
}
|
||||
|
||||
createMenus();
|
||||
createStatusBar();
|
||||
|
||||
@@ -749,6 +731,10 @@ void MainWindow::createMenus() {
|
||||
Qt5Qt6AddAction(importMenu, "&PDB...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::importPdb);
|
||||
auto* exportMenu = file->addMenu("E&xport");
|
||||
Qt5Qt6AddAction(exportMenu, "&C++ Header...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportCpp);
|
||||
Qt5Qt6AddAction(exportMenu, "&Rust Structs...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportRust);
|
||||
Qt5Qt6AddAction(exportMenu, "#&define Offsets...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportDefines);
|
||||
Qt5Qt6AddAction(exportMenu, "C&# Structs...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportCSharp);
|
||||
Qt5Qt6AddAction(exportMenu, "&Python ctypes...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportPython);
|
||||
Qt5Qt6AddAction(exportMenu, "ReClass &XML...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportReclassXmlAction);
|
||||
// Examples submenu — scan once at init
|
||||
{
|
||||
@@ -779,13 +765,22 @@ void MainWindow::createMenus() {
|
||||
// View
|
||||
auto* view = m_menuBar->addMenu("&View");
|
||||
Qt5Qt6AddAction(view, "&Reset Windows", QKeySequence::UnknownKey, QIcon(), this, [this](bool) {
|
||||
// Re-tabify all doc docks into a single group
|
||||
if (m_docDocks.size() < 2) return;
|
||||
// Re-tabify all doc docks into a single group (collapses splits)
|
||||
if (m_docDocks.isEmpty()) return;
|
||||
auto* first = m_docDocks.first();
|
||||
for (int i = 1; i < m_docDocks.size(); ++i) {
|
||||
tabifyDockWidget(first, m_docDocks[i]);
|
||||
m_docDocks[i]->show();
|
||||
}
|
||||
// Merge all sentinels back; keep only the first, delete extras
|
||||
for (int i = 0; i < m_sentinelDocks.size(); ++i) {
|
||||
if (i == 0)
|
||||
tabifyDockWidget(first, m_sentinelDocks[i]);
|
||||
else
|
||||
delete m_sentinelDocks[i];
|
||||
}
|
||||
if (m_sentinelDocks.size() > 1)
|
||||
m_sentinelDocks.resize(1);
|
||||
if (m_activeDocDock) m_activeDocDock->raise();
|
||||
QTimer::singleShot(0, this, [this]() { setupDockTabBars(); });
|
||||
});
|
||||
@@ -881,7 +876,8 @@ void MainWindow::createMenus() {
|
||||
const auto mcpName = QSettings("Reclass", "Reclass").value("autoStartMcp", true).toBool() ? "Stop &MCP Server" : "Start &MCP Server";
|
||||
m_mcpAction = Qt5Qt6AddAction(tools, mcpName, QKeySequence::UnknownKey, QIcon(), this, &MainWindow::toggleMcp);
|
||||
tools->addSeparator();
|
||||
Qt5Qt6AddAction(tools, "&Options...", QKeySequence::UnknownKey, makeIcon(":/vsicons/settings-gear.svg"), this, &MainWindow::showOptionsDialog);
|
||||
Qt5Qt6AddAction(tools, "&Options...", QKeySequence::UnknownKey, makeIcon(":/vsicons/settings-gear.svg"), this,
|
||||
static_cast<void(MainWindow::*)()>(&MainWindow::showOptionsDialog));
|
||||
|
||||
// Plugins
|
||||
auto* plugins = m_menuBar->addMenu("&Plugins");
|
||||
@@ -1203,7 +1199,7 @@ void MainWindow::createStatusBar() {
|
||||
m_statusLabel->setContentsMargins(0, 0, 0, 0);
|
||||
m_statusLabel->setAlignment(Qt::AlignVCenter | Qt::AlignLeft);
|
||||
|
||||
// View toggle is now per-pane via QTabWidget tab bar (Reclass / C/C++ tabs)
|
||||
// View toggle is now per-pane via QTabWidget tab bar (Reclass / Code tabs)
|
||||
sb->tabRow = nullptr;
|
||||
sb->label = m_statusLabel;
|
||||
|
||||
@@ -1423,7 +1419,87 @@ MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) {
|
||||
sci->setFocus();
|
||||
});
|
||||
|
||||
pane.tabWidget->addTab(pane.renderedContainer, "C/C++"); // index 1
|
||||
pane.tabWidget->addTab(pane.renderedContainer, "Code"); // index 1
|
||||
|
||||
// Corner widget: format combo + gear icon
|
||||
{
|
||||
const auto& ct = ThemeManager::instance().current();
|
||||
QSettings cs("Reclass", "Reclass");
|
||||
QString ef = cs.value("font", "JetBrains Mono").toString();
|
||||
|
||||
auto* cornerWidget = new QWidget;
|
||||
auto* cornerLayout = new QHBoxLayout(cornerWidget);
|
||||
cornerLayout->setContentsMargins(0, 0, 4, 0);
|
||||
cornerLayout->setSpacing(2);
|
||||
|
||||
pane.fmtCombo = new QComboBox;
|
||||
for (int fi = 0; fi < static_cast<int>(CodeFormat::_Count); ++fi)
|
||||
pane.fmtCombo->addItem(codeFormatName(static_cast<CodeFormat>(fi)));
|
||||
pane.fmtCombo->setCurrentIndex(cs.value("codeFormat", 0).toInt());
|
||||
pane.fmtCombo->setFixedHeight(22);
|
||||
pane.fmtCombo->setStyleSheet(QStringLiteral(
|
||||
"QComboBox { background: %1; color: %2; border: 1px solid %3;"
|
||||
" padding: 1px 6px; font-family: '%6'; font-size: 9pt; }"
|
||||
"QComboBox::drop-down { border: none; width: 14px; }"
|
||||
"QComboBox::down-arrow { image: url(:/vsicons/chevron-down.svg);"
|
||||
" width: 10px; height: 10px; }"
|
||||
"QComboBox QAbstractItemView { background: %4; color: %2;"
|
||||
" selection-background-color: %5; border: 1px solid %3; }")
|
||||
.arg(ct.background.name(), ct.textMuted.name(), ct.border.name(),
|
||||
ct.backgroundAlt.name(), ct.hover.name(), ef));
|
||||
|
||||
pane.fmtGear = new QToolButton;
|
||||
pane.fmtGear->setIcon(QIcon(":/vsicons/settings-gear.svg"));
|
||||
pane.fmtGear->setFixedSize(22, 22);
|
||||
pane.fmtGear->setToolTip("Generator Options");
|
||||
pane.fmtGear->setStyleSheet(QStringLiteral(
|
||||
"QToolButton { background: %1; color: %2; border: 1px solid %3; border-radius: 2px; }"
|
||||
"QToolButton:hover { background: %4; }")
|
||||
.arg(ct.background.name(), ct.textMuted.name(), ct.border.name(),
|
||||
ct.hover.name()));
|
||||
|
||||
pane.scopeCombo = new QComboBox;
|
||||
for (int si = 0; si < static_cast<int>(CodeScope::_Count); ++si)
|
||||
pane.scopeCombo->addItem(codeScopeName(static_cast<CodeScope>(si)));
|
||||
pane.scopeCombo->setCurrentIndex(cs.value("codeScope", 0).toInt());
|
||||
pane.scopeCombo->setFixedHeight(22);
|
||||
pane.scopeCombo->setStyleSheet(pane.fmtCombo->styleSheet());
|
||||
|
||||
cornerLayout->addWidget(pane.fmtCombo);
|
||||
cornerLayout->addWidget(pane.scopeCombo);
|
||||
cornerLayout->addWidget(pane.fmtGear);
|
||||
pane.tabWidget->setCornerWidget(cornerWidget, Qt::BottomRightCorner);
|
||||
cornerWidget->setVisible(false); // hidden until Code tab selected
|
||||
|
||||
auto refreshAllRendered = [this]() {
|
||||
for (auto& tab : m_tabs)
|
||||
for (auto& p : tab.panes)
|
||||
if (p.viewMode == VM_Rendered)
|
||||
updateRenderedView(tab, p);
|
||||
};
|
||||
|
||||
connect(pane.fmtCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
|
||||
this, [this, refreshAllRendered](int idx) {
|
||||
QSettings("Reclass", "Reclass").setValue("codeFormat", idx);
|
||||
refreshAllRendered();
|
||||
for (auto& tab : m_tabs)
|
||||
for (auto& p : tab.panes)
|
||||
if (p.fmtCombo && p.fmtCombo->currentIndex() != idx)
|
||||
p.fmtCombo->setCurrentIndex(idx);
|
||||
});
|
||||
connect(pane.scopeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
|
||||
this, [this, refreshAllRendered](int idx) {
|
||||
QSettings("Reclass", "Reclass").setValue("codeScope", idx);
|
||||
refreshAllRendered();
|
||||
for (auto& tab : m_tabs)
|
||||
for (auto& p : tab.panes)
|
||||
if (p.scopeCombo && p.scopeCombo->currentIndex() != idx)
|
||||
p.scopeCombo->setCurrentIndex(idx);
|
||||
});
|
||||
connect(pane.fmtGear, &QToolButton::clicked, this, [this]() {
|
||||
showOptionsDialog(2); // Generator page
|
||||
});
|
||||
}
|
||||
|
||||
pane.tabWidget->setCurrentIndex(0);
|
||||
pane.viewMode = VM_Reclass;
|
||||
@@ -1437,6 +1513,10 @@ MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) {
|
||||
SplitPane* p = findPaneByTabWidget(tw);
|
||||
if (!p) return;
|
||||
|
||||
// Show/hide corner controls (format combo, scope combo, gear)
|
||||
if (auto* cw = tw->cornerWidget(Qt::BottomRightCorner))
|
||||
cw->setVisible(index == 1);
|
||||
|
||||
p->viewMode = (index == 1) ? VM_Rendered : VM_Reclass;
|
||||
|
||||
// Sync status bar buttons if this is the active pane
|
||||
@@ -1538,6 +1618,21 @@ QString MainWindow::tabTitle(const TabState& tab) const {
|
||||
return name;
|
||||
}
|
||||
|
||||
// Create a sentinel dock — invisible tab that keeps Qt's tab bar on-screen
|
||||
// when only 1 real dock remains in a group.
|
||||
QDockWidget* MainWindow::createSentinelDock() {
|
||||
auto* sentinel = new QDockWidget(this);
|
||||
sentinel->setObjectName(QStringLiteral("_sentinel_%1").arg(quintptr(sentinel), 0, 16));
|
||||
sentinel->setFeatures(QDockWidget::NoDockWidgetFeatures);
|
||||
sentinel->setWidget(new QWidget(sentinel));
|
||||
auto* stb = new QWidget(sentinel);
|
||||
stb->setFixedHeight(0);
|
||||
sentinel->setTitleBarWidget(stb);
|
||||
sentinel->setWindowTitle(QStringLiteral("\u200B"));
|
||||
m_sentinelDocks.append(sentinel);
|
||||
return sentinel;
|
||||
}
|
||||
|
||||
QDockWidget* MainWindow::createTab(RcxDocument* doc) {
|
||||
auto* splitter = new QSplitter(Qt::Horizontal);
|
||||
splitter->setHandleWidth(1);
|
||||
@@ -1658,19 +1753,22 @@ QDockWidget* MainWindow::createTab(RcxDocument* doc) {
|
||||
});
|
||||
|
||||
// Tabify with existing doc docks, or add to top area
|
||||
if (!m_docDocks.isEmpty())
|
||||
if (!m_docDocks.isEmpty()) {
|
||||
tabifyDockWidget(m_docDocks.last(), dock);
|
||||
else
|
||||
} else {
|
||||
addDockWidget(Qt::TopDockWidgetArea, dock);
|
||||
|
||||
// Bootstrap: tabify the hidden sentinel with the first doc dock so Qt
|
||||
// creates a QTabBar. Then hide sentinel (zero layout space). The event
|
||||
// filter in eventFilter() keeps the tab bar visible even at count==1.
|
||||
if (m_sentinelDock && m_docDocks.isEmpty()) {
|
||||
m_sentinelDock->show();
|
||||
tabifyDockWidget(dock, m_sentinelDock);
|
||||
m_sentinelDock->hide();
|
||||
dock->raise();
|
||||
// Deferred sentinel — must wait for Qt to finish laying out the
|
||||
// first doc dock before tabifyDockWidget can merge them into tabs.
|
||||
QTimer::singleShot(0, this, [this, dock]() {
|
||||
if (!dock->isVisible()) return;
|
||||
// Check if this dock already has a sentinel (e.g. second createTab raced)
|
||||
for (auto* td : tabifiedDockWidgets(dock))
|
||||
if (m_sentinelDocks.contains(static_cast<QDockWidget*>(td))) return;
|
||||
auto* sentinel = createSentinelDock();
|
||||
tabifyDockWidget(dock, sentinel);
|
||||
dock->raise();
|
||||
setupDockTabBars();
|
||||
});
|
||||
}
|
||||
|
||||
m_docDocks.append(dock);
|
||||
@@ -1908,11 +2006,19 @@ void MainWindow::setupDockTabBars() {
|
||||
.arg(theme.background.name(), theme.border.name(), theme.hover.name()));
|
||||
}
|
||||
|
||||
// Force tab bar visible (event filter keeps it alive, belt-and-suspenders)
|
||||
tabBar->show();
|
||||
// Hide sentinel tabs so user sees only real doc tabs.
|
||||
// Qt's updateTabBar() rebuilds tabs each layout pass, resetting
|
||||
// visibility, so we must re-hide every call.
|
||||
static const QString sentinelTitle = QStringLiteral("\u200B");
|
||||
for (int i = 0; i < tabBar->count(); ++i) {
|
||||
if (tabBar->tabText(i) == sentinelTitle)
|
||||
tabBar->setTabVisible(i, false);
|
||||
}
|
||||
|
||||
// Install tab buttons for any tab that doesn't have them yet
|
||||
for (int i = 0; i < tabBar->count(); ++i) {
|
||||
if (tabBar->tabText(i) == sentinelTitle)
|
||||
continue;
|
||||
auto* existing = qobject_cast<DockTabButtons*>(
|
||||
tabBar->tabButton(i, QTabBar::RightSide));
|
||||
if (existing) continue;
|
||||
@@ -1996,8 +2102,11 @@ void MainWindow::setupDockTabBars() {
|
||||
|
||||
menu.addSeparator();
|
||||
|
||||
// New Document Groups (only if >1 tab)
|
||||
if (tabBar->count() > 1) {
|
||||
// New Document Groups (only if >1 visible tab — excludes sentinels)
|
||||
int visibleTabs = 0;
|
||||
for (int i = 0; i < tabBar->count(); ++i)
|
||||
if (tabBar->isTabVisible(i)) ++visibleTabs;
|
||||
if (visibleTabs > 1) {
|
||||
menu.addAction(makeIcon(":/vsicons/split-horizontal.svg"),
|
||||
"New Horizontal Document Group",
|
||||
[this, target]() {
|
||||
@@ -2016,7 +2125,12 @@ void MainWindow::setupDockTabBars() {
|
||||
}
|
||||
if (docks.size() >= 2)
|
||||
resizeDocks(docks, sizes, Qt::Horizontal);
|
||||
QTimer::singleShot(0, this, [this]() { setupDockTabBars(); });
|
||||
QTimer::singleShot(0, this, [this, target]() {
|
||||
auto* s = createSentinelDock();
|
||||
tabifyDockWidget(target, s);
|
||||
target->raise();
|
||||
QTimer::singleShot(0, this, [this]() { setupDockTabBars(); });
|
||||
});
|
||||
});
|
||||
menu.addAction(makeIcon(":/vsicons/split-vertical.svg"),
|
||||
"New Vertical Document Group",
|
||||
@@ -2036,7 +2150,12 @@ void MainWindow::setupDockTabBars() {
|
||||
}
|
||||
if (docks.size() >= 2)
|
||||
resizeDocks(docks, sizes, Qt::Vertical);
|
||||
QTimer::singleShot(0, this, [this]() { setupDockTabBars(); });
|
||||
QTimer::singleShot(0, this, [this, target]() {
|
||||
auto* s = createSentinelDock();
|
||||
tabifyDockWidget(target, s);
|
||||
target->raise();
|
||||
QTimer::singleShot(0, this, [this]() { setupDockTabBars(); });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2046,25 +2165,6 @@ void MainWindow::setupDockTabBars() {
|
||||
}
|
||||
|
||||
bool MainWindow::eventFilter(QObject* obj, QEvent* event) {
|
||||
// Keep dock tab bars visible even when Qt wants to hide them (count==1).
|
||||
// Qt's QMainWindowLayout calls setVisible(false) on the QTabBar when only
|
||||
// one dock remains in a tab group. We catch the resulting Hide event and
|
||||
// immediately re-show the tab bar, provided at least one doc dock is docked.
|
||||
if (event->type() == QEvent::Hide && !m_tabBarShowGuard) {
|
||||
if (auto* tabBar = qobject_cast<QTabBar*>(obj)) {
|
||||
if (tabBar->parent() == this && tabBar->count() >= 1) {
|
||||
bool hasDockedDoc = false;
|
||||
for (auto* d : m_docDocks)
|
||||
if (!d->isFloating() && d->isVisible()) { hasDockedDoc = true; break; }
|
||||
if (hasDockedDoc) {
|
||||
m_tabBarShowGuard = true;
|
||||
tabBar->show();
|
||||
m_tabBarShowGuard = false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (event->type() == QEvent::MouseButtonPress) {
|
||||
auto* me = static_cast<QMouseEvent*>(event);
|
||||
if (me->button() == Qt::MiddleButton) {
|
||||
@@ -2542,7 +2642,7 @@ void MainWindow::applyTheme(const Theme& theme) {
|
||||
}
|
||||
}
|
||||
|
||||
// Restyle per-pane view tab bars (Reclass / C++)
|
||||
// Restyle per-pane view tab bars (Reclass / Code)
|
||||
{
|
||||
QString editorFont = QSettings("Reclass", "Reclass").value("font", "JetBrains Mono").toString();
|
||||
QString paneTabStyle = QStringLiteral(
|
||||
@@ -2558,10 +2658,31 @@ void MainWindow::applyTheme(const Theme& theme) {
|
||||
.arg(theme.background.name(), theme.textMuted.name(), theme.text.name(),
|
||||
theme.backgroundAlt.name(), theme.hover.name(), theme.indHoverSpan.name(),
|
||||
editorFont);
|
||||
QString comboStyle = QStringLiteral(
|
||||
"QComboBox { background: %1; color: %2; border: 1px solid %3;"
|
||||
" padding: 1px 6px; font-family: '%6'; font-size: 9pt; }"
|
||||
"QComboBox::drop-down { border: none; width: 14px; }"
|
||||
"QComboBox::down-arrow { image: url(:/vsicons/chevron-down.svg);"
|
||||
" width: 10px; height: 10px; }"
|
||||
"QComboBox QAbstractItemView { background: %4; color: %2;"
|
||||
" selection-background-color: %5; border: 1px solid %3; }")
|
||||
.arg(theme.background.name(), theme.textMuted.name(), theme.border.name(),
|
||||
theme.backgroundAlt.name(), theme.hover.name(), editorFont);
|
||||
QString gearStyle = QStringLiteral(
|
||||
"QToolButton { background: %1; color: %2; border: 1px solid %3; border-radius: 2px; }"
|
||||
"QToolButton:hover { background: %4; }")
|
||||
.arg(theme.background.name(), theme.textMuted.name(), theme.border.name(),
|
||||
theme.hover.name());
|
||||
for (auto it = m_tabs.begin(); it != m_tabs.end(); ++it) {
|
||||
for (auto& pane : it->panes) {
|
||||
if (pane.tabWidget)
|
||||
pane.tabWidget->setStyleSheet(paneTabStyle);
|
||||
if (pane.fmtCombo)
|
||||
pane.fmtCombo->setStyleSheet(comboStyle);
|
||||
if (pane.scopeCombo)
|
||||
pane.scopeCombo->setStyleSheet(comboStyle);
|
||||
if (pane.fmtGear)
|
||||
pane.fmtGear->setStyleSheet(gearStyle);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2706,7 +2827,7 @@ void MainWindow::applyTheme(const Theme& theme) {
|
||||
}
|
||||
}
|
||||
|
||||
// Rendered C/C++ views: update lexer colors, paper, margins
|
||||
// Rendered Code views: update lexer colors, paper, margins
|
||||
for (auto& tab : m_tabs) {
|
||||
for (auto& pane : tab.panes) {
|
||||
auto* sci = pane.rendered;
|
||||
@@ -2752,7 +2873,9 @@ void MainWindow::editTheme() {
|
||||
}
|
||||
|
||||
// TODO: when adding more and more options, this func becomes very clunky. Fix
|
||||
void MainWindow::showOptionsDialog() {
|
||||
void MainWindow::showOptionsDialog() { showOptionsDialog(-1); }
|
||||
|
||||
void MainWindow::showOptionsDialog(int initialPage) {
|
||||
auto& tm = ThemeManager::instance();
|
||||
OptionsResult current;
|
||||
current.themeIndex = tm.currentIndex();
|
||||
@@ -2767,7 +2890,9 @@ void MainWindow::showOptionsDialog() {
|
||||
current.braceWrap = QSettings("Reclass", "Reclass").value("braceWrap", false).toBool();
|
||||
|
||||
OptionsDialog dlg(current, this);
|
||||
if (dlg.exec() != QDialog::Accepted) return; // OptionsDialog doesn't apply anything. Only apply on OK
|
||||
if (initialPage >= 0)
|
||||
dlg.selectPage(initialPage);
|
||||
if (dlg.exec() != QDialog::Accepted) return;
|
||||
|
||||
auto r = dlg.result();
|
||||
|
||||
@@ -2863,7 +2988,7 @@ void MainWindow::setEditorFont(const QString& fontName) {
|
||||
tabBar->update();
|
||||
}
|
||||
}
|
||||
// Pane tab bars (Reclass / C++) — re-apply stylesheet with new font
|
||||
// Pane tab bars (Reclass / Code) — re-apply stylesheet with new font
|
||||
// (stylesheet overrides setFont, so font must be in the CSS)
|
||||
applyTheme(ThemeManager::instance().current());
|
||||
}
|
||||
@@ -3038,11 +3163,21 @@ void MainWindow::updateRenderedView(TabState& tab, SplitPane& pane) {
|
||||
const QHash<NodeKind, QString>* aliases =
|
||||
tab.doc->typeAliases.isEmpty() ? nullptr : &tab.doc->typeAliases;
|
||||
bool asserts = QSettings("Reclass", "Reclass").value("generatorAsserts", false).toBool();
|
||||
CodeFormat fmt = static_cast<CodeFormat>(
|
||||
QSettings("Reclass", "Reclass").value("codeFormat", 0).toInt());
|
||||
CodeScope scope = static_cast<CodeScope>(
|
||||
QSettings("Reclass", "Reclass").value("codeScope", 0).toInt());
|
||||
QString text;
|
||||
if (rootId != 0)
|
||||
text = renderCpp(tab.doc->tree, rootId, aliases, asserts);
|
||||
else
|
||||
text = renderCppAll(tab.doc->tree, aliases, asserts);
|
||||
if (scope == CodeScope::FullSdk) {
|
||||
text = renderCodeAll(fmt, tab.doc->tree, aliases, asserts);
|
||||
} else if (rootId != 0) {
|
||||
if (scope == CodeScope::WithChildren)
|
||||
text = renderCodeTree(fmt, tab.doc->tree, rootId, aliases, asserts);
|
||||
else
|
||||
text = renderCode(fmt, tab.doc->tree, rootId, aliases, asserts);
|
||||
} else {
|
||||
text = renderCodeAll(fmt, tab.doc->tree, aliases, asserts);
|
||||
}
|
||||
|
||||
// Scroll restoration: save if same root, reset if different
|
||||
int restoreLine = 0;
|
||||
@@ -3113,6 +3248,96 @@ void MainWindow::exportCpp() {
|
||||
setAppStatus("Exported to " + QFileInfo(path).fileName());
|
||||
}
|
||||
|
||||
// ── Export Rust structs ──
|
||||
|
||||
void MainWindow::exportRust() {
|
||||
auto* tab = activeTab();
|
||||
if (!tab) return;
|
||||
|
||||
QString path = QFileDialog::getSaveFileName(this,
|
||||
"Export Rust Structs", {}, "Rust Source (*.rs);;All Files (*)");
|
||||
if (path.isEmpty()) return;
|
||||
|
||||
const QHash<NodeKind, QString>* aliases =
|
||||
tab->doc->typeAliases.isEmpty() ? nullptr : &tab->doc->typeAliases;
|
||||
bool asserts = QSettings("Reclass", "Reclass").value("generatorAsserts", false).toBool();
|
||||
QString text = renderRustAll(tab->doc->tree, aliases, asserts);
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||
QMessageBox::warning(this, "Export Failed",
|
||||
"Could not write to: " + path);
|
||||
return;
|
||||
}
|
||||
file.write(text.toUtf8());
|
||||
setAppStatus("Exported to " + QFileInfo(path).fileName());
|
||||
}
|
||||
|
||||
// ── Export #define offsets ──
|
||||
|
||||
void MainWindow::exportDefines() {
|
||||
auto* tab = activeTab();
|
||||
if (!tab) return;
|
||||
|
||||
QString path = QFileDialog::getSaveFileName(this,
|
||||
"Export #define Offsets", {}, "C Header (*.h);;All Files (*)");
|
||||
if (path.isEmpty()) return;
|
||||
|
||||
QString text = renderDefinesAll(tab->doc->tree);
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||
QMessageBox::warning(this, "Export Failed",
|
||||
"Could not write to: " + path);
|
||||
return;
|
||||
}
|
||||
file.write(text.toUtf8());
|
||||
setAppStatus("Exported to " + QFileInfo(path).fileName());
|
||||
}
|
||||
|
||||
// ── Export C# structs ──
|
||||
|
||||
void MainWindow::exportCSharp() {
|
||||
auto* tab = activeTab();
|
||||
if (!tab) return;
|
||||
|
||||
QString path = QFileDialog::getSaveFileName(this,
|
||||
"Export C# Structs", {}, "C# Source (*.cs);;All Files (*)");
|
||||
if (path.isEmpty()) return;
|
||||
|
||||
const QHash<NodeKind, QString>* aliases =
|
||||
tab->doc->typeAliases.isEmpty() ? nullptr : &tab->doc->typeAliases;
|
||||
bool asserts = QSettings("Reclass", "Reclass").value("generatorAsserts", false).toBool();
|
||||
QString text = renderCSharpAll(tab->doc->tree, aliases, asserts);
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||
QMessageBox::warning(this, "Export Failed",
|
||||
"Could not write to: " + path);
|
||||
return;
|
||||
}
|
||||
file.write(text.toUtf8());
|
||||
setAppStatus("Exported to " + QFileInfo(path).fileName());
|
||||
}
|
||||
|
||||
// ── Export Python ctypes ──
|
||||
|
||||
void MainWindow::exportPython() {
|
||||
auto* tab = activeTab();
|
||||
if (!tab) return;
|
||||
|
||||
QString path = QFileDialog::getSaveFileName(this,
|
||||
"Export Python ctypes", {}, "Python Source (*.py);;All Files (*)");
|
||||
if (path.isEmpty()) return;
|
||||
|
||||
QString text = renderPythonAll(tab->doc->tree);
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
|
||||
QMessageBox::warning(this, "Export Failed",
|
||||
"Could not write to: " + path);
|
||||
return;
|
||||
}
|
||||
file.write(text.toUtf8());
|
||||
setAppStatus("Exported to " + QFileInfo(path).fileName());
|
||||
}
|
||||
|
||||
// ── Export ReClass XML ──
|
||||
|
||||
void MainWindow::exportReclassXmlAction() {
|
||||
|
||||
@@ -16,8 +16,10 @@
|
||||
#include <QLineEdit>
|
||||
#include <QMap>
|
||||
#include <QButtonGroup>
|
||||
#include <QComboBox>
|
||||
#include <QPushButton>
|
||||
#include <QTimer>
|
||||
#include <QToolButton>
|
||||
#include <Qsci/qsciscintilla.h>
|
||||
|
||||
namespace rcx {
|
||||
@@ -58,6 +60,10 @@ private slots:
|
||||
void toggleMcp();
|
||||
void setEditorFont(const QString& fontName);
|
||||
void exportCpp();
|
||||
void exportRust();
|
||||
void exportDefines();
|
||||
void exportCSharp();
|
||||
void exportPython();
|
||||
void exportReclassXmlAction();
|
||||
void importFromSource();
|
||||
void importReclassXml();
|
||||
@@ -65,6 +71,7 @@ private slots:
|
||||
void showTypeAliasesDialog();
|
||||
void editTheme();
|
||||
void showOptionsDialog();
|
||||
void showOptionsDialog(int initialPage);
|
||||
|
||||
public:
|
||||
// Status bar helpers — separate app / MCP channels
|
||||
@@ -106,6 +113,9 @@ private:
|
||||
QLineEdit* findBar = nullptr;
|
||||
QWidget* findContainer = nullptr;
|
||||
QWidget* renderedContainer = nullptr;
|
||||
QComboBox* fmtCombo = nullptr;
|
||||
QComboBox* scopeCombo = nullptr;
|
||||
QToolButton* fmtGear = nullptr;
|
||||
ViewMode viewMode = VM_Reclass;
|
||||
uint64_t lastRenderedRootId = 0;
|
||||
};
|
||||
@@ -120,10 +130,9 @@ private:
|
||||
QMap<QDockWidget*, TabState> m_tabs;
|
||||
QVector<QDockWidget*> m_docDocks; // ordered list for tabByIndex
|
||||
QDockWidget* m_activeDocDock = nullptr; // tracks active document dock
|
||||
QDockWidget* m_sentinelDock = nullptr; // hidden dock to bootstrap tab bar creation
|
||||
QVector<QDockWidget*> m_sentinelDocks; // permanent sentinels for always-visible tab bars
|
||||
QVector<RcxDocument*> m_allDocs; // all open docs, shared with controllers
|
||||
bool m_closingAll = false; // guards spurious project_new during batch close
|
||||
bool m_tabBarShowGuard = false; // prevents recursion in event filter re-show
|
||||
struct ClosingGuard {
|
||||
bool& flag;
|
||||
ClosingGuard(bool& f) : flag(f) { flag = true; }
|
||||
@@ -144,6 +153,7 @@ private:
|
||||
TabState* activeTab();
|
||||
TabState* tabByIndex(int index);
|
||||
int tabCount() const { return m_tabs.size(); }
|
||||
QDockWidget* createSentinelDock();
|
||||
QDockWidget* createTab(RcxDocument* doc);
|
||||
QString tabTitle(const TabState& tab) const;
|
||||
void setupDockTabBars();
|
||||
|
||||
@@ -1,17 +1,42 @@
|
||||
#include "mcp_bridge.h"
|
||||
#include "addressparser.h"
|
||||
#include "core.h"
|
||||
#include "controller.h"
|
||||
#include "generator.h"
|
||||
#include "mainwindow.h"
|
||||
#include "scanner.h"
|
||||
#include <QCoreApplication>
|
||||
#include <QSettings>
|
||||
#include <QTimer>
|
||||
#include <QDebug>
|
||||
#include <cstring>
|
||||
#include <algorithm>
|
||||
|
||||
namespace rcx {
|
||||
|
||||
static constexpr int kMaxReadBuffer = 10 * 1024 * 1024; // 10 MB
|
||||
|
||||
// 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<int64_t>(v.toDouble());
|
||||
return defaultVal;
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// Construction / lifecycle
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
@@ -23,7 +48,7 @@ McpBridge::McpBridge(MainWindow* mainWindow, QObject* parent)
|
||||
m_notifyTimer->setSingleShot(true);
|
||||
m_notifyTimer->setInterval(100);
|
||||
connect(m_notifyTimer, &QTimer::timeout, this, [this]() {
|
||||
if (m_client && m_initialized)
|
||||
if (!m_clients.isEmpty())
|
||||
sendNotification("notifications/resources/updated",
|
||||
QJsonObject{{"uri", "project://tree"}});
|
||||
});
|
||||
@@ -55,10 +80,15 @@ void McpBridge::start() {
|
||||
}
|
||||
|
||||
void McpBridge::stop() {
|
||||
if (m_client) {
|
||||
m_client->disconnectFromServer();
|
||||
m_client = nullptr;
|
||||
for (auto& c : m_clients) {
|
||||
c.socket->disconnect(this);
|
||||
c.socket->disconnectFromServer();
|
||||
c.socket->deleteLater();
|
||||
}
|
||||
m_clients.clear();
|
||||
m_currentSender = nullptr;
|
||||
m_processing = false;
|
||||
m_pendingRequests.clear();
|
||||
if (m_server) {
|
||||
m_server->close();
|
||||
delete m_server;
|
||||
@@ -70,55 +100,95 @@ 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->disconnectFromServer();
|
||||
m_client->deleteLater();
|
||||
}
|
||||
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() {
|
||||
m_readBuffer.append(m_client->readAll());
|
||||
auto* sock = qobject_cast<QLocalSocket*>(sender());
|
||||
auto* cs = findClient(sock);
|
||||
if (!cs) return;
|
||||
cs->readBuffer.append(sock->readAll());
|
||||
|
||||
if (m_readBuffer.size() > kMaxReadBuffer) {
|
||||
if (cs->readBuffer.size() > kMaxReadBuffer) {
|
||||
qWarning() << "[MCP] Read buffer exceeded 10MB, disconnecting client";
|
||||
m_client->disconnectFromServer();
|
||||
sock->disconnectFromServer();
|
||||
return;
|
||||
}
|
||||
|
||||
// Newline-delimited JSON framing (cursor approach avoids quadratic shifting)
|
||||
int consumed = 0;
|
||||
while (true) {
|
||||
int idx = m_readBuffer.indexOf('\n', consumed);
|
||||
// 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.mid(consumed, idx - consumed).trimmed();
|
||||
consumed = 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;
|
||||
}
|
||||
if (consumed > 0)
|
||||
m_readBuffer.remove(0, consumed);
|
||||
}
|
||||
|
||||
void McpBridge::onDisconnected() {
|
||||
qDebug() << "[MCP] Client disconnected";
|
||||
m_client = nullptr;
|
||||
m_initialized = false;
|
||||
auto* sock = qobject_cast<QLocalSocket*>(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);
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
@@ -142,18 +212,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);
|
||||
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) {
|
||||
@@ -219,7 +297,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}};
|
||||
@@ -352,6 +430,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. '<Module.exe> + 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"},
|
||||
@@ -474,6 +567,73 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
|
||||
}}
|
||||
});
|
||||
|
||||
// 10. scanner.scan
|
||||
tools.append(QJsonObject{
|
||||
{"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."}}},
|
||||
{"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{}}
|
||||
}}
|
||||
});
|
||||
|
||||
|
||||
// process.info
|
||||
tools.append(QJsonObject{
|
||||
@@ -509,12 +669,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 == "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);
|
||||
|
||||
@@ -550,7 +714,7 @@ MainWindow::TabState* McpBridge::resolveTab(const QJsonObject& args, int* resolv
|
||||
|
||||
// 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) { if (resolvedIndex) *resolvedIndex = idx; return t; }
|
||||
}
|
||||
@@ -590,16 +754,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();
|
||||
|
||||
@@ -715,6 +881,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;
|
||||
@@ -791,12 +959,12 @@ QJsonObject McpBridge::toolTreeApply(const QJsonObject& args) {
|
||||
skippedOps.append(QStringLiteral("op[%1]: parentId '%2' not found").arg(i).arg(pid));
|
||||
continue;
|
||||
}
|
||||
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 = qBound(1, op.value("strLen").toInt(64), 1000000);
|
||||
n.strLen = qBound(1, (int)parseInteger(op.value("strLen"), 64), 1000000);
|
||||
n.elementKind = kindFromString(op.value("elementKind").toString("UInt8"));
|
||||
n.arrayLen = qBound(1, op.value("arrayLen").toInt(1), 1000000);
|
||||
n.arrayLen = qBound(1, (int)parseInteger(op.value("arrayLen"), 1), 1000000);
|
||||
bool refOk;
|
||||
QString refStr = resolvePlaceholder(op.value("refId").toString("0"), placeholders, &refOk);
|
||||
if (!refOk) {
|
||||
@@ -868,7 +1036,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++;
|
||||
@@ -928,7 +1096,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 = qBound(1, op.value("arrayLen").toInt(1), 1000000);
|
||||
int newLen = qBound(1, (int)parseInteger(op.value("arrayLen"), 1), 1000000);
|
||||
doc->undoStack.push(new RcxCommand(ctrl,
|
||||
cmd::ChangeArrayMeta{tree.nodes[idx].id,
|
||||
tree.nodes[idx].elementKind, newElemKind,
|
||||
@@ -997,7 +1165,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);
|
||||
@@ -1014,11 +1182,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) + ")");
|
||||
}
|
||||
|
||||
@@ -1032,6 +1206,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<MemoryRegion> regions = prov->enumerateRegions();
|
||||
// Build unique modules: name -> { minBase, maxEnd }
|
||||
QHash<QString, QPair<uint64_t, uint64_t>> 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<qint64>(size))}
|
||||
});
|
||||
}
|
||||
|
||||
QJsonObject out;
|
||||
out["modules"] = arr;
|
||||
out["count"] = arr.size();
|
||||
return makeTextResult(QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Indented)));
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
// TOOL: hex.read
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
@@ -1043,10 +1265,11 @@ QJsonObject McpBridge::toolHexRead(const QJsonObject& args) {
|
||||
auto* prov = tab->doc->provider.get();
|
||||
if (!prov) return makeTextResult("No provider", true);
|
||||
|
||||
int64_t offset = static_cast<int64_t>(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;
|
||||
|
||||
if (offset < 0 || !prov->isReadable((uint64_t)offset, length))
|
||||
@@ -1125,10 +1348,10 @@ QJsonObject McpBridge::toolHexWrite(const QJsonObject& args) {
|
||||
auto* doc = tab->doc;
|
||||
auto* prov = doc->provider.get();
|
||||
|
||||
int64_t offset = static_cast<int64_t>(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)
|
||||
@@ -1312,7 +1535,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);
|
||||
@@ -1402,6 +1625,168 @@ QJsonObject McpBridge::toolNodeHistory(const QJsonObject& args) {
|
||||
QJsonDocument(result).toJson(QJsonDocument::Compact)));
|
||||
}
|
||||
|
||||
// TOOL: scanner.scan
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
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<AddressRange> parseRegionsArg(const QJsonObject& args, QString* errOut = nullptr) {
|
||||
QVector<AddressRange> 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);
|
||||
|
||||
ScannerPanel* panel = m_mainWindow->m_scannerPanel;
|
||||
if (!panel) return makeTextResult("Scanner panel not available", 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();
|
||||
|
||||
if (value.isEmpty())
|
||||
return makeTextResult("Missing 'value' (e.g. \"120\")", true);
|
||||
|
||||
QString regErr;
|
||||
auto constrainRegions = parseRegionsArg(args, ®Err);
|
||||
if (!regErr.isEmpty())
|
||||
return makeTextResult(regErr, true);
|
||||
|
||||
ValueType vt = valueTypeFromString(valueTypeStr);
|
||||
QVector<ScanResult> 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<rcx::Provider> 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);
|
||||
}
|
||||
|
||||
QVector<ScanResult> 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&) {
|
||||
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, 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
|
||||
@@ -1440,12 +1825,13 @@ QJsonObject McpBridge::toolProcessInfo(const QJsonObject& args) {
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
void McpBridge::notifyTreeChanged() {
|
||||
if (!m_client || !m_initialized) return;
|
||||
m_notifyTimer->start(); // debounce 100ms
|
||||
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"}});
|
||||
}
|
||||
|
||||
@@ -29,14 +29,32 @@ 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<ClientState> m_clients;
|
||||
QLocalSocket* m_currentSender = nullptr; // set during request processing
|
||||
bool m_slowMode = false;
|
||||
QTimer* m_notifyTimer = nullptr;
|
||||
|
||||
// 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<PendingRequest> m_pendingRequests;
|
||||
|
||||
|
||||
ClientState* findClient(QLocalSocket* sock);
|
||||
void removeClient(QLocalSocket* sock);
|
||||
void drainPendingRequests();
|
||||
|
||||
// JSON-RPC plumbing
|
||||
void onNewConnection();
|
||||
void onReadyRead();
|
||||
@@ -56,12 +74,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 toolScannerScan(const QJsonObject& args);
|
||||
QJsonObject toolScannerScanPattern(const QJsonObject& args);
|
||||
QJsonObject toolReconnect(const QJsonObject& args);
|
||||
QJsonObject toolProcessInfo(const QJsonObject& args);
|
||||
|
||||
// Helpers
|
||||
|
||||
@@ -207,6 +207,16 @@ OptionsDialog::OptionsDialog(const OptionsResult& current, QWidget* parent)
|
||||
|
||||
}
|
||||
|
||||
void OptionsDialog::selectPage(int index) {
|
||||
for (auto it = m_itemPageIndex.begin(); it != m_itemPageIndex.end(); ++it) {
|
||||
if (it.value() == index) {
|
||||
m_tree->setCurrentItem(it.key());
|
||||
m_pages->setCurrentIndex(index);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
OptionsResult OptionsDialog::result() const {
|
||||
OptionsResult r;
|
||||
r.themeIndex = m_themeCombo->currentIndex();
|
||||
|
||||
@@ -27,6 +27,7 @@ public:
|
||||
explicit OptionsDialog(const OptionsResult& current, QWidget* parent = nullptr);
|
||||
|
||||
OptionsResult result() const;
|
||||
void selectPage(int index);
|
||||
|
||||
private:
|
||||
void filterTree(const QString& text);
|
||||
|
||||
@@ -473,14 +473,14 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> 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);
|
||||
}
|
||||
|
||||
@@ -492,6 +492,41 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> 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<AddressRange> 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<MemoryRegion> 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) {
|
||||
@@ -515,7 +550,8 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> 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;
|
||||
@@ -552,6 +588,8 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> 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;
|
||||
@@ -594,9 +632,12 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> 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
|
||||
|
||||
@@ -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<AddressRange> constrainRegions;
|
||||
};
|
||||
|
||||
struct ScanResult {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
#include <QApplication>
|
||||
#include <QMenu>
|
||||
#include <QPainter>
|
||||
#include <QEventLoop>
|
||||
|
||||
namespace rcx {
|
||||
|
||||
@@ -418,6 +419,98 @@ ScanRequest ScannerPanel::buildRequest() {
|
||||
return req;
|
||||
}
|
||||
|
||||
QVector<ScanResult> ScannerPanel::runValueScanAndWait(ValueType valueType, const QString& value,
|
||||
bool filterExecutable, bool filterWritable,
|
||||
const QVector<AddressRange>& constrainRegions) {
|
||||
QVector<ScanResult> 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<ScanResult>& r) {
|
||||
results = r;
|
||||
loop.quit();
|
||||
}, Qt::SingleShotConnection);
|
||||
m_engine->start(provider, req);
|
||||
loop.exec();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
QVector<ScanResult> ScannerPanel::runPatternScanAndWait(const QString& pattern,
|
||||
bool filterExecutable, bool filterWritable,
|
||||
const QVector<AddressRange>& constrainRegions) {
|
||||
auto provider = m_providerGetter ? m_providerGetter() : nullptr;
|
||||
return runPatternScanAndWait(provider, pattern, filterExecutable, filterWritable, constrainRegions);
|
||||
}
|
||||
|
||||
QVector<ScanResult> ScannerPanel::runPatternScanAndWait(std::shared_ptr<Provider> provider,
|
||||
const QString& pattern,
|
||||
bool filterExecutable, bool filterWritable,
|
||||
const QVector<AddressRange>& constrainRegions) {
|
||||
QVector<ScanResult> 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<ScanResult>& r) {
|
||||
results = r;
|
||||
loop.quit();
|
||||
}, Qt::SingleShotConnection);
|
||||
m_engine->start(provider, req);
|
||||
loop.exec();
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
void ScannerPanel::onScanFinished(QVector<ScanResult> results) {
|
||||
m_scanBtn->setText(QStringLiteral("Scan"));
|
||||
m_progressBar->hide();
|
||||
|
||||
@@ -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<ScanResult> runValueScanAndWait(ValueType valueType, const QString& value,
|
||||
bool filterExecutable = false, bool filterWritable = false,
|
||||
const QVector<AddressRange>& constrainRegions = {});
|
||||
|
||||
/** Run a pattern/signature scan and block until done. Pattern: space-separated hex bytes, e.g. "00 00 20 42 ?? ??". */
|
||||
QVector<ScanResult> runPatternScanAndWait(const QString& pattern,
|
||||
bool filterExecutable = false, bool filterWritable = false,
|
||||
const QVector<AddressRange>& constrainRegions = {});
|
||||
|
||||
/** Run pattern scan using the given provider (for MCP: use tab's provider so scan runs on the right tab). */
|
||||
QVector<ScanResult> runPatternScanAndWait(std::shared_ptr<Provider> provider, const QString& pattern,
|
||||
bool filterExecutable = false, bool filterWritable = false,
|
||||
const QVector<AddressRange>& constrainRegions = {});
|
||||
|
||||
signals:
|
||||
void goToAddress(uint64_t address);
|
||||
|
||||
|
||||
@@ -873,6 +873,559 @@ private slots:
|
||||
QVERIFY2(result.contains("sizeof(Small) == 0x4"),
|
||||
qPrintable("Expected sizeof(Small) == 0x4:\n" + result));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ── Rust backend tests ──
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
void testRustSimpleStruct() {
|
||||
auto tree = makeSimpleStruct();
|
||||
uint64_t rootId = tree.nodes[0].id;
|
||||
QString result = rcx::renderRust(tree, rootId, nullptr, true);
|
||||
|
||||
QVERIFY(result.contains("// Generated by Reclass 2027"));
|
||||
QVERIFY(result.contains("#[repr(C)]"));
|
||||
QVERIFY(result.contains("pub struct Player {"));
|
||||
QVERIFY(result.contains("pub health: i32,"));
|
||||
QVERIFY(result.contains("pub speed: f32,"));
|
||||
QVERIFY(result.contains("pub id: u64,"));
|
||||
QVERIFY(result.contains("// 0x0"));
|
||||
QVERIFY(result.contains("// 0x4"));
|
||||
QVERIFY(result.contains("// 0x8"));
|
||||
QVERIFY(result.contains("core::mem::size_of::<Player>() == 0x10"));
|
||||
|
||||
// Without asserts
|
||||
QString noAsserts = rcx::renderRust(tree, rootId);
|
||||
QVERIFY(!noAsserts.contains("size_of"));
|
||||
}
|
||||
|
||||
void testRustPadding() {
|
||||
rcx::NodeTree tree;
|
||||
rcx::Node root;
|
||||
root.kind = rcx::NodeKind::Struct;
|
||||
root.name = "Padded";
|
||||
root.structTypeName = "Padded";
|
||||
root.parentId = 0;
|
||||
int ri = tree.addNode(root);
|
||||
uint64_t rootId = tree.nodes[ri].id;
|
||||
|
||||
rcx::Node f1;
|
||||
f1.kind = rcx::NodeKind::UInt32;
|
||||
f1.name = "a";
|
||||
f1.parentId = rootId;
|
||||
f1.offset = 0;
|
||||
tree.addNode(f1);
|
||||
|
||||
rcx::Node f2;
|
||||
f2.kind = rcx::NodeKind::UInt32;
|
||||
f2.name = "b";
|
||||
f2.parentId = rootId;
|
||||
f2.offset = 8;
|
||||
tree.addNode(f2);
|
||||
|
||||
QString result = rcx::renderRust(tree, rootId);
|
||||
QVERIFY(result.contains("pub _pad"));
|
||||
QVERIFY(result.contains("[u8; 0x4]"));
|
||||
}
|
||||
|
||||
void testRustPointers() {
|
||||
rcx::NodeTree tree;
|
||||
|
||||
rcx::Node target;
|
||||
target.kind = rcx::NodeKind::Struct;
|
||||
target.name = "Target";
|
||||
target.structTypeName = "Target";
|
||||
target.parentId = 0;
|
||||
target.offset = 0x100;
|
||||
int ti = tree.addNode(target);
|
||||
uint64_t targetId = tree.nodes[ti].id;
|
||||
|
||||
rcx::Node tf;
|
||||
tf.kind = rcx::NodeKind::UInt32;
|
||||
tf.name = "val";
|
||||
tf.parentId = targetId;
|
||||
tf.offset = 0;
|
||||
tree.addNode(tf);
|
||||
|
||||
rcx::Node main;
|
||||
main.kind = rcx::NodeKind::Struct;
|
||||
main.name = "PtrTest";
|
||||
main.structTypeName = "PtrTest";
|
||||
main.parentId = 0;
|
||||
int mi = tree.addNode(main);
|
||||
uint64_t mainId = tree.nodes[mi].id;
|
||||
|
||||
rcx::Node p1;
|
||||
p1.kind = rcx::NodeKind::Pointer64;
|
||||
p1.name = "typed";
|
||||
p1.parentId = mainId;
|
||||
p1.offset = 0;
|
||||
p1.refId = targetId;
|
||||
tree.addNode(p1);
|
||||
|
||||
rcx::Node p2;
|
||||
p2.kind = rcx::NodeKind::Pointer64;
|
||||
p2.name = "untyped";
|
||||
p2.parentId = mainId;
|
||||
p2.offset = 8;
|
||||
tree.addNode(p2);
|
||||
|
||||
QString result = rcx::renderRust(tree, mainId);
|
||||
QVERIFY(result.contains("pub typed: *mut Target,"));
|
||||
QVERIFY(result.contains("pub untyped: *mut core::ffi::c_void,"));
|
||||
}
|
||||
|
||||
void testRustVectors() {
|
||||
rcx::NodeTree tree;
|
||||
rcx::Node root;
|
||||
root.kind = rcx::NodeKind::Struct;
|
||||
root.name = "Vecs";
|
||||
root.structTypeName = "Vecs";
|
||||
root.parentId = 0;
|
||||
int ri = tree.addNode(root);
|
||||
uint64_t rootId = tree.nodes[ri].id;
|
||||
|
||||
rcx::Node v2;
|
||||
v2.kind = rcx::NodeKind::Vec2;
|
||||
v2.name = "pos";
|
||||
v2.parentId = rootId;
|
||||
v2.offset = 0;
|
||||
tree.addNode(v2);
|
||||
|
||||
rcx::Node v4;
|
||||
v4.kind = rcx::NodeKind::Vec4;
|
||||
v4.name = "color";
|
||||
v4.parentId = rootId;
|
||||
v4.offset = 8;
|
||||
tree.addNode(v4);
|
||||
|
||||
QString result = rcx::renderRust(tree, rootId);
|
||||
QVERIFY(result.contains("pub pos: [f32; 2],"));
|
||||
QVERIFY(result.contains("pub color: [f32; 4],"));
|
||||
}
|
||||
|
||||
void testRustFuncPtr() {
|
||||
rcx::NodeTree tree;
|
||||
rcx::Node root;
|
||||
root.kind = rcx::NodeKind::Struct;
|
||||
root.name = "FP";
|
||||
root.structTypeName = "FP";
|
||||
root.parentId = 0;
|
||||
int ri = tree.addNode(root);
|
||||
uint64_t rootId = tree.nodes[ri].id;
|
||||
|
||||
rcx::Node fp;
|
||||
fp.kind = rcx::NodeKind::FuncPtr64;
|
||||
fp.name = "callback";
|
||||
fp.parentId = rootId;
|
||||
fp.offset = 0;
|
||||
tree.addNode(fp);
|
||||
|
||||
QString result = rcx::renderRust(tree, rootId);
|
||||
QVERIFY(result.contains("pub callback: Option<unsafe extern \"C\" fn()>,"));
|
||||
}
|
||||
|
||||
void testRustAll() {
|
||||
auto tree = makeSimpleStruct();
|
||||
QString result = rcx::renderRustAll(tree, nullptr, true);
|
||||
QVERIFY(result.contains("#[repr(C)]"));
|
||||
QVERIFY(result.contains("pub struct Player {"));
|
||||
QVERIFY(result.contains("core::mem::size_of::<Player>()"));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ── #define offsets backend tests ──
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
void testDefineSimpleStruct() {
|
||||
auto tree = makeSimpleStruct();
|
||||
uint64_t rootId = tree.nodes[0].id;
|
||||
QString result = rcx::renderDefines(tree, rootId);
|
||||
|
||||
QVERIFY(result.contains("#pragma once"));
|
||||
QVERIFY(result.contains("// Player"));
|
||||
QVERIFY(result.contains("#define Player_health 0x0"));
|
||||
QVERIFY(result.contains("#define Player_speed 0x4"));
|
||||
QVERIFY(result.contains("#define Player_id 0x8"));
|
||||
}
|
||||
|
||||
void testDefineSkipsHex() {
|
||||
rcx::NodeTree tree;
|
||||
rcx::Node root;
|
||||
root.kind = rcx::NodeKind::Struct;
|
||||
root.name = "HexTest";
|
||||
root.structTypeName = "HexTest";
|
||||
root.parentId = 0;
|
||||
int ri = tree.addNode(root);
|
||||
uint64_t rootId = tree.nodes[ri].id;
|
||||
|
||||
rcx::Node h;
|
||||
h.kind = rcx::NodeKind::Hex32;
|
||||
h.name = "padding";
|
||||
h.parentId = rootId;
|
||||
h.offset = 0;
|
||||
tree.addNode(h);
|
||||
|
||||
rcx::Node f;
|
||||
f.kind = rcx::NodeKind::UInt32;
|
||||
f.name = "real_field";
|
||||
f.parentId = rootId;
|
||||
f.offset = 4;
|
||||
tree.addNode(f);
|
||||
|
||||
QString result = rcx::renderDefines(tree, rootId);
|
||||
QVERIFY(!result.contains("padding"));
|
||||
QVERIFY(result.contains("#define HexTest_real_field 0x4"));
|
||||
}
|
||||
|
||||
void testDefineAll() {
|
||||
auto tree = makeSimpleStruct();
|
||||
QString result = rcx::renderDefinesAll(tree);
|
||||
QVERIFY(result.contains("#pragma once"));
|
||||
QVERIFY(result.contains("#define Player_health 0x0"));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ── Format dispatch tests ──
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
void testCodeFormatDispatch() {
|
||||
auto tree = makeSimpleStruct();
|
||||
uint64_t rootId = tree.nodes[0].id;
|
||||
|
||||
QString cpp = rcx::renderCode(rcx::CodeFormat::CppHeader, tree, rootId);
|
||||
QVERIFY(cpp.contains("struct Player"));
|
||||
|
||||
QString rust = rcx::renderCode(rcx::CodeFormat::RustStruct, tree, rootId);
|
||||
QVERIFY(rust.contains("pub struct Player"));
|
||||
|
||||
QString defs = rcx::renderCode(rcx::CodeFormat::DefineOffsets, tree, rootId);
|
||||
QVERIFY(defs.contains("#define Player_health"));
|
||||
}
|
||||
|
||||
void testCodeFormatAllDispatch() {
|
||||
auto tree = makeSimpleStruct();
|
||||
|
||||
QString cpp = rcx::renderCodeAll(rcx::CodeFormat::CppHeader, tree);
|
||||
QVERIFY(cpp.contains("struct Player"));
|
||||
|
||||
QString rust = rcx::renderCodeAll(rcx::CodeFormat::RustStruct, tree);
|
||||
QVERIFY(rust.contains("pub struct Player"));
|
||||
|
||||
QString defs = rcx::renderCodeAll(rcx::CodeFormat::DefineOffsets, tree);
|
||||
QVERIFY(defs.contains("#define Player_health"));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ── Scope tests (Current + Deps) ──
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
void testTreeScopeIncludesReferencedTypes() {
|
||||
rcx::NodeTree tree;
|
||||
|
||||
// Target struct (referenced by pointer)
|
||||
rcx::Node target;
|
||||
target.kind = rcx::NodeKind::Struct;
|
||||
target.name = "Target";
|
||||
target.structTypeName = "Target";
|
||||
target.parentId = 0;
|
||||
target.offset = 0x100;
|
||||
int ti = tree.addNode(target);
|
||||
uint64_t targetId = tree.nodes[ti].id;
|
||||
|
||||
rcx::Node tf;
|
||||
tf.kind = rcx::NodeKind::UInt32;
|
||||
tf.name = "val";
|
||||
tf.parentId = targetId;
|
||||
tf.offset = 0;
|
||||
tree.addNode(tf);
|
||||
|
||||
// Main struct with a pointer to Target
|
||||
rcx::Node main;
|
||||
main.kind = rcx::NodeKind::Struct;
|
||||
main.name = "Main";
|
||||
main.structTypeName = "Main";
|
||||
main.parentId = 0;
|
||||
int mi = tree.addNode(main);
|
||||
uint64_t mainId = tree.nodes[mi].id;
|
||||
|
||||
rcx::Node ptr;
|
||||
ptr.kind = rcx::NodeKind::Pointer64;
|
||||
ptr.name = "pTarget";
|
||||
ptr.parentId = mainId;
|
||||
ptr.offset = 0;
|
||||
ptr.refId = targetId;
|
||||
tree.addNode(ptr);
|
||||
|
||||
// "Current" scope: only Main, no Target definition
|
||||
QString current = rcx::renderCpp(tree, mainId);
|
||||
QVERIFY(current.contains("struct Main\n{"));
|
||||
QVERIFY(!current.contains("struct Target\n{"));
|
||||
|
||||
// "Current + Deps" scope: Main AND Target definitions
|
||||
QString withDeps = rcx::renderCppTree(tree, mainId);
|
||||
QVERIFY(withDeps.contains("struct Main\n{"));
|
||||
QVERIFY(withDeps.contains("struct Target\n{"));
|
||||
|
||||
// Same for Rust
|
||||
QString rustDeps = rcx::renderRustTree(tree, mainId);
|
||||
QVERIFY(rustDeps.contains("pub struct Main {"));
|
||||
QVERIFY(rustDeps.contains("pub struct Target {"));
|
||||
|
||||
// Same for #define
|
||||
QString defDeps = rcx::renderDefinesTree(tree, mainId);
|
||||
QVERIFY(defDeps.contains("#define Main_pTarget"));
|
||||
QVERIFY(defDeps.contains("#define Target_val"));
|
||||
}
|
||||
|
||||
void testTreeScopeDispatch() {
|
||||
rcx::NodeTree tree;
|
||||
|
||||
rcx::Node a;
|
||||
a.kind = rcx::NodeKind::Struct;
|
||||
a.name = "A";
|
||||
a.structTypeName = "A";
|
||||
a.parentId = 0;
|
||||
int ai = tree.addNode(a);
|
||||
uint64_t aId = tree.nodes[ai].id;
|
||||
|
||||
rcx::Node af;
|
||||
af.kind = rcx::NodeKind::UInt32;
|
||||
af.name = "x";
|
||||
af.parentId = aId;
|
||||
af.offset = 0;
|
||||
tree.addNode(af);
|
||||
|
||||
// renderCodeTree should work for all formats
|
||||
QString cpp = rcx::renderCodeTree(rcx::CodeFormat::CppHeader, tree, aId);
|
||||
QVERIFY(cpp.contains("struct A"));
|
||||
|
||||
QString rust = rcx::renderCodeTree(rcx::CodeFormat::RustStruct, tree, aId);
|
||||
QVERIFY(rust.contains("pub struct A"));
|
||||
|
||||
QString defs = rcx::renderCodeTree(rcx::CodeFormat::DefineOffsets, tree, aId);
|
||||
QVERIFY(defs.contains("#define A_x"));
|
||||
|
||||
QString cs = rcx::renderCodeTree(rcx::CodeFormat::CSharpStruct, tree, aId);
|
||||
QVERIFY(cs.contains("public unsafe struct A"));
|
||||
|
||||
QString py = rcx::renderCodeTree(rcx::CodeFormat::PythonCtypes, tree, aId);
|
||||
QVERIFY(py.contains("class A(ctypes.Structure)"));
|
||||
}
|
||||
|
||||
// ── C# backend ──
|
||||
|
||||
void testCSharpSimpleStruct() {
|
||||
auto tree = makeSimpleStruct();
|
||||
uint64_t rootId = tree.nodes[0].id;
|
||||
QString result = rcx::renderCSharp(tree, rootId);
|
||||
|
||||
QVERIFY(result.contains("using System.Runtime.InteropServices;"));
|
||||
QVERIFY(result.contains("[StructLayout(LayoutKind.Explicit, Size = 0x10)]"));
|
||||
QVERIFY(result.contains("public unsafe struct Player"));
|
||||
QVERIFY(result.contains("[FieldOffset(0x0)] public int health;"));
|
||||
QVERIFY(result.contains("[FieldOffset(0x4)] public float speed;"));
|
||||
QVERIFY(result.contains("[FieldOffset(0x8)] public ulong id;"));
|
||||
}
|
||||
|
||||
void testCSharpPointers() {
|
||||
rcx::NodeTree tree;
|
||||
rcx::Node root;
|
||||
root.kind = rcx::NodeKind::Struct;
|
||||
root.name = "Foo";
|
||||
root.structTypeName = "Foo";
|
||||
root.parentId = 0;
|
||||
root.offset = 0;
|
||||
int ri = tree.addNode(root);
|
||||
uint64_t rootId = tree.nodes[ri].id;
|
||||
|
||||
rcx::Node p;
|
||||
p.kind = rcx::NodeKind::Pointer64;
|
||||
p.name = "ptr";
|
||||
p.parentId = rootId;
|
||||
p.offset = 0;
|
||||
tree.addNode(p);
|
||||
|
||||
QString result = rcx::renderCSharp(tree, rootId);
|
||||
QVERIFY(result.contains("IntPtr ptr"));
|
||||
}
|
||||
|
||||
void testCSharpAll() {
|
||||
auto tree = makeSimpleStruct();
|
||||
QString result = rcx::renderCSharpAll(tree);
|
||||
QVERIFY(result.contains("public unsafe struct Player"));
|
||||
QVERIFY(result.contains("[StructLayout("));
|
||||
}
|
||||
|
||||
void testCSharpEnum() {
|
||||
rcx::NodeTree tree;
|
||||
rcx::Node e;
|
||||
e.kind = rcx::NodeKind::Struct;
|
||||
e.name = "Color";
|
||||
e.structTypeName = "Color";
|
||||
e.classKeyword = "enum";
|
||||
e.parentId = 0;
|
||||
e.offset = 0;
|
||||
e.enumMembers = {{"Red", 0}, {"Green", 1}, {"Blue", 2}};
|
||||
tree.addNode(e);
|
||||
|
||||
QString result = rcx::renderCSharpAll(tree);
|
||||
QVERIFY(result.contains("public enum Color : long"));
|
||||
QVERIFY(result.contains("Red = 0"));
|
||||
QVERIFY(result.contains("Green = 1"));
|
||||
QVERIFY(result.contains("Blue = 2"));
|
||||
}
|
||||
|
||||
void testCSharpVectors() {
|
||||
rcx::NodeTree tree;
|
||||
rcx::Node root;
|
||||
root.kind = rcx::NodeKind::Struct;
|
||||
root.name = "Xform";
|
||||
root.structTypeName = "Xform";
|
||||
root.parentId = 0;
|
||||
root.offset = 0;
|
||||
int ri = tree.addNode(root);
|
||||
uint64_t rootId = tree.nodes[ri].id;
|
||||
|
||||
rcx::Node v;
|
||||
v.kind = rcx::NodeKind::Vec3;
|
||||
v.name = "position";
|
||||
v.parentId = rootId;
|
||||
v.offset = 0;
|
||||
tree.addNode(v);
|
||||
|
||||
QString result = rcx::renderCSharp(tree, rootId);
|
||||
QVERIFY(result.contains("public fixed float position[3]"));
|
||||
}
|
||||
|
||||
// ── Python ctypes backend ──
|
||||
|
||||
void testPythonSimpleStruct() {
|
||||
auto tree = makeSimpleStruct();
|
||||
uint64_t rootId = tree.nodes[0].id;
|
||||
QString result = rcx::renderPython(tree, rootId);
|
||||
|
||||
QVERIFY(result.contains("import ctypes"));
|
||||
QVERIFY(result.contains("class Player(ctypes.Structure)"));
|
||||
QVERIFY(result.contains("_fields_ = ["));
|
||||
QVERIFY(result.contains("(\"health\", ctypes.c_int32)"));
|
||||
QVERIFY(result.contains("(\"speed\", ctypes.c_float)"));
|
||||
QVERIFY(result.contains("(\"id\", ctypes.c_uint64)"));
|
||||
}
|
||||
|
||||
void testPythonPointers() {
|
||||
rcx::NodeTree tree;
|
||||
rcx::Node root;
|
||||
root.kind = rcx::NodeKind::Struct;
|
||||
root.name = "Bar";
|
||||
root.structTypeName = "Bar";
|
||||
root.parentId = 0;
|
||||
root.offset = 0;
|
||||
int ri = tree.addNode(root);
|
||||
uint64_t rootId = tree.nodes[ri].id;
|
||||
|
||||
rcx::Node p;
|
||||
p.kind = rcx::NodeKind::Pointer64;
|
||||
p.name = "ptr";
|
||||
p.parentId = rootId;
|
||||
p.offset = 0;
|
||||
tree.addNode(p);
|
||||
|
||||
QString result = rcx::renderPython(tree, rootId);
|
||||
QVERIFY(result.contains("(\"ptr\", ctypes.c_void_p)"));
|
||||
}
|
||||
|
||||
void testPythonTypedPointers() {
|
||||
rcx::NodeTree tree;
|
||||
rcx::Node target;
|
||||
target.kind = rcx::NodeKind::Struct;
|
||||
target.name = "Target";
|
||||
target.structTypeName = "Target";
|
||||
target.parentId = 0;
|
||||
target.offset = 0;
|
||||
int ti = tree.addNode(target);
|
||||
uint64_t targetId = tree.nodes[ti].id;
|
||||
|
||||
rcx::Node root;
|
||||
root.kind = rcx::NodeKind::Struct;
|
||||
root.name = "Holder";
|
||||
root.structTypeName = "Holder";
|
||||
root.parentId = 0;
|
||||
root.offset = 0;
|
||||
int ri = tree.addNode(root);
|
||||
uint64_t rootId = tree.nodes[ri].id;
|
||||
|
||||
rcx::Node p;
|
||||
p.kind = rcx::NodeKind::Pointer64;
|
||||
p.name = "ref";
|
||||
p.parentId = rootId;
|
||||
p.offset = 0;
|
||||
p.refId = targetId;
|
||||
tree.addNode(p);
|
||||
|
||||
QString result = rcx::renderPython(tree, rootId);
|
||||
QVERIFY(result.contains("ctypes.POINTER(Target)"));
|
||||
}
|
||||
|
||||
void testPythonAll() {
|
||||
auto tree = makeSimpleStruct();
|
||||
QString result = rcx::renderPythonAll(tree);
|
||||
QVERIFY(result.contains("class Player(ctypes.Structure)"));
|
||||
}
|
||||
|
||||
void testPythonEnum() {
|
||||
rcx::NodeTree tree;
|
||||
rcx::Node e;
|
||||
e.kind = rcx::NodeKind::Struct;
|
||||
e.name = "Status";
|
||||
e.structTypeName = "Status";
|
||||
e.classKeyword = "enum";
|
||||
e.parentId = 0;
|
||||
e.offset = 0;
|
||||
e.enumMembers = {{"Active", 1}, {"Inactive", 0}};
|
||||
tree.addNode(e);
|
||||
|
||||
QString result = rcx::renderPythonAll(tree);
|
||||
QVERIFY(result.contains("class Status:"));
|
||||
QVERIFY(result.contains("Active = 1"));
|
||||
QVERIFY(result.contains("Inactive = 0"));
|
||||
}
|
||||
|
||||
void testPythonVectors() {
|
||||
rcx::NodeTree tree;
|
||||
rcx::Node root;
|
||||
root.kind = rcx::NodeKind::Struct;
|
||||
root.name = "Pos";
|
||||
root.structTypeName = "Pos";
|
||||
root.parentId = 0;
|
||||
root.offset = 0;
|
||||
int ri = tree.addNode(root);
|
||||
uint64_t rootId = tree.nodes[ri].id;
|
||||
|
||||
rcx::Node v;
|
||||
v.kind = rcx::NodeKind::Vec4;
|
||||
v.name = "color";
|
||||
v.parentId = rootId;
|
||||
v.offset = 0;
|
||||
tree.addNode(v);
|
||||
|
||||
QString result = rcx::renderPython(tree, rootId);
|
||||
QVERIFY(result.contains("(\"color\", ctypes.c_float * 4)"));
|
||||
}
|
||||
|
||||
void testCSharpDispatch() {
|
||||
auto tree = makeSimpleStruct();
|
||||
uint64_t rootId = tree.nodes[0].id;
|
||||
QString result = rcx::renderCode(rcx::CodeFormat::CSharpStruct, tree, rootId);
|
||||
QVERIFY(result.contains("[StructLayout("));
|
||||
}
|
||||
|
||||
void testPythonDispatch() {
|
||||
auto tree = makeSimpleStruct();
|
||||
uint64_t rootId = tree.nodes[0].id;
|
||||
QString result = rcx::renderCode(rcx::CodeFormat::PythonCtypes, tree, rootId);
|
||||
QVERIFY(result.contains("ctypes.Structure"));
|
||||
}
|
||||
};
|
||||
|
||||
QTEST_MAIN(TestGenerator)
|
||||
|
||||
378
tests/test_mcp.cpp
Normal file
378
tests/test_mcp.cpp
Normal file
@@ -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 <QTest>
|
||||
#include <QLocalServer>
|
||||
#include <QLocalSocket>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QElapsedTimer>
|
||||
#include <QTimer>
|
||||
|
||||
// ── 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<Client> 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<QJsonObject> drain(QLocalSocket* s, int ms = 300) {
|
||||
QVector<QJsonObject> 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"
|
||||
@@ -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<BufferProvider>(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<QVector<ScanResult>>();
|
||||
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<MemoryRegion> regions;
|
||||
regions.append({100, 100, true, false, false, {}});
|
||||
auto prov = std::make_shared<RegionProvider>(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<QVector<ScanResult>>();
|
||||
QCOMPARE(results.size(), 1);
|
||||
QCOMPARE(results[0].address, (uint64_t)160);
|
||||
}
|
||||
|
||||
void scan_constrainRegions_noOverlap() {
|
||||
QByteArray data(32, char(0xEE));
|
||||
QVector<MemoryRegion> regions;
|
||||
regions.append({0, 16, true, false, false, {}});
|
||||
auto prov = std::make_shared<RegionProvider>(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<QVector<ScanResult>>();
|
||||
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<MemoryRegion> regions;
|
||||
regions.append({0, 16, true, true, false, {}});
|
||||
regions.append({32, 16, true, true, false, {}});
|
||||
auto prov = std::make_shared<RegionProvider>(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<QVector<ScanResult>>();
|
||||
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<MemoryRegion> regions;
|
||||
regions.append({100, 100, true, true, false, {}});
|
||||
auto prov = std::make_shared<RegionProvider>(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<QVector<ScanResult>>();
|
||||
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<MemoryRegion> regions;
|
||||
regions.append({0x1000, 0x1000, true, false, true, QString("game.exe")});
|
||||
regions.append({0x5000, 0x1000, true, true, false, {}});
|
||||
auto prov = std::make_shared<RegionProvider>(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<QVector<ScanResult>>();
|
||||
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<BufferProvider>(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<QVector<ScanResult>>();
|
||||
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<MemoryRegion> regions;
|
||||
regions.append({0, 16, true, true, false, {}});
|
||||
regions.append({16, 16, true, true, false, {}});
|
||||
auto prov = std::make_shared<RegionProvider>(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<QVector<ScanResult>>();
|
||||
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<MemoryRegion> regions;
|
||||
regions.append({0x1000, 0x1000, true, false, true, {}});
|
||||
regions.append({0x2000, 0x1000, true, true, false, {}});
|
||||
auto prov = std::make_shared<RegionProvider>(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<QVector<ScanResult>>();
|
||||
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<MemoryRegion> regions;
|
||||
regions.append({10, 10, true, true, false, {}});
|
||||
auto prov = std::make_shared<RegionProvider>(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<QVector<ScanResult>>();
|
||||
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<MemoryRegion> regions;
|
||||
regions.append({0, 32, true, true, false, {}});
|
||||
auto prov = std::make_shared<RegionProvider>(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<QVector<ScanResult>>();
|
||||
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<BufferProvider>(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<QVector<ScanResult>>();
|
||||
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<BufferProvider>(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<QVector<ScanResult>>();
|
||||
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<BufferProvider>(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<QVector<ScanResult>>();
|
||||
// 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<MemoryRegion> regions;
|
||||
regions.append({0x8000, 0x1000, true, true, false, {}});
|
||||
auto prov = std::make_shared<RegionProvider>(data, 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<QVector<ScanResult>>();
|
||||
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<BufferProvider>(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<QVector<ScanResult>>();
|
||||
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<BufferProvider>(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<QVector<ScanResult>>();
|
||||
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<BufferProvider>(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<QVector<ScanResult>>();
|
||||
// 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<MemoryRegion> regions;
|
||||
regions.append({0, 64, true, true, false, {}});
|
||||
auto prov = std::make_shared<RegionProvider>(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<QVector<ScanResult>>();
|
||||
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<MemoryRegion> regions;
|
||||
regions.append({0, 64, true, true, false, {}});
|
||||
auto prov = std::make_shared<RegionProvider>(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<QVector<ScanResult>>();
|
||||
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<MemoryRegion> regions;
|
||||
regions.append({0, 64, true, true, false, {}});
|
||||
auto prov = std::make_shared<RegionProvider>(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<QVector<ScanResult>>();
|
||||
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<MemoryRegion> regions;
|
||||
regions.append({0, 64, true, true, false, {}});
|
||||
auto prov = std::make_shared<RegionProvider>(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<QVector<ScanResult>>();
|
||||
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<MemoryRegion> regions;
|
||||
regions.append({0, 64, true, true, false, {}});
|
||||
auto prov = std::make_shared<RegionProvider>(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<QVector<ScanResult>>();
|
||||
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<MemoryRegion> regions;
|
||||
regions.append({0, 16, true, true, false, {}});
|
||||
regions.append({16, 16, true, true, false, {}});
|
||||
auto prov = std::make_shared<RegionProvider>(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<QVector<ScanResult>>();
|
||||
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<MemoryRegion> regions;
|
||||
regions.append({0, 64, true, true, false, {}});
|
||||
auto prov = std::make_shared<RegionProvider>(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<QVector<ScanResult>>();
|
||||
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<uint64_t> scanConstrained(const QByteArray& data,
|
||||
const QByteArray& pat, const QByteArray& mask,
|
||||
int alignment, uint64_t cStart, uint64_t cEnd) {
|
||||
auto prov = std::make_shared<BufferProvider>(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<ScanResult>>();
|
||||
QVector<uint64_t> 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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user