From 41e2f9f662e566c959069fb50b63ebb198910f1b Mon Sep 17 00:00:00 2001 From: IChooseYou Date: Sat, 28 Feb 2026 11:53:51 -0700 Subject: [PATCH] feat: scanner panel with signature/value search, rescan, address delegate - Signature mode (IDA-style patterns with wildcards) and value mode (typed exact match) - Async scan engine with progress, cancel support - Re-scan updates all results with unified progress (single-pass read + table build) - Previous value column appears after first re-scan - WinDbg backtick address format with dimmed leading zeros (AddressDelegate) - Inline editing: address expressions navigate, value edits write to provider - Right-click context menu: Copy Address, Copy Value, Go to Address - Auto-sized columns, themed buttons with icons, dynamic combo width - 49 UI tests covering scan, rescan, editing, theming, progress completion --- CMakeLists.txt | 17 + src/resources.qrc | 3 + src/scanner.cpp | 507 +++++++++++++++++ src/scanner.h | 85 +++ src/scannerpanel.cpp | 726 +++++++++++++++++++++++++ src/scannerpanel.h | 111 ++++ tests/test_scanner.cpp | 1078 +++++++++++++++++++++++++++++++++++++ tests/test_scanner_ui.cpp | 960 +++++++++++++++++++++++++++++++++ 8 files changed, 3487 insertions(+) create mode 100644 src/scanner.cpp create mode 100644 src/scanner.h create mode 100644 src/scannerpanel.cpp create mode 100644 src/scannerpanel.h create mode 100644 tests/test_scanner.cpp create mode 100644 tests/test_scanner_ui.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index bf6ae75..37189e0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -79,6 +79,10 @@ add_executable(Reclass src/imports/import_pdb.cpp src/imports/import_pdb_dialog.h src/imports/import_pdb_dialog.cpp + src/scanner.h + src/scanner.cpp + src/scannerpanel.h + src/scannerpanel.cpp src/mainwindow.h src/optionsdialog.h src/optionsdialog.cpp @@ -235,6 +239,11 @@ if(BUILD_TESTING) target_link_libraries(test_static_fields PRIVATE ${QT}::Core ${QT}::Test) add_test(NAME test_static_fields COMMAND test_static_fields) + add_executable(test_scanner tests/test_scanner.cpp src/scanner.cpp) + target_include_directories(test_scanner PRIVATE src) + target_link_libraries(test_scanner PRIVATE ${QT}::Core ${QT}::Concurrent ${QT}::Test) + add_test(NAME test_scanner COMMAND test_scanner) + if(WIN32) add_executable(test_import_pdb tests/test_import_pdb.cpp src/imports/import_pdb.cpp src/format.cpp src/compose.cpp src/addressparser.cpp) @@ -392,6 +401,14 @@ if(BUILD_TESTING) endif() add_test(NAME test_source_provider COMMAND test_source_provider) + add_executable(test_scanner_ui tests/test_scanner_ui.cpp + src/scanner.cpp src/scannerpanel.cpp src/addressparser.cpp + src/themes/theme.cpp src/themes/thememanager.cpp) + target_include_directories(test_scanner_ui PRIVATE src) + target_link_libraries(test_scanner_ui PRIVATE + ${QT}::Widgets ${QT}::Concurrent ${QT}::Test) + add_test(NAME test_scanner_ui COMMAND test_scanner_ui) + # Disabled: WinDbg provider test has build errors (lastError API changed) #if(WIN32) # add_executable(test_windbg_provider tests/test_windbg_provider.cpp diff --git a/src/resources.qrc b/src/resources.qrc index 2cc6a53..036dcdd 100644 --- a/src/resources.qrc +++ b/src/resources.qrc @@ -57,5 +57,8 @@ vsicons/remote.svg vsicons/plug.svg vsicons/clear-all.svg + vsicons/search.svg + vsicons/regex.svg + vsicons/refresh.svg diff --git a/src/scanner.cpp b/src/scanner.cpp new file mode 100644 index 0000000..3c55bd2 --- /dev/null +++ b/src/scanner.cpp @@ -0,0 +1,507 @@ +#include "scanner.h" +#include +#include +#include +#include + +namespace rcx { + +// ── Pattern parsing ── + +static int hexVal(QChar c) { + ushort u = c.unicode(); + if (u >= '0' && u <= '9') return u - '0'; + if (u >= 'a' && u <= 'f') return u - 'a' + 10; + if (u >= 'A' && u <= 'F') return u - 'A' + 10; + return -1; +} + +bool parseSignature(const QString& input, QByteArray& pattern, QByteArray& mask, + QString* errorMsg) +{ + pattern.clear(); + mask.clear(); + + QString trimmed = input.trimmed(); + if (trimmed.isEmpty()) { + if (errorMsg) *errorMsg = QStringLiteral("Empty pattern"); + return false; + } + + // Check for C-style: \xAB\xCD + if (trimmed.startsWith(QStringLiteral("\\x"))) { + QStringList parts = trimmed.split(QStringLiteral("\\x"), Qt::SkipEmptyParts); + for (const QString& part : parts) { + if (part.size() != 2) { + if (errorMsg) *errorMsg = QStringLiteral("Invalid C-style byte: \\x%1").arg(part); + return false; + } + int hi = hexVal(part[0]); + int lo = hexVal(part[1]); + if (hi < 0 || lo < 0) { + if (errorMsg) *errorMsg = QStringLiteral("Invalid hex char in: \\x%1").arg(part); + return false; + } + pattern.append(char((hi << 4) | lo)); + mask.append(char(0xFF)); + } + return !pattern.isEmpty(); + } + + // Space-separated or packed hex + bool hasSpaces = trimmed.contains(' '); + + if (hasSpaces) { + QStringList tokens = trimmed.split(' ', Qt::SkipEmptyParts); + for (const QString& tok : tokens) { + if (tok == QStringLiteral("??") || tok == QStringLiteral("?")) { + pattern.append(char(0)); + mask.append(char(0)); + } else if (tok.size() == 2) { + int hi = hexVal(tok[0]); + int lo = hexVal(tok[1]); + if (hi < 0 || lo < 0) { + if (errorMsg) *errorMsg = QStringLiteral("Invalid hex byte: %1").arg(tok); + return false; + } + pattern.append(char((hi << 4) | lo)); + mask.append(char(0xFF)); + } else { + if (errorMsg) *errorMsg = QStringLiteral("Invalid token: %1 (expected 2 hex chars or wildcards)").arg(tok); + return false; + } + } + } else { + // Packed: "488B??05" + if (trimmed.size() % 2 != 0) { + if (errorMsg) *errorMsg = QStringLiteral("Odd number of characters in packed pattern"); + return false; + } + for (int i = 0; i < trimmed.size(); i += 2) { + QChar c0 = trimmed[i], c1 = trimmed[i + 1]; + if ((c0 == '?' && c1 == '?')) { + pattern.append(char(0)); + mask.append(char(0)); + } else { + int hi = hexVal(c0); + int lo = hexVal(c1); + if (hi < 0 || lo < 0) { + if (errorMsg) *errorMsg = QStringLiteral("Invalid hex chars at position %1: %2%3") + .arg(i).arg(c0).arg(c1); + return false; + } + pattern.append(char((hi << 4) | lo)); + mask.append(char(0xFF)); + } + } + } + + if (pattern.isEmpty()) { + if (errorMsg) *errorMsg = QStringLiteral("Empty pattern after parsing"); + return false; + } + + return true; +} + +// ── Value serialization ── + +template +static void appendLE(QByteArray& out, T val) { + out.append(reinterpret_cast(&val), sizeof(T)); +} + +bool serializeValue(ValueType type, const QString& input, + QByteArray& pattern, QByteArray& mask, + QString* errorMsg) +{ + pattern.clear(); + mask.clear(); + + QString trimmed = input.trimmed(); + if (trimmed.isEmpty()) { + if (errorMsg) *errorMsg = QStringLiteral("Empty value"); + return false; + } + + bool ok = false; + + switch (type) { + case ValueType::Int8: { + int v = trimmed.toInt(&ok); + if (!ok || v < -128 || v > 127) { + if (errorMsg) *errorMsg = QStringLiteral("Invalid int8 value"); + return false; + } + appendLE(pattern, (int8_t)v); + break; + } + case ValueType::Int16: { + int v = trimmed.toInt(&ok); + if (!ok || v < -32768 || v > 32767) { + if (errorMsg) *errorMsg = QStringLiteral("Invalid int16 value"); + return false; + } + appendLE(pattern, (int16_t)v); + break; + } + case ValueType::Int32: { + int v = trimmed.toInt(&ok); + if (!ok) { + if (errorMsg) *errorMsg = QStringLiteral("Invalid int32 value"); + return false; + } + appendLE(pattern, (int32_t)v); + break; + } + case ValueType::Int64: { + qlonglong v = trimmed.toLongLong(&ok); + if (!ok) { + if (errorMsg) *errorMsg = QStringLiteral("Invalid int64 value"); + return false; + } + appendLE(pattern, (int64_t)v); + break; + } + case ValueType::UInt8: { + uint v = trimmed.toUInt(&ok); + if (!ok || v > 255) { + // Try hex + if (trimmed.startsWith("0x", Qt::CaseInsensitive)) + v = trimmed.toUInt(&ok, 16); + if (!ok || v > 255) { + if (errorMsg) *errorMsg = QStringLiteral("Invalid uint8 value"); + return false; + } + } + appendLE(pattern, (uint8_t)v); + break; + } + case ValueType::UInt16: { + uint v = trimmed.toUInt(&ok); + if (!ok || v > 65535) { + if (trimmed.startsWith("0x", Qt::CaseInsensitive)) + v = trimmed.toUInt(&ok, 16); + if (!ok || v > 65535) { + if (errorMsg) *errorMsg = QStringLiteral("Invalid uint16 value"); + return false; + } + } + appendLE(pattern, (uint16_t)v); + break; + } + case ValueType::UInt32: { + quint32 v = trimmed.toULong(&ok); + if (!ok) { + if (trimmed.startsWith("0x", Qt::CaseInsensitive)) + v = trimmed.toULong(&ok, 16); + if (!ok) { + if (errorMsg) *errorMsg = QStringLiteral("Invalid uint32 value"); + return false; + } + } + appendLE(pattern, v); + break; + } + case ValueType::UInt64: { + quint64 v = trimmed.toULongLong(&ok); + if (!ok) { + if (trimmed.startsWith("0x", Qt::CaseInsensitive)) + v = trimmed.toULongLong(&ok, 16); + if (!ok) { + if (errorMsg) *errorMsg = QStringLiteral("Invalid uint64 value"); + return false; + } + } + appendLE(pattern, v); + break; + } + case ValueType::Float: { + float v = trimmed.toFloat(&ok); + if (!ok) { + if (errorMsg) *errorMsg = QStringLiteral("Invalid float value"); + return false; + } + appendLE(pattern, v); + break; + } + case ValueType::Double: { + double v = trimmed.toDouble(&ok); + if (!ok) { + if (errorMsg) *errorMsg = QStringLiteral("Invalid double value"); + return false; + } + appendLE(pattern, v); + break; + } + case ValueType::Vec2: { + QStringList parts = trimmed.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + if (parts.size() != 2) { + if (errorMsg) *errorMsg = QStringLiteral("Vec2 requires 2 space-separated floats"); + return false; + } + for (const QString& p : parts) { + float v = p.toFloat(&ok); + if (!ok) { + if (errorMsg) *errorMsg = QStringLiteral("Invalid float in vec2: %1").arg(p); + return false; + } + appendLE(pattern, v); + } + break; + } + case ValueType::Vec3: { + QStringList parts = trimmed.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + if (parts.size() != 3) { + if (errorMsg) *errorMsg = QStringLiteral("Vec3 requires 3 space-separated floats"); + return false; + } + for (const QString& p : parts) { + float v = p.toFloat(&ok); + if (!ok) { + if (errorMsg) *errorMsg = QStringLiteral("Invalid float in vec3: %1").arg(p); + return false; + } + appendLE(pattern, v); + } + break; + } + case ValueType::Vec4: { + QStringList parts = trimmed.split(QRegularExpression("\\s+"), Qt::SkipEmptyParts); + if (parts.size() != 4) { + if (errorMsg) *errorMsg = QStringLiteral("Vec4 requires 4 space-separated floats"); + return false; + } + for (const QString& p : parts) { + float v = p.toFloat(&ok); + if (!ok) { + if (errorMsg) *errorMsg = QStringLiteral("Invalid float in vec4: %1").arg(p); + return false; + } + appendLE(pattern, v); + } + break; + } + case ValueType::UTF8: { + QByteArray encoded = trimmed.toUtf8(); + if (encoded.isEmpty()) { + if (errorMsg) *errorMsg = QStringLiteral("Empty UTF-8 string"); + return false; + } + pattern = encoded; + break; + } + case ValueType::UTF16: { + // UTF-16LE encoding + for (int i = 0; i < trimmed.size(); i++) { + ushort u = trimmed[i].unicode(); + appendLE(pattern, u); + } + if (pattern.isEmpty()) { + if (errorMsg) *errorMsg = QStringLiteral("Empty UTF-16 string"); + return false; + } + break; + } + case ValueType::HexBytes: { + // Parse hex bytes (like signature but no wildcards) + QByteArray dummyMask; + if (!parseSignature(trimmed, pattern, dummyMask, errorMsg)) + return false; + // HexBytes = exact match, no wildcards + break; + } + } + + // Set mask to all 0xFF (exact match) for value scans + mask.fill(char(0xFF), pattern.size()); + return true; +} + +int naturalAlignment(ValueType type) { + switch (type) { + case ValueType::Int8: + case ValueType::UInt8: + case ValueType::UTF8: + case ValueType::HexBytes: + return 1; + case ValueType::Int16: + case ValueType::UInt16: + case ValueType::UTF16: + return 2; + case ValueType::Int32: + case ValueType::UInt32: + case ValueType::Float: + case ValueType::Vec2: + case ValueType::Vec3: + case ValueType::Vec4: + return 4; + case ValueType::Int64: + case ValueType::UInt64: + case ValueType::Double: + return 8; + } + return 1; +} + +// ── Scan engine ── + +ScanEngine::ScanEngine(QObject* parent) + : QObject(parent) +{ + qRegisterMetaType>("QVector"); +} + +bool ScanEngine::isRunning() const { + return m_watcher && m_watcher->isRunning(); +} + +void ScanEngine::abort() { + m_abort.store(true); +} + +void ScanEngine::start(std::shared_ptr provider, const ScanRequest& req) { + if (isRunning()) return; + + if (req.pattern.isEmpty()) { + emit error(QStringLiteral("Empty pattern")); + return; + } + if (req.pattern.size() != req.mask.size()) { + emit error(QStringLiteral("Pattern and mask size mismatch")); + return; + } + + m_abort.store(false); + + auto* watcher = new QFutureWatcher>(this); + m_watcher = watcher; + + connect(watcher, &QFutureWatcher>::finished, this, [this, watcher]() { + auto results = watcher->result(); + watcher->deleteLater(); + if (m_watcher == watcher) + m_watcher = nullptr; + emit finished(results); + }); + + watcher->setFuture(QtConcurrent::run([this, provider, req]() { + return runScan(provider, req); + })); +} + +QVector ScanEngine::runScan(std::shared_ptr prov, + const ScanRequest& req) +{ + QVector results; + + if (!prov || req.pattern.isEmpty()) + return results; + + auto regions = prov->enumerateRegions(); + + // Fallback for providers that don't enumerate regions (file/buffer) + if (regions.isEmpty()) { + MemoryRegion fallback; + fallback.base = 0; + fallback.size = (uint64_t)prov->size(); + fallback.readable = true; + fallback.writable = true; + fallback.executable = false; + regions.append(fallback); + } + + const int patternLen = req.pattern.size(); + const char* pat = req.pattern.constData(); + const char* msk = req.mask.constData(); + const int alignment = qMax(1, req.alignment); + + // Pre-compute total bytes for progress + uint64_t totalBytes = 0; + for (const auto& r : regions) { + if (req.filterExecutable && !r.executable) continue; + if (req.filterWritable && !r.writable) continue; + totalBytes += r.size; + } + + if (totalBytes == 0) return results; + + uint64_t scannedBytes = 0; + int lastPct = -1; + + constexpr int kChunk = 256 * 1024; + + for (const auto& region : regions) { + if (m_abort.load()) break; + + if (req.filterExecutable && !region.executable) continue; + if (req.filterWritable && !region.writable) continue; + + if ((uint64_t)patternLen > region.size) { + scannedBytes += region.size; + continue; + } + + const int overlap = patternLen - 1; + QByteArray chunk(qMin((uint64_t)kChunk, region.size), Qt::Uninitialized); + + for (uint64_t off = 0; off < region.size; ) { + if (m_abort.load()) break; + + uint64_t remaining = region.size - off; + int readLen = (int)qMin((uint64_t)chunk.size(), remaining); + + if (!prov->read(region.base + off, chunk.data(), readLen)) { + // Skip unreadable chunk + off += readLen; + scannedBytes += readLen; + continue; + } + + int scanEnd = readLen - patternLen; + const char* data = chunk.constData(); + + 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; + results.append(r); + + if (results.size() >= req.maxResults) + goto done; + } + } + + // Advance with overlap to catch patterns that straddle chunks + uint64_t advance; + if (readLen > overlap) + advance = (uint64_t)(readLen - overlap); + else + advance = 1; // prevent infinite loop on tiny regions + scannedBytes += advance; + off += advance; + + // Throttled progress + int pct = (int)(scannedBytes * 100 / totalBytes); + if (pct > 100) pct = 100; + if (pct != lastPct) { + lastPct = pct; + QMetaObject::invokeMethod(this, "progress", + Qt::QueuedConnection, Q_ARG(int, pct)); + } + } + } + +done: + return results; +} + +} // namespace rcx diff --git a/src/scanner.h b/src/scanner.h new file mode 100644 index 0000000..864e78c --- /dev/null +++ b/src/scanner.h @@ -0,0 +1,85 @@ +#pragma once +#include "providers/provider.h" +#include +#include +#include +#include +#include +#include +#include + +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 ── + +enum class ValueType { + Int8, Int16, Int32, Int64, + UInt8, UInt16, UInt32, UInt64, + Float, Double, + Vec2, Vec3, Vec4, + UTF8, UTF16, + HexBytes +}; + +// ── Pattern parsing ── + +// Parse IDA-style signature string ("48 8B ?? 05") into pattern + mask. +// Returns true on success. On failure, sets errorMsg. +bool parseSignature(const QString& input, QByteArray& pattern, QByteArray& mask, + QString* errorMsg = nullptr); + +// Serialize a typed value into raw bytes for exact-match scanning. +// Returns true on success. On failure, sets errorMsg. +bool serializeValue(ValueType type, const QString& input, + QByteArray& pattern, QByteArray& mask, + QString* errorMsg = nullptr); + +// Natural alignment for a value type (used as default alignment for value scans). +int naturalAlignment(ValueType type); + +// ── Scan engine ── + +class ScanEngine : public QObject { + Q_OBJECT +public: + explicit ScanEngine(QObject* parent = nullptr); + + void start(std::shared_ptr provider, const ScanRequest& req); + void abort(); + bool isRunning() const; + +signals: + void progress(int percent); + void finished(QVector results); + void error(QString message); + +private: + QVector runScan(std::shared_ptr prov, const ScanRequest& req); + + std::atomic m_abort{false}; + QFutureWatcher>* m_watcher = nullptr; +}; + +} // namespace rcx + +Q_DECLARE_METATYPE(QVector) diff --git a/src/scannerpanel.cpp b/src/scannerpanel.cpp new file mode 100644 index 0000000..dba6a28 --- /dev/null +++ b/src/scannerpanel.cpp @@ -0,0 +1,726 @@ +#include "scannerpanel.h" +#include "addressparser.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace rcx { + +void AddressDelegate::paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const { + // Draw background (selection/hover handled by style) + QStyleOptionViewItem opt = option; + initStyleOption(&opt, index); + opt.text.clear(); + QApplication::style()->drawControl(QStyle::CE_ItemViewItem, &opt, painter); + + QString text = index.data(Qt::DisplayRole).toString(); + if (text.isEmpty()) return; + + // Find first non-zero hex digit (skip backtick) + int dimEnd = 0; + for (int i = 0; i < text.size(); i++) { + QChar c = text[i]; + if (c == '`') { dimEnd = i + 1; continue; } + if (c != '0') break; + dimEnd = i + 1; + } + + QRect textRect = opt.rect.adjusted(7, 0, -4, 0); // match item padding + painter->setFont(opt.font); + + if (dimEnd > 0) { + painter->setPen(dimColor); + painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, text.left(dimEnd)); + // Advance past dim prefix + int dimWidth = painter->fontMetrics().horizontalAdvance(text.left(dimEnd)); + textRect.setLeft(textRect.left() + dimWidth); + } + painter->setPen(brightColor); + painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, text.mid(dimEnd)); +} + +ScannerPanel::ScannerPanel(QWidget* parent) + : QWidget(parent) + , m_engine(new ScanEngine(this)) +{ + auto* mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(6, 6, 6, 6); + mainLayout->setSpacing(4); + + // ── Row 1: Mode + pattern/value input ── + auto* inputRow = new QHBoxLayout; + inputRow->setSpacing(6); + + m_modeCombo = new QComboBox(this); + m_modeCombo->addItem(QIcon(QStringLiteral(":/vsicons/regex.svg")), + QStringLiteral("Signature")); + m_modeCombo->addItem(QIcon(QStringLiteral(":/vsicons/symbol-variable.svg")), + QStringLiteral("Value")); + updateComboWidth(); + inputRow->addWidget(m_modeCombo); + + // Signature input + m_patternLabel = new QLabel(QStringLiteral("Pattern:"), this); + inputRow->addWidget(m_patternLabel); + + m_patternEdit = new QLineEdit(this); + m_patternEdit->setPlaceholderText(QStringLiteral("48 8B ?? 05 ?? ?? ?? ?? CC")); + inputRow->addWidget(m_patternEdit, 1); + + // Value input (hidden initially) + m_typeLabel = new QLabel(QStringLiteral("Type:"), this); + inputRow->addWidget(m_typeLabel); + + m_typeCombo = new QComboBox(this); + m_typeCombo->addItem(QStringLiteral("int8"), (int)ValueType::Int8); + m_typeCombo->addItem(QStringLiteral("int16"), (int)ValueType::Int16); + m_typeCombo->addItem(QStringLiteral("int32"), (int)ValueType::Int32); + m_typeCombo->addItem(QStringLiteral("int64"), (int)ValueType::Int64); + m_typeCombo->addItem(QStringLiteral("uint8"), (int)ValueType::UInt8); + m_typeCombo->addItem(QStringLiteral("uint16"), (int)ValueType::UInt16); + m_typeCombo->addItem(QStringLiteral("uint32"), (int)ValueType::UInt32); + m_typeCombo->addItem(QStringLiteral("uint64"), (int)ValueType::UInt64); + m_typeCombo->addItem(QStringLiteral("float"), (int)ValueType::Float); + m_typeCombo->addItem(QStringLiteral("double"), (int)ValueType::Double); + m_typeCombo->setCurrentIndex(2); // default: int32 + inputRow->addWidget(m_typeCombo); + + m_valueLabel = new QLabel(QStringLiteral("Value:"), this); + inputRow->addWidget(m_valueLabel); + + m_valueEdit = new QLineEdit(this); + m_valueEdit->setPlaceholderText(QStringLiteral("12345")); + inputRow->addWidget(m_valueEdit, 1); + + mainLayout->addLayout(inputRow); + + // ── Row 2: Filters + scan button + progress ── + auto* filterRow = new QHBoxLayout; + filterRow->setSpacing(6); + + m_execCheck = new QCheckBox(QStringLiteral("Executable"), this); + filterRow->addWidget(m_execCheck); + + m_writeCheck = new QCheckBox(QStringLiteral("Writable"), this); + filterRow->addWidget(m_writeCheck); + + filterRow->addStretch(); + + m_scanBtn = new QPushButton(QIcon(QStringLiteral(":/vsicons/search.svg")), + QStringLiteral("Scan"), this); + filterRow->addWidget(m_scanBtn); + + m_updateBtn = new QPushButton(QIcon(QStringLiteral(":/vsicons/refresh.svg")), + QStringLiteral("Re-scan"), this); + m_updateBtn->setEnabled(false); + filterRow->addWidget(m_updateBtn); + + m_progressBar = new QProgressBar(this); + m_progressBar->setRange(0, 100); + m_progressBar->setTextVisible(true); + m_progressBar->setFixedWidth(150); + m_progressBar->hide(); + filterRow->addWidget(m_progressBar); + + mainLayout->addLayout(filterRow); + + // ── Results table ── + m_resultTable = new QTableWidget(this); + m_resultTable->setColumnCount(2); + m_resultTable->horizontalHeader()->hide(); + m_resultTable->verticalHeader()->hide(); + m_resultTable->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + m_resultTable->horizontalHeader()->setStretchLastSection(false); + m_resultTable->setSelectionBehavior(QAbstractItemView::SelectRows); + m_resultTable->setSelectionMode(QAbstractItemView::SingleSelection); + m_resultTable->setEditTriggers(QAbstractItemView::DoubleClicked); + m_resultTable->setShowGrid(false); + m_resultTable->setMouseTracking(true); + m_resultTable->setFocusPolicy(Qt::StrongFocus); + m_resultTable->setContextMenuPolicy(Qt::CustomContextMenu); + + // Address column delegate for dimmed leading zeros + m_addrDelegate = new AddressDelegate(this); + m_resultTable->setItemDelegateForColumn(0, m_addrDelegate); + mainLayout->addWidget(m_resultTable, 1); + + // ── Row 3: Status + action buttons ── + auto* actionRow = new QHBoxLayout; + actionRow->setSpacing(6); + + m_statusLabel = new QLabel(QStringLiteral("Ready"), this); + actionRow->addWidget(m_statusLabel, 1); + + m_gotoBtn = new QPushButton(QIcon(QStringLiteral(":/vsicons/arrow-right.svg")), + QStringLiteral("Go to Address"), this); + m_gotoBtn->setEnabled(false); + actionRow->addWidget(m_gotoBtn); + + m_copyBtn = new QPushButton(QIcon(QStringLiteral(":/vsicons/clippy.svg")), + QStringLiteral("Copy Address"), this); + m_copyBtn->setEnabled(false); + actionRow->addWidget(m_copyBtn); + + mainLayout->addLayout(actionRow); + + // ── Initial visibility: signature mode ── + m_typeLabel->hide(); + m_typeCombo->hide(); + m_valueLabel->hide(); + m_valueEdit->hide(); + + // ── Connections ── + connect(m_modeCombo, QOverload::of(&QComboBox::currentIndexChanged), + this, &ScannerPanel::onModeChanged); + connect(m_scanBtn, &QPushButton::clicked, + this, &ScannerPanel::onScanClicked); + connect(m_updateBtn, &QPushButton::clicked, + this, &ScannerPanel::onUpdateClicked); + connect(m_gotoBtn, &QPushButton::clicked, + this, &ScannerPanel::onGoToAddress); + connect(m_copyBtn, &QPushButton::clicked, + this, &ScannerPanel::onCopyAddress); + connect(m_resultTable, &QTableWidget::cellDoubleClicked, + this, &ScannerPanel::onResultDoubleClicked); + connect(m_resultTable, &QTableWidget::cellChanged, + this, &ScannerPanel::onCellEdited); + connect(m_resultTable, &QTableWidget::itemSelectionChanged, this, [this]() { + bool hasSel = !m_resultTable->selectedItems().isEmpty(); + m_gotoBtn->setEnabled(hasSel); + m_copyBtn->setEnabled(hasSel); + }); + + connect(m_resultTable, &QWidget::customContextMenuRequested, this, [this](const QPoint& pos) { + int row = m_resultTable->rowAt(pos.y()); + if (row < 0 || row >= m_results.size()) return; + QMenu menu; + auto* copyAddr = menu.addAction(QIcon(QStringLiteral(":/vsicons/clippy.svg")), + QStringLiteral("Copy Address")); + auto* copyVal = menu.addAction(QIcon(QStringLiteral(":/vsicons/clippy.svg")), + QStringLiteral("Copy Value")); + auto* goTo = menu.addAction(QIcon(QStringLiteral(":/vsicons/arrow-right.svg")), + QStringLiteral("Go to Address")); + auto* chosen = menu.exec(m_resultTable->viewport()->mapToGlobal(pos)); + if (chosen == copyAddr) { + QString addr = QStringLiteral("0x%1") + .arg(m_results[row].address, 0, 16, QLatin1Char('0')).toUpper(); + QApplication::clipboard()->setText(addr); + m_statusLabel->setText(QStringLiteral("Copied: %1").arg(addr)); + } else if (chosen == copyVal) { + QApplication::clipboard()->setText(formatValue(m_results[row].scanValue)); + m_statusLabel->setText(QStringLiteral("Copied value")); + } else if (chosen == goTo) { + emit goToAddress(m_results[row].address); + } + }); + + connect(m_engine, &ScanEngine::progress, this, [this](int pct) { + m_progressBar->setValue(pct); + }); + connect(m_engine, &ScanEngine::finished, + this, &ScannerPanel::onScanFinished); + connect(m_engine, &ScanEngine::error, this, [this](const QString& msg) { + m_statusLabel->setText(QStringLiteral("Error: %1").arg(msg)); + m_scanBtn->setText(QStringLiteral("Scan")); + m_progressBar->hide(); + }); +} + +void ScannerPanel::setProviderGetter(ProviderGetter getter) { + m_providerGetter = std::move(getter); +} + +void ScannerPanel::setEditorFont(const QFont& font) { + m_resultTable->setFont(font); + QFontMetrics fm(font); + m_resultTable->verticalHeader()->setDefaultSectionSize(fm.height() + 6); + m_patternEdit->setFont(font); + m_valueEdit->setFont(font); + m_modeCombo->setFont(font); + m_typeCombo->setFont(font); + m_statusLabel->setFont(font); + m_scanBtn->setFont(font); + m_gotoBtn->setFont(font); + m_copyBtn->setFont(font); + m_patternLabel->setFont(font); + m_typeLabel->setFont(font); + m_valueLabel->setFont(font); + m_execCheck->setFont(font); + m_writeCheck->setFont(font); + m_updateBtn->setFont(font); + updateComboWidth(); +} + +void ScannerPanel::updateComboWidth() { + QFontMetrics fm(m_modeCombo->font()); + int maxW = 0; + for (int i = 0; i < m_modeCombo->count(); i++) + maxW = qMax(maxW, fm.horizontalAdvance(m_modeCombo->itemText(i))); + m_modeCombo->setFixedWidth(maxW + 50); // icon + dropdown arrow + padding +} + +void ScannerPanel::onModeChanged(int index) { + bool isSig = (index == 0); + + m_patternLabel->setVisible(isSig); + m_patternEdit->setVisible(isSig); + + m_typeLabel->setVisible(!isSig); + m_typeCombo->setVisible(!isSig); + m_valueLabel->setVisible(!isSig); + m_valueEdit->setVisible(!isSig); +} + +void ScannerPanel::onScanClicked() { + if (m_engine->isRunning()) { + m_engine->abort(); + m_scanBtn->setText(QStringLiteral("Scan")); + m_progressBar->hide(); + m_statusLabel->setText(QStringLiteral("Scan cancelled")); + return; + } + + // Get provider + std::shared_ptr provider; + if (m_providerGetter) + provider = m_providerGetter(); + + if (!provider) { + m_statusLabel->setText(QStringLiteral("No source attached")); + return; + } + + // Build request + ScanRequest req = buildRequest(); + if (req.pattern.isEmpty()) + return; // error already shown by buildRequest + + m_lastScanMode = m_modeCombo->currentIndex(); + if (m_lastScanMode == 1) + m_lastValueType = (ValueType)m_typeCombo->currentData().toInt(); + m_lastPattern = req.pattern; + + m_scanBtn->setText(QStringLiteral("Cancel")); + m_progressBar->setValue(0); + m_progressBar->show(); + m_statusLabel->setText(QStringLiteral("Scanning...")); + + m_engine->start(provider, req); +} + +ScanRequest ScannerPanel::buildRequest() { + ScanRequest req; + QString err; + + if (m_modeCombo->currentIndex() == 0) { + // Signature mode + if (!parseSignature(m_patternEdit->text(), req.pattern, req.mask, &err)) { + m_statusLabel->setText(QStringLiteral("Pattern error: %1").arg(err)); + return {}; + } + req.alignment = 1; + } else { + // Value mode + auto vt = (ValueType)m_typeCombo->currentData().toInt(); + if (!serializeValue(vt, m_valueEdit->text(), req.pattern, req.mask, &err)) { + m_statusLabel->setText(QStringLiteral("Value error: %1").arg(err)); + return {}; + } + req.alignment = naturalAlignment(vt); + } + + req.filterExecutable = m_execCheck->isChecked(); + req.filterWritable = m_writeCheck->isChecked(); + + return req; +} + +void ScannerPanel::onScanFinished(QVector results) { + m_scanBtn->setText(QStringLiteral("Scan")); + m_progressBar->hide(); + m_results = results; + + // Cache scan-time bytes + if (m_lastScanMode == 1) { + // Value mode — every result matched the same value, no re-read needed + for (auto& r : m_results) { + r.previousValue.clear(); + r.scanValue = m_lastPattern; + } + } else { + // Signature mode — wildcards mean each match may differ, read actual bytes + std::shared_ptr prov; + if (m_providerGetter) + prov = m_providerGetter(); + for (auto& r : m_results) { + r.previousValue.clear(); + r.scanValue = prov ? prov->readBytes(r.address, 16) : QByteArray(); + } + } + + m_updateBtn->setEnabled(!m_results.isEmpty()); + populateTable(false); + + m_statusLabel->setText(QStringLiteral("%1 result%2") + .arg(m_results.size()) + .arg(m_results.size() == 1 ? "" : "s")); +} + +void ScannerPanel::populateTable(bool showPrevious) { + m_resultTable->blockSignals(true); + int cols = showPrevious ? 3 : 2; + m_resultTable->setColumnCount(cols); + m_resultTable->setRowCount(m_results.size()); + + for (int i = 0; i < m_results.size(); i++) { + const auto& r = m_results[i]; + + // Address column — WinDbg backtick format: 00000000`00000000 + QString hexPart = QStringLiteral("%1").arg(r.address, 16, 16, QLatin1Char('0')).toUpper(); + hexPart.insert(8, '`'); + auto* addrItem = new QTableWidgetItem(hexPart); + addrItem->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsEditable); + m_resultTable->setItem(i, 0, addrItem); + + // Value column + auto* valItem = new QTableWidgetItem(formatValue(r.scanValue)); + valItem->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsEditable); + m_resultTable->setItem(i, 1, valItem); + + // Previous column + if (showPrevious) { + auto* prevItem = new QTableWidgetItem( + r.previousValue.isEmpty() ? QString() : formatValue(r.previousValue)); + prevItem->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); + m_resultTable->setItem(i, 2, prevItem); + } + } + + m_resultTable->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + m_resultTable->horizontalHeader()->setStretchLastSection(false); + m_resultTable->blockSignals(false); +} + +void ScannerPanel::onUpdateClicked() { + if (m_results.isEmpty()) return; + + std::shared_ptr prov; + if (m_providerGetter) + prov = m_providerGetter(); + if (!prov) { + m_statusLabel->setText(QStringLiteral("No source attached")); + return; + } + + m_updateBtn->setEnabled(false); + m_scanBtn->setEnabled(false); + m_statusLabel->setText(QStringLiteral("Re-scanning...")); + m_progressBar->setValue(0); + m_progressBar->show(); + + int readSize = (m_lastScanMode == 1) ? valueSize() : 16; + int total = m_results.size(); + + // Single pass: read new values + build table rows + m_resultTable->blockSignals(true); + m_resultTable->setColumnCount(3); + m_resultTable->setRowCount(total); + + for (int i = 0; i < total; i++) { + auto& r = m_results[i]; + r.previousValue = r.scanValue; + r.scanValue = prov->readBytes(r.address, readSize); + + QString hexPart = QStringLiteral("%1").arg(r.address, 16, 16, QLatin1Char('0')).toUpper(); + hexPart.insert(8, '`'); + auto* addrItem = new QTableWidgetItem(hexPart); + addrItem->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsEditable); + m_resultTable->setItem(i, 0, addrItem); + + auto* valItem = new QTableWidgetItem(formatValue(r.scanValue)); + valItem->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsEditable); + m_resultTable->setItem(i, 1, valItem); + + auto* prevItem = new QTableWidgetItem(formatValue(r.previousValue)); + prevItem->setFlags(Qt::ItemIsSelectable | Qt::ItemIsEnabled); + m_resultTable->setItem(i, 2, prevItem); + + if ((i & 0xFF) == 0) { + m_progressBar->setValue(i * 100 / total); + QApplication::processEvents(); + } + } + + m_resultTable->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + m_resultTable->horizontalHeader()->setStretchLastSection(false); + m_resultTable->blockSignals(false); + + m_progressBar->setValue(100); + m_progressBar->hide(); + m_scanBtn->setEnabled(true); + m_updateBtn->setEnabled(true); + m_statusLabel->setText(QStringLiteral("Updated %1 result%2") + .arg(total).arg(total == 1 ? "" : "s")); +} + +void ScannerPanel::onGoToAddress() { + int row = m_resultTable->currentRow(); + if (row < 0 || row >= m_results.size()) return; + emit goToAddress(m_results[row].address); +} + +void ScannerPanel::onCopyAddress() { + int row = m_resultTable->currentRow(); + if (row < 0 || row >= m_results.size()) return; + + QString addr = QStringLiteral("0x%1") + .arg(m_results[row].address, 0, 16, QLatin1Char('0')).toUpper(); + QApplication::clipboard()->setText(addr); + m_statusLabel->setText(QStringLiteral("Copied: %1").arg(addr)); +} + +void ScannerPanel::onResultDoubleClicked(int row, int col) { + // Double-click on address column navigates (editing also starts via edit trigger) + // Double-click on preview column only starts inline editing + Q_UNUSED(col); + Q_UNUSED(row); + // Navigation is handled by Go to Address button or onCellEdited for address expressions +} + +void ScannerPanel::onCellEdited(int row, int col) { + if (row < 0 || row >= m_results.size()) return; + + auto* item = m_resultTable->item(row, col); + if (!item) return; + QString text = item->text().trimmed(); + + if (col == 0) { + // Address column — evaluate expression via AddressParser + AddressParserCallbacks cbs; + std::shared_ptr prov; + if (m_providerGetter) + prov = m_providerGetter(); + if (prov) { + auto* p = prov.get(); + cbs.resolveModule = [p](const QString& name, bool* ok) -> uint64_t { + uint64_t base = p->symbolToAddress(name); + *ok = (base != 0); + return base; + }; + cbs.readPointer = [p](uint64_t addr, bool* ok) -> uint64_t { + uint64_t val = 0; + *ok = p->read(addr, &val, 8); + return val; + }; + } + auto result = AddressParser::evaluate(text, 8, &cbs); + if (result.ok) { + m_results[row].address = result.value; + emit goToAddress(result.value); + // Reformat the address cell + m_resultTable->blockSignals(true); + QString hexPart = QStringLiteral("%1").arg(result.value, 16, 16, QLatin1Char('0')).toUpper(); + hexPart.insert(8, '`'); + item->setText(hexPart); + // Re-read preview at new address and update cache + if (prov) { + int readSize = (m_lastScanMode == 1) ? valueSize() : 16; + m_results[row].scanValue = prov->readBytes(result.value, readSize); + if (auto* prevItem = m_resultTable->item(row, 1)) + prevItem->setText(formatValue(m_results[row].scanValue)); + } + m_resultTable->blockSignals(false); + } else { + m_statusLabel->setText(QStringLiteral("Expression error: %1").arg(result.error)); + // Restore original address + m_resultTable->blockSignals(true); + QString hexPart = QStringLiteral("%1").arg(m_results[row].address, 16, 16, QLatin1Char('0')).toUpper(); + hexPart.insert(8, '`'); + item->setText(hexPart); + m_resultTable->blockSignals(false); + } + } else if (col == 1) { + // Preview column — parse hex bytes and write to provider + std::shared_ptr prov; + if (m_providerGetter) + prov = m_providerGetter(); + if (!prov || !prov->isWritable()) { + m_statusLabel->setText(QStringLiteral("Provider is read-only")); + return; + } + QByteArray bytes; + uint64_t addr = m_results[row].address; + + if (m_lastScanMode == 0) { + // Signature mode — parse space-separated hex bytes + QStringList tokens = text.split(' ', Qt::SkipEmptyParts); + for (const QString& tok : tokens) { + bool ok; + uint val = tok.toUInt(&ok, 16); + if (!ok || val > 0xFF) { + m_statusLabel->setText(QStringLiteral("Invalid hex byte: %1").arg(tok)); + return; + } + bytes.append(char(val)); + } + } else { + // Value mode — parse native type + bool ok = false; + bytes.resize(valueSize()); + char* d = bytes.data(); + switch (m_lastValueType) { + case ValueType::Int8: { auto v = (int8_t)text.toInt(&ok); if (ok) memcpy(d, &v, 1); break; } + case ValueType::UInt8: { auto v = (uint8_t)text.toUInt(&ok); if (ok) memcpy(d, &v, 1); break; } + case ValueType::Int16: { auto v = (int16_t)text.toShort(&ok); if (ok) memcpy(d, &v, 2); break; } + case ValueType::UInt16: { auto v = text.toUShort(&ok); if (ok) memcpy(d, &v, 2); break; } + case ValueType::Int32: { auto v = text.toInt(&ok); if (ok) memcpy(d, &v, 4); break; } + case ValueType::UInt32: { auto v = text.toUInt(&ok); if (ok) memcpy(d, &v, 4); break; } + case ValueType::Int64: { auto v = text.toLongLong(&ok); if (ok) memcpy(d, &v, 8); break; } + case ValueType::UInt64: { auto v = text.toULongLong(&ok); if (ok) memcpy(d, &v, 8); break; } + case ValueType::Float: { auto v = text.toFloat(&ok); if (ok) memcpy(d, &v, 4); break; } + case ValueType::Double: { auto v = text.toDouble(&ok); if (ok) memcpy(d, &v, 8); break; } + default: break; + } + if (!ok) { + m_statusLabel->setText(QStringLiteral("Invalid value")); + return; + } + } + if (bytes.isEmpty()) return; + + if (prov->writeBytes(addr, bytes)) { + m_statusLabel->setText(QStringLiteral("Wrote %1 byte%2 to 0x%3") + .arg(bytes.size()) + .arg(bytes.size() == 1 ? "" : "s") + .arg(addr, 0, 16, QLatin1Char('0')).toUpper()); + // Re-read and update cache + m_resultTable->blockSignals(true); + int readSize = (m_lastScanMode == 1) ? valueSize() : 16; + m_results[row].scanValue = prov->readBytes(addr, readSize); + item->setText(formatValue(m_results[row].scanValue)); + m_resultTable->blockSignals(false); + } else { + m_statusLabel->setText(QStringLiteral("Write failed")); + } + } +} + +void ScannerPanel::applyTheme(const Theme& theme) { + // Address delegate colors + m_addrDelegate->dimColor = theme.textFaint; + m_addrDelegate->brightColor = theme.text; + + // Results table — editor-matching style + m_resultTable->setStyleSheet(QStringLiteral( + "QTableWidget { background: %1; color: %2; border: none; }" + "QTableWidget::item { padding: 2px 6px; border: none; }" + "QTableWidget::item:hover { background: %3; padding: 2px 6px; border: none; }" + "QTableWidget::item:selected { background: %3; color: %2; padding: 2px 6px; border: none; }" + "QTableWidget QLineEdit { background: %1; color: %2; border: 1px solid %4;" + " padding: 1px 4px; selection-background-color: %5; }") + .arg(theme.background.name(), theme.text.name(), theme.hover.name(), + theme.borderFocused.name(), theme.selection.name())); + + // Input fields + QString lineEditStyle = QStringLiteral( + "QLineEdit { background: %1; color: %2; border: 1px solid %3; padding: 2px 4px; }" + "QLineEdit:focus { border-color: %4; }") + .arg(theme.background.name(), theme.text.name(), + theme.border.name(), theme.borderFocused.name()); + m_patternEdit->setStyleSheet(lineEditStyle); + m_valueEdit->setStyleSheet(lineEditStyle); + + // Combo boxes + QString comboStyle = QStringLiteral( + "QComboBox { background: %1; color: %2; border: 1px solid %3; padding: 2px 4px 2px 4px; }" + "QComboBox::drop-down { subcontrol-origin: padding; subcontrol-position: top right;" + " width: 16px; border-left: 1px solid %3; }" + "QComboBox::down-arrow { image: url(:/vsicons/chevron-down.svg); width: 10px; height: 10px; }" + "QComboBox QAbstractItemView { background: %1; color: %2; selection-background-color: %4; }") + .arg(theme.background.name(), theme.text.name(), + theme.border.name(), theme.hover.name()); + m_modeCombo->setStyleSheet(comboStyle); + m_typeCombo->setStyleSheet(comboStyle); + + // Labels + QPalette lp; + lp.setColor(QPalette::WindowText, theme.textDim); + m_patternLabel->setPalette(lp); + m_typeLabel->setPalette(lp); + m_valueLabel->setPalette(lp); + m_statusLabel->setPalette(lp); + + // Checkboxes + QPalette cp; + cp.setColor(QPalette::WindowText, theme.textDim); + m_execCheck->setPalette(cp); + m_writeCheck->setPalette(cp); + + // Buttons + QString btnStyle = QStringLiteral( + "QPushButton { background: %1; color: %2; border: 1px solid %3; padding: 4px 12px; }" + "QPushButton:hover { background: %4; }" + "QPushButton:pressed { background: %5; }" + "QPushButton:disabled { color: %6; }") + .arg(theme.button.name(), theme.text.name(), theme.border.name(), + theme.hover.name(), theme.hover.darker(130).name(), + theme.textMuted.name()); + m_scanBtn->setStyleSheet(btnStyle); + m_updateBtn->setStyleSheet(btnStyle); + m_gotoBtn->setStyleSheet(btnStyle); + m_copyBtn->setStyleSheet(btnStyle); + + // Progress bar + m_progressBar->setStyleSheet(QStringLiteral( + "QProgressBar { background: %1; border: 1px solid %2; text-align: center; color: %3; }" + "QProgressBar::chunk { background: %4; }") + .arg(theme.background.name(), theme.border.name(), + theme.textDim.name(), theme.indHoverSpan.name())); +} + +int ScannerPanel::valueSize() const { + switch (m_lastValueType) { + 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; + default: return 16; + } +} + +QString ScannerPanel::formatValue(const QByteArray& bytes) const { + if (m_lastScanMode == 0) { + // Signature mode — hex bytes + QString s; + for (int j = 0; j < bytes.size(); j++) { + if (j > 0) s += ' '; + s += QStringLiteral("%1").arg((uint8_t)bytes[j], 2, 16, QLatin1Char('0')).toUpper(); + } + return s; + } + // Value mode — native type + const char* d = bytes.constData(); + int sz = bytes.size(); + switch (m_lastValueType) { + case ValueType::Int8: if (sz >= 1) return QString::number((int8_t)d[0]); break; + case ValueType::UInt8: if (sz >= 1) return QString::number((uint8_t)d[0]); break; + case ValueType::Int16: if (sz >= 2) { int16_t v; memcpy(&v, d, 2); return QString::number(v); } break; + case ValueType::UInt16: if (sz >= 2) { uint16_t v; memcpy(&v, d, 2); return QString::number(v); } break; + case ValueType::Int32: if (sz >= 4) { int32_t v; memcpy(&v, d, 4); return QString::number(v); } break; + case ValueType::UInt32: if (sz >= 4) { uint32_t v; memcpy(&v, d, 4); return QString::number(v); } break; + case ValueType::Int64: if (sz >= 8) { int64_t v; memcpy(&v, d, 8); return QString::number(v); } break; + case ValueType::UInt64: if (sz >= 8) { uint64_t v; memcpy(&v, d, 8); return QString::number(v); } break; + case ValueType::Float: if (sz >= 4) { float v; memcpy(&v, d, 4); return QString::number(v, 'g', 9); } break; + case ValueType::Double: if (sz >= 8) { double v; memcpy(&v, d, 8); return QString::number(v, 'g', 17); } break; + default: break; + } + return QStringLiteral("??"); +} + +} // namespace rcx diff --git a/src/scannerpanel.h b/src/scannerpanel.h new file mode 100644 index 0000000..9e48ed5 --- /dev/null +++ b/src/scannerpanel.h @@ -0,0 +1,111 @@ +#pragma once +#include "scanner.h" +#include "themes/theme.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace rcx { + +// Delegate that paints address with dimmed high-bytes prefix +class AddressDelegate : public QStyledItemDelegate { + Q_OBJECT +public: + using QStyledItemDelegate::QStyledItemDelegate; + QColor dimColor; + QColor brightColor; + void paint(QPainter* painter, const QStyleOptionViewItem& option, + const QModelIndex& index) const override; +}; + +class ScannerPanel : public QWidget { + Q_OBJECT +public: + explicit ScannerPanel(QWidget* parent = nullptr); + + using ProviderGetter = std::function()>; + void setProviderGetter(ProviderGetter getter); + + void setEditorFont(const QFont& font); + void applyTheme(const Theme& theme); + + // Test accessors + QComboBox* modeCombo() const { return m_modeCombo; } + QLineEdit* patternEdit() const { return m_patternEdit; } + QComboBox* typeCombo() const { return m_typeCombo; } + QLineEdit* valueEdit() const { return m_valueEdit; } + QCheckBox* execCheck() const { return m_execCheck; } + QCheckBox* writeCheck() const { return m_writeCheck; } + QPushButton* scanButton() const { return m_scanBtn; } + QPushButton* updateButton() const { return m_updateBtn; } + QProgressBar* progressBar() const { return m_progressBar; } + QTableWidget* resultsTable() const { return m_resultTable; } + QLabel* statusLabel() const { return m_statusLabel; } + QPushButton* gotoButton() const { return m_gotoBtn; } + QPushButton* copyButton() const { return m_copyBtn; } + ScanEngine* engine() const { return m_engine; } + +signals: + void goToAddress(uint64_t address); + +private slots: + void onModeChanged(int index); + void onScanClicked(); + void onScanFinished(QVector results); + void onGoToAddress(); + void onCopyAddress(); + void onResultDoubleClicked(int row, int col); + void onCellEdited(int row, int col); + void onUpdateClicked(); + +private: + ScanRequest buildRequest(); + void populateTable(bool showPrevious); + void updateComboWidth(); + + // Input widgets + QComboBox* m_modeCombo; // Signature / Value + QLineEdit* m_patternEdit; // Signature pattern input + QComboBox* m_typeCombo; // Value type dropdown + QLineEdit* m_valueEdit; // Value input + QLabel* m_patternLabel; + QLabel* m_typeLabel; + QLabel* m_valueLabel; + + // Filters + QCheckBox* m_execCheck; + QCheckBox* m_writeCheck; + + // Actions + QPushButton* m_scanBtn; + QPushButton* m_updateBtn; + QProgressBar* m_progressBar; + + // Results + QTableWidget* m_resultTable; + AddressDelegate* m_addrDelegate; + QLabel* m_statusLabel; + QPushButton* m_gotoBtn; + QPushButton* m_copyBtn; + + // Engine + ScanEngine* m_engine; + ProviderGetter m_providerGetter; + QVector m_results; + int m_lastScanMode = 0; // 0=signature, 1=value + ValueType m_lastValueType = ValueType::Int32; + QByteArray m_lastPattern; // serialized search value + + QString formatValue(const QByteArray& bytes) const; + int valueSize() const; +}; + +} // namespace rcx diff --git a/tests/test_scanner.cpp b/tests/test_scanner.cpp new file mode 100644 index 0000000..d5fd78d --- /dev/null +++ b/tests/test_scanner.cpp @@ -0,0 +1,1078 @@ +#include +#include +#include +#include +#include +#include "scanner.h" +#include "providers/provider.h" +#include "providers/buffer_provider.h" +#include "providers/null_provider.h" + +using namespace rcx; + +// ── Test provider that exposes custom memory regions ── +class RegionProvider : public BufferProvider { + QVector m_regions; +public: + RegionProvider(QByteArray data, QVector regions) + : BufferProvider(std::move(data), "test") + , m_regions(std::move(regions)) {} + + QVector enumerateRegions() const override { return m_regions; } +}; + +class TestScanner : public QObject { + Q_OBJECT + +private slots: + + // ═══════════════════════════════════════════════════════════════════ + // Pattern Parsing — Signature mode + // ═══════════════════════════════════════════════════════════════════ + + void parse_emptyPattern() { + QByteArray pat, mask; + QString err; + QVERIFY(!parseSignature("", pat, mask, &err)); + QVERIFY(err.contains("Empty")); + } + + void parse_spacesOnly() { + QByteArray pat, mask; + QString err; + QVERIFY(!parseSignature(" ", pat, mask, &err)); + QVERIFY(err.contains("Empty")); + } + + void parse_singleByte() { + QByteArray pat, mask; + QVERIFY(parseSignature("AB", pat, mask)); + QCOMPARE(pat.size(), 1); + QCOMPARE((uint8_t)pat[0], (uint8_t)0xAB); + QCOMPARE((uint8_t)mask[0], (uint8_t)0xFF); + } + + void parse_spaceSeparated() { + QByteArray pat, mask; + QVERIFY(parseSignature("48 8B 05", pat, mask)); + QCOMPARE(pat.size(), 3); + QCOMPARE((uint8_t)pat[0], (uint8_t)0x48); + QCOMPARE((uint8_t)pat[1], (uint8_t)0x8B); + QCOMPARE((uint8_t)pat[2], (uint8_t)0x05); + QCOMPARE((uint8_t)mask[0], (uint8_t)0xFF); + QCOMPARE((uint8_t)mask[1], (uint8_t)0xFF); + QCOMPARE((uint8_t)mask[2], (uint8_t)0xFF); + } + + void parse_withWildcards() { + QByteArray pat, mask; + QVERIFY(parseSignature("48 ?? 05 ?? ??", pat, mask)); + QCOMPARE(pat.size(), 5); + QCOMPARE((uint8_t)pat[0], (uint8_t)0x48); + QCOMPARE((uint8_t)pat[2], (uint8_t)0x05); + QCOMPARE((uint8_t)mask[0], (uint8_t)0xFF); + QCOMPARE((uint8_t)mask[1], (uint8_t)0x00); // wildcard + QCOMPARE((uint8_t)mask[2], (uint8_t)0xFF); + QCOMPARE((uint8_t)mask[3], (uint8_t)0x00); + QCOMPARE((uint8_t)mask[4], (uint8_t)0x00); + } + + void parse_singleQuestionMark() { + QByteArray pat, mask; + QVERIFY(parseSignature("48 ? 05", pat, mask)); + QCOMPARE((uint8_t)mask[1], (uint8_t)0x00); + } + + void parse_packedNoSpaces() { + QByteArray pat, mask; + QVERIFY(parseSignature("488B??05CC", pat, mask)); + QCOMPARE(pat.size(), 5); + QCOMPARE((uint8_t)pat[0], (uint8_t)0x48); + QCOMPARE((uint8_t)pat[1], (uint8_t)0x8B); + QCOMPARE((uint8_t)mask[2], (uint8_t)0x00); + QCOMPARE((uint8_t)pat[3], (uint8_t)0x05); + QCOMPARE((uint8_t)pat[4], (uint8_t)0xCC); + } + + void parse_cStyle() { + QByteArray pat, mask; + QVERIFY(parseSignature("\\x48\\x8B\\x05", pat, mask)); + QCOMPARE(pat.size(), 3); + QCOMPARE((uint8_t)pat[0], (uint8_t)0x48); + QCOMPARE((uint8_t)pat[1], (uint8_t)0x8B); + QCOMPARE((uint8_t)pat[2], (uint8_t)0x05); + } + + void parse_lowercaseHex() { + QByteArray pat, mask; + QVERIFY(parseSignature("ab cd ef", pat, mask)); + QCOMPARE((uint8_t)pat[0], (uint8_t)0xAB); + QCOMPARE((uint8_t)pat[1], (uint8_t)0xCD); + QCOMPARE((uint8_t)pat[2], (uint8_t)0xEF); + } + + void parse_mixedCase() { + QByteArray pat, mask; + QVERIFY(parseSignature("aB Cd eF", pat, mask)); + QCOMPARE((uint8_t)pat[0], (uint8_t)0xAB); + QCOMPARE((uint8_t)pat[1], (uint8_t)0xCD); + QCOMPARE((uint8_t)pat[2], (uint8_t)0xEF); + } + + void parse_invalidHex() { + QByteArray pat, mask; + QString err; + QVERIFY(!parseSignature("GG", pat, mask, &err)); + QVERIFY(!err.isEmpty()); + } + + void parse_oddCharsNoSpaces() { + QByteArray pat, mask; + QString err; + QVERIFY(!parseSignature("ABC", pat, mask, &err)); + QVERIFY(err.contains("Odd")); + } + + void parse_invalidTokenWidth() { + QByteArray pat, mask; + QString err; + QVERIFY(!parseSignature("48 ABC 05", pat, mask, &err)); + QVERIFY(!err.isEmpty()); + } + + void parse_leadingTrailingSpaces() { + QByteArray pat, mask; + QVERIFY(parseSignature(" 48 8B ", pat, mask)); + QCOMPARE(pat.size(), 2); + } + + void parse_allWildcards() { + QByteArray pat, mask; + QVERIFY(parseSignature("?? ?? ??", pat, mask)); + QCOMPARE(pat.size(), 3); + for (int i = 0; i < 3; i++) + QCOMPARE((uint8_t)mask[i], (uint8_t)0x00); + } + + // ═══════════════════════════════════════════════════════════════════ + // Value Serialization + // ═══════════════════════════════════════════════════════════════════ + + void serialize_int8() { + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Int8, "-42", pat, mask)); + QCOMPARE(pat.size(), 1); + QCOMPARE((int8_t)pat[0], (int8_t)-42); + QCOMPARE((uint8_t)mask[0], (uint8_t)0xFF); + } + + void serialize_int8_overflow() { + QByteArray pat, mask; + QString err; + QVERIFY(!serializeValue(ValueType::Int8, "200", pat, mask, &err)); + } + + void serialize_int16() { + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Int16, "-1000", pat, mask)); + QCOMPARE(pat.size(), 2); + int16_t v; + std::memcpy(&v, pat.constData(), 2); + QCOMPARE(v, (int16_t)-1000); + } + + void serialize_int32() { + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Int32, "12345", pat, mask)); + QCOMPARE(pat.size(), 4); + int32_t v; + std::memcpy(&v, pat.constData(), 4); + QCOMPARE(v, 12345); + } + + void serialize_int32_negative() { + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Int32, "-1", pat, mask)); + int32_t v; + std::memcpy(&v, pat.constData(), 4); + QCOMPARE(v, -1); + } + + void serialize_int64() { + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Int64, "9999999999", pat, mask)); + QCOMPARE(pat.size(), 8); + int64_t v; + std::memcpy(&v, pat.constData(), 8); + QCOMPARE(v, (int64_t)9999999999LL); + } + + void serialize_uint8() { + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::UInt8, "255", pat, mask)); + QCOMPARE(pat.size(), 1); + QCOMPARE((uint8_t)pat[0], (uint8_t)255); + } + + void serialize_uint8_hex() { + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::UInt8, "0xFF", pat, mask)); + QCOMPARE((uint8_t)pat[0], (uint8_t)0xFF); + } + + void serialize_uint16() { + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::UInt16, "1234", pat, mask)); + QCOMPARE(pat.size(), 2); + uint16_t v; + std::memcpy(&v, pat.constData(), 2); + QCOMPARE(v, (uint16_t)1234); + } + + void serialize_uint32() { + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::UInt32, "0xDEADBEEF", pat, mask)); + QCOMPARE(pat.size(), 4); + uint32_t v; + std::memcpy(&v, pat.constData(), 4); + QCOMPARE(v, (uint32_t)0xDEADBEEF); + } + + void serialize_uint64() { + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::UInt64, "0xCAFEBABEDEADBEEF", pat, mask)); + QCOMPARE(pat.size(), 8); + uint64_t v; + std::memcpy(&v, pat.constData(), 8); + QCOMPARE(v, (uint64_t)0xCAFEBABEDEADBEEFULL); + } + + void serialize_float() { + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Float, "3.14", pat, mask)); + QCOMPARE(pat.size(), 4); + float v; + std::memcpy(&v, pat.constData(), 4); + QCOMPARE(v, 3.14f); + } + + void serialize_double() { + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Double, "2.71828", pat, mask)); + QCOMPARE(pat.size(), 8); + double v; + std::memcpy(&v, pat.constData(), 8); + QCOMPARE(v, 2.71828); + } + + void serialize_vec2() { + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Vec2, "1.0 2.0", pat, mask)); + QCOMPARE(pat.size(), 8); + float v[2]; + std::memcpy(v, pat.constData(), 8); + QCOMPARE(v[0], 1.0f); + QCOMPARE(v[1], 2.0f); + } + + void serialize_vec3() { + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Vec3, "1.0 0.0 0.0", pat, mask)); + QCOMPARE(pat.size(), 12); + float v[3]; + std::memcpy(v, pat.constData(), 12); + QCOMPARE(v[0], 1.0f); + QCOMPARE(v[1], 0.0f); + QCOMPARE(v[2], 0.0f); + } + + void serialize_vec3_wrongCount() { + QByteArray pat, mask; + QString err; + QVERIFY(!serializeValue(ValueType::Vec3, "1.0 2.0", pat, mask, &err)); + QVERIFY(err.contains("3")); + } + + void serialize_vec4() { + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Vec4, "1.0 2.0 3.0 4.0", pat, mask)); + QCOMPARE(pat.size(), 16); + float v[4]; + std::memcpy(v, pat.constData(), 16); + QCOMPARE(v[0], 1.0f); + QCOMPARE(v[1], 2.0f); + QCOMPARE(v[2], 3.0f); + QCOMPARE(v[3], 4.0f); + } + + void serialize_utf8() { + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::UTF8, "Hello", pat, mask)); + QCOMPARE(pat, QByteArray("Hello")); + } + + void serialize_utf16() { + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::UTF16, "Hi", pat, mask)); + QCOMPARE(pat.size(), 4); // 2 chars * 2 bytes + uint16_t v[2]; + std::memcpy(v, pat.constData(), 4); + QCOMPARE(v[0], (uint16_t)'H'); + QCOMPARE(v[1], (uint16_t)'i'); + } + + void serialize_hexBytes() { + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::HexBytes, "DE AD BE EF", pat, mask)); + QCOMPARE(pat.size(), 4); + QCOMPARE((uint8_t)pat[0], (uint8_t)0xDE); + QCOMPARE((uint8_t)pat[1], (uint8_t)0xAD); + QCOMPARE((uint8_t)pat[2], (uint8_t)0xBE); + QCOMPARE((uint8_t)pat[3], (uint8_t)0xEF); + } + + void serialize_emptyValue() { + QByteArray pat, mask; + QString err; + QVERIFY(!serializeValue(ValueType::Int32, "", pat, mask, &err)); + QVERIFY(err.contains("Empty")); + } + + void serialize_invalidInt() { + QByteArray pat, mask; + QString err; + QVERIFY(!serializeValue(ValueType::Int32, "notanumber", pat, mask, &err)); + } + + void serialize_invalidFloat() { + QByteArray pat, mask; + QString err; + QVERIFY(!serializeValue(ValueType::Float, "abc", pat, mask, &err)); + } + + // ═══════════════════════════════════════════════════════════════════ + // Natural Alignment + // ═══════════════════════════════════════════════════════════════════ + + void alignment_int8() { QCOMPARE(naturalAlignment(ValueType::Int8), 1); } + void alignment_int16() { QCOMPARE(naturalAlignment(ValueType::Int16), 2); } + void alignment_int32() { QCOMPARE(naturalAlignment(ValueType::Int32), 4); } + void alignment_int64() { QCOMPARE(naturalAlignment(ValueType::Int64), 8); } + void alignment_float() { QCOMPARE(naturalAlignment(ValueType::Float), 4); } + void alignment_double(){ QCOMPARE(naturalAlignment(ValueType::Double),8); } + void alignment_vec3() { QCOMPARE(naturalAlignment(ValueType::Vec3), 4); } + void alignment_utf8() { QCOMPARE(naturalAlignment(ValueType::UTF8), 1); } + void alignment_utf16() { QCOMPARE(naturalAlignment(ValueType::UTF16), 2); } + + // ═══════════════════════════════════════════════════════════════════ + // Scan Engine — Basic functionality + // ═══════════════════════════════════════════════════════════════════ + + void scan_exactMatch() { + // Buffer: 00 11 22 33 44 55 66 77 + QByteArray data(8, '\0'); + data[2] = 0x22; data[3] = 0x33; + auto prov = std::make_shared(data); + + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray("\x22\x33", 2); + req.mask = QByteArray("\xFF\xFF", 2); + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)2); + } + + void scan_wildcardMatch() { + // Buffer with known pattern + QByteArray data(16, '\0'); + data[0] = 0x48; data[1] = 0x8B; data[2] = 0xAA; data[3] = 0x05; + data[8] = 0x48; data[9] = 0x8B; data[10] = 0xBB; data[11] = 0x05; + + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + // Pattern: 48 8B ?? 05 + req.pattern = QByteArray("\x48\x8B\x00\x05", 4); + req.mask = QByteArray("\xFF\xFF\x00\xFF", 4); + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 2); + QCOMPARE(results[0].address, (uint64_t)0); + QCOMPARE(results[1].address, (uint64_t)8); + } + + void scan_noMatch() { + QByteArray data(32, '\0'); + auto prov = std::make_shared(data); + + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray("\xFF\xFF", 2); + req.mask = QByteArray("\xFF\xFF", 2); + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 0); + } + + void scan_alignment4() { + // Put pattern at offset 2 (not 4-aligned) and offset 4 (4-aligned) + QByteArray data(16, '\0'); + data[2] = 0xAA; data[3] = 0xBB; + data[4] = 0xAA; data[5] = 0xBB; + + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray("\xAA\xBB", 2); + req.mask = QByteArray("\xFF\xFF", 2); + req.alignment = 4; + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)4); // only 4-aligned match + } + + void scan_maxResults() { + // Fill buffer with pattern every byte + QByteArray data(1000, '\xAA'); + auto prov = std::make_shared(data); + + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray("\xAA", 1); + req.mask = QByteArray("\xFF", 1); + req.maxResults = 10; + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 10); // capped at maxResults + } + + void scan_emptyProvider() { + auto prov = std::make_shared(); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray("\xAA", 1); + req.mask = QByteArray("\xFF", 1); + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 0); + } + + void scan_emptyPattern() { + auto prov = std::make_shared(QByteArray(16, '\0')); + ScanEngine engine; + QSignalSpy errSpy(&engine, &ScanEngine::error); + + ScanRequest req; + // Empty pattern — error emitted synchronously + engine.start(prov, req); + QCOMPARE(errSpy.size(), 1); + } + + void scan_chunkBoundaryOverlap() { + // Create a buffer where the pattern straddles a chunk boundary + // Use a small-ish buffer and simulate by creating a pattern + // that sits at a position that would be at the overlap zone + const int kChunkSize = 256 * 1024; + QByteArray data(kChunkSize + 16, '\0'); + + // Place pattern right at chunk boundary + int pos = kChunkSize - 2; // pattern starts 2 bytes before boundary + data[pos] = 0xDE; + data[pos + 1] = 0xAD; + data[pos + 2] = 0xBE; + data[pos + 3] = 0xEF; + + auto prov = std::make_shared(data); + 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); + + engine.start(prov, req); + QVERIFY(finSpy.wait(10000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)pos); + } + + void scan_multipleMatches() { + QByteArray data(64, '\0'); + // Place pattern at offsets 0, 16, 32, 48 + for (int i = 0; i < 4; i++) { + data[i * 16] = 0xCA; + data[i * 16 + 1] = 0xFE; + } + + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray("\xCA\xFE", 2); + req.mask = QByteArray("\xFF\xFF", 2); + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 4); + for (int i = 0; i < 4; i++) + QCOMPARE(results[i].address, (uint64_t)(i * 16)); + } + + void scan_singleBytePattern() { + QByteArray data(8, '\0'); + data[5] = 0x42; + + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray("\x42", 1); + req.mask = QByteArray("\xFF", 1); + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)5); + } + + void scan_patternLargerThanData() { + QByteArray data(4, '\xAA'); + auto prov = std::make_shared(data); + + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray(8, '\xAA'); + req.mask = QByteArray(8, '\xFF'); + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 0); + } + + void scan_patternExactSize() { + QByteArray data("\xDE\xAD\xBE\xEF", 4); + auto prov = std::make_shared(data); + + 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); + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)0); + } + + void scan_atEndOfBuffer() { + QByteArray data(32, '\0'); + data[30] = 0xAB; + data[31] = 0xCD; + + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray("\xAB\xCD", 2); + req.mask = QByteArray("\xFF\xFF", 2); + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)30); + } + + // ═══════════════════════════════════════════════════════════════════ + // Scan Engine — Region filtering + // ═══════════════════════════════════════════════════════════════════ + + void scan_filterExecutable() { + QByteArray data(32, '\0'); + data[0] = 0xAA; // in region 0 (not executable) + data[16] = 0xAA; // in region 1 (executable) + + QVector regions; + regions.append({0, 16, true, true, false, "heap"}); + regions.append({16, 16, true, false, true, "code"}); + + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray("\xAA", 1); + req.mask = QByteArray("\xFF", 1); + req.filterExecutable = true; + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)16); + QCOMPARE(results[0].regionModule, QStringLiteral("code")); + } + + void scan_filterWritable() { + QByteArray data(32, '\0'); + data[0] = 0xBB; // region 0 (writable) + data[16] = 0xBB; // region 1 (not writable) + + QVector regions; + regions.append({0, 16, true, true, false, "data"}); + regions.append({16, 16, true, false, true, "code"}); + + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray("\xBB", 1); + req.mask = QByteArray("\xFF", 1); + req.filterWritable = true; + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)0); + } + + void scan_bothFilters() { + QByteArray data(48, '\0'); + data[0] = 0xCC; // region 0: +w -x + data[16] = 0xCC; // region 1: -w +x + data[32] = 0xCC; // region 2: +w +x + + QVector regions; + regions.append({0, 16, true, true, false, "data"}); + regions.append({16, 16, true, false, true, "code"}); + regions.append({32, 16, true, true, true, "rwx"}); + + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray("\xCC", 1); + req.mask = QByteArray("\xFF", 1); + req.filterExecutable = true; + req.filterWritable = true; + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + // Both filters: region must be BOTH executable AND writable + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)32); + } + + void scan_regionModuleName() { + QByteArray data(16, '\0'); + data[0] = 0xDD; + + QVector regions; + regions.append({0, 16, true, true, true, "Game.exe"}); + + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray("\xDD", 1); + req.mask = QByteArray("\xFF", 1); + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].regionModule, QStringLiteral("Game.exe")); + } + + // ═══════════════════════════════════════════════════════════════════ + // Scan Engine — Abort + // ═══════════════════════════════════════════════════════════════════ + + void scan_abort() { + // Large buffer to ensure scan takes measurable time + QByteArray data(1024 * 1024, '\0'); // 1MB + auto prov = std::make_shared(data); + + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray("\xFF", 1); + req.mask = QByteArray("\xFF", 1); + + engine.start(prov, req); + QVERIFY(engine.isRunning()); + + engine.abort(); + QVERIFY(finSpy.wait(5000)); + + // Should complete (possibly with 0 results since buffer is all zeros anyway) + QCOMPARE(finSpy.size(), 1); + } + + // ═══════════════════════════════════════════════════════════════════ + // Scan Engine — Progress signal + // ═══════════════════════════════════════════════════════════════════ + + void scan_progressEmitted() { + QByteArray data(512 * 1024, '\0'); // 512KB + auto prov = std::make_shared(data); + + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + QSignalSpy progSpy(&engine, &ScanEngine::progress); + + ScanRequest req; + req.pattern = QByteArray("\xFF", 1); + req.mask = QByteArray("\xFF", 1); + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + // Should have at least one progress signal + QVERIFY(progSpy.size() > 0); + + // Last progress should be near 100 + int lastPct = progSpy.last().first().toInt(); + QVERIFY(lastPct >= 50); // at least past halfway + } + + // ═══════════════════════════════════════════════════════════════════ + // Scan Engine — isRunning + // ═══════════════════════════════════════════════════════════════════ + + void scan_isRunning() { + ScanEngine engine; + QVERIFY(!engine.isRunning()); + + QByteArray data(256 * 1024, '\0'); + auto prov = std::make_shared(data); + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray("\xFF", 1); + req.mask = QByteArray("\xFF", 1); + + engine.start(prov, req); + // May or may not be running depending on thread scheduling + // Just verify it completes + QVERIFY(finSpy.wait(5000)); + QVERIFY(!engine.isRunning()); + } + + // ═══════════════════════════════════════════════════════════════════ + // Scan Engine — Value scan integration + // ═══════════════════════════════════════════════════════════════════ + + void scan_findInt32Value() { + QByteArray data(64, '\0'); + int32_t target = 12345; + std::memcpy(data.data() + 20, &target, 4); + + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Int32, "12345", pat, mask)); + + ScanRequest req; + req.pattern = pat; + req.mask = mask; + req.alignment = 4; + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)20); + } + + void scan_findFloatValue() { + QByteArray data(64, '\0'); + float target = 3.14f; + std::memcpy(data.data() + 8, &target, 4); + + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Float, "3.14", pat, mask)); + + ScanRequest req; + req.pattern = pat; + req.mask = mask; + req.alignment = 4; + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)8); + } + + void scan_findUtf16String() { + QByteArray data(128, '\0'); + // Write "Hi" in UTF-16LE at offset 32 + uint16_t chars[] = { 'H', 'i' }; + std::memcpy(data.data() + 32, chars, 4); + + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::UTF16, "Hi", pat, mask)); + + ScanRequest req; + req.pattern = pat; + req.mask = mask; + req.alignment = 2; + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)32); + } + + void scan_findVec3() { + QByteArray data(64, '\0'); + float v[] = { 1.0f, 0.0f, 0.0f }; + std::memcpy(data.data() + 12, v, 12); + + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + QByteArray pat, mask; + QVERIFY(serializeValue(ValueType::Vec3, "1.0 0.0 0.0", pat, mask)); + + ScanRequest req; + req.pattern = pat; + req.mask = mask; + req.alignment = 4; + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)12); + } + + // ═══════════════════════════════════════════════════════════════════ + // Provider — enumerateRegions default + // ═══════════════════════════════════════════════════════════════════ + + void provider_defaultRegionsEmpty() { + BufferProvider p(QByteArray(16, '\0')); + auto regions = p.enumerateRegions(); + QVERIFY(regions.isEmpty()); + } + + void provider_nullProviderRegionsEmpty() { + NullProvider p; + auto regions = p.enumerateRegions(); + QVERIFY(regions.isEmpty()); + } + + void provider_customRegions() { + QVector regs; + regs.append({0x1000, 0x2000, true, true, false, "heap"}); + regs.append({0x3000, 0x1000, true, false, true, "code"}); + + RegionProvider p(QByteArray(0x4000, '\0'), regs); + auto result = p.enumerateRegions(); + QCOMPARE(result.size(), 2); + QCOMPARE(result[0].base, (uint64_t)0x1000); + QCOMPARE(result[0].moduleName, QStringLiteral("heap")); + QCOMPARE(result[1].executable, true); + } + + // ═══════════════════════════════════════════════════════════════════ + // Scan Engine — Mask/pattern size mismatch + // ═══════════════════════════════════════════════════════════════════ + + void scan_maskSizeMismatch() { + auto prov = std::make_shared(QByteArray(16, '\0')); + ScanEngine engine; + QSignalSpy errSpy(&engine, &ScanEngine::error); + + ScanRequest req; + req.pattern = QByteArray(4, '\x00'); + req.mask = QByteArray(2, '\xFF'); // mismatch! + + engine.start(prov, req); + QCOMPARE(errSpy.size(), 1); + } + + // ═══════════════════════════════════════════════════════════════════ + // Scan Engine — Multiple regions, pattern found in each + // ═══════════════════════════════════════════════════════════════════ + + void scan_multipleRegions() { + QByteArray data(48, '\0'); + data[4] = 0xEE; // region 0 + data[20] = 0xEE; // region 1 + data[36] = 0xEE; // region 2 + + QVector regions; + regions.append({0, 16, true, true, false, "region0"}); + regions.append({16, 16, true, true, false, "region1"}); + regions.append({32, 16, true, true, false, "region2"}); + + auto prov = std::make_shared(data, regions); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray("\xEE", 1); + req.mask = QByteArray("\xFF", 1); + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 3); + QCOMPARE(results[0].regionModule, QStringLiteral("region0")); + QCOMPARE(results[1].regionModule, QStringLiteral("region1")); + QCOMPARE(results[2].regionModule, QStringLiteral("region2")); + } + + // ═══════════════════════════════════════════════════════════════════ + // Scan Engine — Overlapping matches + // ═══════════════════════════════════════════════════════════════════ + + void scan_overlappingMatches() { + // Pattern AA AA — in buffer AA AA AA should find matches at 0 and 1 + QByteArray data(4, '\0'); + data[0] = 0xAA; data[1] = 0xAA; data[2] = 0xAA; + + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray("\xAA\xAA", 2); + req.mask = QByteArray("\xFF\xFF", 2); + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 2); + QCOMPARE(results[0].address, (uint64_t)0); + QCOMPARE(results[1].address, (uint64_t)1); + } + + // ═══════════════════════════════════════════════════════════════════ + // Edge case: scan 1-byte buffer + // ═══════════════════════════════════════════════════════════════════ + + void scan_oneByteBuffer() { + QByteArray data(1, '\xAB'); + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray("\xAB", 1); + req.mask = QByteArray("\xFF", 1); + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 1); + QCOMPARE(results[0].address, (uint64_t)0); + } + + // ═══════════════════════════════════════════════════════════════════ + // Edge case: all-wildcard pattern matches everywhere + // ═══════════════════════════════════════════════════════════════════ + + void scan_allWildcardPattern() { + QByteArray data(8, '\x42'); + auto prov = std::make_shared(data); + ScanEngine engine; + QSignalSpy finSpy(&engine, &ScanEngine::finished); + + ScanRequest req; + req.pattern = QByteArray(2, '\x00'); + req.mask = QByteArray(2, '\x00'); // all wildcards + + engine.start(prov, req); + QVERIFY(finSpy.wait(5000)); + + auto results = finSpy.first().first().value>(); + QCOMPARE(results.size(), 7); // 8 - 2 + 1 = 7 positions + } +}; + +QTEST_MAIN(TestScanner) +#include "test_scanner.moc" diff --git a/tests/test_scanner_ui.cpp b/tests/test_scanner_ui.cpp new file mode 100644 index 0000000..e7fc8b9 --- /dev/null +++ b/tests/test_scanner_ui.cpp @@ -0,0 +1,960 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "scannerpanel.h" +#include "scanner.h" +#include "providers/buffer_provider.h" +#include "providers/null_provider.h" + +using namespace rcx; + +class TestScannerUI : public QObject { + Q_OBJECT + +private: + ScannerPanel* m_panel = nullptr; + +private slots: + + void init() { + m_panel = new ScannerPanel(); + m_panel->show(); + QApplication::processEvents(); + } + + void cleanup() { + delete m_panel; + m_panel = nullptr; + } + + // ═══════════════════════════════════════════════════════════════════ + // Widget creation and initial state + // ═══════════════════════════════════════════════════════════════════ + + void initialState_modeCombo() { + QCOMPARE(m_panel->modeCombo()->count(), 2); + QCOMPARE(m_panel->modeCombo()->currentIndex(), 0); // Signature + QCOMPARE(m_panel->modeCombo()->itemText(0), QStringLiteral("Signature")); + QCOMPARE(m_panel->modeCombo()->itemText(1), QStringLiteral("Value")); + } + + void initialState_signatureMode() { + // In signature mode: pattern visible, value hidden + QVERIFY(m_panel->patternEdit()->isVisible()); + QVERIFY(!m_panel->typeCombo()->isVisible()); + QVERIFY(!m_panel->valueEdit()->isVisible()); + } + + void initialState_scanButton() { + QCOMPARE(m_panel->scanButton()->text(), QStringLiteral("Scan")); + } + + void initialState_progressBarHidden() { + QVERIFY(!m_panel->progressBar()->isVisible()); + } + + void initialState_resultsEmpty() { + QCOMPARE(m_panel->resultsTable()->rowCount(), 0); + } + + void initialState_buttonsDisabled() { + QVERIFY(!m_panel->gotoButton()->isEnabled()); + QVERIFY(!m_panel->copyButton()->isEnabled()); + } + + void initialState_statusLabel() { + QCOMPARE(m_panel->statusLabel()->text(), QStringLiteral("Ready")); + } + + void initialState_filterCheckboxes() { + QVERIFY(!m_panel->execCheck()->isChecked()); + QVERIFY(!m_panel->writeCheck()->isChecked()); + } + + void initialState_patternPlaceholder() { + QVERIFY(!m_panel->patternEdit()->placeholderText().isEmpty()); + } + + void initialState_typeComboHasAllTypes() { + QCOMPARE(m_panel->typeCombo()->count(), 10); // int8..double = 10 types + } + + void initialState_resultsTableColumns() { + QCOMPARE(m_panel->resultsTable()->columnCount(), 2); + } + + void initialState_noHeaders() { + QVERIFY(!m_panel->resultsTable()->horizontalHeader()->isVisible()); + QVERIFY(!m_panel->resultsTable()->verticalHeader()->isVisible()); + } + + void initialState_noGrid() { + QVERIFY(!m_panel->resultsTable()->showGrid()); + } + + // ═══════════════════════════════════════════════════════════════════ + // Mode switching + // ═══════════════════════════════════════════════════════════════════ + + void switchToValueMode() { + m_panel->modeCombo()->setCurrentIndex(1); // Value + + QVERIFY(!m_panel->patternEdit()->isVisible()); + QVERIFY(m_panel->typeCombo()->isVisible()); + QVERIFY(m_panel->valueEdit()->isVisible()); + } + + void switchBackToSignatureMode() { + m_panel->modeCombo()->setCurrentIndex(1); // Value + m_panel->modeCombo()->setCurrentIndex(0); // Signature + + QVERIFY(m_panel->patternEdit()->isVisible()); + QVERIFY(!m_panel->typeCombo()->isVisible()); + QVERIFY(!m_panel->valueEdit()->isVisible()); + } + + // ═══════════════════════════════════════════════════════════════════ + // No provider — error handling + // ═══════════════════════════════════════════════════════════════════ + + void scan_noProvider() { + // No provider getter set + m_panel->patternEdit()->setText("48 8B"); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + + QVERIFY(m_panel->statusLabel()->text().contains("No source")); + } + + void scan_nullProvider() { + m_panel->setProviderGetter([]() -> std::shared_ptr { + return nullptr; + }); + m_panel->patternEdit()->setText("48 8B"); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + + QVERIFY(m_panel->statusLabel()->text().contains("No source")); + } + + // ═══════════════════════════════════════════════════════════════════ + // Invalid pattern — error handling + // ═══════════════════════════════════════════════════════════════════ + + void scan_emptyPattern() { + auto prov = std::make_shared(QByteArray(16, '\0')); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText(""); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + + QVERIFY(m_panel->statusLabel()->text().contains("error", Qt::CaseInsensitive)); + } + + void scan_invalidPattern() { + auto prov = std::make_shared(QByteArray(16, '\0')); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText("GG HH"); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + + QVERIFY(m_panel->statusLabel()->text().contains("error", Qt::CaseInsensitive)); + } + + // ═══════════════════════════════════════════════════════════════════ + // Successful scan — signature mode + // ═══════════════════════════════════════════════════════════════════ + + void scan_signatureFindsResults() { + QByteArray data(32, '\0'); + data[8] = 0x48; data[9] = 0x8B; + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText("48 8B"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + + QCOMPARE(m_panel->resultsTable()->rowCount(), 1); + QVERIFY(m_panel->statusLabel()->text().contains("1 result")); + } + + void scan_signatureNoResults() { + QByteArray data(32, '\0'); + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText("FF FF"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + + QCOMPARE(m_panel->resultsTable()->rowCount(), 0); + QVERIFY(m_panel->statusLabel()->text().contains("0 result")); + } + + // ═══════════════════════════════════════════════════════════════════ + // Scan button toggle (Scan ↔ Cancel) + // ═══════════════════════════════════════════════════════════════════ + + void scan_buttonShowsCancel() { + // Use a large buffer so scan takes a measurable amount of time + QByteArray data(4 * 1024 * 1024, '\0'); // 4MB + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText("FF"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + + // If scan hasn't finished yet, button should be Cancel + if (m_panel->engine()->isRunning()) { + QCOMPARE(m_panel->scanButton()->text(), QStringLiteral("Cancel")); + QVERIFY(m_panel->progressBar()->isVisible()); + } + + // Wait for finish + QVERIFY(finSpy.wait(30000)); + QApplication::processEvents(); + + // Button back to Scan + QCOMPARE(m_panel->scanButton()->text(), QStringLiteral("Scan")); + QVERIFY(!m_panel->progressBar()->isVisible()); + } + + void scan_cancelMidScan() { + // This test verifies the cancel codepath works. + // The scan may complete faster than we can click cancel, + // so we just verify the final state is correct. + QByteArray data(1024 * 1024, '\0'); // 1MB + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText("FF"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + + // Immediately try to cancel (may or may not succeed depending on timing) + m_panel->engine()->abort(); + + // Wait for finished signal + if (finSpy.isEmpty()) + QVERIFY(finSpy.wait(10000)); + QApplication::processEvents(); + + // After scan completes (or is cancelled), verify button returns to "Scan" + // Need to process any remaining events (the finished handler sets button text) + QApplication::processEvents(); + // If the panel still shows "Cancel", click it to reset + if (m_panel->scanButton()->text() == QStringLiteral("Cancel")) { + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QApplication::processEvents(); + } + QCOMPARE(m_panel->scanButton()->text(), QStringLiteral("Scan")); + } + + // ═══════════════════════════════════════════════════════════════════ + // Value mode scan + // ═══════════════════════════════════════════════════════════════════ + + void scan_valueInt32() { + QByteArray data(64, '\0'); + int32_t target = 42; + std::memcpy(data.data() + 8, &target, 4); + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->modeCombo()->setCurrentIndex(1); // Value mode + // Find int32 in combo + for (int i = 0; i < m_panel->typeCombo()->count(); i++) { + if (m_panel->typeCombo()->itemData(i).toInt() == (int)ValueType::Int32) { + m_panel->typeCombo()->setCurrentIndex(i); + break; + } + } + m_panel->valueEdit()->setText("42"); + + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + + QCOMPARE(m_panel->resultsTable()->rowCount(), 1); + // Preview should show native int32 value, not hex + auto* prevItem = m_panel->resultsTable()->item(0, 1); + QVERIFY(prevItem); + QCOMPARE(prevItem->text(), QStringLiteral("42")); + } + + void scan_valueInvalidInput() { + auto prov = std::make_shared(QByteArray(16, '\0')); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->modeCombo()->setCurrentIndex(1); // Value + m_panel->valueEdit()->setText("not_a_number"); + + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + + QVERIFY(m_panel->statusLabel()->text().contains("error", Qt::CaseInsensitive)); + } + + // ═══════════════════════════════════════════════════════════════════ + // Go to Address signal + // ═══════════════════════════════════════════════════════════════════ + + void goToAddress_signal() { + QByteArray data(32, '\0'); + data[16] = 0xAB; data[17] = 0xCD; + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText("AB CD"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + + QCOMPARE(m_panel->resultsTable()->rowCount(), 1); + + // Select the row + m_panel->resultsTable()->selectRow(0); + QVERIFY(m_panel->gotoButton()->isEnabled()); + + QSignalSpy goSpy(m_panel, &ScannerPanel::goToAddress); + QTest::mouseClick(m_panel->gotoButton(), Qt::LeftButton); + QCOMPARE(goSpy.size(), 1); + QCOMPARE(goSpy.first().first().value(), (uint64_t)16); + } + + void doubleClick_startsEditing() { + // Double-click now starts inline editing, not goToAddress + QByteArray data(32, '\0'); + data[4] = 0xEF; + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText("EF"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + + QCOMPARE(m_panel->resultsTable()->rowCount(), 1); + + // Double-click should NOT emit goToAddress directly + QSignalSpy goSpy(m_panel, &ScannerPanel::goToAddress); + emit m_panel->resultsTable()->cellDoubleClicked(0, 0); + QCOMPARE(goSpy.size(), 0); + + // Edit triggers should be DoubleClicked + QVERIFY(m_panel->resultsTable()->editTriggers() & QAbstractItemView::DoubleClicked); + } + + // ═══════════════════════════════════════════════════════════════════ + // Copy Address + // ═══════════════════════════════════════════════════════════════════ + + void copyAddress() { + QByteArray data(32, '\0'); + data[20] = 0xAA; + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText("AA"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + + m_panel->resultsTable()->selectRow(0); + QVERIFY(m_panel->copyButton()->isEnabled()); + + QTest::mouseClick(m_panel->copyButton(), Qt::LeftButton); + + QString clip = QApplication::clipboard()->text(); + QVERIFY(clip.startsWith("0x", Qt::CaseInsensitive)); + // 20 = 0x14, verify it's present somewhere + QVERIFY(clip.toUpper().endsWith("14")); + } + + // ═══════════════════════════════════════════════════════════════════ + // Results table formatting + // ═══════════════════════════════════════════════════════════════════ + + void results_addressFormat() { + QByteArray data(32, '\0'); + data[0] = (char)0xFF; + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText("FF"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + + // Check address column has WinDbg backtick format + auto* item = m_panel->resultsTable()->item(0, 0); + QVERIFY(item); + QVERIFY(item->text().contains('`')); // backtick separator + // Address 0 should be: 00000000`00000000 + QCOMPARE(item->text(), QStringLiteral("00000000`00000000")); + } + + void results_previewColumn() { + QByteArray data(32, '\0'); + data[0] = 0xDE; data[1] = 0xAD; data[2] = 0xBE; data[3] = 0xEF; + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText("DE AD"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + + auto* item = m_panel->resultsTable()->item(0, 1); + QVERIFY(item); + QVERIFY(item->text().contains("DE")); + QVERIFY(item->text().contains("AD")); + QVERIFY(item->text().contains("BE")); + QVERIFY(item->text().contains("EF")); + } + + // ═══════════════════════════════════════════════════════════════════ + // Filter checkboxes + // ═══════════════════════════════════════════════════════════════════ + + void filters_toggleExecutable() { + m_panel->execCheck()->setChecked(true); + QVERIFY(m_panel->execCheck()->isChecked()); + m_panel->execCheck()->setChecked(false); + QVERIFY(!m_panel->execCheck()->isChecked()); + } + + void filters_toggleWritable() { + m_panel->writeCheck()->setChecked(true); + QVERIFY(m_panel->writeCheck()->isChecked()); + } + + // ═══════════════════════════════════════════════════════════════════ + // Theme application + // ═══════════════════════════════════════════════════════════════════ + + void theme_apply() { + Theme t; + t.background = QColor("#1e1e1e"); + t.backgroundAlt = QColor("#252526"); + t.text = QColor("#d4d4d4"); + t.textDim = QColor("#858585"); + t.textMuted = QColor("#555555"); + t.textFaint = QColor("#333333"); + t.border = QColor("#333333"); + t.borderFocused = QColor("#007acc"); + t.hover = QColor("#2a2d2e"); + t.selection = QColor("#264f78"); + t.button = QColor("#333333"); + t.indHoverSpan = QColor("#007acc"); + + // Should not crash + m_panel->applyTheme(t); + + // Verify stylesheet was applied (background color in stylesheet) + QVERIFY(m_panel->resultsTable()->styleSheet().contains(t.background.name())); + QVERIFY(m_panel->resultsTable()->styleSheet().contains(t.hover.name())); + } + + // ═══════════════════════════════════════════════════════════════════ + // Multiple scans — results get replaced + // ═══════════════════════════════════════════════════════════════════ + + void scan_resultsReplaced() { + QByteArray data(32, '\0'); + data[0] = 0xAA; + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + // First scan + m_panel->patternEdit()->setText("AA"); + QSignalSpy finSpy1(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy1.wait(5000)); + QApplication::processEvents(); + QCOMPARE(m_panel->resultsTable()->rowCount(), 1); + + // Second scan with different pattern (no results) + m_panel->patternEdit()->setText("FF FF FF"); + QSignalSpy finSpy2(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy2.wait(5000)); + QApplication::processEvents(); + QCOMPARE(m_panel->resultsTable()->rowCount(), 0); + } + + // ═══════════════════════════════════════════════════════════════════ + // Selection enables/disables action buttons + // ═══════════════════════════════════════════════════════════════════ + + void buttons_enableOnSelection() { + QByteArray data(32, '\0'); + data[0] = 0xBB; data[16] = 0xBB; + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText("BB"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + + QCOMPARE(m_panel->resultsTable()->rowCount(), 2); + + // No selection yet + QVERIFY(!m_panel->gotoButton()->isEnabled()); + QVERIFY(!m_panel->copyButton()->isEnabled()); + + // Select row + m_panel->resultsTable()->selectRow(0); + QVERIFY(m_panel->gotoButton()->isEnabled()); + QVERIFY(m_panel->copyButton()->isEnabled()); + + // Clear selection + m_panel->resultsTable()->clearSelection(); + QVERIFY(!m_panel->gotoButton()->isEnabled()); + QVERIFY(!m_panel->copyButton()->isEnabled()); + } + + // ═══════════════════════════════════════════════════════════════════ + // Wildcard signature scan + // ═══════════════════════════════════════════════════════════════════ + + void scan_wildcardSignature() { + QByteArray data(32, '\0'); + data[0] = 0x48; data[1] = 0x99; data[2] = 0x05; + data[16] = 0x48; data[17] = 0xAA; data[18] = 0x05; + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText("48 ?? 05"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + + QCOMPARE(m_panel->resultsTable()->rowCount(), 2); + } + + // ═══════════════════════════════════════════════════════════════════ + // Multiple results — status label pluralization + // ═══════════════════════════════════════════════════════════════════ + + void status_singleResult() { + QByteArray data(16, '\0'); + data[0] = 0xCC; + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText("CC"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + + QCOMPARE(m_panel->statusLabel()->text(), QStringLiteral("1 result")); + } + + void status_multipleResults() { + QByteArray data(16, '\0'); + data[0] = 0xDD; data[8] = 0xDD; + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText("DD"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + + QCOMPARE(m_panel->statusLabel()->text(), QStringLiteral("2 results")); + } + + // ═══════════════════════════════════════════════════════════════════ + // Provider getter is lazy (captures at scan time) + // ═══════════════════════════════════════════════════════════════════ + + // ═══════════════════════════════════════════════════════════════════ + // Inline editing + // ═══════════════════════════════════════════════════════════════════ + + void edit_addressExpression() { + QByteArray data(64, '\0'); + data[0] = 0xAA; + data[32] = 0x55; + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText("AA"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + QCOMPARE(m_panel->resultsTable()->rowCount(), 1); + + // Edit address cell with hex expression + QSignalSpy goSpy(m_panel, &ScannerPanel::goToAddress); + m_panel->resultsTable()->item(0, 0)->setText("0x20"); + QApplication::processEvents(); + + // Should emit goToAddress with the new address + QCOMPARE(goSpy.size(), 1); + QCOMPARE(goSpy.first().first().value(), (uint64_t)0x20); + } + + void edit_previewHex() { + QByteArray data(32, '\0'); + data[0] = 0xCC; + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText("CC"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + QCOMPARE(m_panel->resultsTable()->rowCount(), 1); + + // Edit preview cell with new hex bytes + m_panel->resultsTable()->item(0, 1)->setText("DE AD BE EF"); + QApplication::processEvents(); + + // Verify bytes were written + QByteArray written = prov->readBytes(0, 4); + QCOMPARE((uint8_t)written[0], (uint8_t)0xDE); + QCOMPARE((uint8_t)written[1], (uint8_t)0xAD); + QCOMPARE((uint8_t)written[2], (uint8_t)0xBE); + QCOMPARE((uint8_t)written[3], (uint8_t)0xEF); + } + + void edit_previewValueMode() { + QByteArray data(64, '\0'); + int32_t target = 100; + std::memcpy(data.data() + 8, &target, 4); + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->modeCombo()->setCurrentIndex(1); // Value mode + for (int i = 0; i < m_panel->typeCombo()->count(); i++) { + if (m_panel->typeCombo()->itemData(i).toInt() == (int)ValueType::Int32) { + m_panel->typeCombo()->setCurrentIndex(i); + break; + } + } + m_panel->valueEdit()->setText("100"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + QCOMPARE(m_panel->resultsTable()->rowCount(), 1); + + // Preview shows "100", edit to "999" + QCOMPARE(m_panel->resultsTable()->item(0, 1)->text(), QStringLiteral("100")); + m_panel->resultsTable()->item(0, 1)->setText("999"); + QApplication::processEvents(); + + // Verify int32 999 was written at offset 8 + int32_t written; + QByteArray raw = prov->readBytes(8, 4); + std::memcpy(&written, raw.constData(), 4); + QCOMPARE(written, 999); + } + + void edit_invalidAddress() { + QByteArray data(32, '\0'); + data[0] = 0xDD; + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText("DD"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + + // Edit with invalid expression — should show error and restore original + m_panel->resultsTable()->item(0, 0)->setText("invalid!!!"); + QApplication::processEvents(); + + QVERIFY(m_panel->statusLabel()->text().contains("error", Qt::CaseInsensitive)); + // Address should be restored to original (00000000`00000000) + QVERIFY(m_panel->resultsTable()->item(0, 0)->text().contains('`')); + } + + // ═══════════════════════════════════════════════════════════════════ + // Update button (rescan) + // ═══════════════════════════════════════════════════════════════════ + + void update_disabledInitially() { + QVERIFY(!m_panel->updateButton()->isEnabled()); + } + + void update_enabledAfterScan() { + QByteArray data(32, '\0'); + data[0] = 0xAA; + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->patternEdit()->setText("AA"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + + QVERIFY(m_panel->updateButton()->isEnabled()); + } + + void update_showsPreviousColumn() { + QByteArray data(64, '\0'); + int32_t val = 50; + std::memcpy(data.data() + 8, &val, 4); + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->modeCombo()->setCurrentIndex(1); // Value + for (int i = 0; i < m_panel->typeCombo()->count(); i++) { + if (m_panel->typeCombo()->itemData(i).toInt() == (int)ValueType::Int32) { + m_panel->typeCombo()->setCurrentIndex(i); + break; + } + } + m_panel->valueEdit()->setText("50"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + QCOMPARE(m_panel->resultsTable()->rowCount(), 1); + QCOMPARE(m_panel->resultsTable()->columnCount(), 2); // no previous yet + + // Modify via provider — change value from 50 to 99 + int32_t newVal = 99; + QByteArray newBytes(4, '\0'); + std::memcpy(newBytes.data(), &newVal, 4); + prov->writeBytes(8, newBytes); + + // Click update + QTest::mouseClick(m_panel->updateButton(), Qt::LeftButton); + QApplication::processEvents(); + QCOMPARE(m_panel->resultsTable()->columnCount(), 3); + // Current value = 99, previous = 50 + QCOMPARE(m_panel->resultsTable()->item(0, 1)->text(), QStringLiteral("99")); + QCOMPARE(m_panel->resultsTable()->item(0, 2)->text(), QStringLiteral("50")); + } + + // ═══════════════════════════════════════════════════════════════════ + // Re-scan: progress completes, values update, table populates + // ═══════════════════════════════════════════════════════════════════ + + void update_progressCompletes() { + // Use a buffer with many results to verify progress reaches 100% + // and doesn't hang. Place int32 value "7" every 4 bytes. + QByteArray data(4096, '\0'); + int32_t val = 7; + for (int i = 0; i < 1024; i++) + std::memcpy(data.data() + i * 4, &val, 4); + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->modeCombo()->setCurrentIndex(1); // Value + for (int i = 0; i < m_panel->typeCombo()->count(); i++) { + if (m_panel->typeCombo()->itemData(i).toInt() == (int)ValueType::Int32) { + m_panel->typeCombo()->setCurrentIndex(i); + break; + } + } + m_panel->valueEdit()->setText("7"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + QVERIFY(m_panel->resultsTable()->rowCount() >= 512); + QCOMPARE(m_panel->resultsTable()->columnCount(), 2); + + // Modify all values: 7 → 21 + int32_t newVal = 21; + for (int i = 0; i < 1024; i++) { + QByteArray nb(4, '\0'); + std::memcpy(nb.data(), &newVal, 4); + prov->writeBytes(i * 4, nb); + } + + // Click Re-scan + QTest::mouseClick(m_panel->updateButton(), Qt::LeftButton); + QApplication::processEvents(); + + // Progress bar should be hidden (completed) + QVERIFY(!m_panel->progressBar()->isVisible()); + // Table should have 3 columns now + QCOMPARE(m_panel->resultsTable()->columnCount(), 3); + // All rows should be populated + QCOMPARE(m_panel->resultsTable()->rowCount(), m_panel->resultsTable()->rowCount()); + // Spot check first and last row + QCOMPARE(m_panel->resultsTable()->item(0, 1)->text(), QStringLiteral("21")); + QCOMPARE(m_panel->resultsTable()->item(0, 2)->text(), QStringLiteral("7")); + int lastRow = m_panel->resultsTable()->rowCount() - 1; + QVERIFY(m_panel->resultsTable()->item(lastRow, 0) != nullptr); + QCOMPARE(m_panel->resultsTable()->item(lastRow, 1)->text(), QStringLiteral("21")); + QCOMPARE(m_panel->resultsTable()->item(lastRow, 2)->text(), QStringLiteral("7")); + // Status should say "Updated" + QVERIFY(m_panel->statusLabel()->text().contains("Updated")); + // Buttons should be re-enabled + QVERIFY(m_panel->updateButton()->isEnabled()); + QVERIFY(m_panel->scanButton()->isEnabled()); + } + + void update_signatureMode() { + // Re-scan in signature mode (reads actual bytes, not cached pattern) + QByteArray data(64, '\0'); + data[0] = 0x48; data[1] = 0x8B; data[2] = 0xAA; + data[32] = 0x48; data[33] = 0x8B; data[34] = 0xBB; + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->modeCombo()->setCurrentIndex(0); // Signature + m_panel->patternEdit()->setText("48 8B ??"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + QCOMPARE(m_panel->resultsTable()->rowCount(), 2); + + // Modify bytes at first match + QByteArray mod(3, '\0'); + mod[0] = 0x48; mod[1] = 0x8B; mod[2] = (char)0xFF; + prov->writeBytes(0, mod); + + // Re-scan + QTest::mouseClick(m_panel->updateButton(), Qt::LeftButton); + QApplication::processEvents(); + + QVERIFY(!m_panel->progressBar()->isVisible()); + QCOMPARE(m_panel->resultsTable()->columnCount(), 3); + // First result current value should contain FF + QVERIFY(m_panel->resultsTable()->item(0, 1)->text().contains("FF")); + // First result previous value should contain AA + QVERIFY(m_panel->resultsTable()->item(0, 2)->text().contains("AA")); + } + + void update_doubleRescan() { + // Two consecutive re-scans: previous column updates each time + QByteArray data(32, '\0'); + int32_t v1 = 10; + std::memcpy(data.data() + 4, &v1, 4); + + auto prov = std::make_shared(data); + m_panel->setProviderGetter([prov]() { return prov; }); + + m_panel->modeCombo()->setCurrentIndex(1); + for (int i = 0; i < m_panel->typeCombo()->count(); i++) { + if (m_panel->typeCombo()->itemData(i).toInt() == (int)ValueType::Int32) { + m_panel->typeCombo()->setCurrentIndex(i); + break; + } + } + m_panel->valueEdit()->setText("10"); + QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy.wait(5000)); + QApplication::processEvents(); + QCOMPARE(m_panel->resultsTable()->rowCount(), 1); + QCOMPARE(m_panel->resultsTable()->item(0, 1)->text(), QStringLiteral("10")); + + // First update: 10 → 20 + int32_t v2 = 20; + QByteArray nb2(4, '\0'); + std::memcpy(nb2.data(), &v2, 4); + prov->writeBytes(4, nb2); + QTest::mouseClick(m_panel->updateButton(), Qt::LeftButton); + QApplication::processEvents(); + QCOMPARE(m_panel->resultsTable()->item(0, 1)->text(), QStringLiteral("20")); + QCOMPARE(m_panel->resultsTable()->item(0, 2)->text(), QStringLiteral("10")); + + // Second update: 20 → 30 + int32_t v3 = 30; + QByteArray nb3(4, '\0'); + std::memcpy(nb3.data(), &v3, 4); + prov->writeBytes(4, nb3); + QTest::mouseClick(m_panel->updateButton(), Qt::LeftButton); + QApplication::processEvents(); + QCOMPARE(m_panel->resultsTable()->item(0, 1)->text(), QStringLiteral("30")); + QCOMPARE(m_panel->resultsTable()->item(0, 2)->text(), QStringLiteral("20")); + } + + // ═══════════════════════════════════════════════════════════════════ + // Provider getter is lazy (captures at scan time) + // ═══════════════════════════════════════════════════════════════════ + + void providerGetter_lazy() { + auto prov1 = std::make_shared(QByteArray(16, '\xAA')); + auto prov2 = std::make_shared(QByteArray(16, '\xBB')); + + auto current = prov1; + m_panel->setProviderGetter([¤t]() { return current; }); + + // Scan with prov1 — should find AA + m_panel->patternEdit()->setText("AA"); + QSignalSpy finSpy1(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy1.wait(5000)); + QApplication::processEvents(); + QVERIFY(m_panel->resultsTable()->rowCount() > 0); + + // Switch provider + current = prov2; + + // Scan for BB — should find in new provider + m_panel->patternEdit()->setText("BB"); + QSignalSpy finSpy2(m_panel->engine(), &ScanEngine::finished); + QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton); + QVERIFY(finSpy2.wait(5000)); + QApplication::processEvents(); + QVERIFY(m_panel->resultsTable()->rowCount() > 0); + } +}; + +QTEST_MAIN(TestScannerUI) +#include "test_scanner_ui.moc"