feat: scanner unknown value + comparison rescan modes, find bar height fix

Add Cheat Engine-style scan conditions: Unknown Value captures all
aligned addresses as baseline, then Changed/Unchanged/Increased/Decreased
narrow results by comparing current vs previous values. Exact Value
mode unchanged. Also fix find bar search box height to match buttons
and improve MCP bridge instructions.
This commit is contained in:
Matty
2026-03-03 11:32:13 -07:00
committed by IChooseYou
parent 86499e58ee
commit 9c72265901
7 changed files with 323 additions and 90 deletions

View File

@@ -405,7 +405,7 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
// Find bar (hidden by default, shown with Ctrl+F) // Find bar (hidden by default, shown with Ctrl+F)
m_findBarContainer = new QWidget(this); m_findBarContainer = new QWidget(this);
auto* fbLayout = new QHBoxLayout(m_findBarContainer); auto* fbLayout = new QHBoxLayout(m_findBarContainer);
fbLayout->setContentsMargins(4, 0, 0, 0); fbLayout->setContentsMargins(4, 1, 4, 1);
fbLayout->setSpacing(2); fbLayout->setSpacing(2);
auto* findPrevBtn = new QToolButton(m_findBarContainer); auto* findPrevBtn = new QToolButton(m_findBarContainer);
findPrevBtn->setText(QStringLiteral("\u25C0")); findPrevBtn->setText(QStringLiteral("\u25C0"));
@@ -418,6 +418,7 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
findCloseBtn->setFixedSize(24, 24); findCloseBtn->setFixedSize(24, 24);
m_findBar = new QLineEdit(m_findBarContainer); m_findBar = new QLineEdit(m_findBarContainer);
m_findBar->setPlaceholderText(QStringLiteral("Find...")); m_findBar->setPlaceholderText(QStringLiteral("Find..."));
m_findBar->setFixedHeight(24);
fbLayout->addWidget(findPrevBtn); fbLayout->addWidget(findPrevBtn);
fbLayout->addWidget(findNextBtn); fbLayout->addWidget(findNextBtn);
fbLayout->addWidget(findCloseBtn); fbLayout->addWidget(findCloseBtn);
@@ -889,7 +890,7 @@ void RcxEditor::applyTheme(const Theme& theme) {
if (m_findBarContainer) { if (m_findBarContainer) {
m_findBar->setStyleSheet( m_findBar->setStyleSheet(
QStringLiteral("QLineEdit { background: %1; color: %2; border: 1px solid %3;" QStringLiteral("QLineEdit { background: %1; color: %2; border: 1px solid %3;"
" padding: 4px 8px; font-size: 13px; }") " padding: 2px 6px; font-size: 13px; }")
.arg(theme.backgroundAlt.name(), theme.text.name(), theme.border.name())); .arg(theme.backgroundAlt.name(), theme.text.name(), theme.border.name()));
m_findBarContainer->setStyleSheet( m_findBarContainer->setStyleSheet(
QStringLiteral("QToolButton { background: %1; color: %2; border: 1px solid %3; border-radius: 2px; }" QStringLiteral("QToolButton { background: %1; color: %2; border: 1px solid %3; border-radius: 2px; }"

View File

@@ -1125,7 +1125,7 @@ MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) {
// Find bar with prev/next buttons (hidden by default) // Find bar with prev/next buttons (hidden by default)
pane.findContainer = new QWidget; pane.findContainer = new QWidget;
auto* fcLayout = new QHBoxLayout(pane.findContainer); auto* fcLayout = new QHBoxLayout(pane.findContainer);
fcLayout->setContentsMargins(4, 0, 0, 0); fcLayout->setContentsMargins(4, 1, 4, 1);
fcLayout->setSpacing(2); fcLayout->setSpacing(2);
const auto& fbTheme = ThemeManager::instance().current(); const auto& fbTheme = ThemeManager::instance().current();
auto* ccPrevBtn = new QToolButton; auto* ccPrevBtn = new QToolButton;
@@ -1148,9 +1148,10 @@ MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) {
ccCloseBtn->setStyleSheet(btnCss); ccCloseBtn->setStyleSheet(btnCss);
pane.findBar = new QLineEdit; pane.findBar = new QLineEdit;
pane.findBar->setPlaceholderText("Find..."); pane.findBar->setPlaceholderText("Find...");
pane.findBar->setFixedHeight(24);
pane.findBar->setStyleSheet( pane.findBar->setStyleSheet(
QStringLiteral("QLineEdit { background: %1; color: %2; border: 1px solid %3;" QStringLiteral("QLineEdit { background: %1; color: %2; border: 1px solid %3;"
" padding: 4px 8px; font-size: 13px; }") " padding: 2px 6px; font-size: 13px; }")
.arg(fbTheme.backgroundAlt.name(), fbTheme.text.name(), fbTheme.border.name())); .arg(fbTheme.backgroundAlt.name(), fbTheme.text.name(), fbTheme.border.name()));
fcLayout->addWidget(ccPrevBtn); fcLayout->addWidget(ccPrevBtn);
fcLayout->addWidget(ccNextBtn); fcLayout->addWidget(ccNextBtn);

View File

@@ -203,7 +203,28 @@ QJsonObject McpBridge::handleInitialize(const QJsonValue& id, const QJsonObject&
{"serverInfo", QJsonObject{ {"serverInfo", QJsonObject{
{"name", "reclass-mcp"}, {"name", "reclass-mcp"},
{"version", "1.0.0"} {"version", "1.0.0"}
}} }},
{"instructions",
"You are connected to ReClass, a live memory structure editor for reverse engineering. "
"You have two types of data available:\n"
"1. STRUCTURE: The node tree defines typed fields (project.state, tree.search, tree.apply). "
"Each node has a kind (the data type: UInt32, Float, Hex64, etc.) and a name.\n"
"2. LIVE DATA: The provider reads real memory from an attached process (hex.read, hex.write). "
"node.history returns timestamped value changes with heat levels (0=static, 1=cold, 2=warm, 3=hot).\n\n"
"CRITICAL RULES:\n"
"- When labeling/identifying a field, ALWAYS change BOTH name AND kind in one tree.apply call. "
"Example: [{op:'rename',nodeId:'X',name:'health'},{op:'change_kind',nodeId:'X',kind:'Int32'}]. "
"A node named 'health' with kind Hex64 is WRONG — the kind must match the actual data type.\n"
"- To detect what changed after an in-game event: call ui.action with action:'reset_tracking', "
"then have the user perform the action, then call node.history on the relevant nodes "
"to see which ones have new timestamped entries.\n"
"- hex.read offset is relative to the struct base address by default. "
"Use baseRelative=true for absolute virtual addresses in the process.\n"
"- tree.apply operations are atomic (undo macro). Batch related changes into one call.\n"
"- Use tree.search to quickly find nodes by name instead of paging through project.state.\n"
"- project.state returns structure metadata only (kinds, names, offsets), NOT live values. "
"Use hex.read for actual memory values and node.history for tracking changes over time."
}
}; };
return okReply(id, result); return okReply(id, result);
} }
@@ -219,6 +240,8 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
tools.append(QJsonObject{ tools.append(QJsonObject{
{"name", "project.state"}, {"name", "project.state"},
{"description", "Returns project state with paginated node tree. " {"description", "Returns project state with paginated node tree. "
"NOTE: This returns structure metadata only (kinds, names, offsets), NOT live memory values. "
"Use hex.read to read actual values and node.history to track value changes over time. "
"Responses return max 'limit' nodes (default 50). " "Responses return max 'limit' nodes (default 50). "
"Use depth:1 first, then parentId to drill into a struct. " "Use depth:1 first, then parentId to drill into a struct. "
"Enum/bitfield member arrays are omitted by default (counts shown instead); " "Enum/bitfield member arrays are omitted by default (counts shown instead); "
@@ -249,6 +272,10 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
tools.append(QJsonObject{ tools.append(QJsonObject{
{"name", "tree.apply"}, {"name", "tree.apply"},
{"description", "Apply batch of tree operations atomically (undo macro). " {"description", "Apply batch of tree operations atomically (undo macro). "
"IMPORTANT: When identifying/labeling a field, you MUST use BOTH rename AND change_kind "
"in the same batch. A renamed node still has its original kind (e.g. Hex64) unless you "
"explicitly change it. Example: "
"[{op:'rename',nodeId:'ID',name:'health'},{op:'change_kind',nodeId:'ID',kind:'Int32'}]. "
"Each op is a JSON object with an 'op' field for the operation type and 'nodeId' (string) for the target node. " "Each op is a JSON object with an 'op' field for the operation type and 'nodeId' (string) for the target node. "
"Operations: " "Operations: "
"remove: {op:'remove', nodeId:'ID'}. " "remove: {op:'remove', nodeId:'ID'}. "
@@ -301,10 +328,11 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
// 4. hex.read // 4. hex.read
tools.append(QJsonObject{ tools.append(QJsonObject{
{"name", "hex.read"}, {"name", "hex.read"},
{"description", "Read raw bytes from provider. Returns hex dump, ASCII, and multi-type " {"description", "Read raw bytes from provider (live process memory). Returns hex dump, ASCII, and multi-type "
"interpretations (u8/u16/u32/u64/i32/f32/f64/ptr/string heuristics). " "interpretations (u8/u16/u32/u64/i32/f32/f64/ptr/string heuristics). "
"Use this to see what actual values are in memory at any offset. "
"Offset is tree-relative (0-based, baseAddress added automatically) " "Offset is tree-relative (0-based, baseAddress added automatically) "
"unless baseRelative=true (offset is absolute)."}, "unless baseRelative=true (offset is absolute virtual address in the process)."},
{"inputSchema", QJsonObject{ {"inputSchema", QJsonObject{
{"type", "object"}, {"type", "object"},
{"properties", QJsonObject{ {"properties", QJsonObject{
@@ -359,8 +387,10 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
{"description", "Trigger a UI action. Fallback for operations without dedicated tools. " {"description", "Trigger a UI action. Fallback for operations without dedicated tools. "
"Actions: undo, redo, new_file, open_file, save_file, save_file_as, " "Actions: undo, redo, new_file, open_file, save_file, save_file_as, "
"export_cpp, set_view_root, scroll_to_node, collapse_node, expand_node, " "export_cpp, set_view_root, scroll_to_node, collapse_node, expand_node, "
"select_node, refresh. " "select_node, refresh, reset_tracking. "
"export_cpp accepts optional nodeId to export a single struct (recommended for large projects)."}, "export_cpp accepts optional nodeId to export a single struct (recommended for large projects). "
"reset_tracking clears all value change histories — use before an in-game event, "
"then check node.history afterward to see what changed."},
{"inputSchema", QJsonObject{ {"inputSchema", QJsonObject{
{"type", "object"}, {"type", "object"},
{"properties", QJsonObject{ {"properties", QJsonObject{
@@ -399,8 +429,11 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
// 9. node.history // 9. node.history
tools.append(QJsonObject{ tools.append(QJsonObject{
{"name", "node.history"}, {"name", "node.history"},
{"description", "Returns timestamped value change history (up to 10 entries) " {"description", "Returns timestamped value change history (up to 10 entries) for specified nodes. "
"for specified nodes. Requires live provider with value tracking enabled."}, "Use this to detect what changed after an in-game event — no need to manually snapshot memory. "
"Each node returns: entries[] with {value, timestamp}, heatLevel (0=static to 3=hot), "
"and uniqueCount. Heat level 3 means the field is actively changing. "
"Requires live provider with value tracking enabled."},
{"inputSchema", QJsonObject{ {"inputSchema", QJsonObject{
{"type", "object"}, {"type", "object"},
{"properties", QJsonObject{ {"properties", QJsonObject{
@@ -1180,6 +1213,12 @@ QJsonObject McpBridge::toolUiAction(const QJsonObject& args) {
return makeTextResult("Selected node " + nodeIdStr); return makeTextResult("Selected node " + nodeIdStr);
} }
if (action == "reset_tracking") {
if (!ctrl) return makeTextResult("No active tab", true);
ctrl->resetChangeTracking();
return makeTextResult("Value tracking reset. All histories cleared.");
}
return makeTextResult("Unknown action: " + action, true); return makeTextResult("Unknown action: " + action, true);
} }

View File

@@ -347,6 +347,64 @@ int naturalAlignment(ValueType type) {
return 1; return 1;
} }
int valueSizeForType(ValueType type) {
switch (type) {
case ValueType::Int8: case ValueType::UInt8: return 1;
case ValueType::Int16: case ValueType::UInt16: return 2;
case ValueType::Int32: case ValueType::UInt32: case ValueType::Float: return 4;
case ValueType::Int64: case ValueType::UInt64: case ValueType::Double: return 8;
case ValueType::Vec2: return 8;
case ValueType::Vec3: return 12;
case ValueType::Vec4: return 16;
default: return 4;
}
}
// ── Typed comparison for rescan conditions ──
static int compareTyped(const QByteArray& a, const QByteArray& b, ValueType vt) {
const char* da = a.constData();
const char* db = b.constData();
int sz = qMin(a.size(), b.size());
switch (vt) {
case ValueType::Int8:
if (sz >= 1) { int8_t va, vb; memcpy(&va, da, 1); memcpy(&vb, db, 1); return (va > vb) - (va < vb); }
break;
case ValueType::UInt8:
if (sz >= 1) { uint8_t va, vb; memcpy(&va, da, 1); memcpy(&vb, db, 1); return (va > vb) - (va < vb); }
break;
case ValueType::Int16:
if (sz >= 2) { int16_t va, vb; memcpy(&va, da, 2); memcpy(&vb, db, 2); return (va > vb) - (va < vb); }
break;
case ValueType::UInt16:
if (sz >= 2) { uint16_t va, vb; memcpy(&va, da, 2); memcpy(&vb, db, 2); return (va > vb) - (va < vb); }
break;
case ValueType::Int32:
if (sz >= 4) { int32_t va, vb; memcpy(&va, da, 4); memcpy(&vb, db, 4); return (va > vb) - (va < vb); }
break;
case ValueType::UInt32:
if (sz >= 4) { uint32_t va, vb; memcpy(&va, da, 4); memcpy(&vb, db, 4); return (va > vb) - (va < vb); }
break;
case ValueType::Int64:
if (sz >= 8) { int64_t va, vb; memcpy(&va, da, 8); memcpy(&vb, db, 8); return (va > vb) - (va < vb); }
break;
case ValueType::UInt64:
if (sz >= 8) { uint64_t va, vb; memcpy(&va, da, 8); memcpy(&vb, db, 8); return (va > vb) - (va < vb); }
break;
case ValueType::Float:
if (sz >= 4) { float va, vb; memcpy(&va, da, 4); memcpy(&vb, db, 4); return (va > vb) - (va < vb); }
break;
case ValueType::Double:
if (sz >= 8) { double va, vb; memcpy(&va, da, 8); memcpy(&vb, db, 8); return (va > vb) - (va < vb); }
break;
default:
break;
}
// Fallback: byte comparison
return memcmp(da, db, sz);
}
// ── Scan engine ── // ── Scan engine ──
ScanEngine::ScanEngine(QObject* parent) ScanEngine::ScanEngine(QObject* parent)
@@ -366,13 +424,15 @@ void ScanEngine::abort() {
void ScanEngine::start(std::shared_ptr<Provider> provider, const ScanRequest& req) { void ScanEngine::start(std::shared_ptr<Provider> provider, const ScanRequest& req) {
if (isRunning()) return; if (isRunning()) return;
if (req.pattern.isEmpty()) { if (req.condition != ScanCondition::UnknownValue) {
emit error(QStringLiteral("Empty pattern")); if (req.pattern.isEmpty()) {
return; emit error(QStringLiteral("Empty pattern"));
} return;
if (req.pattern.size() != req.mask.size()) { }
emit error(QStringLiteral("Pattern and mask size mismatch")); if (req.pattern.size() != req.mask.size()) {
return; emit error(QStringLiteral("Pattern and mask size mismatch"));
return;
}
} }
m_abort.store(false); m_abort.store(false);
@@ -400,14 +460,16 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
timer.start(); timer.start();
QVector<ScanResult> results; QVector<ScanResult> results;
const bool isUnknown = (req.condition == ScanCondition::UnknownValue);
if (!prov || req.pattern.isEmpty()) if (!prov || (!isUnknown && req.pattern.isEmpty()))
return results; return results;
auto regions = prov->enumerateRegions(); auto regions = prov->enumerateRegions();
qDebug() << "[scan] regions:" << regions.size() qDebug() << "[scan] regions:" << regions.size()
<< " pattern:" << req.pattern.size() << "bytes" << " pattern:" << req.pattern.size() << "bytes"
<< " align:" << req.alignment << " align:" << req.alignment
<< " condition:" << (int)req.condition
<< " filterExec:" << req.filterExecutable << " filterExec:" << req.filterExecutable
<< " filterWrite:" << req.filterWritable; << " filterWrite:" << req.filterWritable;
@@ -422,10 +484,11 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
regions.append(fallback); regions.append(fallback);
} }
const int patternLen = req.pattern.size(); const int patternLen = isUnknown ? req.valueSize : req.pattern.size();
const char* pat = req.pattern.constData(); const char* pat = isUnknown ? nullptr : req.pattern.constData();
const char* msk = req.mask.constData(); const char* msk = isUnknown ? nullptr : req.mask.constData();
const int alignment = qMax(1, req.alignment); const int alignment = qMax(1, req.alignment);
const int valSize = isUnknown ? req.valueSize : patternLen;
// Pre-compute total bytes for progress // Pre-compute total bytes for progress
uint64_t totalBytes = 0; uint64_t totalBytes = 0;
@@ -474,24 +537,38 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
int scanEnd = readLen - patternLen; int scanEnd = readLen - patternLen;
const char* data = chunk.constData(); const char* data = chunk.constData();
for (int i = 0; i <= scanEnd; i += alignment) { if (isUnknown) {
bool match = true; // Unknown value: capture every aligned address
for (int j = 0; j < patternLen; j++) { for (int i = 0; i <= scanEnd; i += alignment) {
if ((data[i + j] & msk[j]) != (pat[j] & msk[j])) {
match = false;
break;
}
}
if (match) {
ScanResult r; ScanResult r;
r.address = region.base + off + (uint64_t)i; r.address = region.base + off + (uint64_t)i;
r.regionModule = region.moduleName; r.scanValue = QByteArray(data + i, valSize);
r.scanValue = QByteArray(data + i, qMin(16, readLen - i));
results.append(r); results.append(r);
if (results.size() >= req.maxResults) if (results.size() >= req.maxResults)
goto done; goto done;
} }
} else {
// Exact pattern match
for (int i = 0; i <= scanEnd; i += alignment) {
bool match = true;
for (int j = 0; j < patternLen; j++) {
if ((data[i + j] & msk[j]) != (pat[j] & msk[j])) {
match = false;
break;
}
}
if (match) {
ScanResult r;
r.address = region.base + off + (uint64_t)i;
r.regionModule = region.moduleName;
r.scanValue = QByteArray(data + i, qMin(16, readLen - i));
results.append(r);
if (results.size() >= req.maxResults)
goto done;
}
}
} }
// Advance with overlap to catch patterns that straddle chunks // Advance with overlap to catch patterns that straddle chunks
@@ -522,6 +599,7 @@ done:
void ScanEngine::startRescan(std::shared_ptr<Provider> provider, void ScanEngine::startRescan(std::shared_ptr<Provider> provider,
QVector<ScanResult> results, int readSize, QVector<ScanResult> results, int readSize,
ScanCondition condition, ValueType valueType,
const QByteArray& filterPattern, const QByteArray& filterPattern,
const QByteArray& filterMask) { const QByteArray& filterMask) {
if (isRunning()) return; if (isRunning()) return;
@@ -541,14 +619,15 @@ void ScanEngine::startRescan(std::shared_ptr<Provider> provider,
watcher->setFuture(QtConcurrent::run( watcher->setFuture(QtConcurrent::run(
[this, provider, results = std::move(results), readSize, [this, provider, results = std::move(results), readSize,
filterPattern, filterMask]() mutable { condition, valueType, filterPattern, filterMask]() mutable {
return runRescan(provider, std::move(results), readSize, return runRescan(provider, std::move(results), readSize,
filterPattern, filterMask); condition, valueType, filterPattern, filterMask);
})); }));
} }
QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov, QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
QVector<ScanResult> results, int readSize, QVector<ScanResult> results, int readSize,
ScanCondition condition, ValueType valueType,
const QByteArray& filterPattern, const QByteArray& filterPattern,
const QByteArray& filterMask) { const QByteArray& filterMask) {
QElapsedTimer timer; QElapsedTimer timer;
@@ -557,9 +636,17 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
int total = results.size(); int total = results.size();
if (total == 0 || !prov) return results; if (total == 0 || !prov) return results;
bool hasFilter = !filterPattern.isEmpty(); bool hasExactFilter = !filterPattern.isEmpty() && condition == ScanCondition::ExactValue;
bool hasComparison = (condition == ScanCondition::Changed ||
condition == ScanCondition::Unchanged ||
condition == ScanCondition::Increased ||
condition == ScanCondition::Decreased);
bool needsFilter = hasExactFilter || hasComparison;
qDebug() << "[rescan] start:" << total << "results, readSize:" << readSize qDebug() << "[rescan] start:" << total << "results, readSize:" << readSize
<< "filter:" << (hasFilter ? "yes" : "no"); << "condition:" << (int)condition
<< "exactFilter:" << (hasExactFilter ? "yes" : "no")
<< "comparison:" << (hasComparison ? "yes" : "no");
// Save previous values // Save previous values
for (auto& r : results) for (auto& r : results)
@@ -579,8 +666,8 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
uint64_t totalBytesRead = 0; uint64_t totalBytesRead = 0;
int i = 0; int i = 0;
// Track which results matched the filter (by original index) // Track which results matched (by original index)
QVector<bool> matched(total, !hasFilter); // if no filter, all match QVector<bool> matched(total, !needsFilter); // if no filter, all match
while (i < total && !m_abort.load()) { while (i < total && !m_abort.load()) {
uint64_t spanBase = results[order[i]].address; uint64_t spanBase = results[order[i]].address;
@@ -604,8 +691,8 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
int off = (int)(r.address - spanBase); int off = (int)(r.address - spanBase);
r.scanValue = chunk.mid(off, readSize); r.scanValue = chunk.mid(off, readSize);
// Apply filter: compare re-read bytes against the new pattern // Apply exact-value filter
if (hasFilter) { if (hasExactFilter) {
int patLen = filterPattern.size(); int patLen = filterPattern.size();
if (r.scanValue.size() >= patLen) { if (r.scanValue.size() >= patLen) {
bool ok = true; bool ok = true;
@@ -621,6 +708,18 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
matched[idx] = ok; matched[idx] = ok;
} }
} }
// Apply comparison-based filter
if (hasComparison && !r.previousValue.isEmpty()) {
int cmp = compareTyped(r.scanValue, r.previousValue, valueType);
switch (condition) {
case ScanCondition::Changed: matched[idx] = (cmp != 0); break;
case ScanCondition::Unchanged: matched[idx] = (cmp == 0); break;
case ScanCondition::Increased: matched[idx] = (cmp > 0); break;
case ScanCondition::Decreased: matched[idx] = (cmp < 0); break;
default: break;
}
}
} }
chunks++; chunks++;
@@ -637,7 +736,7 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
} }
// Filter out non-matching results // Filter out non-matching results
if (hasFilter) { if (needsFilter) {
QVector<ScanResult> filtered; QVector<ScanResult> filtered;
filtered.reserve(total); filtered.reserve(total);
for (int k = 0; k < total; k++) { for (int k = 0; k < total; k++) {

View File

@@ -10,26 +10,6 @@
namespace rcx { namespace rcx {
// ── Scan request / result ──
struct ScanRequest {
QByteArray pattern; // literal bytes to match
QByteArray mask; // 0xFF = must match, 0x00 = wildcard
bool filterExecutable = false; // only scan +x regions
bool filterWritable = false; // only scan +w regions
int alignment = 1; // 1 = every byte, 4 = dword, 8 = qword
int maxResults = 50000;
};
struct ScanResult {
uint64_t address;
QString regionModule;
QByteArray scanValue; // cached bytes at scan/update time
QByteArray previousValue; // value before last update
};
// ── Value scan types ── // ── Value scan types ──
enum class ValueType { enum class ValueType {
@@ -41,6 +21,40 @@ enum class ValueType {
HexBytes HexBytes
}; };
// ── Scan condition (Cheat Engine-style) ──
enum class ScanCondition {
ExactValue, // first scan + rescan: match specific bytes
UnknownValue, // first scan only: capture all aligned addresses
Changed, // rescan: current != previous
Unchanged, // rescan: current == previous
Increased, // rescan: current > previous (numeric)
Decreased // rescan: current < previous (numeric)
};
// ── Scan request / result ──
struct ScanRequest {
QByteArray pattern; // literal bytes to match (empty for UnknownValue)
QByteArray mask; // 0xFF = must match, 0x00 = wildcard
bool filterExecutable = false; // only scan +x regions
bool filterWritable = false; // only scan +w regions
int alignment = 1; // 1 = every byte, 4 = dword, 8 = qword
int maxResults = 50000;
ScanCondition condition = ScanCondition::ExactValue;
int valueSize = 4; // bytes per value (for unknown scans)
};
struct ScanResult {
uint64_t address;
QString regionModule;
QByteArray scanValue; // cached bytes at scan/update time
QByteArray previousValue; // value before last update
};
// ── Pattern parsing ── // ── Pattern parsing ──
// Parse IDA-style signature string ("48 8B ?? 05") into pattern + mask. // Parse IDA-style signature string ("48 8B ?? 05") into pattern + mask.
@@ -57,6 +71,9 @@ bool serializeValue(ValueType type, const QString& input,
// Natural alignment for a value type (used as default alignment for value scans). // Natural alignment for a value type (used as default alignment for value scans).
int naturalAlignment(ValueType type); int naturalAlignment(ValueType type);
// Byte-size for a value type (used for unknown scans and rescan read size).
int valueSizeForType(ValueType type);
// ── Scan engine ── // ── Scan engine ──
class ScanEngine : public QObject { class ScanEngine : public QObject {
@@ -67,6 +84,8 @@ public:
void start(std::shared_ptr<Provider> provider, const ScanRequest& req); void start(std::shared_ptr<Provider> provider, const ScanRequest& req);
void startRescan(std::shared_ptr<Provider> provider, void startRescan(std::shared_ptr<Provider> provider,
QVector<ScanResult> results, int readSize, QVector<ScanResult> results, int readSize,
ScanCondition condition = ScanCondition::ExactValue,
ValueType valueType = ValueType::Int32,
const QByteArray& filterPattern = {}, const QByteArray& filterPattern = {},
const QByteArray& filterMask = {}); const QByteArray& filterMask = {});
void abort(); void abort();
@@ -82,6 +101,7 @@ private:
QVector<ScanResult> runScan(std::shared_ptr<Provider> prov, const ScanRequest& req); QVector<ScanResult> runScan(std::shared_ptr<Provider> prov, const ScanRequest& req);
QVector<ScanResult> runRescan(std::shared_ptr<Provider> prov, QVector<ScanResult> runRescan(std::shared_ptr<Provider> prov,
QVector<ScanResult> results, int readSize, QVector<ScanResult> results, int readSize,
ScanCondition condition, ValueType valueType,
const QByteArray& filterPattern, const QByteArray& filterPattern,
const QByteArray& filterMask); const QByteArray& filterMask);

View File

@@ -93,6 +93,18 @@ ScannerPanel::ScannerPanel(QWidget* parent)
m_typeCombo->setCurrentIndex(2); // default: int32 m_typeCombo->setCurrentIndex(2); // default: int32
inputRow->addWidget(m_typeCombo); inputRow->addWidget(m_typeCombo);
m_condLabel = new QLabel(QStringLiteral("Scan:"), this);
inputRow->addWidget(m_condLabel);
m_condCombo = new QComboBox(this);
m_condCombo->addItem(QStringLiteral("Exact Value"), (int)ScanCondition::ExactValue);
m_condCombo->addItem(QStringLiteral("Unknown Value"), (int)ScanCondition::UnknownValue);
m_condCombo->addItem(QStringLiteral("Changed"), (int)ScanCondition::Changed);
m_condCombo->addItem(QStringLiteral("Unchanged"), (int)ScanCondition::Unchanged);
m_condCombo->addItem(QStringLiteral("Increased"), (int)ScanCondition::Increased);
m_condCombo->addItem(QStringLiteral("Decreased"), (int)ScanCondition::Decreased);
inputRow->addWidget(m_condCombo);
m_valueLabel = new QLabel(QStringLiteral("Value:"), this); m_valueLabel = new QLabel(QStringLiteral("Value:"), this);
inputRow->addWidget(m_valueLabel); inputRow->addWidget(m_valueLabel);
@@ -174,6 +186,8 @@ ScannerPanel::ScannerPanel(QWidget* parent)
// ── Initial state: signature mode ── // ── Initial state: signature mode ──
m_typeLabel->hide(); m_typeLabel->hide();
m_typeCombo->hide(); m_typeCombo->hide();
m_condLabel->hide();
m_condCombo->hide();
m_valueLabel->hide(); m_valueLabel->hide();
m_valueEdit->hide(); m_valueEdit->hide();
m_execCheck->setChecked(true); m_execCheck->setChecked(true);
@@ -181,6 +195,8 @@ ScannerPanel::ScannerPanel(QWidget* parent)
// ── Connections ── // ── Connections ──
connect(m_modeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged), connect(m_modeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &ScannerPanel::onModeChanged); this, &ScannerPanel::onModeChanged);
connect(m_condCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &ScannerPanel::onConditionChanged);
connect(m_scanBtn, &QPushButton::clicked, connect(m_scanBtn, &QPushButton::clicked,
this, &ScannerPanel::onScanClicked); this, &ScannerPanel::onScanClicked);
connect(m_updateBtn, &QPushButton::clicked, connect(m_updateBtn, &QPushButton::clicked,
@@ -251,12 +267,14 @@ void ScannerPanel::setEditorFont(const QFont& font) {
m_valueEdit->setFont(font); m_valueEdit->setFont(font);
m_modeCombo->setFont(font); m_modeCombo->setFont(font);
m_typeCombo->setFont(font); m_typeCombo->setFont(font);
m_condCombo->setFont(font);
m_statusLabel->setFont(font); m_statusLabel->setFont(font);
m_scanBtn->setFont(font); m_scanBtn->setFont(font);
m_gotoBtn->setFont(font); m_gotoBtn->setFont(font);
m_copyBtn->setFont(font); m_copyBtn->setFont(font);
m_patternLabel->setFont(font); m_patternLabel->setFont(font);
m_typeLabel->setFont(font); m_typeLabel->setFont(font);
m_condLabel->setFont(font);
m_valueLabel->setFont(font); m_valueLabel->setFont(font);
m_execCheck->setFont(font); m_execCheck->setFont(font);
m_writeCheck->setFont(font); m_writeCheck->setFont(font);
@@ -280,14 +298,27 @@ void ScannerPanel::onModeChanged(int index) {
m_typeLabel->setVisible(!isSig); m_typeLabel->setVisible(!isSig);
m_typeCombo->setVisible(!isSig); m_typeCombo->setVisible(!isSig);
m_valueLabel->setVisible(!isSig); m_condLabel->setVisible(!isSig);
m_valueEdit->setVisible(!isSig); m_condCombo->setVisible(!isSig);
// Show/hide value input based on condition
auto cond = (ScanCondition)m_condCombo->currentData().toInt();
bool needsValue = !isSig && (cond == ScanCondition::ExactValue);
m_valueLabel->setVisible(needsValue);
m_valueEdit->setVisible(needsValue);
// Auto-toggle filters: signatures → executable code, values → writable data // Auto-toggle filters: signatures → executable code, values → writable data
m_execCheck->setChecked(isSig); m_execCheck->setChecked(isSig);
m_writeCheck->setChecked(!isSig); m_writeCheck->setChecked(!isSig);
} }
void ScannerPanel::onConditionChanged(int /*index*/) {
auto cond = (ScanCondition)m_condCombo->currentData().toInt();
bool needsValue = (cond == ScanCondition::ExactValue);
m_valueLabel->setVisible(needsValue);
m_valueEdit->setVisible(needsValue);
}
void ScannerPanel::onScanClicked() { void ScannerPanel::onScanClicked() {
if (m_engine->isRunning()) { if (m_engine->isRunning()) {
m_engine->abort(); m_engine->abort();
@@ -306,12 +337,14 @@ void ScannerPanel::onScanClicked() {
// Build request // Build request
ScanRequest req = buildRequest(); ScanRequest req = buildRequest();
if (req.pattern.isEmpty()) if (req.condition != ScanCondition::UnknownValue && req.pattern.isEmpty())
return; // error already shown by buildRequest return; // error already shown by buildRequest
m_lastScanMode = m_modeCombo->currentIndex(); m_lastScanMode = m_modeCombo->currentIndex();
if (m_lastScanMode == 1) if (m_lastScanMode == 1) {
m_lastValueType = (ValueType)m_typeCombo->currentData().toInt(); m_lastValueType = (ValueType)m_typeCombo->currentData().toInt();
m_lastCondition = req.condition;
}
m_lastPattern = req.pattern; m_lastPattern = req.pattern;
m_scanBtn->setText(QStringLiteral("Cancel")); m_scanBtn->setText(QStringLiteral("Cancel"));
@@ -336,11 +369,28 @@ ScanRequest ScannerPanel::buildRequest() {
} else { } else {
// Value mode // Value mode
auto vt = (ValueType)m_typeCombo->currentData().toInt(); auto vt = (ValueType)m_typeCombo->currentData().toInt();
if (!serializeValue(vt, m_valueEdit->text(), req.pattern, req.mask, &err)) { auto cond = (ScanCondition)m_condCombo->currentData().toInt();
m_statusLabel->setText(QStringLiteral("Value error: %1").arg(err));
return {}; // Comparison conditions on fresh scan → treat as unknown
if (cond == ScanCondition::Changed || cond == ScanCondition::Unchanged ||
cond == ScanCondition::Increased || cond == ScanCondition::Decreased) {
cond = ScanCondition::UnknownValue;
} }
req.condition = cond;
req.alignment = naturalAlignment(vt); req.alignment = naturalAlignment(vt);
req.valueSize = valueSizeForType(vt);
if (cond == ScanCondition::UnknownValue) {
// No pattern needed — capture all aligned addresses
req.maxResults = 500000;
} else {
// Exact value mode
if (!serializeValue(vt, m_valueEdit->text(), req.pattern, req.mask, &err)) {
m_statusLabel->setText(QStringLiteral("Value error: %1").arg(err));
return {};
}
}
} }
req.filterExecutable = m_execCheck->isChecked(); req.filterExecutable = m_execCheck->isChecked();
@@ -355,10 +405,11 @@ void ScannerPanel::onScanFinished(QVector<ScanResult> results) {
m_results = std::move(results); m_results = std::move(results);
// Bytes are cached by the engine during scan. // Bytes are cached by the engine during scan.
// Value mode: override with exact search pattern (engine caches raw chunk bytes). // Value mode (exact): override with exact search pattern (engine caches raw chunk bytes).
// Unknown mode: keep engine-captured bytes as-is (they're the baseline).
for (auto& r : m_results) { for (auto& r : m_results) {
r.previousValue.clear(); r.previousValue.clear();
if (m_lastScanMode == 1) if (m_lastScanMode == 1 && m_lastCondition == ScanCondition::ExactValue)
r.scanValue = m_lastPattern; r.scanValue = m_lastPattern;
} }
@@ -425,29 +476,41 @@ void ScannerPanel::onUpdateClicked() {
int readSize = (m_lastScanMode == 1) ? valueSize() : 16; int readSize = (m_lastScanMode == 1) ? valueSize() : 16;
// Build filter from current input field // Determine rescan condition
ScanCondition cond = ScanCondition::ExactValue;
if (m_lastScanMode == 1)
cond = (ScanCondition)m_condCombo->currentData().toInt();
// For UnknownValue on rescan, just re-read all (update only, no filter)
if (cond == ScanCondition::UnknownValue)
cond = ScanCondition::ExactValue; // with empty filter = update only
// Build filter from current input field (only for ExactValue condition)
QByteArray filterPattern, filterMask; QByteArray filterPattern, filterMask;
if (m_lastScanMode == 0) { if (cond == ScanCondition::ExactValue) {
// Signature mode if (m_lastScanMode == 0) {
QString err; // Signature mode
if (!m_patternEdit->text().trimmed().isEmpty()) { QString err;
if (!parseSignature(m_patternEdit->text(), filterPattern, filterMask, &err)) { if (!m_patternEdit->text().trimmed().isEmpty()) {
m_statusLabel->setText(QStringLiteral("Pattern error: %1").arg(err)); if (!parseSignature(m_patternEdit->text(), filterPattern, filterMask, &err)) {
return; m_statusLabel->setText(QStringLiteral("Pattern error: %1").arg(err));
return;
}
} }
} } else {
} else { // Value mode — exact value filter
// Value mode QString err;
QString err; if (!m_valueEdit->text().trimmed().isEmpty()) {
if (!m_valueEdit->text().trimmed().isEmpty()) { auto vt = (ValueType)m_typeCombo->currentData().toInt();
auto vt = (ValueType)m_typeCombo->currentData().toInt(); if (!serializeValue(vt, m_valueEdit->text(), filterPattern, filterMask, &err)) {
if (!serializeValue(vt, m_valueEdit->text(), filterPattern, filterMask, &err)) { m_statusLabel->setText(QStringLiteral("Value error: %1").arg(err));
m_statusLabel->setText(QStringLiteral("Value error: %1").arg(err)); return;
return; }
m_lastValueType = vt;
} }
m_lastValueType = vt;
} }
} }
// Comparison conditions (Changed/Unchanged/Increased/Decreased) don't need a filter pattern
// Update last pattern so display uses the new value // Update last pattern so display uses the new value
if (!filterPattern.isEmpty()) if (!filterPattern.isEmpty())
@@ -460,7 +523,8 @@ void ScannerPanel::onUpdateClicked() {
m_progressBar->setValue(0); m_progressBar->setValue(0);
m_progressBar->show(); m_progressBar->show();
m_engine->startRescan(prov, m_results, readSize, filterPattern, filterMask); m_engine->startRescan(prov, m_results, readSize, cond, m_lastValueType,
filterPattern, filterMask);
} }
void ScannerPanel::onRescanFinished(QVector<ScanResult> results) { void ScannerPanel::onRescanFinished(QVector<ScanResult> results) {
@@ -666,12 +730,14 @@ void ScannerPanel::applyTheme(const Theme& theme) {
theme.border.name(), theme.hover.name()); theme.border.name(), theme.hover.name());
m_modeCombo->setStyleSheet(comboStyle); m_modeCombo->setStyleSheet(comboStyle);
m_typeCombo->setStyleSheet(comboStyle); m_typeCombo->setStyleSheet(comboStyle);
m_condCombo->setStyleSheet(comboStyle);
// Labels // Labels
QPalette lp; QPalette lp;
lp.setColor(QPalette::WindowText, theme.textDim); lp.setColor(QPalette::WindowText, theme.textDim);
m_patternLabel->setPalette(lp); m_patternLabel->setPalette(lp);
m_typeLabel->setPalette(lp); m_typeLabel->setPalette(lp);
m_condLabel->setPalette(lp);
m_valueLabel->setPalette(lp); m_valueLabel->setPalette(lp);
m_statusLabel->setPalette(lp); m_statusLabel->setPalette(lp);

View File

@@ -52,6 +52,8 @@ public:
QPushButton* gotoButton() const { return m_gotoBtn; } QPushButton* gotoButton() const { return m_gotoBtn; }
QPushButton* copyButton() const { return m_copyBtn; } QPushButton* copyButton() const { return m_copyBtn; }
ScanEngine* engine() const { return m_engine; } ScanEngine* engine() const { return m_engine; }
QComboBox* condCombo() const { return m_condCombo; }
QLabel* condLabel() const { return m_condLabel; }
signals: signals:
void goToAddress(uint64_t address); void goToAddress(uint64_t address);
@@ -72,13 +74,17 @@ private:
void populateTable(bool showPrevious); void populateTable(bool showPrevious);
void updateComboWidth(); void updateComboWidth();
void onConditionChanged(int index);
// Input widgets // Input widgets
QComboBox* m_modeCombo; // Signature / Value QComboBox* m_modeCombo; // Signature / Value
QLineEdit* m_patternEdit; // Signature pattern input QLineEdit* m_patternEdit; // Signature pattern input
QComboBox* m_typeCombo; // Value type dropdown QComboBox* m_typeCombo; // Value type dropdown
QComboBox* m_condCombo; // Scan condition (Exact/Unknown/Changed/...)
QLineEdit* m_valueEdit; // Value input QLineEdit* m_valueEdit; // Value input
QLabel* m_patternLabel; QLabel* m_patternLabel;
QLabel* m_typeLabel; QLabel* m_typeLabel;
QLabel* m_condLabel;
QLabel* m_valueLabel; QLabel* m_valueLabel;
// Filters // Filters
@@ -103,6 +109,7 @@ private:
QVector<ScanResult> m_results; QVector<ScanResult> m_results;
int m_lastScanMode = 0; // 0=signature, 1=value int m_lastScanMode = 0; // 0=signature, 1=value
ValueType m_lastValueType = ValueType::Int32; ValueType m_lastValueType = ValueType::Int32;
ScanCondition m_lastCondition = ScanCondition::ExactValue;
QByteArray m_lastPattern; // serialized search value QByteArray m_lastPattern; // serialized search value
int m_preRescanCount = 0; // result count before last rescan int m_preRescanCount = 0; // result count before last rescan