From 60fda32af0001d73287c2c6fe31db00d38729282 Mon Sep 17 00:00:00 2001 From: sysadmin Date: Sat, 7 Feb 2026 05:46:01 -0700 Subject: [PATCH] Add async auto-refresh with change detection and self-test harness BBB --- CMakeLists.txt | 11 +- src/compose.cpp | 22 ++-- src/controller.cpp | 179 ++++++++++++++++++++++++++++-- src/controller.h | 23 +++- src/core.h | 25 +++-- src/editor.cpp | 38 ++++++- src/editor.h | 1 + src/format.cpp | 49 ++++---- src/main.cpp | 67 ++++++++++- src/providers/process_provider.h | 1 + src/providers/provider.h | 4 + src/providers/snapshot_provider.h | 54 +++++++++ 12 files changed, 406 insertions(+), 68 deletions(-) create mode 100644 src/providers/snapshot_provider.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 715b64e..758aa55 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -7,7 +7,7 @@ set(CMAKE_AUTOMOC ON) set(CMAKE_AUTORCC ON) set(CMAKE_AUTOUIC ON) -find_package(Qt6 REQUIRED COMPONENTS Widgets PrintSupport Svg) +find_package(Qt6 REQUIRED COMPONENTS Widgets PrintSupport Svg Concurrent) list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake") find_package(QScintilla REQUIRED) @@ -27,7 +27,7 @@ add_executable(ReclassX src/processpicker.ui src/resources.qrc src/core.h - src/providers/buffer_provider.h src/providers/null_provider.h src/providers/process_provider.h src/providers/provider.h + src/providers/buffer_provider.h src/providers/null_provider.h src/providers/process_provider.h src/providers/provider.h src/providers/snapshot_provider.h ) target_include_directories(ReclassX PRIVATE src) @@ -36,6 +36,7 @@ target_link_libraries(ReclassX PRIVATE Qt6::Widgets Qt6::PrintSupport Qt6::Svg + Qt6::Concurrent QScintilla::QScintilla dbghelp psapi @@ -125,7 +126,7 @@ if(BUILD_TESTING) src/processpicker.cpp src/processpicker.ui) target_include_directories(test_controller PRIVATE src) target_link_libraries(test_controller PRIVATE - Qt6::Widgets Qt6::PrintSupport Qt6::Test + Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test QScintilla::QScintilla dbghelp psapi) add_test(NAME test_controller COMMAND test_controller) @@ -134,7 +135,7 @@ if(BUILD_TESTING) src/processpicker.cpp src/processpicker.ui) target_include_directories(test_validation PRIVATE src) target_link_libraries(test_validation PRIVATE - Qt6::Widgets Qt6::PrintSupport Qt6::Test + Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test QScintilla::QScintilla dbghelp psapi) add_test(NAME test_validation COMMAND test_validation) @@ -149,7 +150,7 @@ if(BUILD_TESTING) src/processpicker.cpp src/processpicker.ui) target_include_directories(test_context_menu PRIVATE src) target_link_libraries(test_context_menu PRIVATE - Qt6::Widgets Qt6::PrintSupport Qt6::Test + Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test QScintilla::QScintilla dbghelp psapi) add_test(NAME test_context_menu COMMAND test_context_menu) endif() diff --git a/src/compose.cpp b/src/compose.cpp index 43fc9d9..c648faa 100644 --- a/src/compose.cpp +++ b/src/compose.cpp @@ -141,7 +141,7 @@ void composeLeaf(ComposeState& state, const NodeTree& tree, lm.isContinuation = isCont; lm.lineKind = isCont ? LineKind::Continuation : LineKind::Field; lm.nodeKind = node.kind; - lm.offsetText = fmt::fmtOffsetMargin(absAddr, isCont); + lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, isCont); lm.markerMask = computeMarkers(node, prov, absAddr, isCont, depth); lm.foldLevel = computeFoldLevel(depth, false); lm.effectiveTypeW = typeW; @@ -178,7 +178,7 @@ void composeParent(ComposeState& state, const NodeTree& tree, lm.nodeId = node.id; lm.depth = depth; lm.lineKind = LineKind::Field; - lm.offsetText = fmt::fmtOffsetMargin(absAddr, false); + lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false); lm.nodeKind = node.kind; lm.markerMask = (1u << M_CYCLE) | (1u << M_ERR); lm.foldLevel = computeFoldLevel(depth, false); @@ -195,7 +195,7 @@ void composeParent(ComposeState& state, const NodeTree& tree, lm.nodeId = node.id; lm.depth = depth; lm.lineKind = LineKind::ArrayElementSeparator; - lm.offsetText = fmt::fmtOffsetMargin(absAddr, false); + lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false); lm.nodeKind = node.kind; lm.foldLevel = computeFoldLevel(depth, false); lm.markerMask = 0; @@ -214,7 +214,7 @@ void composeParent(ComposeState& state, const NodeTree& tree, lm.nodeId = node.id; lm.depth = depth; lm.lineKind = LineKind::Header; - lm.offsetText = fmt::fmtOffsetMargin(absAddr, false); + lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false); lm.nodeKind = node.kind; lm.foldHead = true; lm.foldCollapsed = node.collapsed; @@ -300,7 +300,7 @@ void composeNode(ComposeState& state, const NodeTree& tree, lm.nodeId = node.id; lm.depth = depth; lm.lineKind = node.collapsed ? LineKind::Field : LineKind::Header; - lm.offsetText = fmt::fmtOffsetMargin(absAddr, false); + lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false); lm.nodeKind = node.kind; lm.foldHead = true; lm.foldCollapsed = node.collapsed; @@ -401,8 +401,8 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov) { // Include struct/array names - they now use columnar layout too int maxNameLen = kMinNameW; for (const Node& node : tree.nodes) { - // Skip padding (it shows ASCII preview, not name column) - if (node.kind == NodeKind::Padding) continue; + // Skip hex/padding (they show ASCII preview, not name column) + if (isHexPreview(node.kind)) continue; maxNameLen = qMax(maxNameLen, (int)node.name.size()); } state.nameW = qBound(kMinNameW, maxNameLen, kMaxNameW); @@ -420,8 +420,8 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov) { const Node& child = tree.nodes[childIdx]; scopeMaxType = qMax(scopeMaxType, (int)nodeTypeName(child).size()); - // Name width (skip padding, but include hex and containers) - if (child.kind != NodeKind::Padding) { + // Name width (skip hex/padding, but include containers) + if (!isHexPreview(child.kind)) { scopeMaxName = qMax(scopeMaxName, (int)child.name.size()); } } @@ -439,8 +439,8 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov) { const Node& child = tree.nodes[childIdx]; rootMaxType = qMax(rootMaxType, (int)nodeTypeName(child).size()); - // Name width (skip padding, include hex and containers) - if (child.kind != NodeKind::Padding) { + // Name width (skip hex/padding, include containers) + if (!isHexPreview(child.kind)) { rootMaxName = qMax(rootMaxName, (int)child.name.size()); } } diff --git a/src/controller.cpp b/src/controller.cpp index 9689d74..b1ad128 100644 --- a/src/controller.cpp +++ b/src/controller.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #ifdef _WIN32 #include #endif @@ -55,7 +56,7 @@ static QString crumbFor(const rcx::NodeTree& t, uint64_t nodeId) { RcxDocument::RcxDocument(QObject* parent) : QObject(parent) - , provider(std::make_unique()) + , provider(std::make_shared()) { connect(&undoStack, &QUndoStack::cleanChanged, this, [this](bool clean) { modified = !clean; @@ -97,7 +98,7 @@ void RcxDocument::loadData(const QString& binaryPath) { if (!file.open(QIODevice::ReadOnly)) return; undoStack.clear(); - provider = std::make_unique( + provider = std::make_shared( file.readAll(), QFileInfo(binaryPath).fileName()); dataPath = binaryPath; tree.baseAddress = 0; @@ -106,7 +107,7 @@ void RcxDocument::loadData(const QString& binaryPath) { void RcxDocument::loadData(const QByteArray& data) { undoStack.clear(); - provider = std::make_unique(data); + provider = std::make_shared(data); tree.baseAddress = 0; emit documentChanged(); } @@ -125,6 +126,14 @@ RcxController::RcxController(RcxDocument* doc, QWidget* parent) : QObject(parent), m_doc(doc) { connect(m_doc, &RcxDocument::documentChanged, this, &RcxController::refresh); + setupAutoRefresh(); +} + +RcxController::~RcxController() { + if (m_refreshWatcher) { + m_refreshWatcher->cancel(); + m_refreshWatcher->waitForFinished(); + } } RcxEditor* RcxController::primaryEditor() const { @@ -172,8 +181,8 @@ void RcxController::connectEditor(RcxEditor* editor) { case EditTarget::Name: { if (text.isEmpty()) break; const Node& node = m_doc->tree.nodes[nodeIdx]; - // ASCII edit on Padding nodes - if (node.kind == NodeKind::Padding) { + // ASCII edit on Hex/Padding nodes + if (isHexPreview(node.kind)) { setNodeValue(nodeIdx, subLine, text, /*isAscii=*/true); } else { renameNode(nodeIdx, text); @@ -427,7 +436,26 @@ void RcxController::connectEditor(RcxEditor* editor) { } void RcxController::refresh() { - m_lastResult = m_doc->compose(); + // Compose against snapshot provider if active, otherwise real provider + if (m_snapshotProv) + m_lastResult = rcx::compose(m_doc->tree, *m_snapshotProv); + else + m_lastResult = m_doc->compose(); + + // Mark lines whose node data changed since last refresh + if (!m_changedOffsets.isEmpty()) { + for (auto& lm : m_lastResult.meta) { + if (lm.nodeIdx < 0 || lm.nodeIdx >= m_doc->tree.nodes.size()) continue; + int64_t offset = m_doc->tree.computeOffset(lm.nodeIdx); + int sz = m_doc->tree.nodes[lm.nodeIdx].byteSize(); + for (int64_t b = offset; b < offset + sz; b++) { + if (m_changedOffsets.contains(b)) { + lm.dataChanged = true; + break; + } + } + } + } // Prune stale selections (nodes removed by undo/redo/delete) QSet valid; @@ -624,6 +652,11 @@ void RcxController::applyCommand(const Command& command, bool isUndo) { tree.nodes[idx].collapsed = isUndo ? c.oldState : c.newState; } else if constexpr (std::is_same_v) { if (isUndo) { + // Revert offset adjustments + for (const auto& adj : c.offAdjs) { + int ai = tree.indexOfId(adj.nodeId); + if (ai >= 0) tree.nodes[ai].offset = adj.oldOffset; + } int idx = tree.indexOfId(c.node.id); if (idx >= 0) { tree.nodes.remove(idx); @@ -631,6 +664,11 @@ void RcxController::applyCommand(const Command& command, bool isUndo) { } } else { tree.addNode(c.node); + // Apply offset adjustments + for (const auto& adj : c.offAdjs) { + int ai = tree.indexOfId(adj.nodeId); + if (ai >= 0) tree.nodes[ai].offset = adj.newOffset; + } } } else if constexpr (std::is_same_v) { if (isUndo) { @@ -661,6 +699,9 @@ void RcxController::applyCommand(const Command& command, bool isUndo) { const QByteArray& bytes = isUndo ? c.oldBytes : c.newBytes; if (!m_doc->provider->writeBytes(c.addr, bytes)) qWarning() << "WriteBytes failed at address" << Qt::hex << c.addr; + // Patch snapshot so compose sees the new value immediately + if (m_snapshotProv) + m_snapshotProv->patchSnapshot(c.addr, bytes.constData(), bytes.size()); } else if constexpr (std::is_same_v) { int idx = tree.indexOfId(c.nodeId); if (idx >= 0) { @@ -735,7 +776,30 @@ void RcxController::duplicateNode(int nodeIdx) { if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return; const Node& src = m_doc->tree.nodes[nodeIdx]; if (src.kind == NodeKind::Struct || src.kind == NodeKind::Array) return; - insertNode(src.parentId, src.offset + src.byteSize(), src.kind, src.name + "_copy"); + + int copySize = src.byteSize(); + int copyOffset = src.offset + copySize; + + // Shift later siblings down to make room for the copy + QVector adjs; + if (src.parentId != 0) { + auto siblings = m_doc->tree.childrenOf(src.parentId); + for (int si : siblings) { + if (si == nodeIdx) continue; + auto& sib = m_doc->tree.nodes[si]; + if (sib.offset >= copyOffset) + adjs.append({sib.id, sib.offset, sib.offset + copySize}); + } + } + + Node n; + n.kind = src.kind; + n.name = src.name + "_copy"; + n.parentId = src.parentId; + n.offset = copyOffset; + n.id = m_doc->tree.reserveId(); + + m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{n, adjs})); } void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, @@ -1101,10 +1165,11 @@ void RcxController::attachToProcess(uint32_t pid, const QString& processName) { } m_doc->undoStack.clear(); - m_doc->provider = std::make_unique( + m_doc->provider = std::make_shared( hProc, base, regionSize, processName); m_doc->dataPath.clear(); m_doc->tree.baseAddress = base; + resetSnapshot(); emit m_doc->documentChanged(); refresh(); #else @@ -1148,6 +1213,104 @@ void RcxController::pushSavedSourcesToEditors() { editor->setSavedSources(display); } +// ── Auto-refresh ── + +void RcxController::setupAutoRefresh() { + m_refreshTimer = new QTimer(this); + m_refreshTimer->setInterval(2000); + connect(m_refreshTimer, &QTimer::timeout, this, &RcxController::onRefreshTick); + m_refreshTimer->start(); + + m_refreshWatcher = new QFutureWatcher(this); + connect(m_refreshWatcher, &QFutureWatcher::finished, + this, &RcxController::onReadComplete); +} + +void RcxController::onRefreshTick() { + if (m_readInFlight) return; + if (!m_doc->provider || !m_doc->provider->isLive()) return; + if (m_suppressRefresh) return; + for (auto* editor : m_editors) + if (editor->isEditing()) return; + + int extent = computeDataExtent(); + if (extent <= 0) return; + + m_readInFlight = true; + m_readGen = m_refreshGen; + + // Capture shared_ptr copy — keeps provider alive during async read + auto prov = m_doc->provider; + m_refreshWatcher->setFuture(QtConcurrent::run([prov, extent]() -> QByteArray { + return prov->readBytes(0, extent); + })); +} + +void RcxController::onReadComplete() { + m_readInFlight = false; + + // Stale read (provider changed while we were reading) — discard + if (m_readGen != m_refreshGen) return; + + QByteArray newData = m_refreshWatcher->result(); + + // Fast path: no changes at all — skip full recompose + if (!m_prevSnapshot.isEmpty() && m_prevSnapshot.size() == newData.size() + && memcmp(m_prevSnapshot.constData(), newData.constData(), newData.size()) == 0) + return; + + // Compute which byte offsets changed + m_changedOffsets.clear(); + if (!m_prevSnapshot.isEmpty()) { + int compareLen = qMin(m_prevSnapshot.size(), newData.size()); + const char* oldP = m_prevSnapshot.constData(); + const char* newP = newData.constData(); + for (int i = 0; i < compareLen; i++) { + if (oldP[i] != newP[i]) + m_changedOffsets.insert(i); + } + // Bytes beyond old snapshot are all "new" + for (int i = compareLen; i < newData.size(); i++) + m_changedOffsets.insert(i); + } + m_prevSnapshot = newData; + + // Update or create snapshot provider + if (m_snapshotProv) + m_snapshotProv->updateSnapshot(std::move(newData)); + else + m_snapshotProv = std::make_unique(m_doc->provider, std::move(newData)); + + refresh(); + + // Clear changed offsets after refresh consumed them + m_changedOffsets.clear(); +} + +int RcxController::computeDataExtent() const { + // Use provider size as the extent (for ProcessProvider this is the module/region size) + int provSize = m_doc->provider->size(); + if (provSize > 0) return provSize; + + // Fallback: walk tree to find maximum byte offset + int maxEnd = 0; + for (int i = 0; i < m_doc->tree.nodes.size(); i++) { + int64_t off = m_doc->tree.computeOffset(i); + int sz = m_doc->tree.nodes[i].byteSize(); + int end = (int)(off + sz); + if (end > maxEnd) maxEnd = end; + } + return maxEnd; +} + +void RcxController::resetSnapshot() { + m_refreshGen++; + m_readInFlight = false; + m_snapshotProv.reset(); + m_prevSnapshot.clear(); + m_changedOffsets.clear(); +} + void RcxController::handleMarginClick(RcxEditor* editor, int margin, int line, Qt::KeyboardModifiers) { const LineMeta* lm = editor->metaForLine(line); diff --git a/src/controller.h b/src/controller.h index 6c2324b..f35d81b 100644 --- a/src/controller.h +++ b/src/controller.h @@ -1,9 +1,12 @@ #pragma once #include "core.h" #include "editor.h" +#include "providers/snapshot_provider.h" #include #include #include +#include +#include #include class QSplitter; @@ -20,7 +23,7 @@ public: explicit RcxDocument(QObject* parent = nullptr); NodeTree tree; - std::unique_ptr provider; + std::shared_ptr provider; QUndoStack undoStack; QString filePath; QString dataPath; @@ -65,6 +68,7 @@ class RcxController : public QObject { Q_OBJECT public: explicit RcxController(RcxDocument* doc, QWidget* parent = nullptr); + ~RcxController() override; RcxEditor* primaryEditor() const; RcxEditor* addSplitEditor(QSplitter* splitter); @@ -111,12 +115,29 @@ private: QVector m_savedSources; int m_activeSourceIdx = -1; + // ── Auto-refresh state ── + QTimer* m_refreshTimer = nullptr; + QFutureWatcher* m_refreshWatcher = nullptr; + std::unique_ptr m_snapshotProv; + QByteArray m_prevSnapshot; + QSet m_changedOffsets; + uint64_t m_refreshGen = 0; + uint64_t m_readGen = 0; + bool m_readInFlight = false; + void connectEditor(RcxEditor* editor); void handleMarginClick(RcxEditor* editor, int margin, int line, Qt::KeyboardModifiers mods); void updateCommandRow(); void attachToProcess(uint32_t pid, const QString& processName); void switchToSavedSource(int idx); void pushSavedSourcesToEditors(); + + // ── Auto-refresh methods ── + void setupAutoRefresh(); + void onRefreshTick(); + void onReadComplete(); + int computeDataExtent() const; + void resetSnapshot(); }; } // namespace rcx diff --git a/src/core.h b/src/core.h index 0988036..47af15a 100644 --- a/src/core.h +++ b/src/core.h @@ -399,6 +399,7 @@ struct LineMeta { int arrayElementIdx = -1; // Index of this element within parent array (-1 if not array element) QString offsetText; uint32_t markerMask = 0; + bool dataChanged = false; // true if any byte in this node changed since last refresh int effectiveTypeW = 14; // Per-line type column width used for rendering int effectiveNameW = 22; // Per-line name column width used for rendering QString pointerTargetName; // Resolved target type name for Pointer32/64 (empty = "void") @@ -431,7 +432,7 @@ namespace cmd { QVector offAdjs; }; struct Rename { uint64_t nodeId; QString oldName, newName; }; struct Collapse { uint64_t nodeId; bool oldState, newState; }; - struct Insert { Node node; }; + struct Insert { Node node; QVector offAdjs; }; struct Remove { uint64_t nodeId; QVector subtree; QVector offAdjs; }; struct ChangeBase { uint64_t oldBase, newBase; }; @@ -486,8 +487,8 @@ inline ColumnSpan nameSpanFor(const LineMeta& lm, int typeW = kColType, int name int ind = kFoldCol + lm.depth * 3; int start = ind + typeW + kSepWidth; - // Padding: ASCII preview takes the name column position (8 chars) - if (lm.nodeKind == NodeKind::Padding) + // Hex/Padding: ASCII preview takes the name column position (8 chars) + if (isHexPreview(lm.nodeKind)) return {start, start + 8, true}; return {start, start + nameW, true}; @@ -498,12 +499,12 @@ inline ColumnSpan valueSpanFor(const LineMeta& lm, int /*lineLength*/, int typeW lm.lineKind == LineKind::ArrayElementSeparator) return {}; int ind = kFoldCol + lm.depth * 3; - // Padding layout: [Type][sep][ASCII(8)][sep][hex bytes(23)] - bool isPad = (lm.nodeKind == NodeKind::Padding); - int valWidth = isPad ? 23 : kColValue; + // Hex/Padding layout: [Type][sep][ASCII(8)][sep][hex bytes(23)] + bool isHexPad = isHexPreview(lm.nodeKind); + int valWidth = isHexPad ? 23 : kColValue; if (lm.isContinuation) { - int prefixW = isPad + int prefixW = isHexPad ? (typeW + kSepWidth + 8 + kSepWidth) : (typeW + nameW + 2 * kSepWidth); int start = ind + prefixW; @@ -511,7 +512,7 @@ inline ColumnSpan valueSpanFor(const LineMeta& lm, int /*lineLength*/, int typeW } if (lm.lineKind != LineKind::Field) return {}; - int start = isPad + int start = isHexPad ? (ind + typeW + kSepWidth + 8 + kSepWidth) : (ind + typeW + kSepWidth + nameW + kSepWidth); return {start, start + valWidth, true}; @@ -521,17 +522,17 @@ inline ColumnSpan commentSpanFor(const LineMeta& lm, int lineLength, int typeW = if (lm.lineKind == LineKind::Header || lm.lineKind == LineKind::Footer) return {}; int ind = kFoldCol + lm.depth * 3; - bool isPad = (lm.nodeKind == NodeKind::Padding); - int valWidth = isPad ? 23 : kColValue; + bool isHexPad = isHexPreview(lm.nodeKind); + int valWidth = isHexPad ? 23 : kColValue; int start; if (lm.isContinuation) { - int prefixW = isPad + int prefixW = isHexPad ? (typeW + kSepWidth + 8 + kSepWidth) : (typeW + nameW + 2 * kSepWidth); start = ind + prefixW + valWidth; } else { - start = isPad + start = isHexPad ? (ind + typeW + kSepWidth + 8 + kSepWidth + valWidth) : (ind + typeW + kSepWidth + nameW + kSepWidth + valWidth); } diff --git a/src/editor.cpp b/src/editor.cpp index cbebe17..9b7543b 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -27,6 +27,7 @@ static constexpr int IND_HEX_DIM = 9; static constexpr int IND_BASE_ADDR = 10; // Green color for base address static constexpr int IND_HOVER_SPAN = 11; // Blue text on hover (link-like) static constexpr int IND_CMD_PILL = 12; // Rounded chip behind command row spans +static constexpr int IND_DATA_CHANGED = 13; // Amber text for changed data values static QString g_fontName = "Consolas"; @@ -168,6 +169,12 @@ void RcxEditor::setupScintilla() { m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETUNDER, IND_CMD_PILL, (long)1); + // Data-changed indicator — amber text for values that changed since last refresh + m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE, + IND_DATA_CHANGED, 17 /*INDIC_TEXTFORE*/); + m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE, + IND_DATA_CHANGED, QColor("#E5A00D")); + } void RcxEditor::setupLexer() { @@ -321,6 +328,7 @@ void RcxEditor::applyDocument(const ComposeResult& result) { applyMarkers(result.meta); applyFoldLevels(result.meta); applyHexDimming(result.meta); + applyDataChangedHighlight(result.meta); applyCommandRowPills(); // Reset hint line - applySelectionOverlay will repaint indicators @@ -404,7 +412,7 @@ void RcxEditor::fillIndicatorCols(int indic, int line, int colA, int colB) { void RcxEditor::applyHexDimming(const QVector& meta) { m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, IND_HEX_DIM); for (int i = 0; i < meta.size(); i++) { - if (meta[i].nodeKind == NodeKind::Padding) { + if (isHexPreview(meta[i].nodeKind)) { long pos, len; lineRangeNoEol(m_sci, i, pos, len); if (len > 0) m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORFILLRANGE, pos, len); @@ -544,6 +552,20 @@ static QString getLineText(QsciScintilla* sci, int line) { return text; } +void RcxEditor::applyDataChangedHighlight(const QVector& meta) { + for (int i = 0; i < meta.size(); i++) { + if (!meta[i].dataChanged) continue; + if (isSyntheticLine(meta[i])) continue; + + QString lineText = getLineText(m_sci, i); + int typeW = meta[i].effectiveTypeW; + int nameW = meta[i].effectiveNameW; + ColumnSpan vs = valueSpan(meta[i], lineText.size(), typeW, nameW); + if (vs.valid) + fillIndicatorCols(IND_DATA_CHANGED, i, vs.start, vs.end); + } +} + void RcxEditor::applyBaseAddressColoring(const QVector& meta) { if (meta.isEmpty() || meta[0].lineKind != LineKind::CommandRow) return; @@ -609,16 +631,20 @@ static ColumnSpan headerNameSpan(const LineMeta& lm, const QString& lineText) { int ind = kFoldCol + lm.depth * 3; int typeW = lm.effectiveTypeW; - int nameW = lm.effectiveNameW; int nameStart = ind + typeW + kSepWidth; - int nameEnd = nameStart + nameW; - // Clamp to line length if (nameStart >= lineText.size()) return {}; - if (nameEnd > lineText.size()) nameEnd = lineText.size(); + + // Name ends before " {" suffix (expanded) or at line end (collapsed) + int nameEnd = lineText.size(); + if (lineText.endsWith(QStringLiteral(" {"))) + nameEnd = lineText.size() - 2; + + if (nameEnd <= nameStart) return {}; // Don't allow editing array element names like "[0]", "[1]", etc. QString name = lineText.mid(nameStart, nameEnd - nameStart).trimmed(); + if (name.isEmpty()) return {}; if (name.startsWith('[') && name.endsWith(']')) return {}; @@ -1678,7 +1704,7 @@ void RcxEditor::applyHoverCursor() { bool inHexDataArea = false; uint64_t hoverNodeId = 0; if (hoverLine >= 0 && hoverLine < m_meta.size() - && m_meta[hoverLine].nodeKind == NodeKind::Padding) { + && isHexPreview(m_meta[hoverLine].nodeKind)) { hoverNodeId = m_meta[hoverLine].nodeId; if (hoverNodeId != 0 && h.col >= 0) { int ind = kFoldCol + m_meta[hoverLine].depth * 3; diff --git a/src/editor.h b/src/editor.h index 2ae339d..e2c4989 100644 --- a/src/editor.h +++ b/src/editor.h @@ -135,6 +135,7 @@ private: void applyMarkers(const QVector& meta); void applyFoldLevels(const QVector& meta); void applyHexDimming(const QVector& meta); + void applyDataChangedHighlight(const QVector& meta); void applyBaseAddressColoring(const QVector& meta); void applyCommandRowPills(); diff --git a/src/format.cpp b/src/format.cpp index 8d0d71c..af6aa1a 100644 --- a/src/format.cpp +++ b/src/format.cpp @@ -121,9 +121,8 @@ QString fmtStructHeader(const Node& node, int depth, bool collapsed, int colType // Columnar format: { (or no brace when collapsed) QString ind = indent(depth); QString type = fit(structTypeName(node), colType); - QString name = fit(node.name, colName); QString suffix = collapsed ? QString() : QStringLiteral("{"); - return ind + type + SEP + name + SEP + suffix; + return ind + type + SEP + node.name + SEP + suffix; } QString fmtStructFooter(const Node& /*node*/, int depth, int /*totalSize*/) { @@ -135,9 +134,8 @@ QString fmtStructFooter(const Node& /*node*/, int depth, int /*totalSize*/) { QString fmtArrayHeader(const Node& node, int depth, int /*viewIdx*/, bool collapsed, int colType, int colName) { QString ind = indent(depth); QString type = fit(arrayTypeName(node.elementKind, node.arrayLen), colType); - QString name = fit(node.name, colName); QString suffix = collapsed ? QString() : QStringLiteral("{"); - return ind + type + SEP + name + SEP + suffix; + return ind + type + SEP + node.name + SEP + suffix; } // ── Pointer header (merged pointer + struct header) ── @@ -147,13 +145,13 @@ QString fmtPointerHeader(const Node& node, int depth, bool collapsed, const QString& ptrTypeName, int colType, int colName) { QString ind = indent(depth); QString type = fit(ptrTypeName, colType); - QString name = fit(node.name, colName); if (collapsed) { - // Collapsed: show pointer value instead of brace + // Collapsed: show pointer value instead of brace (name padded for value alignment) + QString name = fit(node.name, colName); QString val = fit(readValue(node, prov, addr, 0), COL_VALUE); return ind + type + SEP + name + SEP + val; } - return ind + type + SEP + name + SEP + QStringLiteral("{"); + return ind + type + SEP + node.name + SEP + QStringLiteral("{"); } // ── Hex / ASCII preview ── @@ -211,10 +209,6 @@ enum class ValueMode { Display, Editable }; static QString readValueImpl(const Node& node, const Provider& prov, uint64_t addr, int subLine, ValueMode mode) { - int sz = node.byteSize(); - if (sz > 0 && !prov.isReadable(addr, sz)) - return (mode == ValueMode::Display) ? QStringLiteral("???") : QString(); - const bool display = (mode == ValueMode::Display); switch (node.kind) { case NodeKind::Hex8: return display ? hexVal(prov.readU8(addr)) : rawHex(prov.readU8(addr), 2); @@ -328,18 +322,27 @@ QString fmtNodeLine(const Node& node, const Provider& prov, return ind + QString(prefixW, ' ') + val + cmtSuffix; } - // Padding: ASCII preview + hex bytes (compact, multi-line) - if (node.kind == NodeKind::Padding) { - const int totalSz = qMax(1, node.arrayLen); - const int lineOff = subLine * 8; - const int lineBytes = qMin(8, totalSz - lineOff); - QByteArray b = prov.isReadable(addr + lineOff, lineBytes) - ? prov.readBytes(addr + lineOff, lineBytes) : QByteArray(lineBytes, '\0'); - QString ascii = bytesToAscii(b, lineBytes); - QString hex = bytesToHex(b, lineBytes).leftJustified(23, ' '); // 8*3-1 - if (subLine == 0) - return ind + type + SEP + ascii + SEP + hex + cmtSuffix; - return ind + QString(colType + (int)SEP.size(), ' ') + ascii + SEP + hex + cmtSuffix; + // Hex nodes and Padding: hex byte preview + if (isHexPreview(node.kind)) { + if (node.kind == NodeKind::Padding) { + const int totalSz = qMax(1, node.arrayLen); + const int lineOff = subLine * 8; + const int lineBytes = qMin(8, totalSz - lineOff); + QByteArray b = prov.isReadable(addr + lineOff, lineBytes) + ? prov.readBytes(addr + lineOff, lineBytes) : QByteArray(lineBytes, '\0'); + QString ascii = bytesToAscii(b, lineBytes); + QString hex = bytesToHex(b, lineBytes).leftJustified(23, ' '); // 8*3-1 + if (subLine == 0) + return ind + type + SEP + ascii + SEP + hex + cmtSuffix; + return ind + QString(colType + (int)SEP.size(), ' ') + ascii + SEP + hex + cmtSuffix; + } + // Hex8..Hex64: single line, ASCII padded to 8 chars so hex column aligns + const int sz = sizeForKind(node.kind); + QByteArray b = prov.isReadable(addr, sz) + ? prov.readBytes(addr, sz) : QByteArray(sz, '\0'); + QString ascii = bytesToAscii(b, sz).leftJustified(8, ' '); + QString hex = bytesToHex(b, sz).leftJustified(23, ' '); + return ind + type + SEP + ascii + SEP + hex + cmtSuffix; } QString val = fit(readValue(node, prov, addr, subLine), COL_VALUE); diff --git a/src/main.cpp b/src/main.cpp index db78307..28b7bf0 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,5 +1,6 @@ #include "controller.h" #include "generator.h" +#include "providers/process_provider.h" #include #include #include @@ -101,6 +102,31 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS* ep) { } #endif +// ── Self-test: live data for verifying auto-refresh ── +#include +#include +#include + +struct TestLiveData { + int32_t valA = 100; + int32_t valB = 200; + int32_t valC = 300; + int32_t valD = 400; +}; + +static TestLiveData* g_testData = nullptr; +static std::atomic g_testRunning{false}; + +static void testLiveThread() { + std::mt19937 rng(42); + std::uniform_int_distribution dist(0, 3); + while (g_testRunning.load()) { + std::this_thread::sleep_for(std::chrono::seconds(1)); + int32_t* fields = &g_testData->valA; + fields[dist(rng)]++; + } +} + namespace rcx { class MainWindow : public QMainWindow { @@ -110,6 +136,7 @@ public: private slots: void newFile(); + void selfTest(); void openFile(); void saveFile(); void saveFileAs(); @@ -382,6 +409,42 @@ void MainWindow::newFile() { createTab(doc); } +void MainWindow::selfTest() { +#ifdef _WIN32 + // Allocate test struct — lives until process exit + g_testData = new TestLiveData(); + g_testRunning = true; + std::thread(testLiveThread).detach(); + + auto* doc = new RcxDocument(this); + uint64_t base = (uint64_t)g_testData; + + HANDLE hProc = OpenProcess( + PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION + | PROCESS_QUERY_INFORMATION, + FALSE, GetCurrentProcessId()); + doc->provider = std::make_shared( + hProc, base, (int)sizeof(TestLiveData), "ReclassX.exe"); + doc->tree.baseAddress = base; + + Node root; + root.kind = NodeKind::Struct; + root.name = "TestLiveData"; + root.structTypeName = "TestLiveData"; + root.parentId = 0; + root.offset = 0; + int ri = doc->tree.addNode(root); + uint64_t rootId = doc->tree.nodes[ri].id; + + { Node n; n.kind = NodeKind::Int32; n.name = "valA"; n.parentId = rootId; n.offset = 0; doc->tree.addNode(n); } + { Node n; n.kind = NodeKind::Int32; n.name = "valB"; n.parentId = rootId; n.offset = 4; doc->tree.addNode(n); } + { Node n; n.kind = NodeKind::Int32; n.name = "valC"; n.parentId = rootId; n.offset = 8; doc->tree.addNode(n); } + { Node n; n.kind = NodeKind::Int32; n.name = "valD"; n.parentId = rootId; n.offset = 12; doc->tree.addNode(n); } + + createTab(doc); +#endif +} + void MainWindow::openFile() { QString path = QFileDialog::getOpenFileName(this, "Open Definition", {}, "ReclassX (*.rcx);;JSON (*.json);;All (*)"); @@ -774,8 +837,8 @@ int main(int argc, char* argv[]) { window.setWindowOpacity(0.0); window.show(); - // Always auto-open PEB64 demo on startup - QMetaObject::invokeMethod(&window, "newFile"); + // Auto-open self-test tab (live data refresh test) + QMetaObject::invokeMethod(&window, "selfTest"); if (screenshotMode) { QString out = "screenshot.png"; diff --git a/src/providers/process_provider.h b/src/providers/process_provider.h index b6f4fa9..cec764d 100644 --- a/src/providers/process_provider.h +++ b/src/providers/process_provider.h @@ -55,6 +55,7 @@ public: QString name() const override { return m_name; } QString kind() const override { return QStringLiteral("Process"); } + bool isLive() const override { return true; } // getSymbol takes an absolute virtual address and resolves it to // "module.dll+0xOFFSET" using the cached module list. diff --git a/src/providers/provider.h b/src/providers/provider.h index 787d703..fc700ae 100644 --- a/src/providers/provider.h +++ b/src/providers/provider.h @@ -25,6 +25,10 @@ public: // Examples: "notepad.exe", "dump.bin", "tcp://10.0.0.1:1337" virtual QString name() const { return {}; } + // Whether data can change externally (e.g. live process, network socket). + // Auto-refresh is only active for live providers. + virtual bool isLive() const { return false; } + // Category tag for the command row Source span. // Examples: "File", "Process", "Socket" virtual QString kind() const { return QStringLiteral("File"); } diff --git a/src/providers/snapshot_provider.h b/src/providers/snapshot_provider.h new file mode 100644 index 0000000..408f2e5 --- /dev/null +++ b/src/providers/snapshot_provider.h @@ -0,0 +1,54 @@ +#pragma once +#include "provider.h" +#include + +namespace rcx { + +// Provider that reads from a cached QByteArray snapshot but delegates +// metadata (name, kind, getSymbol) to the underlying real provider. +// Used for async refresh: worker thread reads bulk data into a snapshot, +// UI thread composes against it without blocking. +class SnapshotProvider : public Provider { + std::shared_ptr m_real; + QByteArray m_data; + +public: + SnapshotProvider(std::shared_ptr real, QByteArray snapshot) + : m_real(std::move(real)), m_data(std::move(snapshot)) {} + + bool read(uint64_t addr, void* buf, int len) const override { + if (!isReadable(addr, len)) return false; + std::memcpy(buf, m_data.constData() + addr, len); + return true; + } + + int size() const override { return m_data.size(); } + bool isWritable() const override { return m_real ? m_real->isWritable() : false; } + bool isLive() const override { return m_real ? m_real->isLive() : false; } + QString name() const override { return m_real ? m_real->name() : QString(); } + QString kind() const override { return m_real ? m_real->kind() : QStringLiteral("File"); } + QString getSymbol(uint64_t addr) const override { + return m_real ? m_real->getSymbol(addr) : QString(); + } + + bool write(uint64_t addr, const void* buf, int len) override { + if (!m_real) return false; + bool ok = m_real->write(addr, buf, len); + if (ok && isReadable(addr, len)) + std::memcpy(m_data.data() + addr, buf, len); + return ok; + } + + // Update the entire snapshot (called after async read completes) + void updateSnapshot(QByteArray data) { m_data = std::move(data); } + + // Patch specific bytes in the snapshot (called after user writes a value) + void patchSnapshot(uint64_t addr, const void* buf, int len) { + if (isReadable(addr, len)) + std::memcpy(m_data.data() + addr, buf, len); + } + + const QByteArray& snapshot() const { return m_data; } +}; + +} // namespace rcx