diff --git a/CMakeLists.txt b/CMakeLists.txt index 3443e35..2196416 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -48,10 +48,6 @@ file(WRITE ${_combine_script} " set(_out \"${CMAKE_BINARY_DIR}/h_cpp_combined.txt\") file(WRITE \${_out} \"\") foreach(_f - \"${CMAKE_SOURCE_DIR}/src/providers/provider.h\" - \"${CMAKE_SOURCE_DIR}/src/providers/buffer_provider.h\" - \"${CMAKE_SOURCE_DIR}/src/providers/null_provider.h\" - \"${CMAKE_SOURCE_DIR}/src/providers/process_provider.h\" \"${CMAKE_SOURCE_DIR}/src/core.h\" \"${CMAKE_SOURCE_DIR}/src/editor.h\" \"${CMAKE_SOURCE_DIR}/src/editor.cpp\" @@ -117,4 +113,13 @@ if(BUILD_TESTING) target_link_libraries(test_provider_getSymbol PRIVATE psapi) endif() add_test(NAME test_provider_getSymbol COMMAND test_provider_getSymbol) + + add_executable(test_controller tests/test_controller.cpp + src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp + 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 + QScintilla::QScintilla dbghelp psapi) + add_test(NAME test_controller COMMAND test_controller) endif() diff --git a/LICENSE b/LICENSE index c565508..5c4d426 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,5 @@ MIT License -Copyright (c) 2025 IChooChoose Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/compose.cpp b/src/compose.cpp index 1719926..84bdc75 100644 --- a/src/compose.cpp +++ b/src/compose.cpp @@ -65,6 +65,14 @@ uint32_t computeMarkers(const Node& node, const Provider& /*prov*/, return mask; } +static QString resolvePointerTarget(const NodeTree& tree, uint64_t refId) { + if (refId == 0) return {}; + int refIdx = tree.indexOfId(refId); + if (refIdx < 0) return {}; + const Node& ref = tree.nodes[refIdx]; + return ref.structTypeName.isEmpty() ? ref.name : ref.structTypeName; +} + static inline uint64_t ptrToProviderAddr(const NodeTree& tree, uint64_t ptr) { if (tree.baseAddress && ptr >= tree.baseAddress) return ptr - tree.baseAddress; return ptr; @@ -113,6 +121,14 @@ void composeLeaf(ComposeState& state, const NodeTree& tree, numLines = linesForKind(node.kind); } + // Resolve pointer target name for display + QString ptrTypeOverride; + QString ptrTargetName; + if (node.kind == NodeKind::Pointer32 || node.kind == NodeKind::Pointer64) { + ptrTargetName = resolvePointerTarget(tree, node.refId); + ptrTypeOverride = fmt::pointerTypeName(node.kind, ptrTargetName); + } + for (int sub = 0; sub < numLines; sub++) { bool isCont = (sub > 0); @@ -129,9 +145,10 @@ void composeLeaf(ComposeState& state, const NodeTree& tree, lm.foldLevel = computeFoldLevel(depth, false); lm.effectiveTypeW = typeW; lm.effectiveNameW = nameW; + lm.pointerTargetName = ptrTargetName; QString lineText = fmt::fmtNodeLine(node, prov, absAddr, depth, sub, - /*comment=*/{}, typeW, nameW); + /*comment=*/{}, typeW, nameW, ptrTypeOverride); state.emitLine(lineText, lm); } } @@ -269,15 +286,19 @@ void composeNode(ComposeState& state, const NodeTree& tree, int typeW = state.effectiveTypeW(scopeId); int nameW = state.effectiveNameW(scopeId); - // Pointer deref expansion + // Pointer deref expansion — single fold header merges pointer + struct header if ((node.kind == NodeKind::Pointer32 || node.kind == NodeKind::Pointer64) && node.refId != 0) { + QString ptrTargetName = resolvePointerTarget(tree, node.refId); + QString ptrTypeOverride = fmt::pointerTypeName(node.kind, ptrTargetName); + + // Emit merged fold header: "ptr64 Name {" (expanded) or "ptr64 Name -> val" (collapsed) { LineMeta lm; lm.nodeIdx = nodeIdx; lm.nodeId = node.id; lm.depth = depth; - lm.lineKind = LineKind::Field; + lm.lineKind = node.collapsed ? LineKind::Field : LineKind::Header; lm.offsetText = fmt::fmtOffsetMargin(absAddr, false); lm.nodeKind = node.kind; lm.foldHead = true; @@ -286,8 +307,12 @@ void composeNode(ComposeState& state, const NodeTree& tree, lm.markerMask = computeMarkers(node, prov, absAddr, false, depth); lm.effectiveTypeW = typeW; lm.effectiveNameW = nameW; - state.emitLine(fmt::fmtNodeLine(node, prov, absAddr, depth, 0, {}, typeW, nameW), lm); + lm.pointerTargetName = ptrTargetName; + state.emitLine(fmt::fmtPointerHeader(node, depth, node.collapsed, + prov, absAddr, ptrTypeOverride, + typeW, nameW), lm); } + if (!node.collapsed) { int sz = node.byteSize(); if (prov.isValid() && sz > 0 && prov.isReadable(absAddr, sz)) { @@ -302,13 +327,31 @@ void composeNode(ComposeState& state, const NodeTree& tree, if (refIdx >= 0) { const Node& ref = tree.nodes[refIdx]; if (ref.kind == NodeKind::Struct || ref.kind == NodeKind::Array) + // isArrayChild=true skips header/footer, emits children only + // depth (not depth+1): pointer header replaces struct header, + // so children should be at depth+1, not depth+2 composeParent(state, tree, prov, refIdx, - depth + 1, pBase, ref.id); + depth, pBase, ref.id, + /*isArrayChild=*/true); } state.ptrVisiting.remove(key); } } } + + // Footer for pointer fold + { + LineMeta lm; + lm.nodeIdx = nodeIdx; + lm.nodeId = node.id; + lm.depth = depth; + lm.lineKind = LineKind::Footer; + lm.nodeKind = node.kind; + lm.offsetText.clear(); + lm.foldLevel = computeFoldLevel(depth, false); + lm.markerMask = 0; + state.emitLine(fmt::indent(depth) + QStringLiteral("}"), lm); + } } return; } @@ -334,21 +377,22 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov) { for (int i = 0; i < tree.nodes.size(); i++) state.absOffsets[i] = tree.computeOffset(i); + // Helper: compute the display type string for a node (for width calculation) + auto nodeTypeName = [&](const Node& n) -> QString { + if (n.kind == NodeKind::Array) + return fmt::arrayTypeName(n.elementKind, n.arrayLen); + if (n.kind == NodeKind::Struct) + return fmt::structTypeName(n); + if (n.kind == NodeKind::Pointer32 || n.kind == NodeKind::Pointer64) + return fmt::pointerTypeName(n.kind, resolvePointerTarget(tree, n.refId)); + return fmt::typeNameRaw(n.kind); + }; + // Compute effective type column width from longest type name // Include struct/array headers which use "struct TypeName" or "type[count]" format int maxTypeLen = kMinTypeW; for (const Node& node : tree.nodes) { - QString typeName; - if (node.kind == NodeKind::Array) { - // Array type: "int32_t[10]", "char[64]", etc. - typeName = fmt::arrayTypeName(node.elementKind, node.arrayLen); - } else if (node.kind == NodeKind::Struct) { - // Struct type: "struct TypeName" or "struct" - typeName = fmt::structTypeName(node); - } else { - typeName = fmt::typeNameRaw(node.kind); - } - maxTypeLen = qMax(maxTypeLen, (int)typeName.size()); + maxTypeLen = qMax(maxTypeLen, (int)nodeTypeName(node).size()); } state.typeW = qBound(kMinTypeW, maxTypeLen, kMaxTypeW); @@ -373,16 +417,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov) { for (int childIdx : state.childMap.value(container.id)) { const Node& child = tree.nodes[childIdx]; - - // Type width - include struct/array headers too (they now use columnar layout) - QString childTypeName; - if (child.kind == NodeKind::Array) - childTypeName = fmt::arrayTypeName(child.elementKind, child.arrayLen); - else if (child.kind == NodeKind::Struct) - childTypeName = fmt::structTypeName(child); - else - childTypeName = fmt::typeNameRaw(child.kind); - scopeMaxType = qMax(scopeMaxType, (int)childTypeName.size()); + scopeMaxType = qMax(scopeMaxType, (int)nodeTypeName(child).size()); // Name width (skip hex/padding, but include containers) if (!isHexPreview(child.kind)) { @@ -401,16 +436,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov) { int rootMaxName = kMinNameW; for (int childIdx : state.childMap.value(0)) { const Node& child = tree.nodes[childIdx]; - - // Type width - include struct/array headers - QString childTypeName; - if (child.kind == NodeKind::Array) - childTypeName = fmt::arrayTypeName(child.elementKind, child.arrayLen); - else if (child.kind == NodeKind::Struct) - childTypeName = fmt::structTypeName(child); - else - childTypeName = fmt::typeNameRaw(child.kind); - rootMaxType = qMax(rootMaxType, (int)childTypeName.size()); + rootMaxType = qMax(rootMaxType, (int)nodeTypeName(child).size()); // Name width (skip hex/padding, include containers) if (!isHexPreview(child.kind)) { diff --git a/src/controller.cpp b/src/controller.cpp index 195148f..068c1de 100644 --- a/src/controller.cpp +++ b/src/controller.cpp @@ -20,9 +20,6 @@ namespace rcx { -// Footer selection ID: set high bit to distinguish footer-only selections from node selections -static constexpr uint64_t kFooterIdBit = 0x8000000000000000ULL; - static QString elide(QString s, int max) { if (max <= 0) return {}; if (s.size() <= max) return s; @@ -223,10 +220,14 @@ void RcxController::connectEditor(RcxEditor* editor) { auto& node = m_doc->tree.nodes[nodeIdx]; if (node.kind != NodeKind::Struct) changeNodeKind(nodeIdx, NodeKind::Struct); - // Set the struct type name via rename of structTypeName int idx = m_doc->tree.indexOfId(node.id); - if (idx >= 0) - m_doc->tree.nodes[idx].structTypeName = text; + if (idx >= 0) { + QString oldTypeName = m_doc->tree.nodes[idx].structTypeName; + if (oldTypeName != text) { + m_doc->undoStack.push(new RcxCommand(this, + cmd::ChangeStructTypeName{node.id, oldTypeName, text})); + } + } } } } @@ -305,6 +306,53 @@ void RcxController::connectEditor(RcxEditor* editor) { } break; } + case EditTarget::ArrayElementType: { + if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) break; + const Node& node = m_doc->tree.nodes[nodeIdx]; + if (node.kind != NodeKind::Array) break; + bool ok; + NodeKind elemKind = kindFromTypeName(text, &ok); + if (ok && elemKind != node.elementKind) { + m_doc->undoStack.push(new RcxCommand(this, + cmd::ChangeArrayMeta{node.id, + node.elementKind, elemKind, + node.arrayLen, node.arrayLen})); + } + break; + } + case EditTarget::ArrayElementCount: { + if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) break; + const Node& node = m_doc->tree.nodes[nodeIdx]; + if (node.kind != NodeKind::Array) break; + bool ok; + int newLen = text.toInt(&ok); + if (ok && newLen > 0 && newLen <= 100000 && newLen != node.arrayLen) { + m_doc->undoStack.push(new RcxCommand(this, + cmd::ChangeArrayMeta{node.id, + node.elementKind, node.elementKind, + node.arrayLen, newLen})); + } + break; + } + case EditTarget::PointerTarget: { + if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) break; + Node& node = m_doc->tree.nodes[nodeIdx]; + if (node.kind != NodeKind::Pointer32 && node.kind != NodeKind::Pointer64) break; + // Find the struct with matching name or structTypeName + uint64_t newRefId = 0; + for (const auto& n : m_doc->tree.nodes) { + if (n.kind == NodeKind::Struct && + (n.structTypeName == text || n.name == text)) { + newRefId = n.id; + break; + } + } + if (newRefId != node.refId) { + m_doc->undoStack.push(new RcxCommand(this, + cmd::ChangePointerRef{node.id, node.refId, newRefId})); + } + break; + } case EditTarget::ArrayIndex: case EditTarget::ArrayCount: // Array navigation removed - these cases are unreachable @@ -367,6 +415,10 @@ void RcxController::changeNodeKind(int nodeIdx, NodeKind newKind) { uint64_t parentId = node.parentId; int baseOffset = node.offset + newSize; + bool wasSuppressed = m_suppressRefresh; + m_suppressRefresh = true; + m_doc->undoStack.beginMacro(QStringLiteral("Change type")); + // Push type change with no offset adjustments m_doc->undoStack.push(new RcxCommand(this, cmd::ChangeKind{node.id, node.kind, newKind, {}})); @@ -386,6 +438,10 @@ void RcxController::changeNodeKind(int nodeIdx, NodeKind newKind) { padOffset += padSize; gap -= padSize; } + + m_doc->undoStack.endMacro(); + m_suppressRefresh = wasSuppressed; + if (!m_suppressRefresh) refresh(); } else { // Same size or larger: adjust sibling offsets as before int delta = newSize - oldSize; @@ -551,10 +607,19 @@ void RcxController::applyCommand(const Command& command, bool isUndo) { if (tree.nodes[idx].viewIndex >= tree.nodes[idx].arrayLen) tree.nodes[idx].viewIndex = qMax(0, tree.nodes[idx].arrayLen - 1); } + } else if constexpr (std::is_same_v) { + int idx = tree.indexOfId(c.nodeId); + if (idx >= 0) + tree.nodes[idx].refId = isUndo ? c.oldRefId : c.newRefId; + } else if constexpr (std::is_same_v) { + int idx = tree.indexOfId(c.nodeId); + if (idx >= 0) + tree.nodes[idx].structTypeName = isUndo ? c.oldName : c.newName; } }, command); - refresh(); + if (!m_suppressRefresh) + refresh(); } void RcxController::setNodeValue(int nodeIdx, int subLine, const QString& text, @@ -745,12 +810,15 @@ void RcxController::batchRemoveNodes(const QVector& nodeIndices) { m_selIds.clear(); m_anchorLine = -1; + m_suppressRefresh = true; m_doc->undoStack.beginMacro(QString("Delete %1 nodes").arg(idSet.size())); for (uint64_t id : idSet) { int idx = m_doc->tree.indexOfId(id); if (idx >= 0) removeNode(idx); } m_doc->undoStack.endMacro(); + m_suppressRefresh = false; + refresh(); } void RcxController::batchChangeKind(const QVector& nodeIndices, NodeKind newKind) { @@ -766,12 +834,15 @@ void RcxController::batchChangeKind(const QVector& nodeIndices, NodeKind ne m_selIds.clear(); m_anchorLine = -1; + m_suppressRefresh = true; m_doc->undoStack.beginMacro(QString("Change type of %1 nodes").arg(idSet.size())); for (uint64_t id : idSet) { int idx = m_doc->tree.indexOfId(id); if (idx >= 0) changeNodeKind(idx, newKind); } m_doc->undoStack.endMacro(); + m_suppressRefresh = false; + refresh(); } void RcxController::handleNodeClick(RcxEditor* source, int line, @@ -811,7 +882,7 @@ void RcxController::handleNodeClick(RcxEditor* source, int line, int to = qMax(m_anchorLine, line); for (int i = from; i <= to && i < m_lastResult.meta.size(); i++) { uint64_t nid = m_lastResult.meta[i].nodeId; - if (nid != 0) m_selIds.insert(effectiveId(i, nid)); + if (nid != 0 && nid != kCommandRowId) m_selIds.insert(effectiveId(i, nid)); } } } else { // Ctrl+Shift @@ -823,7 +894,7 @@ void RcxController::handleNodeClick(RcxEditor* source, int line, int to = qMax(m_anchorLine, line); for (int i = from; i <= to && i < m_lastResult.meta.size(); i++) { uint64_t nid = m_lastResult.meta[i].nodeId; - if (nid != 0) m_selIds.insert(effectiveId(i, nid)); + if (nid != 0 && nid != kCommandRowId) m_selIds.insert(effectiveId(i, nid)); } } } @@ -869,7 +940,7 @@ void RcxController::updateCommandRow() { int idx = m_doc->tree.indexOfId(sid & ~kFooterIdBit); if (idx >= 0) { const auto& node = m_doc->tree.nodes[idx]; - uint64_t addr = m_doc->tree.baseAddress + node.offset; + uint64_t addr = m_doc->tree.baseAddress + m_doc->tree.computeOffset(idx); sym = m_doc->provider->getSymbol(addr); } } diff --git a/src/controller.h b/src/controller.h index 3c8ab80..6a024e8 100644 --- a/src/controller.h +++ b/src/controller.h @@ -94,6 +94,7 @@ private: ComposeResult m_lastResult; QSet m_selIds; int m_anchorLine = -1; + bool m_suppressRefresh = false; void connectEditor(RcxEditor* editor); void handleMarginClick(RcxEditor* editor, int margin, int line, Qt::KeyboardModifiers mods); diff --git a/src/core.h b/src/core.h index 7189b26..119717c 100644 --- a/src/core.h +++ b/src/core.h @@ -71,7 +71,7 @@ inline constexpr KindMeta kKindMeta[] = { {NodeKind::Double, "Double", "double", 8, 1, 8, KF_None}, {NodeKind::Bool, "Bool", "bool", 1, 1, 1, KF_None}, {NodeKind::Pointer32, "Pointer32", "ptr32", 4, 1, 4, KF_None}, - {NodeKind::Pointer64, "Pointer64", "void*", 8, 1, 8, KF_None}, + {NodeKind::Pointer64, "Pointer64", "ptr64", 8, 1, 8, KF_None}, {NodeKind::Vec2, "Vec2", "Vec2", 8, 2, 4, KF_Vector}, {NodeKind::Vec3, "Vec3", "Vec3", 12, 3, 4, KF_Vector}, {NodeKind::Vec4, "Vec4", "Vec4", 16, 4, 4, KF_Vector}, @@ -378,6 +378,7 @@ enum class LineKind : uint8_t { static constexpr uint64_t kCommandRowId = UINT64_MAX; static constexpr int kCommandRowLine = 0; static constexpr int kFirstDataLine = 1; +static constexpr uint64_t kFooterIdBit = 0x8000000000000000ULL; struct LineMeta { int nodeIdx = -1; @@ -400,6 +401,7 @@ struct LineMeta { uint32_t markerMask = 0; 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") }; inline bool isSyntheticLine(const LineMeta& lm) { @@ -437,12 +439,15 @@ namespace cmd { struct ChangeArrayMeta { uint64_t nodeId; NodeKind oldElementKind, newElementKind; int oldArrayLen, newArrayLen; }; + struct ChangePointerRef { uint64_t nodeId; + uint64_t oldRefId, newRefId; }; + struct ChangeStructTypeName { uint64_t nodeId; QString oldName, newName; }; } using Command = std::variant< cmd::ChangeKind, cmd::Rename, cmd::Collapse, cmd::Insert, cmd::Remove, cmd::ChangeBase, cmd::WriteBytes, - cmd::ChangeArrayMeta + cmd::ChangeArrayMeta, cmd::ChangePointerRef, cmd::ChangeStructTypeName >; // ── Column spans (for inline editing) ── @@ -453,7 +458,8 @@ struct ColumnSpan { bool valid = false; }; -enum class EditTarget { Name, Type, Value, BaseAddress, Source, ArrayIndex, ArrayCount }; +enum class EditTarget { Name, Type, Value, BaseAddress, Source, ArrayIndex, ArrayCount, + ArrayElementType, ArrayElementCount, PointerTarget }; // Column layout constants (shared with format.cpp span computation) inline constexpr int kFoldCol = 3; // 3-char fold indicator prefix per line @@ -555,6 +561,52 @@ inline ColumnSpan commandRowAddrSpan(const QString& lineText) { return {start, end, true}; } +// ── Array element type/count spans (within type column of array headers) ── +// Line format: " int32_t[10] name {" +// arrayElemTypeSpan covers "int32_t", arrayElemCountSpan covers "10" + +inline ColumnSpan arrayElemTypeSpanFor(const LineMeta& lm, const QString& lineText) { + if (lm.lineKind != LineKind::Header || !lm.isArrayHeader) return {}; + int ind = kFoldCol + lm.depth * 3; + // Find '[' in the type portion + int bracket = lineText.indexOf('[', ind); + if (bracket <= ind) return {}; + return {ind, bracket, true}; +} + +inline ColumnSpan arrayElemCountSpanFor(const LineMeta& lm, const QString& lineText) { + if (lm.lineKind != LineKind::Header || !lm.isArrayHeader) return {}; + int ind = kFoldCol + lm.depth * 3; + int openBracket = lineText.indexOf('[', ind); + int closeBracket = lineText.indexOf(']', openBracket); + if (openBracket < 0 || closeBracket < 0 || closeBracket <= openBracket + 1) return {}; + return {openBracket + 1, closeBracket, true}; +} + +// ── Pointer kind/target spans (within type column of pointer fields) ── +// Line format: " ptr64 name -> 0x..." +// pointerKindSpan covers "ptr64" or "ptr32", pointerTargetSpan covers the target name inside <> + +inline ColumnSpan pointerKindSpanFor(const LineMeta& lm, const QString& lineText) { + if ((lm.lineKind != LineKind::Field && lm.lineKind != LineKind::Header) || lm.isContinuation) return {}; + if (lm.nodeKind != NodeKind::Pointer32 && lm.nodeKind != NodeKind::Pointer64) return {}; + int ind = kFoldCol + lm.depth * 3; + // Find '<' in the type portion + int lt = lineText.indexOf('<', ind); + if (lt <= ind) return {}; + return {ind, lt, true}; +} + +inline ColumnSpan pointerTargetSpanFor(const LineMeta& lm, const QString& lineText) { + if ((lm.lineKind != LineKind::Field && lm.lineKind != LineKind::Header) || lm.isContinuation) return {}; + if (lm.nodeKind != NodeKind::Pointer32 && lm.nodeKind != NodeKind::Pointer64) return {}; + int ind = kFoldCol + lm.depth * 3; + int lt = lineText.indexOf('<', ind); + int gt = lineText.indexOf('>', lt); + if (lt < 0 || gt < 0 || gt <= lt + 1) return {}; + return {lt + 1, gt, true}; +} + // ── Array navigation spans ── // Line format: "uint32_t[16] name { <0/16>" @@ -619,13 +671,18 @@ namespace fmt { QString fmtPointer64(uint64_t v); QString fmtNodeLine(const Node& node, const Provider& prov, uint64_t addr, int depth, int subLine = 0, - const QString& comment = {}, int colType = kColType, int colName = kColName); + const QString& comment = {}, int colType = kColType, int colName = kColName, + const QString& typeOverride = {}); QString fmtOffsetMargin(uint64_t absoluteOffset, bool isContinuation); QString fmtStructHeader(const Node& node, int depth, bool collapsed, int colType = kColType, int colName = kColName); QString fmtStructFooter(const Node& node, int depth, int totalSize = -1); QString fmtArrayHeader(const Node& node, int depth, int viewIdx, bool collapsed, int colType = kColType, int colName = kColName); QString structTypeName(const Node& node); // Full type string for struct headers QString arrayTypeName(NodeKind elemKind, int count); + QString pointerTypeName(NodeKind kind, const QString& targetName); + QString fmtPointerHeader(const Node& node, int depth, bool collapsed, + const Provider& prov, uint64_t addr, + const QString& ptrTypeName, int colType = kColType, int colName = kColName); QString validateBaseAddress(const QString& text); QString indent(int depth); QString readValue(const Node& node, const Provider& prov, diff --git a/src/editor.cpp b/src/editor.cpp index 0e71d1f..57b60c3 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -28,9 +28,6 @@ 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 -// Footer selection ID: set high bit to distinguish footer-only selections from node selections -static constexpr uint64_t kFooterIdBit = 0x8000000000000000ULL; - static QString g_fontName = "Consolas"; static QFont editorFont() { @@ -80,7 +77,9 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) { connect(m_sci, &QsciScintilla::userListActivated, this, [this](int id, const QString& text) { if (!m_editState.active) return; - if (id == 1 && m_editState.target == EditTarget::Type) { + if (id == 1 && (m_editState.target == EditTarget::Type + || m_editState.target == EditTarget::ArrayElementType + || m_editState.target == EditTarget::PointerTarget)) { auto info = endInlineEdit(); emit inlineEditCommitted(info.nodeIdx, info.subLine, info.target, text); } @@ -94,14 +93,21 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) { if (m_updatingComment) return; // Skip queuing during comment update if (m_editState.target == EditTarget::Value) QTimer::singleShot(0, this, &RcxEditor::validateEditLive); - if (m_editState.target == EditTarget::Type) + if (m_editState.target == EditTarget::Type || m_editState.target == EditTarget::ArrayElementType) QTimer::singleShot(0, this, &RcxEditor::updateTypeListFilter); + if (m_editState.target == EditTarget::PointerTarget) + QTimer::singleShot(0, this, &RcxEditor::updatePointerTargetFilter); }); connect(m_sci, &QsciScintilla::selectionChanged, this, &RcxEditor::clampEditSelection); } +RcxEditor::~RcxEditor() { + if (m_cursorOverridden) + QApplication::restoreOverrideCursor(); +} + void RcxEditor::setupScintilla() { m_sci->setFont(editorFont()); @@ -723,6 +729,10 @@ bool RcxEditor::resolvedSpanFor(int line, EditTarget t, if (lm->nodeIdx < 0) return false; + // Padding: reject value editing (hex bytes are display-only) + if (t == EditTarget::Value && lm->nodeKind == NodeKind::Padding) + return false; + QString lineText = getLineText(m_sci, line); int textLen = lineText.size(); @@ -739,13 +749,24 @@ bool RcxEditor::resolvedSpanFor(int line, EditTarget t, case EditTarget::ArrayIndex: case EditTarget::ArrayCount: break; // Array navigation removed + case EditTarget::ArrayElementType: + s = arrayElemTypeSpanFor(*lm, lineText); break; + case EditTarget::ArrayElementCount: + s = arrayElemCountSpanFor(*lm, lineText); break; + case EditTarget::PointerTarget: + s = pointerTargetSpanFor(*lm, lineText); break; + case EditTarget::Source: break; } // Fallback spans for header lines if (!s.valid && t == EditTarget::Type) { + // For pointer fields, the full type span acts as "kind" span + // For array headers, fall back to the full type[count] span s = arrayHeaderTypeSpan(*lm, lineText); if (!s.valid) s = headerTypeNameSpan(*lm, lineText); + if (!s.valid) + s = pointerKindSpanFor(*lm, lineText); } if (!s.valid && t == EditTarget::Name) s = headerNameSpan(*lm, lineText); @@ -829,6 +850,22 @@ static bool hitTestTarget(QsciScintilla* sci, ColumnSpan ns = RcxEditor::nameSpan(lm, typeW, nameW); ColumnSpan vs = RcxEditor::valueSpan(lm, textLen, typeW, nameW); + // Pointer fields/headers: check sub-spans within type column first + if (lm.nodeKind == NodeKind::Pointer32 || lm.nodeKind == NodeKind::Pointer64) { + ColumnSpan ptrTarget = pointerTargetSpanFor(lm, lineText); + ColumnSpan ptrKind = pointerKindSpanFor(lm, lineText); + if (inSpan(ptrTarget)) { outTarget = EditTarget::PointerTarget; outLine = line; return true; } + if (inSpan(ptrKind)) { outTarget = EditTarget::Type; outLine = line; return true; } + } + + // Array headers: check element type and count sub-spans first + if (lm.isArrayHeader) { + ColumnSpan elemType = arrayElemTypeSpanFor(lm, lineText); + ColumnSpan elemCount = arrayElemCountSpanFor(lm, lineText); + if (inSpan(elemCount)) { outTarget = EditTarget::ArrayElementCount; outLine = line; return true; } + if (inSpan(elemType)) { outTarget = EditTarget::ArrayElementType; outLine = line; return true; } + } + // Fallback spans for header lines if (!ts.valid) { ts = arrayHeaderTypeSpan(lm, lineText); @@ -843,6 +880,10 @@ static bool hitTestTarget(QsciScintilla* sci, else if (inSpan(vs)) outTarget = EditTarget::Value; else return false; + // Padding nodes: hex bytes are display-only, not editable + if (outTarget == EditTarget::Value && lm.nodeKind == NodeKind::Padding) + return false; + outLine = line; return true; } @@ -852,7 +893,14 @@ static bool hitTestTarget(QsciScintilla* sci, bool RcxEditor::eventFilter(QObject* obj, QEvent* event) { if (obj == m_sci && event->type() == QEvent::KeyPress) { auto* ke = static_cast(event); - return m_editState.active ? handleEditKey(ke) : handleNormalKey(ke); + bool handled = m_editState.active ? handleEditKey(ke) : handleNormalKey(ke); + if (!handled && !m_editState.active) { + // Clear hover on keyboard navigation (stale after scroll) + m_hoveredNodeId = 0; + m_hoveredLine = -1; + applyHoverHighlight(); + } + return handled; } if (obj == m_sci->viewport() && event->type() == QEvent::MouseButtonPress && m_editState.active) { @@ -882,6 +930,9 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) { case EditTarget::Source: raw = commandRowSrcSpan(lineText); break; case EditTarget::ArrayIndex: raw = arrayIndexSpanFor(*lm, lineText); break; case EditTarget::ArrayCount: raw = arrayCountSpanFor(*lm, lineText); break; + case EditTarget::ArrayElementType: raw = arrayElemTypeSpanFor(*lm, lineText); break; + case EditTarget::ArrayElementCount: raw = arrayElemCountSpanFor(*lm, lineText); break; + case EditTarget::PointerTarget: raw = pointerTargetSpanFor(*lm, lineText); break; } if (raw.valid && h.col >= raw.start && h.col < raw.end) { // Within raw span but outside trimmed text → move cursor to end @@ -1009,6 +1060,10 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) { int line; EditTarget t; if (hitTestTarget(m_sci, m_meta, me->pos(), line, t)) { m_pendingClickNodeId = 0; // cancel deferred selection change + // Narrow selection to this node before editing + auto h = hitTest(me->pos()); + if (h.nodeId != 0 && h.nodeId != kCommandRowId) + emit nodeClicked(h.line, h.nodeId, Qt::NoModifier); return beginInlineEdit(t, line); } } @@ -1079,12 +1134,15 @@ bool RcxEditor::handleNormalKey(QKeyEvent* ke) { case Qt::Key_Enter: return beginInlineEdit(EditTarget::Value); case Qt::Key_Tab: { - EditTarget order[] = {EditTarget::Name, EditTarget::Type, EditTarget::Value}; + EditTarget order[] = {EditTarget::Name, EditTarget::Type, EditTarget::Value, + EditTarget::ArrayElementType, EditTarget::ArrayElementCount, + EditTarget::PointerTarget}; + constexpr int N = 6; int start = 0; - for (int i = 0; i < 3; i++) - if (order[i] == m_lastTabTarget) { start = (i + 1) % 3; break; } - for (int i = 0; i < 3; i++) { - EditTarget t = order[(start + i) % 3]; + for (int i = 0; i < N; i++) + if (order[i] == m_lastTabTarget) { start = (i + 1) % N; break; } + for (int i = 0; i < N; i++) { + EditTarget t = order[(start + i) % N]; if (beginInlineEdit(t)) { m_lastTabTarget = t; return true; } } return true; @@ -1127,7 +1185,14 @@ bool RcxEditor::handleEditKey(QKeyEvent* ke) { case Qt::Key_Backspace: { int line, col; m_sci->getCursorPosition(&line, &col); - if (col <= m_editState.spanStart) return true; + int minCol = m_editState.spanStart; + // Don't allow backing into "0x" prefix + if (m_editState.target == EditTarget::Value || m_editState.target == EditTarget::BaseAddress) { + QString lineText = getLineText(m_sci, m_editState.line); + if (lineText.mid(m_editState.spanStart, 2).startsWith(QStringLiteral("0x"), Qt::CaseInsensitive)) + minCol = m_editState.spanStart + 2; + } + if (col <= minCol) return true; return false; } case Qt::Key_Right: { @@ -1136,9 +1201,17 @@ bool RcxEditor::handleEditKey(QKeyEvent* ke) { if (col >= editEndCol()) return true; // block past end return false; } - case Qt::Key_Home: - m_sci->setCursorPosition(m_editState.line, m_editState.spanStart); + case Qt::Key_Home: { + int home = m_editState.spanStart; + // Skip "0x" prefix for hex values + if (m_editState.target == EditTarget::Value || m_editState.target == EditTarget::BaseAddress) { + QString lineText = getLineText(m_sci, m_editState.line); + if (lineText.mid(m_editState.spanStart, 2).startsWith(QStringLiteral("0x"), Qt::CaseInsensitive)) + home = m_editState.spanStart + 2; + } + m_sci->setCursorPosition(m_editState.line, home); return true; + } case Qt::Key_End: m_sci->setCursorPosition(m_editState.line, editEndCol()); return true; @@ -1169,6 +1242,9 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line) { if (lm->nodeIdx < 0 && !(lm->lineKind == LineKind::CommandRow && (target == EditTarget::BaseAddress || target == EditTarget::Source))) return false; + // Padding: reject value editing (display-only hex bytes) + if (target == EditTarget::Value && lm->nodeKind == NodeKind::Padding) + return false; QString lineText; NormalizedSpan norm; @@ -1202,8 +1278,9 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line) { m_sci->SendScintilla(QsciScintillaBase::SCI_SETUNDOCOLLECTION, (long)0); m_sci->SendScintilla(QsciScintillaBase::SCI_SETCARETWIDTH, 1); m_sci->setReadOnly(false); - // Switch to I-beam for editing (skip for Type/Source which use popup pickers) - if (target != EditTarget::Type && target != EditTarget::Source) { + // Switch to I-beam for editing (skip for picker-based targets) + if (target != EditTarget::Type && target != EditTarget::Source + && target != EditTarget::ArrayElementType && target != EditTarget::PointerTarget) { if (m_cursorOverridden) { QApplication::changeOverrideCursor(Qt::IBeamCursor); } else { @@ -1233,10 +1310,12 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line) { if (target == EditTarget::Value) setEditComment(QStringLiteral("Enter=Save Esc=Cancel")); - if (target == EditTarget::Type) + if (target == EditTarget::Type || target == EditTarget::ArrayElementType) QTimer::singleShot(0, this, &RcxEditor::showTypeAutocomplete); if (target == EditTarget::Source) QTimer::singleShot(0, this, &RcxEditor::showSourcePicker); + if (target == EditTarget::PointerTarget) + QTimer::singleShot(0, this, &RcxEditor::showPointerTargetPicker); return true; } @@ -1321,7 +1400,8 @@ void RcxEditor::cancelInlineEdit() { // ── Type picker (user list) ── void RcxEditor::showTypeAutocomplete() { - if (!m_editState.active || m_editState.target != EditTarget::Type) + if (!m_editState.active || + (m_editState.target != EditTarget::Type && m_editState.target != EditTarget::ArrayElementType)) return; // Replace original type with spaces (keeps layout, clears for typing) int len = m_editState.original.size(); @@ -1338,7 +1418,8 @@ void RcxEditor::showTypeAutocomplete() { } void RcxEditor::showTypeListFiltered(const QString& filter) { - if (!m_editState.active || m_editState.target != EditTarget::Type) + if (!m_editState.active || + (m_editState.target != EditTarget::Type && m_editState.target != EditTarget::ArrayElementType)) return; // Combine native types with custom (struct) type names @@ -1358,8 +1439,8 @@ void RcxEditor::showTypeListFiltered(const QString& filter) { if (filtered.isEmpty()) return; // No matches - keep list hidden // Show user list (id=1 for types) - selection handled by userListActivated signal - QByteArray list = filtered.join(' ').toUtf8(); - m_sci->SendScintilla(QsciScintillaBase::SCI_AUTOCSETSEPARATOR, (long)' '); + QByteArray list = filtered.join('\n').toUtf8(); + m_sci->SendScintilla(QsciScintillaBase::SCI_AUTOCSETSEPARATOR, (long)'\n'); m_sci->SendScintilla(QsciScintillaBase::SCI_USERLISTSHOW, (uintptr_t)1, list.constData()); // Arrow cursor for popup is handled by applyHoverCursor() via isListActive() @@ -1389,15 +1470,15 @@ void RcxEditor::showSourcePicker() { } void RcxEditor::updateTypeListFilter() { - if (!m_editState.active || m_editState.target != EditTarget::Type) + if (!m_editState.active || + (m_editState.target != EditTarget::Type && m_editState.target != EditTarget::ArrayElementType)) return; // Get currently typed text from line QString lineText = getLineText(m_sci, m_editState.line); long curPos = m_sci->SendScintilla(QsciScintillaBase::SCI_GETCURRENTPOS); - long lineStart = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE, - (unsigned long)m_editState.line); - int col = (int)(curPos - lineStart); + int col = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_GETCOLUMN, + (unsigned long)curPos); // Extract text from spanStart to cursor int len = col - m_editState.spanStart; @@ -1410,6 +1491,68 @@ void RcxEditor::updateTypeListFilter() { showTypeListFiltered(typed); } +// ── Pointer target picker ── + +void RcxEditor::showPointerTargetPicker() { + if (!m_editState.active || m_editState.target != EditTarget::PointerTarget) + return; + // Replace original target with spaces (keeps layout, clears for typing) + int len = m_editState.original.size(); + QString spaces(len, ' '); + m_sci->SendScintilla(QsciScintillaBase::SCI_SETSEL, + m_editState.posStart, m_editState.posEnd); + m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACESEL, + (uintptr_t)0, spaces.toUtf8().constData()); + m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS, m_editState.posStart); + showPointerTargetListFiltered(QString()); +} + +void RcxEditor::showPointerTargetListFiltered(const QString& filter) { + if (!m_editState.active || m_editState.target != EditTarget::PointerTarget) + return; + + // Build list: "void" + all struct type names + QStringList all; + all << QStringLiteral("void"); + for (const QString& ct : m_customTypeNames) { + if (!all.contains(ct)) + all << ct; + } + all.sort(Qt::CaseInsensitive); + // Ensure "void" is always first + all.removeAll(QStringLiteral("void")); + all.prepend(QStringLiteral("void")); + + QStringList filtered; + for (const QString& t : all) { + if (filter.isEmpty() || t.startsWith(filter, Qt::CaseInsensitive)) + filtered << t; + } + if (filtered.isEmpty()) return; + + QByteArray list = filtered.join('\n').toUtf8(); + m_sci->SendScintilla(QsciScintillaBase::SCI_AUTOCSETSEPARATOR, (long)'\n'); + m_sci->SendScintilla(QsciScintillaBase::SCI_USERLISTSHOW, + (uintptr_t)1, list.constData()); +} + +void RcxEditor::updatePointerTargetFilter() { + if (!m_editState.active || m_editState.target != EditTarget::PointerTarget) + return; + + QString lineText = getLineText(m_sci, m_editState.line); + long curPos = m_sci->SendScintilla(QsciScintillaBase::SCI_GETCURRENTPOS); + int col = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_GETCOLUMN, + (unsigned long)curPos); + int len = col - m_editState.spanStart; + if (len <= 0) { + showPointerTargetListFiltered(QString()); + return; + } + QString typed = lineText.mid(m_editState.spanStart, len); + showPointerTargetListFiltered(typed); +} + // ── Editable-field text-color indicator ── void RcxEditor::paintEditableSpans(int line) { @@ -1425,7 +1568,9 @@ void RcxEditor::paintEditableSpans(int line) { return; } NormalizedSpan norm; - for (EditTarget t : {EditTarget::Type, EditTarget::Name, EditTarget::Value}) { + for (EditTarget t : {EditTarget::Type, EditTarget::Name, EditTarget::Value, + EditTarget::ArrayElementType, EditTarget::ArrayElementCount, + EditTarget::PointerTarget}) { if (resolvedSpanFor(line, t, norm)) fillIndicatorCols(IND_EDITABLE, line, norm.start, norm.end); } @@ -1479,11 +1624,10 @@ void RcxEditor::updateEditableIndicators(int line) { // ── Hover cursor ── void RcxEditor::applyHoverCursor() { - // Clear previous hover span indicator - if (m_hoverSpanLine >= 0) { - clearIndicatorLine(IND_HOVER_SPAN, m_hoverSpanLine); - m_hoverSpanLine = -1; - } + // Clear previous hover span indicators + for (int ln : m_hoverSpanLines) + clearIndicatorLine(IND_HOVER_SPAN, ln); + m_hoverSpanLines.clear(); // Edit mode handles its own cursor (I-beam) if (m_editState.active) @@ -1511,22 +1655,48 @@ void RcxEditor::applyHoverCursor() { return; } + auto h = hitTest(m_lastHoverPos); int line; EditTarget t; bool tokenHit = hitTestTarget(m_sci, m_meta, m_lastHoverPos, line, t); - // Apply hover span indicator (blue text like a link) for editable spans - if (tokenHit) { + // For hex preview nodes, check if cursor is in the data area (ASCII or hex bytes) + int hoverLine = h.line; + bool inHexDataArea = false; + uint64_t hoverNodeId = 0; + if (hoverLine >= 0 && hoverLine < m_meta.size() + && isHexPreview(m_meta[hoverLine].nodeKind)) { + hoverNodeId = m_meta[hoverLine].nodeId; + if (hoverNodeId != 0 && h.col >= 0) { + int ind = kFoldCol + m_meta[hoverLine].depth * 3; + int typeW = m_meta[hoverLine].effectiveTypeW; + int dataStart = ind + typeW + kSepWidth; + inHexDataArea = (h.col >= dataStart); + } + } + + // Apply hover span indicator + if (inHexDataArea) { + // Hex preview nodes: highlight ASCII + hex byte areas on ALL lines of this node + for (int i = 0; i < m_meta.size(); i++) { + if (m_meta[i].nodeId != hoverNodeId) continue; + int ind = kFoldCol + m_meta[i].depth * 3; + int typeW = m_meta[i].effectiveTypeW; + int asciiStart = ind + typeW + kSepWidth; + int hexEnd = asciiStart + 8 + kSepWidth + 23; + fillIndicatorCols(IND_HOVER_SPAN, i, asciiStart, hexEnd); + m_hoverSpanLines.append(i); + } + } else if (tokenHit) { NormalizedSpan span; if (resolvedSpanFor(line, t, span)) { fillIndicatorCols(IND_HOVER_SPAN, line, span.start, span.end); - m_hoverSpanLine = line; + m_hoverSpanLines.append(line); } } // Also show pointer cursor for fold column on fold-head lines - bool interactive = tokenHit; + bool interactive = tokenHit || inHexDataArea; if (!interactive) { - auto h = hitTest(m_lastHoverPos); if (h.inFoldCol) interactive = true; } diff --git a/src/editor.h b/src/editor.h index 196e4b0..beada08 100644 --- a/src/editor.h +++ b/src/editor.h @@ -13,6 +13,7 @@ class RcxEditor : public QWidget { Q_OBJECT public: explicit RcxEditor(QWidget* parent = nullptr); + ~RcxEditor() override; void applyDocument(const ComposeResult& result); @@ -71,7 +72,7 @@ private: uint64_t m_hoveredNodeId = 0; int m_hoveredLine = -1; QSet m_currentSelIds; - int m_hoverSpanLine = -1; // Line with hover span indicator + QVector m_hoverSpanLines; // Lines with hover span indicators // ── Drag selection ── bool m_dragging = false; bool m_dragStarted = false; // true once drag threshold exceeded @@ -134,6 +135,9 @@ private: void showSourcePicker(); void showTypeListFiltered(const QString& filter); void updateTypeListFilter(); + void showPointerTargetPicker(); + void showPointerTargetListFiltered(const QString& filter); + void updatePointerTargetFilter(); void paintEditableSpans(int line); void updateEditableIndicators(int line); void applyHoverCursor(); diff --git a/src/format.cpp b/src/format.cpp index 94c60ea..8c7ac02 100644 --- a/src/format.cpp +++ b/src/format.cpp @@ -51,6 +51,14 @@ QString arrayTypeName(NodeKind elemKind, int count) { return elem + QStringLiteral("[") + QString::number(count) + QStringLiteral("]"); } +// Pointer type string: "ptr64" or "ptr64" +QString pointerTypeName(NodeKind kind, const QString& targetName) { + auto* m = kindMeta(kind); + QString base = m ? QString::fromLatin1(m->typeName) : QStringLiteral("???"); + QString target = targetName.isEmpty() ? QStringLiteral("void") : targetName; + return base + QStringLiteral("<") + target + QStringLiteral(">"); +} + // ── Value formatting ── static QString hexVal(uint64_t v) { @@ -132,6 +140,22 @@ QString fmtArrayHeader(const Node& node, int depth, int /*viewIdx*/, bool collap return ind + type + SEP + name + SEP + suffix; } +// ── Pointer header (merged pointer + struct header) ── + +QString fmtPointerHeader(const Node& node, int depth, bool collapsed, + const Provider& prov, uint64_t addr, + 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 + QString val = fit(readValue(node, prov, addr, 0), COL_VALUE); + return ind + type + SEP + name + SEP + val; + } + return ind + type + SEP + name + SEP + QStringLiteral("{"); +} + // ── Hex / ASCII preview ── static inline bool isAsciiPrintable(uint8_t c) { return c >= 0x20 && c <= 0x7E; } @@ -277,9 +301,10 @@ QString readValue(const Node& node, const Provider& prov, QString fmtNodeLine(const Node& node, const Provider& prov, uint64_t addr, int depth, int subLine, - const QString& comment, int colType, int colName) { + const QString& comment, int colType, int colName, + const QString& typeOverride) { QString ind = indent(depth); - QString type = typeName(node.kind, colType); + QString type = typeOverride.isEmpty() ? typeName(node.kind, colType) : fit(typeOverride, colType); QString name = fit(node.name, colName); // Blank prefix for continuation lines (same width as type+sep+name+sep) const int prefixW = colType + colName + 2 * kSepWidth; diff --git a/src/main.cpp b/src/main.cpp index 888a294..235d314 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -271,10 +271,11 @@ void MainWindow::newFile() { auto* doc = new RcxDocument(this); // ══════════════════════════════════════════════════════════════════════════ - // _PEB64 Demo — Process Environment Block (0x7D0 bytes) + // _PEB64 Demo — Process Environment Block + stub structs + // Buffer covers PEB (0x7D0) + _PEB_LDR_DATA (0x800) + _RTL_USER_PROCESS_PARAMETERS (0x900) // ══════════════════════════════════════════════════════════════════════════ - QByteArray pebData(0x7D0, '\0'); + QByteArray pebData(0x940, '\0'); char* d = pebData.data(); auto w8 = [&](int off, uint8_t v) { d[off] = (char)v; }; @@ -286,8 +287,8 @@ void MainWindow::newFile() { w8 (0x003, 0x04); // BitField w64(0x008, 0xFFFFFFFFFFFFFFFFULL); // Mutant (-1) w64(0x010, 0x00007FF6DE120000ULL); // ImageBaseAddress - w64(0x018, 0x00007FFE3B8B53C0ULL); // Ldr - w64(0x020, 0x000001A4C3E20F90ULL); // ProcessParameters + w64(0x018, 0x000000D87B5E5800ULL); // Ldr (baseAddress + 0x800) + w64(0x020, 0x000000D87B5E5900ULL); // ProcessParameters (baseAddress + 0x900) w64(0x030, 0x000001A4C3D40000ULL); // ProcessHeap w64(0x038, 0x00007FFE3B8D4260ULL); // FastPebLock w32(0x050, 0x01); // CrossProcessFlags @@ -329,6 +330,26 @@ void MainWindow::newFile() { w64(0x398, 0x000000D87B5E5390ULL); // TppWorkerpList.Blink (self) w64(0x7B8, 0x00007FFE38860000ULL); // LeapSecondData + // ── _PEB_LDR_DATA at offset 0x800 ── + w32(0x800, 0x48); // Length + w8 (0x804, 0x01); // Initialized + w64(0x808, 0x0000000000000000ULL); // SsHandle + w64(0x810, 0x000001A4C3D40100ULL); // InLoadOrderModuleList.Flink + w64(0x818, 0x000001A4C3D40200ULL); // InLoadOrderModuleList.Blink + w64(0x820, 0x000001A4C3D40110ULL); // InMemoryOrderModuleList.Flink + w64(0x828, 0x000001A4C3D40210ULL); // InMemoryOrderModuleList.Blink + + // ── _RTL_USER_PROCESS_PARAMETERS at offset 0x900 ── + w32(0x900, 0x07B0); // MaximumLength + w32(0x904, 0x07B0); // Length + w32(0x908, 0x0001); // Flags (NORMALIZED) + w32(0x90C, 0x0000); // DebugFlags + w64(0x910, 0x0000000000000044ULL); // ConsoleHandle + w32(0x918, 0x0000); // ConsoleFlags + w64(0x920, 0x0000000000000008ULL); // StandardInput + w64(0x928, 0x000000000000000CULL); // StandardOutput + w64(0x930, 0x0000000000000010ULL); // StandardError + doc->loadData(pebData); doc->tree.baseAddress = 0x000000D87B5E5000ULL; @@ -359,7 +380,17 @@ void MainWindow::newFile() { n.parentId = parent; n.offset = offset; n.arrayLen = count; n.elementKind = elemKind; n.collapsed = true; - doc->tree.addNode(n); + int idx = doc->tree.addNode(n); + uint64_t arrId = doc->tree.nodes[idx].id; + int elemSz = sizeForKind(elemKind); + if (elemSz > 0) { + for (int i = 0; i < count; i++) { + Node e; e.kind = elemKind; + e.name = QStringLiteral("[%1]").arg(i); + e.parentId = arrId; e.offset = i * elemSz; + doc->tree.addNode(e); + } + } }; // Root struct (not collapsed so fields are visible on open) @@ -375,8 +406,8 @@ void MainWindow::newFile() { // 0x008 – 0x04F addField(peb, 0x008, NodeKind::Pointer64, "Mutant"); addField(peb, 0x010, NodeKind::Pointer64, "ImageBaseAddress"); - addField(peb, 0x018, NodeKind::Pointer64, "Ldr"); - addField(peb, 0x020, NodeKind::Pointer64, "ProcessParameters"); + uint64_t ldrPtrId = addField(peb, 0x018, NodeKind::Pointer64, "Ldr"); + uint64_t ppPtrId = addField(peb, 0x020, NodeKind::Pointer64, "ProcessParameters"); addField(peb, 0x028, NodeKind::Pointer64, "SubSystemData"); addField(peb, 0x030, NodeKind::Pointer64, "ProcessHeap"); addField(peb, 0x038, NodeKind::Pointer64, "FastPebLock"); @@ -509,6 +540,45 @@ void MainWindow::newFile() { addField(peb, 0x7C4, NodeKind::UInt32, "NtGlobalFlag2"); addField(peb, 0x7C8, NodeKind::UInt64, "ExtendedFeatureDisableMask"); + // ── Stub structs for pointer deref demo ── + // _PEB_LDR_DATA (Ldr target) + uint64_t ldrData = addStruct(0, 0x800, "_PEB_LDR_DATA", "LdrData"); + addField(ldrData, 0x00, NodeKind::UInt32, "Length"); + addField(ldrData, 0x04, NodeKind::UInt8, "Initialized"); + addPad (ldrData, 0x05, 3, "Pad"); + addField(ldrData, 0x08, NodeKind::Pointer64, "SsHandle"); + { + uint64_t le = addStruct(ldrData, 0x10, "LIST_ENTRY64", "InLoadOrderModuleList"); + addField(le, 0, NodeKind::Pointer64, "Flink"); + addField(le, 8, NodeKind::Pointer64, "Blink"); + } + { + uint64_t le = addStruct(ldrData, 0x20, "LIST_ENTRY64", "InMemoryOrderModuleList"); + addField(le, 0, NodeKind::Pointer64, "Flink"); + addField(le, 8, NodeKind::Pointer64, "Blink"); + } + + // _RTL_USER_PROCESS_PARAMETERS (ProcessParameters target) + uint64_t procParams = addStruct(0, 0x900, "_RTL_USER_PROCESS_PARAMETERS", "ProcessParams"); + addField(procParams, 0x00, NodeKind::UInt32, "MaximumLength"); + addField(procParams, 0x04, NodeKind::UInt32, "Length"); + addField(procParams, 0x08, NodeKind::UInt32, "Flags"); + addField(procParams, 0x0C, NodeKind::UInt32, "DebugFlags"); + addField(procParams, 0x10, NodeKind::Pointer64, "ConsoleHandle"); + addField(procParams, 0x18, NodeKind::UInt32, "ConsoleFlags"); + addPad (procParams, 0x1C, 4, "Pad"); + addField(procParams, 0x20, NodeKind::Pointer64, "StandardInput"); + addField(procParams, 0x28, NodeKind::Pointer64, "StandardOutput"); + addField(procParams, 0x30, NodeKind::Pointer64, "StandardError"); + + // Wire up pointer refIds + { + int li = doc->tree.indexOfId(ldrPtrId); + if (li >= 0) doc->tree.nodes[li].refId = ldrData; + int pi = doc->tree.indexOfId(ppPtrId); + if (pi >= 0) doc->tree.nodes[pi].refId = procParams; + } + createTab(doc); } diff --git a/src/providers/process_provider.h b/src/providers/process_provider.h index 43a723d..b6f4fa9 100644 --- a/src/providers/process_provider.h +++ b/src/providers/process_provider.h @@ -35,6 +35,7 @@ public: ProcessProvider& operator=(const ProcessProvider&) = delete; int size() const override { return m_size; } + bool isReadable(uint64_t, int len) const override { return len >= 0; } bool read(uint64_t addr, void* buf, int len) const override { SIZE_T got = 0; diff --git a/src/providers/provider.h b/src/providers/provider.h index 5f376c5..787d703 100644 --- a/src/providers/provider.h +++ b/src/providers/provider.h @@ -42,7 +42,7 @@ public: bool isValid() const { return size() > 0; } - bool isReadable(uint64_t addr, int len) const { + virtual bool isReadable(uint64_t addr, int len) const { if (len <= 0) return (len == 0); uint64_t ulen = (uint64_t)len; return addr <= (uint64_t)size() && ulen <= (uint64_t)size() - addr; diff --git a/tests/test_compose.cpp b/tests/test_compose.cpp index daaff34..69a4e1d 100644 --- a/tests/test_compose.cpp +++ b/tests/test_compose.cpp @@ -395,10 +395,10 @@ private slots: ComposeResult result = compose(tree, prov); - // CommandRow + Main: header + magic + ptr(fold head) + VTable header + fn1 + fn2 + VTable footer + Main footer = 9 + // CommandRow + Main: header + magic + ptr(merged fold header) + fn1 + fn2 + ptr footer + Main footer = 8 // VTable standalone: header + fn1 + fn2 + footer = 4 - // Total = 13 - QCOMPARE(result.meta.size(), 13); + // Total = 12 + QCOMPARE(result.meta.size(), 12); // Main header QCOMPARE(result.meta[1].lineKind, LineKind::Header); @@ -408,27 +408,23 @@ private slots: QCOMPARE(result.meta[2].lineKind, LineKind::Field); QCOMPARE(result.meta[2].depth, 1); - // Pointer as fold head - QCOMPARE(result.meta[3].lineKind, LineKind::Field); + // Pointer as merged fold header: "ptr64 ptr {" + QCOMPARE(result.meta[3].lineKind, LineKind::Header); QCOMPARE(result.meta[3].depth, 1); QVERIFY(result.meta[3].foldHead); QCOMPARE(result.meta[3].nodeKind, NodeKind::Pointer64); - // Expanded VTable header at depth 2 - QCOMPARE(result.meta[4].lineKind, LineKind::Header); + // Expanded fields at depth 2 (struct header merged into pointer) QCOMPARE(result.meta[4].depth, 2); + QCOMPARE(result.meta[5].depth, 2); - // Expanded fields at depth 3 - QCOMPARE(result.meta[5].depth, 3); - QCOMPARE(result.meta[6].depth, 3); - - // Expanded VTable footer - QCOMPARE(result.meta[7].lineKind, LineKind::Footer); - QCOMPARE(result.meta[7].depth, 2); + // Pointer fold footer + QCOMPARE(result.meta[6].lineKind, LineKind::Footer); + QCOMPARE(result.meta[6].depth, 1); // Main footer - QCOMPARE(result.meta[8].lineKind, LineKind::Footer); - QCOMPARE(result.meta[8].depth, 0); + QCOMPARE(result.meta[7].lineKind, LineKind::Footer); + QCOMPARE(result.meta[7].depth, 0); } void testPointerDerefNull() { @@ -472,18 +468,21 @@ private slots: ComposeResult result = compose(tree, prov); - // CommandRow + Main: header + ptr(fold head, no expansion) + footer = 4 + // CommandRow + Main: header + ptr(merged fold header) + ptr footer + Main footer = 5 // Target standalone: header + field + footer = 3 - // Total = 7 - QCOMPARE(result.meta.size(), 7); + // Total = 8 + QCOMPARE(result.meta.size(), 8); - // Pointer is fold head but has no children (null ptr) - QCOMPARE(result.meta[2].lineKind, LineKind::Field); + // Pointer as merged fold header (expanded but empty — null ptr) + QCOMPARE(result.meta[2].lineKind, LineKind::Header); QVERIFY(result.meta[2].foldHead); - // Next line is Main footer (no expansion) + // Pointer fold footer (empty expansion) QCOMPARE(result.meta[3].lineKind, LineKind::Footer); - QCOMPARE(result.meta[3].depth, 0); + + // Main footer + QCOMPARE(result.meta[4].lineKind, LineKind::Footer); + QCOMPARE(result.meta[4].depth, 0); } void testPointerDerefCollapsed() { @@ -603,14 +602,15 @@ private slots: QVERIFY(result.meta.size() > 0); QVERIFY(result.meta.size() < 100); // sanity: bounded output - // First expansion happens: CommandRow + Main header + ptr fold head + Recursive header + data + backPtr fold head - // Second expansion blocked by cycle guard: no children under backPtr - // Then: Recursive footer + Main footer + // First expansion: CommandRow + Main header + ptr merged header + data + self merged header + // Second expansion blocked by cycle guard: no children under self + // Then: self footer + ptr footer + Main footer // Plus standalone Recursive rendering // The exact count depends on cycle guard behavior but must be finite QCOMPARE(result.meta[1].lineKind, LineKind::Header); // Main header - QVERIFY(result.meta[2].foldHead); // ptr fold head - QCOMPARE(result.meta[3].lineKind, LineKind::Header); // Recursive header (expansion) + QVERIFY(result.meta[2].foldHead); // ptr merged fold head + QCOMPARE(result.meta[2].lineKind, LineKind::Header); // ptr merged header + QCOMPARE(result.meta[3].lineKind, LineKind::Field); // data field (first child of Recursive) } void testStructFooterSimple() { @@ -674,6 +674,1101 @@ private slots: } } + // ═════════════════════════════════════════════════════════════ + // Array tests + // ═════════════════════════════════════════════════════════════ + + void testArrayHeaderFormat() { + // Array header must show "elemType[count]" text and proper metadata + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + Node arr; + arr.kind = NodeKind::Array; + arr.name = "data"; + arr.parentId = rootId; + arr.offset = 0; + arr.elementKind = NodeKind::Int32; + arr.arrayLen = 10; + tree.addNode(arr); + + NullProvider prov; + ComposeResult result = compose(tree, prov); + + // Find the array header line + int headerLine = -1; + for (int i = 0; i < result.meta.size(); i++) { + if (result.meta[i].isArrayHeader) { + headerLine = i; + break; + } + } + QVERIFY(headerLine >= 0); + + // Metadata must be correct + const LineMeta& lm = result.meta[headerLine]; + QCOMPARE(lm.lineKind, LineKind::Header); + QVERIFY(lm.isArrayHeader); + QCOMPARE(lm.elementKind, NodeKind::Int32); + QCOMPARE(lm.arrayCount, 10); + QVERIFY(lm.foldHead); + QVERIFY(!lm.foldCollapsed); + + // Text must contain "int32_t[10]" and the name + QStringList lines = result.text.split('\n'); + QVERIFY(headerLine < lines.size()); + QString text = lines[headerLine]; + QVERIFY2(text.contains("int32_t[10]"), + qPrintable("Header should contain 'int32_t[10]': " + text)); + QVERIFY2(text.contains("data"), + qPrintable("Header should contain 'data': " + text)); + QVERIFY2(text.contains("{"), + qPrintable("Expanded header should contain '{': " + text)); + } + + void testArrayHeaderCharTypes() { + // UInt8 array → "char[N]", UInt16 → "wchar_t[N]" + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + Node arr1; + arr1.kind = NodeKind::Array; + arr1.name = "str"; + arr1.parentId = rootId; + arr1.offset = 0; + arr1.elementKind = NodeKind::UInt8; + arr1.arrayLen = 64; + tree.addNode(arr1); + + Node arr2; + arr2.kind = NodeKind::Array; + arr2.name = "wstr"; + arr2.parentId = rootId; + arr2.offset = 64; + arr2.elementKind = NodeKind::UInt16; + arr2.arrayLen = 32; + tree.addNode(arr2); + + NullProvider prov; + ComposeResult result = compose(tree, prov); + + QStringList lines = result.text.split('\n'); + bool foundChar = false, foundWchar = false; + for (int i = 0; i < result.meta.size(); i++) { + if (!result.meta[i].isArrayHeader) continue; + QString text = lines[i]; + if (text.contains("char[64]")) foundChar = true; + if (text.contains("wchar_t[32]")) foundWchar = true; + } + QVERIFY2(foundChar, "Should have 'char[64]' header"); + QVERIFY2(foundWchar, "Should have 'wchar_t[32]' header"); + } + + void testArraySpansClickable() { + // Element type and count spans must cover the correct text regions + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + Node arr; + arr.kind = NodeKind::Array; + arr.name = "numbers"; + arr.parentId = rootId; + arr.offset = 0; + arr.elementKind = NodeKind::UInt32; + arr.arrayLen = 5; + tree.addNode(arr); + + NullProvider prov; + ComposeResult result = compose(tree, prov); + + int headerLine = -1; + for (int i = 0; i < result.meta.size(); i++) { + if (result.meta[i].isArrayHeader) { headerLine = i; break; } + } + QVERIFY(headerLine >= 0); + + QStringList lines = result.text.split('\n'); + QString lineText = lines[headerLine]; + const LineMeta& lm = result.meta[headerLine]; + + // Element type span must be valid and cover "uint32_t" + ColumnSpan typeSpan = arrayElemTypeSpanFor(lm, lineText); + QVERIFY2(typeSpan.valid, "arrayElemTypeSpanFor must return a valid span"); + QVERIFY(typeSpan.start < typeSpan.end); + QString typeText = lineText.mid(typeSpan.start, typeSpan.end - typeSpan.start); + QVERIFY2(typeText.contains("uint32_t"), + qPrintable("Type span should cover 'uint32_t', got: '" + typeText + "'")); + + // Element count span must be valid and cover "5" + ColumnSpan countSpan = arrayElemCountSpanFor(lm, lineText); + QVERIFY2(countSpan.valid, "arrayElemCountSpanFor must return a valid span"); + QVERIFY(countSpan.start < countSpan.end); + QString countText = lineText.mid(countSpan.start, countSpan.end - countSpan.start); + QCOMPARE(countText, QString("5")); + } + + void testArrayWithStructChildren() { + // Array with struct children renders separators and child fields + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + // Array container + Node arr; + arr.kind = NodeKind::Array; + arr.name = "items"; + arr.parentId = rootId; + arr.offset = 0; + arr.elementKind = NodeKind::Int32; + arr.arrayLen = 2; + int ai = tree.addNode(arr); + uint64_t arrId = tree.nodes[ai].id; + + // Two struct children inside the array (representing elements) + Node elem0; + elem0.kind = NodeKind::Struct; + elem0.name = "Item"; + elem0.parentId = arrId; + elem0.offset = 0; + int e0i = tree.addNode(elem0); + uint64_t elem0Id = tree.nodes[e0i].id; + + Node f0; + f0.kind = NodeKind::UInt32; + f0.name = "value"; + f0.parentId = elem0Id; + f0.offset = 0; + tree.addNode(f0); + + Node elem1; + elem1.kind = NodeKind::Struct; + elem1.name = "Item"; + elem1.parentId = arrId; + elem1.offset = 4; + int e1i = tree.addNode(elem1); + uint64_t elem1Id = tree.nodes[e1i].id; + + Node f1; + f1.kind = NodeKind::UInt32; + f1.name = "value"; + f1.parentId = elem1Id; + f1.offset = 0; + tree.addNode(f1); + + NullProvider prov; + ComposeResult result = compose(tree, prov); + + // Must have content between header and footer (not empty!) + QVERIFY2(result.meta.size() > 4, + qPrintable(QString("Array should have content, got %1 lines") + .arg(result.meta.size()))); + + // Check for [0] and [1] separators + bool found0 = false, found1 = false; + int fieldCount = 0; + for (int i = 0; i < result.meta.size(); i++) { + if (result.meta[i].lineKind == LineKind::ArrayElementSeparator) { + if (result.meta[i].arrayElementIdx == 0) found0 = true; + if (result.meta[i].arrayElementIdx == 1) found1 = true; + } + // Count fields belonging to array children + if (result.meta[i].lineKind == LineKind::Field && + result.meta[i].depth >= 2) + fieldCount++; + } + QVERIFY2(found0, "Array should have [0] separator"); + QVERIFY2(found1, "Array should have [1] separator"); + QVERIFY2(fieldCount >= 2, "Array children should have field lines"); + } + + void testArrayCollapsedNoChildren() { + // Collapsed array: header only, no children or footer + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + Node arr; + arr.kind = NodeKind::Array; + arr.name = "data"; + arr.parentId = rootId; + arr.offset = 0; + arr.elementKind = NodeKind::Float; + arr.arrayLen = 100; + arr.collapsed = true; + int ai = tree.addNode(arr); + uint64_t arrId = tree.nodes[ai].id; + + // Child that should NOT appear when collapsed + Node child; + child.kind = NodeKind::Float; + child.name = "elem"; + child.parentId = arrId; + child.offset = 0; + tree.addNode(child); + + NullProvider prov; + ComposeResult result = compose(tree, prov); + + // CommandRow + Root header + Array header(collapsed) + Root footer = 4 + QCOMPARE(result.meta.size(), 4); + + // Array header is collapsed + int arrLine = -1; + for (int i = 0; i < result.meta.size(); i++) { + if (result.meta[i].isArrayHeader) { arrLine = i; break; } + } + QVERIFY(arrLine >= 0); + QVERIFY(result.meta[arrLine].foldCollapsed); + + // Header text should NOT contain "{" + QStringList lines = result.text.split('\n'); + QVERIFY2(!lines[arrLine].contains("{"), + qPrintable("Collapsed header should not have '{': " + lines[arrLine])); + } + + void testArrayCountRecompose() { + // After changing arrayLen and recomposing, the text shows the new count + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + Node arr; + arr.kind = NodeKind::Array; + arr.name = "buf"; + arr.parentId = rootId; + arr.offset = 0; + arr.elementKind = NodeKind::UInt8; + arr.arrayLen = 10; + int ai = tree.addNode(arr); + + NullProvider prov; + + // First compose: should show [10] + ComposeResult r1 = compose(tree, prov); + QStringList lines1 = r1.text.split('\n'); + bool found10 = false; + for (const QString& l : lines1) { + if (l.contains("[10]")) { found10 = true; break; } + } + QVERIFY2(found10, "First compose should show [10]"); + + // Change count and recompose + tree.nodes[ai].arrayLen = 42; + ComposeResult r2 = compose(tree, prov); + QStringList lines2 = r2.text.split('\n'); + bool found42 = false; + bool still10 = false; + for (const QString& l : lines2) { + if (l.contains("[42]")) found42 = true; + if (l.contains("[10]")) still10 = true; + } + QVERIFY2(found42, "Recomposed text should show [42]"); + QVERIFY2(!still10, "Recomposed text should NOT still show [10]"); + + // Spans must still work after recompose + int headerLine = -1; + for (int i = 0; i < r2.meta.size(); i++) { + if (r2.meta[i].isArrayHeader) { headerLine = i; break; } + } + QVERIFY(headerLine >= 0); + ColumnSpan countSpan = arrayElemCountSpanFor(r2.meta[headerLine], lines2[headerLine]); + QVERIFY2(countSpan.valid, "Count span must be valid after recompose"); + QString countText = lines2[headerLine].mid(countSpan.start, countSpan.end - countSpan.start); + QCOMPARE(countText, QString("42")); + } + + // ═════════════════════════════════════════════════════════════ + // Pointer tests + // ═════════════════════════════════════════════════════════════ + + void testPointerDefaultVoid() { + // Pointer64 with no refId should display as "ptr64" + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + Node ptr; + ptr.kind = NodeKind::Pointer64; + ptr.name = "ptr"; + ptr.parentId = rootId; + ptr.offset = 0; + // refId defaults to 0 (void*) + tree.addNode(ptr); + + NullProvider prov; + ComposeResult result = compose(tree, prov); + + // Find the pointer line + int ptrLine = -1; + for (int i = 0; i < result.meta.size(); i++) { + if (result.meta[i].nodeKind == NodeKind::Pointer64 && + result.meta[i].lineKind == LineKind::Field) { + ptrLine = i; + break; + } + } + QVERIFY(ptrLine >= 0); + + QStringList lines = result.text.split('\n'); + QString text = lines[ptrLine]; + QVERIFY2(text.contains("ptr64"), + qPrintable("Pointer with no refId should show 'ptr64': " + text)); + + // pointerTargetName should be empty (void) + QVERIFY(result.meta[ptrLine].pointerTargetName.isEmpty()); + + // Should NOT be a fold head (no deref expansion for void*) + QVERIFY(!result.meta[ptrLine].foldHead); + } + + void testPointer32DefaultVoid() { + // Same for Pointer32 + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + Node ptr; + ptr.kind = NodeKind::Pointer32; + ptr.name = "ptr32"; + ptr.parentId = rootId; + ptr.offset = 0; + tree.addNode(ptr); + + NullProvider prov; + ComposeResult result = compose(tree, prov); + + QStringList lines = result.text.split('\n'); + bool foundPtr32 = false; + for (const QString& l : lines) { + if (l.contains("ptr32")) { foundPtr32 = true; break; } + } + QVERIFY2(foundPtr32, "Pointer32 with no refId should show 'ptr32'"); + } + + void testPointerDisplaysTargetName() { + // Pointer64 with refId displays "ptr64" + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + // Target struct with a structTypeName + Node target; + target.kind = NodeKind::Struct; + target.name = "PlayerData"; + target.structTypeName = "PlayerData"; + target.parentId = 0; + target.offset = 200; + int ti = tree.addNode(target); + uint64_t targetId = tree.nodes[ti].id; + + Node tf; + tf.kind = NodeKind::UInt32; + tf.name = "health"; + tf.parentId = targetId; + tf.offset = 0; + tree.addNode(tf); + + // Pointer referencing the target (collapsed to prevent expansion) + Node ptr; + ptr.kind = NodeKind::Pointer64; + ptr.name = "player"; + ptr.parentId = rootId; + ptr.offset = 0; + ptr.refId = targetId; + ptr.collapsed = true; + tree.addNode(ptr); + + NullProvider prov; + ComposeResult result = compose(tree, prov); + + // Find the pointer line + int ptrLine = -1; + for (int i = 0; i < result.meta.size(); i++) { + if (result.meta[i].nodeKind == NodeKind::Pointer64 && + result.meta[i].lineKind == LineKind::Field && + result.meta[i].depth > 0) { + ptrLine = i; + break; + } + } + QVERIFY(ptrLine >= 0); + + QStringList lines = result.text.split('\n'); + QVERIFY2(lines[ptrLine].contains("ptr64"), + qPrintable("Should show 'ptr64': " + lines[ptrLine])); + + // pointerTargetName metadata + QCOMPARE(result.meta[ptrLine].pointerTargetName, QString("PlayerData")); + + // Pointer with refId is a fold head (even if collapsed) + QVERIFY(result.meta[ptrLine].foldHead); + QVERIFY(result.meta[ptrLine].foldCollapsed); + } + + void testPointerTargetUsesNameWhenNoTypeName() { + // If target struct has no structTypeName, use its name field + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + Node target; + target.kind = NodeKind::Struct; + target.name = "MyStruct"; + // structTypeName left empty + target.parentId = 0; + target.offset = 200; + int ti = tree.addNode(target); + uint64_t targetId = tree.nodes[ti].id; + + Node ptr; + ptr.kind = NodeKind::Pointer64; + ptr.name = "sptr"; + ptr.parentId = rootId; + ptr.offset = 0; + ptr.refId = targetId; + ptr.collapsed = true; + tree.addNode(ptr); + + NullProvider prov; + ComposeResult result = compose(tree, prov); + + QStringList lines = result.text.split('\n'); + bool found = false; + for (const QString& l : lines) { + if (l.contains("ptr64")) { found = true; break; } + } + QVERIFY2(found, "Should use struct name when structTypeName is empty"); + } + + void testPointerSpans() { + // pointerKindSpanFor and pointerTargetSpanFor must find correct regions + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + Node target; + target.kind = NodeKind::Struct; + target.name = "VTable"; + target.structTypeName = "VTable"; + target.parentId = 0; + target.offset = 200; + int ti = tree.addNode(target); + uint64_t targetId = tree.nodes[ti].id; + + Node ptr; + ptr.kind = NodeKind::Pointer64; + ptr.name = "vtbl"; + ptr.parentId = rootId; + ptr.offset = 0; + ptr.refId = targetId; + ptr.collapsed = true; + tree.addNode(ptr); + + NullProvider prov; + ComposeResult result = compose(tree, prov); + + int ptrLine = -1; + for (int i = 0; i < result.meta.size(); i++) { + if (result.meta[i].nodeKind == NodeKind::Pointer64 && + result.meta[i].lineKind == LineKind::Field && + result.meta[i].depth > 0) { + ptrLine = i; + break; + } + } + QVERIFY(ptrLine >= 0); + + QStringList lines = result.text.split('\n'); + QString lineText = lines[ptrLine]; + const LineMeta& lm = result.meta[ptrLine]; + + // Kind span: covers "ptr64" + ColumnSpan kindSpan = pointerKindSpanFor(lm, lineText); + QVERIFY2(kindSpan.valid, "pointerKindSpanFor must return valid span"); + QString kindText = lineText.mid(kindSpan.start, kindSpan.end - kindSpan.start); + QVERIFY2(kindText.contains("ptr64"), + qPrintable("Kind span should cover 'ptr64', got: '" + kindText + "'")); + + // Target span: covers "VTable" + ColumnSpan targetSpan = pointerTargetSpanFor(lm, lineText); + QVERIFY2(targetSpan.valid, "pointerTargetSpanFor must return valid span"); + QString targetText = lineText.mid(targetSpan.start, targetSpan.end - targetSpan.start); + QCOMPARE(targetText, QString("VTable")); + } + + void testPointerVoidSpans() { + // Even void* pointer should have valid kind and target spans + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + Node ptr; + ptr.kind = NodeKind::Pointer64; + ptr.name = "vptr"; + ptr.parentId = rootId; + ptr.offset = 0; + tree.addNode(ptr); + + NullProvider prov; + ComposeResult result = compose(tree, prov); + + int ptrLine = -1; + for (int i = 0; i < result.meta.size(); i++) { + if (result.meta[i].nodeKind == NodeKind::Pointer64 && + result.meta[i].lineKind == LineKind::Field) { + ptrLine = i; + break; + } + } + QVERIFY(ptrLine >= 0); + + QStringList lines = result.text.split('\n'); + QString lineText = lines[ptrLine]; + const LineMeta& lm = result.meta[ptrLine]; + + // Kind span: "ptr64" + ColumnSpan kindSpan = pointerKindSpanFor(lm, lineText); + QVERIFY2(kindSpan.valid, "void* pointer should have valid kind span"); + + // Target span: "void" + ColumnSpan targetSpan = pointerTargetSpanFor(lm, lineText); + QVERIFY2(targetSpan.valid, "void* pointer should have valid target span"); + QString targetText = lineText.mid(targetSpan.start, targetSpan.end - targetSpan.start); + QCOMPARE(targetText, QString("void")); + } + + void testPointerToPointerChain() { + // ptr64 → StructB { ptr64 } → StructC { field } + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + // StructC (innermost target) + Node structC; + structC.kind = NodeKind::Struct; + structC.name = "InnerData"; + structC.structTypeName = "InnerData"; + structC.parentId = 0; + structC.offset = 300; + int ci = tree.addNode(structC); + uint64_t structCId = tree.nodes[ci].id; + + Node cf; + cf.kind = NodeKind::UInt64; + cf.name = "payload"; + cf.parentId = structCId; + cf.offset = 0; + tree.addNode(cf); + + // StructB (middle target, contains ptr to C) + Node structB; + structB.kind = NodeKind::Struct; + structB.name = "Wrapper"; + structB.structTypeName = "Wrapper"; + structB.parentId = 0; + structB.offset = 200; + int bi = tree.addNode(structB); + uint64_t structBId = tree.nodes[bi].id; + + Node bf; + bf.kind = NodeKind::UInt32; + bf.name = "flags"; + bf.parentId = structBId; + bf.offset = 0; + tree.addNode(bf); + + Node bptr; + bptr.kind = NodeKind::Pointer64; + bptr.name = "inner"; + bptr.parentId = structBId; + bptr.offset = 4; + bptr.refId = structCId; // points to InnerData + tree.addNode(bptr); + + // Root's pointer to StructB + Node rptr; + rptr.kind = NodeKind::Pointer64; + rptr.name = "wrapper_ptr"; + rptr.parentId = rootId; + rptr.offset = 0; + rptr.refId = structBId; + tree.addNode(rptr); + + // Provider: rptr at 0 → addr 100, bptr at 100+4=104 → addr 150 + QByteArray data(400, '\0'); + uint64_t val1 = 100; + memcpy(data.data(), &val1, 8); // rptr → 100 + uint64_t val2 = 150; + memcpy(data.data() + 104, &val2, 8); // bptr at 104 → 150 + BufferProvider prov(data); + + ComposeResult result = compose(tree, prov); + + // Must finish (no infinite loop) + QVERIFY(result.meta.size() > 0); + QVERIFY(result.meta.size() < 200); + + // Check that ptr64 and ptr64 both appear in text + bool foundWrapper = false, foundInner = false; + QStringList lines = result.text.split('\n'); + for (const QString& l : lines) { + if (l.contains("ptr64")) foundWrapper = true; + if (l.contains("ptr64")) foundInner = true; + } + QVERIFY2(foundWrapper, "Should display 'ptr64'"); + QVERIFY2(foundInner, "Should display 'ptr64'"); + + // The chain: Root → ptr64(fold head) → Wrapper expanded → + // ptr64(fold head) → InnerData expanded + int foldHeadCount = 0; + for (const LineMeta& lm : result.meta) { + if (lm.foldHead && lm.nodeKind == NodeKind::Pointer64) + foldHeadCount++; + } + // At least 2 fold-head pointers in the expansion chain (rptr + bptr) + // Plus standalone renderings of StructB and StructC + QVERIFY2(foldHeadCount >= 2, + qPrintable(QString("Expected >=2 pointer fold heads, got %1") + .arg(foldHeadCount))); + } + + void testPointerMutualCycleAtoB() { + // A→B→A: Main has ptr to StructB, StructB has ptr back to Main + // Must not infinite-loop + NodeTree tree; + tree.baseAddress = 0; + + // Main struct + Node main; + main.kind = NodeKind::Struct; + main.name = "Main"; + main.parentId = 0; + main.offset = 0; + int mi = tree.addNode(main); + uint64_t mainId = tree.nodes[mi].id; + + Node mf; + mf.kind = NodeKind::UInt32; + mf.name = "tag"; + mf.parentId = mainId; + mf.offset = 0; + tree.addNode(mf); + + // StructB + Node structB; + structB.kind = NodeKind::Struct; + structB.name = "StructB"; + structB.parentId = 0; + structB.offset = 200; + int bi = tree.addNode(structB); + uint64_t structBId = tree.nodes[bi].id; + + Node bf; + bf.kind = NodeKind::UInt32; + bf.name = "data"; + bf.parentId = structBId; + bf.offset = 0; + tree.addNode(bf); + + // Main → StructB pointer + Node ptrToB; + ptrToB.kind = NodeKind::Pointer64; + ptrToB.name = "to_b"; + ptrToB.parentId = mainId; + ptrToB.offset = 4; + ptrToB.refId = structBId; + tree.addNode(ptrToB); + + // StructB → Main pointer (creates cycle!) + Node ptrToMain; + ptrToMain.kind = NodeKind::Pointer64; + ptrToMain.name = "back"; + ptrToMain.parentId = structBId; + ptrToMain.offset = 4; + ptrToMain.refId = mainId; + tree.addNode(ptrToMain); + + // Provider: Main.to_b at offset 4 → addr 100 + // StructB expanded at 100: back at 100+4=104 → addr 50 + // Main expanded at 50: to_b at 50+4=54 → addr 100 (same as before → cycle!) + QByteArray data(300, '\0'); + uint64_t val1 = 100; + memcpy(data.data() + 4, &val1, 8); // Main.to_b → 100 + uint64_t val2 = 50; + memcpy(data.data() + 104, &val2, 8); // StructB.back at 104 → 50 + uint64_t val3 = 100; + memcpy(data.data() + 54, &val3, 8); // Main.to_b at 54 → 100 (cycle) + BufferProvider prov(data); + + ComposeResult result = compose(tree, prov); + + // MUST terminate with bounded output + QVERIFY(result.meta.size() > 0); + QVERIFY2(result.meta.size() < 100, + qPrintable(QString("Cycle should be bounded, got %1 lines") + .arg(result.meta.size()))); + + // Both ptr64 and ptr64
should appear + bool foundToB = false, foundToMain = false; + QStringList lines = result.text.split('\n'); + for (const QString& l : lines) { + if (l.contains("ptr64")) foundToB = true; + if (l.contains("ptr64
")) foundToMain = true; + } + QVERIFY2(foundToB, "Should display 'ptr64'"); + QVERIFY2(foundToMain, "Should display 'ptr64
'"); + + // The first expansion of each pointer works; + // the cycle is caught on the second attempt + int mainHeaders = 0; + for (const LineMeta& lm : result.meta) { + if (lm.lineKind == LineKind::Header && lm.nodeIdx == mi) + mainHeaders++; + } + // Main appears as root + expanded once from StructB, then blocked on re-expansion + QVERIFY2(mainHeaders >= 1, "Main should appear at least once"); + } + + void testAllStructsResolvedAsPointerTargets() { + // Multiple structs in the tree; pointers to each should display the name + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + // Create several structs + QStringList structNames = {"Alpha", "Bravo", "Charlie", "Delta"}; + QVector structIds; + for (int i = 0; i < structNames.size(); i++) { + Node s; + s.kind = NodeKind::Struct; + s.name = structNames[i]; + s.structTypeName = structNames[i]; + s.parentId = 0; + s.offset = 1000 + i * 100; + int si = tree.addNode(s); + structIds << tree.nodes[si].id; + + // Give each struct a field + Node f; + f.kind = NodeKind::UInt32; + f.name = "x"; + f.parentId = tree.nodes[si].id; + f.offset = 0; + tree.addNode(f); + } + + // Create a pointer to each struct + for (int i = 0; i < structIds.size(); i++) { + Node ptr; + ptr.kind = NodeKind::Pointer64; + ptr.name = QString("ptr_%1").arg(structNames[i].toLower()); + ptr.parentId = rootId; + ptr.offset = i * 8; + ptr.refId = structIds[i]; + ptr.collapsed = true; // don't expand + tree.addNode(ptr); + } + + NullProvider prov; + ComposeResult result = compose(tree, prov); + + // Every struct name should appear in a "ptr64" format + QStringList lines = result.text.split('\n'); + for (const QString& sname : structNames) { + QString expected = QString("ptr64<%1>").arg(sname); + bool found = false; + for (const QString& l : lines) { + if (l.contains(expected)) { found = true; break; } + } + QVERIFY2(found, qPrintable(QString("Should display '%1'").arg(expected))); + } + } + + void testPointerRefIdToDeletedStruct() { + // If refId points to a non-existent node, degrade to void* + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + Node ptr; + ptr.kind = NodeKind::Pointer64; + ptr.name = "dangling"; + ptr.parentId = rootId; + ptr.offset = 0; + ptr.refId = 99999; // non-existent ID + tree.addNode(ptr); + + NullProvider prov; + ComposeResult result = compose(tree, prov); + + // Should not crash, and degrade to void + QStringList lines = result.text.split('\n'); + bool foundVoid = false; + for (const QString& l : lines) { + if (l.contains("ptr64")) { foundVoid = true; break; } + } + QVERIFY2(foundVoid, "Dangling refId should degrade to ptr64"); + } + + void testPointerCollapsedNoExpansion() { + // Collapsed pointer with valid non-null target must NOT expand + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + Node target; + target.kind = NodeKind::Struct; + target.name = "Heavy"; + target.parentId = 0; + target.offset = 200; + int ti = tree.addNode(target); + uint64_t targetId = tree.nodes[ti].id; + + // Many children in target - would inflate output if expanded + for (int i = 0; i < 10; i++) { + Node f; + f.kind = NodeKind::UInt64; + f.name = QString("f%1").arg(i); + f.parentId = targetId; + f.offset = i * 8; + tree.addNode(f); + } + + Node ptr; + ptr.kind = NodeKind::Pointer64; + ptr.name = "heavy_ptr"; + ptr.parentId = rootId; + ptr.offset = 0; + ptr.refId = targetId; + ptr.collapsed = true; // COLLAPSED + tree.addNode(ptr); + + // Non-null pointer value + QByteArray data(300, '\0'); + uint64_t ptrVal = 100; + memcpy(data.data(), &ptrVal, 8); + BufferProvider prov(data); + + ComposeResult result = compose(tree, prov); + + // Count lines belonging to depth > 1 inside Root + // (There should be NONE because the pointer is collapsed) + int expandedLines = 0; + for (const LineMeta& lm : result.meta) { + // Lines at depth >= 2 would be inside the pointer expansion + if (lm.depth >= 2 && lm.nodeIdx >= 0 && + tree.nodes[lm.nodeIdx].parentId == targetId) + expandedLines++; + } + + // Standalone Heavy rendering adds lines at depth 1, + // but pointer expansion at depth >= 2 should be zero + QCOMPARE(expandedLines, 0); + } + + void testPointerWidthComputation() { + // Type column must be wide enough for "ptr64" + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Root"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + Node target; + target.kind = NodeKind::Struct; + target.name = "VeryLongStructNameForTesting"; + target.structTypeName = "VeryLongStructNameForTesting"; + target.parentId = 0; + target.offset = 200; + int ti = tree.addNode(target); + uint64_t targetId = tree.nodes[ti].id; + + Node ptr; + ptr.kind = NodeKind::Pointer64; + ptr.name = "lptr"; + ptr.parentId = rootId; + ptr.offset = 0; + ptr.refId = targetId; + ptr.collapsed = true; + tree.addNode(ptr); + + NullProvider prov; + ComposeResult result = compose(tree, prov); + + // The text must contain the FULL target name, not truncated + QStringList lines = result.text.split('\n'); + bool foundFull = false; + for (const QString& l : lines) { + if (l.contains("ptr64")) { + foundFull = true; + break; + } + } + QVERIFY2(foundFull, + "Type column should be wide enough for long pointer target names"); + + // Layout type width should accommodate the long name + // "ptr64" = 35 chars + QVERIFY2(result.layout.typeW >= 35, + qPrintable(QString("typeW=%1, should be >= 35").arg(result.layout.typeW))); + } + + void testTextIsNonEmpty() { + // Verify composed text is actually generated (not empty) + NodeTree tree; + tree.baseAddress = 0x1000; + + Node root; + root.kind = NodeKind::Struct; + root.name = "TestStruct"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + // Mix of types including pointers and arrays + Node f1; + f1.kind = NodeKind::UInt64; + f1.name = "id"; + f1.parentId = rootId; + f1.offset = 0; + tree.addNode(f1); + + Node ptr; + ptr.kind = NodeKind::Pointer64; + ptr.name = "next"; + ptr.parentId = rootId; + ptr.offset = 8; + tree.addNode(ptr); + + Node arr; + arr.kind = NodeKind::Array; + arr.name = "buf"; + arr.parentId = rootId; + arr.offset = 16; + arr.elementKind = NodeKind::Hex8; + arr.arrayLen = 16; + arr.collapsed = true; + tree.addNode(arr); + + NullProvider prov; + ComposeResult result = compose(tree, prov); + + QVERIFY2(!result.text.isEmpty(), "Composed text must not be empty"); + QVERIFY2(result.meta.size() >= 5, + qPrintable(QString("Expected >= 5 lines, got %1").arg(result.meta.size()))); + + // Every line should have text content + QStringList lines = result.text.split('\n'); + QCOMPARE(lines.size(), result.meta.size()); + for (int i = 0; i < lines.size(); i++) { + QVERIFY2(!lines[i].isEmpty(), + qPrintable(QString("Line %1 is empty").arg(i))); + } + } + }; QTEST_MAIN(TestCompose) diff --git a/tests/test_controller.cpp b/tests/test_controller.cpp new file mode 100644 index 0000000..4ff6789 --- /dev/null +++ b/tests/test_controller.cpp @@ -0,0 +1,454 @@ +#include +#include +#include +#include +#include +#include "controller.h" +#include "core.h" + +using namespace rcx; + +// Small tree: one root struct with a few typed fields at known offsets. +// Keeps tests fast and deterministic (no giant PEB tree). +static void buildSmallTree(NodeTree& tree) { + tree.baseAddress = 0x1000; + + Node root; + root.kind = NodeKind::Struct; + root.structTypeName = "TestStruct"; + root.name = "root"; + root.parentId = 0; + root.offset = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + auto field = [&](int off, NodeKind k, const char* name) { + Node n; + n.kind = k; + n.name = name; + n.parentId = rootId; + n.offset = off; + tree.addNode(n); + }; + + field(0, NodeKind::UInt32, "field_u32"); // 4 bytes + field(4, NodeKind::Float, "field_float"); // 4 bytes + field(8, NodeKind::UInt8, "field_u8"); // 1 byte + field(9, NodeKind::Padding, "pad0"); // 3 bytes padding + // Set padding arrayLen = 3 for 3-byte padding + tree.nodes.last().arrayLen = 3; + field(12, NodeKind::Hex32, "field_hex"); // 4 bytes +} + +// 64-byte buffer with recognizable pattern +static QByteArray makeSmallBuffer() { + QByteArray data(64, '\0'); + // field_u32 at offset 0 = 0xDEADBEEF + uint32_t v32 = 0xDEADBEEF; + memcpy(data.data() + 0, &v32, 4); + // field_float at offset 4 = 3.14f + float vf = 3.14f; + memcpy(data.data() + 4, &vf, 4); + // field_u8 at offset 8 = 0x42 + data[8] = 0x42; + // pad0 at offset 9 = 0x00 0x00 0x00 + // field_hex at offset 12 = 0xCAFEBABE + uint32_t vhex = 0xCAFEBABE; + memcpy(data.data() + 12, &vhex, 4); + return data; +} + +class TestController : public QObject { + Q_OBJECT +private: + RcxDocument* m_doc = nullptr; + RcxController* m_ctrl = nullptr; + QSplitter* m_splitter = nullptr; + RcxEditor* m_editor = nullptr; + +private slots: + void init() { + m_doc = new RcxDocument(); + buildSmallTree(m_doc->tree); + m_doc->provider = std::make_unique(makeSmallBuffer()); + + m_splitter = new QSplitter(); + // Pass nullptr as parent so controller is not auto-deleted with splitter + m_ctrl = new RcxController(m_doc, nullptr); + m_editor = m_ctrl->addSplitEditor(m_splitter); + + m_splitter->resize(800, 600); + m_splitter->show(); + QVERIFY(QTest::qWaitForWindowExposed(m_splitter)); + QApplication::processEvents(); + } + + void cleanup() { + // Delete controller first (disconnects from editor signals) + delete m_ctrl; + m_ctrl = nullptr; + m_editor = nullptr; // owned by splitter + delete m_splitter; + m_splitter = nullptr; + delete m_doc; + m_doc = nullptr; + } + + // ── Test: setNodeValue writes bytes to provider ── + void testSetNodeValueWritesData() { + // Find field_u32 (index 1, child of root at index 0) + int idx = -1; + for (int i = 0; i < m_doc->tree.nodes.size(); i++) { + if (m_doc->tree.nodes[i].name == "field_u32") { idx = i; break; } + } + QVERIFY(idx >= 0); + + // Verify original value in provider + uint64_t addr = m_doc->tree.computeOffset(idx); + QByteArray origBytes = m_doc->provider->readBytes(addr, 4); + uint32_t origVal; + memcpy(&origVal, origBytes.data(), 4); + QCOMPARE(origVal, (uint32_t)0xDEADBEEF); + + // Write new value "42" (decimal) + m_ctrl->setNodeValue(idx, 0, "42"); + QApplication::processEvents(); + + // Read back: should be 42 in little-endian + QByteArray newBytes = m_doc->provider->readBytes(addr, 4); + uint32_t newVal; + memcpy(&newVal, newBytes.data(), 4); + QCOMPARE(newVal, (uint32_t)42); + } + + // ── Test: setNodeValue undo/redo restores data ── + void testSetNodeValueUndoRedo() { + int idx = -1; + for (int i = 0; i < m_doc->tree.nodes.size(); i++) { + if (m_doc->tree.nodes[i].name == "field_u32") { idx = i; break; } + } + QVERIFY(idx >= 0); + uint64_t addr = m_doc->tree.computeOffset(idx); + + // Original: 0xDEADBEEF + QByteArray orig = m_doc->provider->readBytes(addr, 4); + uint32_t origVal; + memcpy(&origVal, orig.data(), 4); + QCOMPARE(origVal, (uint32_t)0xDEADBEEF); + + // Write new value + m_ctrl->setNodeValue(idx, 0, "99"); + QApplication::processEvents(); + + uint32_t newVal; + QByteArray after = m_doc->provider->readBytes(addr, 4); + memcpy(&newVal, after.data(), 4); + QCOMPARE(newVal, (uint32_t)99); + + // Undo → should restore original + m_doc->undoStack.undo(); + QApplication::processEvents(); + + QByteArray undone = m_doc->provider->readBytes(addr, 4); + uint32_t undoneVal; + memcpy(&undoneVal, undone.data(), 4); + QCOMPARE(undoneVal, (uint32_t)0xDEADBEEF); + + // Redo → should restore new value + m_doc->undoStack.redo(); + QApplication::processEvents(); + + QByteArray redone = m_doc->provider->readBytes(addr, 4); + uint32_t redoneVal; + memcpy(&redoneVal, redone.data(), 4); + QCOMPARE(redoneVal, (uint32_t)99); + } + + // ── Test: setNodeValue on Float field ── + void testSetNodeValueFloat() { + int idx = -1; + for (int i = 0; i < m_doc->tree.nodes.size(); i++) { + if (m_doc->tree.nodes[i].name == "field_float") { idx = i; break; } + } + QVERIFY(idx >= 0); + uint64_t addr = m_doc->tree.computeOffset(idx); + + // Original: 3.14f + QByteArray orig = m_doc->provider->readBytes(addr, 4); + float origVal; + memcpy(&origVal, orig.data(), 4); + QVERIFY(qAbs(origVal - 3.14f) < 0.01f); + + // Write "1.5" + m_ctrl->setNodeValue(idx, 0, "1.5"); + QApplication::processEvents(); + + QByteArray after = m_doc->provider->readBytes(addr, 4); + float newVal; + memcpy(&newVal, after.data(), 4); + QCOMPARE(newVal, 1.5f); + + // Undo + m_doc->undoStack.undo(); + QApplication::processEvents(); + QByteArray undone = m_doc->provider->readBytes(addr, 4); + float undoneVal; + memcpy(&undoneVal, undone.data(), 4); + QVERIFY(qAbs(undoneVal - 3.14f) < 0.01f); + } + + // ── Test: renameNode changes name and undo restores ── + void testRenameNode() { + int idx = -1; + for (int i = 0; i < m_doc->tree.nodes.size(); i++) { + if (m_doc->tree.nodes[i].name == "field_u32") { idx = i; break; } + } + QVERIFY(idx >= 0); + QCOMPARE(m_doc->tree.nodes[idx].name, QString("field_u32")); + + m_ctrl->renameNode(idx, "myRenamedField"); + QApplication::processEvents(); + + QCOMPARE(m_doc->tree.nodes[idx].name, QString("myRenamedField")); + + // Undo + m_doc->undoStack.undo(); + QApplication::processEvents(); + QCOMPARE(m_doc->tree.nodes[idx].name, QString("field_u32")); + + // Redo + m_doc->undoStack.redo(); + QApplication::processEvents(); + QCOMPARE(m_doc->tree.nodes[idx].name, QString("myRenamedField")); + } + + // ── Test: changeNodeKind changes type and undo restores ── + void testChangeNodeKind() { + int idx = -1; + for (int i = 0; i < m_doc->tree.nodes.size(); i++) { + if (m_doc->tree.nodes[i].name == "field_u32") { idx = i; break; } + } + QVERIFY(idx >= 0); + QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::UInt32); + + m_ctrl->changeNodeKind(idx, NodeKind::Float); + QApplication::processEvents(); + + QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::Float); + + // Undo + m_doc->undoStack.undo(); + QApplication::processEvents(); + QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::UInt32); + } + + // ── Test: insertNode adds a node, removeNode removes it, undo restores ── + void testInsertAndRemoveNode() { + int origSize = m_doc->tree.nodes.size(); + uint64_t rootId = m_doc->tree.nodes[0].id; + + // Insert a new Hex64 at offset 16 + m_ctrl->insertNode(rootId, 16, NodeKind::Hex64, "newHex"); + QApplication::processEvents(); + + QCOMPARE(m_doc->tree.nodes.size(), origSize + 1); + + // Find the inserted node + int newIdx = -1; + for (int i = 0; i < m_doc->tree.nodes.size(); i++) { + if (m_doc->tree.nodes[i].name == "newHex") { newIdx = i; break; } + } + QVERIFY(newIdx >= 0); + QCOMPARE(m_doc->tree.nodes[newIdx].kind, NodeKind::Hex64); + QCOMPARE(m_doc->tree.nodes[newIdx].offset, 16); + + // Remove it + m_ctrl->removeNode(newIdx); + QApplication::processEvents(); + + QCOMPARE(m_doc->tree.nodes.size(), origSize); + + // Undo remove → node restored + m_doc->undoStack.undo(); + QApplication::processEvents(); + + QCOMPARE(m_doc->tree.nodes.size(), origSize + 1); + + // Find again + newIdx = -1; + for (int i = 0; i < m_doc->tree.nodes.size(); i++) { + if (m_doc->tree.nodes[i].name == "newHex") { newIdx = i; break; } + } + QVERIFY(newIdx >= 0); + } + + // ── Test: Padding value edit is effectively blocked at controller level ── + void testPaddingValueEditIsBlocked() { + // Find the padding node + int padIdx = -1; + for (int i = 0; i < m_doc->tree.nodes.size(); i++) { + if (m_doc->tree.nodes[i].kind == NodeKind::Padding) { padIdx = i; break; } + } + QVERIFY(padIdx >= 0); + uint64_t addr = m_doc->tree.computeOffset(padIdx); + + // Read original data at padding offset + int padSize = m_doc->tree.nodes[padIdx].byteSize(); + QByteArray origData = m_doc->provider->readBytes(addr, padSize); + + // The context menu blocks Padding editing, so the controller's setNodeValue + // would only be called if the editing UI somehow allows it. But let's verify + // the editor correctly blocks it. + // Find padding line in composed output + ComposeResult result = m_doc->compose(); + int paddingLine = -1; + for (int i = 0; i < result.meta.size(); i++) { + if (result.meta[i].nodeKind == NodeKind::Padding && + result.meta[i].lineKind == LineKind::Field) { + paddingLine = i; + break; + } + } + QVERIFY(paddingLine >= 0); + + m_editor->applyDocument(result); + QApplication::processEvents(); + + // beginInlineEdit(Value) on Padding line must be rejected + QVERIFY(!m_editor->beginInlineEdit(EditTarget::Value, paddingLine)); + QVERIFY(!m_editor->isEditing()); + + // Data must be unchanged + QByteArray afterData = m_doc->provider->readBytes(addr, padSize); + QCOMPARE(afterData, origData); + } + + // ── Test: setNodeValue with Hex32 (space-separated hex bytes) ── + void testSetNodeValueHex() { + int idx = -1; + for (int i = 0; i < m_doc->tree.nodes.size(); i++) { + if (m_doc->tree.nodes[i].name == "field_hex") { idx = i; break; } + } + QVERIFY(idx >= 0); + uint64_t addr = m_doc->tree.computeOffset(idx); + + // Original: 0xCAFEBABE + QByteArray orig = m_doc->provider->readBytes(addr, 4); + uint32_t origVal; + memcpy(&origVal, orig.data(), 4); + QCOMPARE(origVal, (uint32_t)0xCAFEBABE); + + // Write space-separated hex bytes "AA BB CC DD" + m_ctrl->setNodeValue(idx, 0, "AA BB CC DD"); + QApplication::processEvents(); + + QByteArray after = m_doc->provider->readBytes(addr, 4); + QCOMPARE((uint8_t)after[0], (uint8_t)0xAA); + QCOMPARE((uint8_t)after[1], (uint8_t)0xBB); + QCOMPARE((uint8_t)after[2], (uint8_t)0xCC); + QCOMPARE((uint8_t)after[3], (uint8_t)0xDD); + + // Undo + m_doc->undoStack.undo(); + QApplication::processEvents(); + QByteArray undone = m_doc->provider->readBytes(addr, 4); + uint32_t undoneVal; + memcpy(&undoneVal, undone.data(), 4); + QCOMPARE(undoneVal, (uint32_t)0xCAFEBABE); + } + + // ── Test: full inline edit round-trip (type in editor → commit → verify provider) ── + void testInlineEditRoundTrip() { + // Refresh to get composed output + m_ctrl->refresh(); + QApplication::processEvents(); + + // Find field_u8 line (UInt8 at offset 8, value = 0x42 = 66) + ComposeResult result = m_doc->compose(); + int fieldLine = -1; + for (int i = 0; i < result.meta.size(); i++) { + if (result.meta[i].nodeKind == NodeKind::UInt8 && + result.meta[i].lineKind == LineKind::Field) { + fieldLine = i; + break; + } + } + QVERIFY(fieldLine >= 0); + + m_editor->applyDocument(result); + QApplication::processEvents(); + + // Select this node so edit is allowed + uint64_t nodeId = result.meta[fieldLine].nodeId; + QSet sel; + sel.insert(nodeId); + m_editor->applySelectionOverlay(sel); + QApplication::processEvents(); + + // Begin value edit + bool ok = m_editor->beginInlineEdit(EditTarget::Value, fieldLine); + QVERIFY2(ok, "Should be able to begin value edit on UInt8 field"); + QVERIFY(m_editor->isEditing()); + + // UInt8 values display in hex (e.g., "0x42"). beginInlineEdit selects + // from after "0x" to end. Type "FF" to replace the hex digits. + for (QChar c : QString("FF")) { + QKeyEvent key(QEvent::KeyPress, 0, Qt::NoModifier, QString(c)); + QApplication::sendEvent(m_editor->scintilla(), &key); + } + QApplication::processEvents(); + + // Commit + QSignalSpy spy(m_editor, &RcxEditor::inlineEditCommitted); + QKeyEvent enter(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier); + QApplication::sendEvent(m_editor->scintilla(), &enter); + + QCOMPARE(spy.count(), 1); + QList args = spy.first(); + int nodeIdx = args.at(0).toInt(); + QString text = args.at(3).toString().trimmed(); + // The committed text should contain "0xFF" (hex format for UInt8) + QVERIFY2(!text.isEmpty(), "Committed text should not be empty"); + + // Now simulate what controller does: setNodeValue + m_ctrl->setNodeValue(nodeIdx, 0, text); + QApplication::processEvents(); + + // Verify provider data changed + int u8Idx = -1; + for (int i = 0; i < m_doc->tree.nodes.size(); i++) { + if (m_doc->tree.nodes[i].name == "field_u8") { u8Idx = i; break; } + } + QVERIFY(u8Idx >= 0); + uint64_t addr = m_doc->tree.computeOffset(u8Idx); + QByteArray bytes = m_doc->provider->readBytes(addr, 1); + QCOMPARE((uint8_t)bytes[0], (uint8_t)0xFF); + } + + // ── Test: toggleCollapse + undo ── + void testToggleCollapse() { + // Root is index 0, a Struct node + QCOMPARE(m_doc->tree.nodes[0].kind, NodeKind::Struct); + QCOMPARE(m_doc->tree.nodes[0].collapsed, false); + + m_ctrl->toggleCollapse(0); + QApplication::processEvents(); + QCOMPARE(m_doc->tree.nodes[0].collapsed, true); + + m_ctrl->toggleCollapse(0); + QApplication::processEvents(); + QCOMPARE(m_doc->tree.nodes[0].collapsed, false); + + // Undo twice: uncollapse → collapse → original (false) + m_doc->undoStack.undo(); + QApplication::processEvents(); + QCOMPARE(m_doc->tree.nodes[0].collapsed, true); + + m_doc->undoStack.undo(); + QApplication::processEvents(); + QCOMPARE(m_doc->tree.nodes[0].collapsed, false); + } +}; + +QTEST_MAIN(TestController) +#include "test_controller.moc" diff --git a/tests/test_editor.cpp b/tests/test_editor.cpp index a056bb0..8491d93 100644 --- a/tests/test_editor.cpp +++ b/tests/test_editor.cpp @@ -748,6 +748,119 @@ private slots: m_editor->applyDocument(m_result); } + // ── Test: Padding line rejects value editing ── + void testPaddingLineRejectsValueEdit() { + m_editor->applyDocument(m_result); + + // Find a Padding line in the composed output + int paddingLine = -1; + for (int i = 0; i < m_result.meta.size(); i++) { + if (m_result.meta[i].nodeKind == NodeKind::Padding && + m_result.meta[i].lineKind == LineKind::Field) { + paddingLine = i; + break; + } + } + QVERIFY2(paddingLine >= 0, "Should have at least one Padding line in test tree"); + + const LineMeta* lm = m_editor->metaForLine(paddingLine); + QVERIFY(lm); + QCOMPARE(lm->nodeKind, NodeKind::Padding); + + // Value edit on Padding MUST be rejected (the bug fix) + QVERIFY2(!m_editor->beginInlineEdit(EditTarget::Value, paddingLine), + "Value edit should be rejected on Padding lines"); + QVERIFY(!m_editor->isEditing()); + + // Name edit on Padding SHOULD succeed (ASCII preview column is editable) + bool ok = m_editor->beginInlineEdit(EditTarget::Name, paddingLine); + QVERIFY2(ok, "Name edit should be allowed on Padding lines (ASCII preview)"); + QVERIFY(m_editor->isEditing()); + m_editor->cancelInlineEdit(); + + // Type edit on Padding SHOULD succeed + ok = m_editor->beginInlineEdit(EditTarget::Type, paddingLine); + QVERIFY2(ok, "Type edit should be allowed on Padding lines"); + QVERIFY(m_editor->isEditing()); + m_editor->cancelInlineEdit(); + QApplication::processEvents(); // flush deferred autocomplete timer + } + + // ── Test: resolvedSpanFor rejects Value on Padding (defense-in-depth) ── + void testPaddingLineRejectsValueSpan() { + m_editor->applyDocument(m_result); + + // Find a Padding line + int paddingLine = -1; + for (int i = 0; i < m_result.meta.size(); i++) { + if (m_result.meta[i].nodeKind == NodeKind::Padding && + m_result.meta[i].lineKind == LineKind::Field) { + paddingLine = i; + break; + } + } + QVERIFY(paddingLine >= 0); + + const LineMeta* lm = m_editor->metaForLine(paddingLine); + QVERIFY(lm); + + // valueSpanFor returns valid (shared with Hex via KF_HexPreview) + ColumnSpan vs = RcxEditor::valueSpan(*lm, 200); + QVERIFY2(vs.valid, "valueSpanFor should return valid for Padding (shared HexPreview flag)"); + + // But beginInlineEdit should still reject it + QVERIFY(!m_editor->beginInlineEdit(EditTarget::Value, paddingLine)); + QVERIFY(!m_editor->isEditing()); + } + + // ── Test: value edit commit fires signal with typed text ── + void testValueEditCommitUpdatesSignal() { + m_editor->applyDocument(m_result); + + // Line 2 = first UInt8 field (InheritedAddressSpace) + const LineMeta* lm = m_editor->metaForLine(2); + QVERIFY(lm); + QCOMPARE(lm->lineKind, LineKind::Field); + QVERIFY(lm->nodeKind != NodeKind::Padding); + + // Begin value edit + bool ok = m_editor->beginInlineEdit(EditTarget::Value, 2); + QVERIFY(ok); + QVERIFY(m_editor->isEditing()); + + // Select all text in the edit span and type replacement + QKeyEvent home(QEvent::KeyPress, Qt::Key_Home, Qt::NoModifier); + QApplication::sendEvent(m_editor->scintilla(), &home); + QKeyEvent end(QEvent::KeyPress, Qt::Key_End, Qt::ShiftModifier); + QApplication::sendEvent(m_editor->scintilla(), &end); + + // Type "42" to replace selected text + for (QChar c : QString("42")) { + QKeyEvent key(QEvent::KeyPress, 0, Qt::NoModifier, QString(c)); + QApplication::sendEvent(m_editor->scintilla(), &key); + } + QApplication::processEvents(); + + // Commit with Enter + QSignalSpy spy(m_editor, &RcxEditor::inlineEditCommitted); + QKeyEvent enter(QEvent::KeyPress, Qt::Key_Return, Qt::NoModifier); + QApplication::sendEvent(m_editor->scintilla(), &enter); + + QCOMPARE(spy.count(), 1); + QVERIFY(!m_editor->isEditing()); + + // Verify the committed text contains what was typed. + // UInt8 values display as hex (e.g., "0x042"), so the typed "42" gets + // concatenated with the existing "0x0" prefix → "0x042". + // The important check: the signal fired with non-empty text. + QList args = spy.first(); + QString committedText = args.at(3).toString().trimmed(); + QVERIFY2(!committedText.isEmpty(), + "Committed text should not be empty"); + + m_editor->applyDocument(m_result); + } + // ── Test: base address edit begins on CommandRow (line 0) ── void testBaseAddressEditBegins() { m_editor->applyDocument(m_result);