From 9962e55820ebabb2ef547021abf05ff8a64d1014 Mon Sep 17 00:00:00 2001 From: "megablocks(tm)" Date: Sat, 7 Feb 2026 12:02:41 -0700 Subject: [PATCH] =?UTF-8?q?Fix=20cursor=20jump=20on=20command=20row=20edit?= =?UTF-8?q?,=20data-change=20highlighting=20for=20containers,=20invalid=20?= =?UTF-8?q?pointer=20expansion,=20cycle=20detection=20with=20node=20IDs,?= =?UTF-8?q?=20O(n=C2=B2)=20addNode=20cache,=20duplicate=20struct=20in=20C+?= =?UTF-8?q?+=20export,=20collapse=20bug=20for=20non-first=20root=20structs?= =?UTF-8?q?,=20remove=20hardcoded=20demo=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/compose.cpp | 19 ++-- src/controller.cpp | 12 ++- src/core.h | 20 ++-- src/editor.cpp | 12 +++ src/generator.cpp | 11 ++- src/main.cpp | 140 ++-------------------------- tests/test_new_features.cpp | 180 +++++++++++++++++++++++++++++++++--- 7 files changed, 225 insertions(+), 169 deletions(-) diff --git a/src/compose.cpp b/src/compose.cpp index 4e17610..3444f01 100644 --- a/src/compose.cpp +++ b/src/compose.cpp @@ -75,17 +75,19 @@ static QString resolvePointerTarget(const NodeTree& tree, uint64_t refId) { } static inline uint64_t ptrToProviderAddr(const NodeTree& tree, uint64_t ptr) { - if (tree.baseAddress && ptr >= tree.baseAddress) return ptr - tree.baseAddress; - return ptr; + if (tree.baseAddress == 0) return ptr; + if (ptr >= tree.baseAddress) return ptr - tree.baseAddress; + return UINT64_MAX; // Invalid: ptr below base address } static int64_t relOffsetFromRoot(const NodeTree& tree, int idx, uint64_t rootId) { int64_t total = 0; - QSet visited; + QSet visited; int cur = idx; while (cur >= 0 && cur < tree.nodes.size()) { - if (visited.contains(cur)) break; - visited.insert(cur); + uint64_t nid = tree.nodes[cur].id; + if (visited.contains(nid)) break; + visited.insert(nid); const Node& n = tree.nodes[cur]; if (n.id == rootId) break; total += n.offset; @@ -337,6 +339,10 @@ void composeNode(ComposeState& state, const NodeTree& tree, if (prov.isValid() && sz > 0 && prov.isReadable(absAddr, sz)) { uint64_t ptrVal = (node.kind == NodeKind::Pointer32) ? (uint64_t)prov.readU32(absAddr) : prov.readU64(absAddr); + if (ptrVal != 0) { + uint64_t pBase = ptrToProviderAddr(tree, ptrVal); + if (pBase == UINT64_MAX) ptrVal = 0; // ptr below base: invalid + } if (ptrVal != 0) { uint64_t pBase = ptrToProviderAddr(tree, ptrVal); qulonglong key = pBase ^ (node.refId * kGoldenRatio); @@ -507,9 +513,6 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR // If viewRootId is set, skip roots that don't match if (viewRootId != 0 && tree.nodes[idx].id != viewRootId) continue; - // Skip collapsed roots unless specifically targeted by viewRootId - if (viewRootId == 0 && tree.nodes[idx].collapsed) - continue; composeNode(state, tree, prov, idx, 0); } diff --git a/src/controller.cpp b/src/controller.cpp index 7b573d0..904fba3 100644 --- a/src/controller.cpp +++ b/src/controller.cpp @@ -540,8 +540,9 @@ void RcxController::refresh() { } } } else { - // Existing boolean logic for non-hex nodes - int sz = node.byteSize(); + // Use structSpan for containers (byteSize returns 0 for Array-of-Struct) + int sz = (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) + ? m_doc->tree.structSpan(node.id) : node.byteSize(); for (int64_t b = offset; b < offset + sz; b++) { if (m_changedOffsets.contains(b)) { lm.dataChanged = true; @@ -1108,16 +1109,19 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx, } menu.addAction(icon("add.svg"), "Add Hex64 at Root", [this]() { - insertNode(0, -1, NodeKind::Hex64, "newField"); + uint64_t target = m_viewRootId ? m_viewRootId : 0; + insertNode(target, -1, NodeKind::Hex64, "newField"); }); menu.addAction(icon("symbol-structure.svg"), "Add Struct at Root", [this]() { insertNode(0, -1, NodeKind::Struct, "NewClass"); + setViewRootId(0); // show all so the new struct is visible }); menu.addAction(icon("diff-added.svg"), "Append 128 bytes", [this]() { + uint64_t target = m_viewRootId ? m_viewRootId : 0; m_suppressRefresh = true; m_doc->undoStack.beginMacro(QStringLiteral("Append 128 bytes")); for (int i = 0; i < 16; i++) - insertNode(0, -1, NodeKind::Hex64, + insertNode(target, -1, NodeKind::Hex64, QStringLiteral("field_%1").arg(i)); m_doc->undoStack.endMacro(); m_suppressRefresh = false; diff --git a/src/core.h b/src/core.h index 89694ab..12e47aa 100644 --- a/src/core.h +++ b/src/core.h @@ -240,9 +240,11 @@ struct NodeTree { Node copy = n; if (copy.id == 0) copy.id = m_nextId++; else if (copy.id >= m_nextId) m_nextId = copy.id + 1; + int idx = nodes.size(); nodes.append(copy); - m_idCache.clear(); - return nodes.size() - 1; + if (!m_idCache.isEmpty()) + m_idCache[copy.id] = idx; + return idx; } // Reserve a unique ID atomically (for use before pushing undo commands) @@ -297,11 +299,12 @@ struct NodeTree { int depthOf(int idx) const { int d = 0; - QSet visited; + QSet visited; int cur = idx; while (cur >= 0 && cur < nodes.size() && nodes[cur].parentId != 0) { - if (visited.contains(cur)) break; - visited.insert(cur); + uint64_t nid = nodes[cur].id; + if (visited.contains(nid)) break; + visited.insert(nid); cur = indexOfId(nodes[cur].parentId); if (cur < 0) break; d++; @@ -311,11 +314,12 @@ struct NodeTree { int64_t computeOffset(int idx) const { int64_t total = 0; - QSet visited; + QSet visited; int cur = idx; while (cur >= 0 && cur < nodes.size()) { - if (visited.contains(cur)) break; - visited.insert(cur); + uint64_t nid = nodes[cur].id; + if (visited.contains(nid)) break; + visited.insert(nid); total += nodes[cur].offset; if (nodes[cur].parentId == 0) break; cur = indexOfId(nodes[cur].parentId); diff --git a/src/editor.cpp b/src/editor.cpp index b7550d1..e2e5c76 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -1982,10 +1982,16 @@ void RcxEditor::setCommandRowText(const QString& line) { long start = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE, 0); long end = m_sci->SendScintilla(QsciScintillaBase::SCI_GETLINEENDPOSITION, 0); QByteArray utf8 = s.toUtf8(); + long oldLen = end - start; m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART, start); m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND, end); m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACETARGET, (uintptr_t)utf8.size(), utf8.constData()); + // Adjust saved cursor/anchor for length change in line 0 + long delta = (long)utf8.size() - oldLen; + if (savedPos > end) savedPos += delta; + if (savedAnchor > end) savedAnchor += delta; + if (wasReadOnly) m_sci->setReadOnly(true); m_sci->SendScintilla(QsciScintillaBase::SCI_SETUNDOCOLLECTION, 1); if (!wasModified) m_sci->SendScintilla(QsciScintillaBase::SCI_SETSAVEPOINT); @@ -2011,11 +2017,17 @@ void RcxEditor::setCommandRow2Text(const QString& line) { long start = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE, 1); long end = m_sci->SendScintilla(QsciScintillaBase::SCI_GETLINEENDPOSITION, 1); + long oldLen = end - start; QByteArray utf8 = s.toUtf8(); m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART, start); m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND, end); m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACETARGET, (uintptr_t)utf8.size(), utf8.constData()); + // Adjust saved cursor/anchor for length change in line 1 + long delta = (long)utf8.size() - oldLen; + if (savedPos > end) savedPos += delta; + if (savedAnchor > end) savedAnchor += delta; + if (wasReadOnly) m_sci->setReadOnly(true); m_sci->SendScintilla(QsciScintillaBase::SCI_SETUNDOCOLLECTION, 1); if (!wasModified) m_sci->SendScintilla(QsciScintillaBase::SCI_SETSAVEPOINT); diff --git a/src/generator.cpp b/src/generator.cpp index e1cd49c..b83c38e 100644 --- a/src/generator.cpp +++ b/src/generator.cpp @@ -255,6 +255,14 @@ static void emitStruct(GenContext& ctx, uint64_t structId) { return; } + // Deduplicate by struct type name (different nodes may share the same type) + QString typeName = ctx.structName(node); + if (ctx.emittedTypeNames.contains(typeName)) { + ctx.emittedIds.insert(structId); + ctx.visiting.remove(structId); + return; + } + // Emit nested struct types first (dependency order) QVector children = ctx.childMap.value(structId); for (int ci : children) { @@ -283,8 +291,7 @@ static void emitStruct(GenContext& ctx, uint64_t structId) { } ctx.emittedIds.insert(structId); - - QString typeName = ctx.structName(node); + ctx.emittedTypeNames.insert(typeName); int structSize = ctx.tree.structSpan(structId, &ctx.childMap); ctx.output += QStringLiteral("#pragma pack(push, 1)\n"); diff --git a/src/main.cpp b/src/main.cpp index 28fb2dd..ef7aea8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,6 +1,5 @@ #include "controller.h" #include "generator.h" -#include "providers/process_provider.h" #include #include #include @@ -111,24 +110,6 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS* ep) { } #endif -// ── Self-test: live data for verifying auto-refresh ── -#include -#include -#include - -static uint8_t* g_testData = nullptr; -static constexpr int kTestDataSize = 128; -static std::atomic g_testRunning{false}; - -static void testLiveThread() { - std::mt19937 rng(42); - std::uniform_int_distribution dist(0, kTestDataSize - 1); - while (g_testRunning.load()) { - std::this_thread::sleep_for(std::chrono::seconds(1)); - g_testData[dist(rng)]++; - } -} - namespace rcx { class MainWindow : public QMainWindow { @@ -414,118 +395,13 @@ void MainWindow::newFile() { } void MainWindow::selfTest() { -#ifdef _WIN32 - // Allocate 128 bytes — lives until process exit - g_testData = new uint8_t[kTestDataSize](); - g_testRunning = true; - std::thread(testLiveThread).detach(); - - auto* doc = new RcxDocument(this); - uint64_t base = (uint64_t)g_testData; - - HANDLE hProc = OpenProcess( - PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION - | PROCESS_QUERY_INFORMATION, - FALSE, GetCurrentProcessId()); - doc->provider = std::make_shared( - hProc, base, kTestDataSize, "ReclassX.exe"); - doc->tree.baseAddress = base; - - // ── Pet (root struct, 64 bytes) ── - { - Node pet; - pet.kind = NodeKind::Struct; - pet.name = "aPet"; - pet.structTypeName = "Pet"; - pet.parentId = 0; - pet.offset = 0; - int pi = doc->tree.addNode(pet); - uint64_t petId = doc->tree.nodes[pi].id; - - { Node n; n.kind = NodeKind::UTF8; n.name = "name"; n.parentId = petId; n.offset = 0; n.strLen = 24; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_18"; n.parentId = petId; n.offset = 24; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Int32; n.name = "age"; n.parentId = petId; n.offset = 32; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Float; n.name = "weight"; n.parentId = petId; n.offset = 36; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Pointer64; n.name = "owner"; n.parentId = petId; n.offset = 40; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Bool; n.name = "alive"; n.parentId = petId; n.offset = 48; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex8; n.name = "field_31"; n.parentId = petId; n.offset = 49; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex16; n.name = "field_32"; n.parentId = petId; n.offset = 50; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::UInt32; n.name = "flags"; n.parentId = petId; n.offset = 52; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_38"; n.parentId = petId; n.offset = 56; doc->tree.addNode(n); } + // Load demo.rcx if it exists, otherwise create a blank project + QString demoPath = QCoreApplication::applicationDirPath() + "/demo.rcx"; + if (QFile::exists(demoPath)) { + project_open(demoPath); + } else { + project_new(); } - - // ── Cat : Pet (root struct, inherits Pet at offset 0) ── - { - Node cat; - cat.kind = NodeKind::Struct; - cat.name = "aCat"; - cat.structTypeName = "Cat"; - cat.classKeyword = "class"; - cat.parentId = 0; - cat.offset = 0; - int ci = doc->tree.addNode(cat); - uint64_t catId = doc->tree.nodes[ci].id; - - // Embedded base Pet - Node base; - base.kind = NodeKind::Struct; - base.name = "base"; - base.structTypeName = "Pet"; - base.parentId = catId; - base.offset = 0; - int bi = doc->tree.addNode(base); - uint64_t baseId = doc->tree.nodes[bi].id; - - { Node n; n.kind = NodeKind::UTF8; n.name = "name"; n.parentId = baseId; n.offset = 0; n.strLen = 24; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_18"; n.parentId = baseId; n.offset = 24; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Int32; n.name = "age"; n.parentId = baseId; n.offset = 32; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Float; n.name = "weight"; n.parentId = baseId; n.offset = 36; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Pointer64; n.name = "owner"; n.parentId = baseId; n.offset = 40; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Bool; n.name = "alive"; n.parentId = baseId; n.offset = 48; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex8; n.name = "field_31"; n.parentId = baseId; n.offset = 49; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex16; n.name = "field_32"; n.parentId = baseId; n.offset = 50; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::UInt32; n.name = "flags"; n.parentId = baseId; n.offset = 52; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_38"; n.parentId = baseId; n.offset = 56; doc->tree.addNode(n); } - - // Cat's own fields after base - { Node n; n.kind = NodeKind::Float; n.name = "whiskerLen"; n.parentId = catId; n.offset = 64; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::UInt8; n.name = "lives"; n.parentId = catId; n.offset = 68; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex8; n.name = "field_45"; n.parentId = catId; n.offset = 69; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex16; n.name = "field_46"; n.parentId = catId; n.offset = 70; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Bool; n.name = "indoor"; n.parentId = catId; n.offset = 72; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex8; n.name = "field_49"; n.parentId = catId; n.offset = 73; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex16; n.name = "field_4A"; n.parentId = catId; n.offset = 74; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Int32; n.name = "miceKilled"; n.parentId = catId; n.offset = 76; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_50"; n.parentId = catId; n.offset = 80; doc->tree.addNode(n); } - } - - // ── Ball (standalone root struct) ── - { - Node ball; - ball.kind = NodeKind::Struct; - ball.name = "aBall"; - ball.structTypeName = "Ball"; - ball.collapsed = true; - ball.parentId = 0; - ball.offset = 0; - int bli = doc->tree.addNode(ball); - uint64_t ballId = doc->tree.nodes[bli].id; - - { Node n; n.kind = NodeKind::Vec4; n.name = "position"; n.parentId = ballId; n.offset = 0; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Vec3; n.name = "velocity"; n.parentId = ballId; n.offset = 16; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Float; n.name = "speed"; n.parentId = ballId; n.offset = 28; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::UInt32; n.name = "color"; n.parentId = ballId; n.offset = 32; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Float; n.name = "radius"; n.parentId = ballId; n.offset = 36; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Double; n.name = "mass"; n.parentId = ballId; n.offset = 40; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Bool; n.name = "bouncy"; n.parentId = ballId; n.offset = 48; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex8; n.name = "field_31"; n.parentId = ballId; n.offset = 49; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex16; n.name = "field_32"; n.parentId = ballId; n.offset = 50; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::UInt32; n.name = "bounceCount"; n.parentId = ballId; n.offset = 52; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "field_38"; n.parentId = ballId; n.offset = 56; doc->tree.addNode(n); } - } - - createTab(doc); -#endif } void MainWindow::openFile() { @@ -553,7 +429,7 @@ void MainWindow::addNode() { auto* ctrl = activeController(); if (!ctrl) return; - uint64_t parentId = 0; + uint64_t parentId = ctrl->viewRootId(); // default to current view root auto* primary = ctrl->primaryEditor(); if (primary && primary->isEditing()) return; if (primary) { @@ -1123,7 +999,7 @@ int main(int argc, char* argv[]) { window.setWindowOpacity(0.0); window.show(); - // Auto-open self-test tab (live data refresh test) + // Auto-open demo project from saved .rcx file QMetaObject::invokeMethod(&window, "selfTest"); if (screenshotMode) { diff --git a/tests/test_new_features.cpp b/tests/test_new_features.cpp index 201d2f3..c09a39c 100644 --- a/tests/test_new_features.cpp +++ b/tests/test_new_features.cpp @@ -99,10 +99,10 @@ private: int pi = tree.addNode(pet); uint64_t petId = tree.nodes[pi].id; - { Node n; n.kind = NodeKind::Hex8; n.name = "hex_00"; n.parentId = petId; n.offset = 0; tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex8; n.name = "hex_01"; n.parentId = petId; n.offset = 1; tree.addNode(n); } - { Node n; n.kind = NodeKind::UTF8; n.name = "name"; n.parentId = petId; n.offset = 2; n.strLen = 32; tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex16; n.name = "hex_22"; n.parentId = petId; n.offset = 34; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "hex_00"; n.parentId = petId; n.offset = 0; tree.addNode(n); } + { Node n; n.kind = NodeKind::UTF8; n.name = "name"; n.parentId = petId; n.offset = 8; n.strLen = 16; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "hex_18"; n.parentId = petId; n.offset = 24; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex32; n.name = "hex_20"; n.parentId = petId; n.offset = 32; tree.addNode(n); } { Node n; n.kind = NodeKind::Hex32; n.name = "hex_24"; n.parentId = petId; n.offset = 36; tree.addNode(n); } { Node n; n.kind = NodeKind::Pointer64; n.name = "owner"; n.parentId = petId; n.offset = 40; tree.addNode(n); } { Node n; n.kind = NodeKind::Hex64; n.name = "hex_30"; n.parentId = petId; n.offset = 48; tree.addNode(n); } @@ -129,15 +129,21 @@ private: uint64_t baseId = tree.nodes[bi].id; // Children inside the nested Pet base - { Node n; n.kind = NodeKind::UTF8; n.name = "name"; n.parentId = baseId; n.offset = 0; n.strLen = 32; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "hex_00"; n.parentId = baseId; n.offset = 0; tree.addNode(n); } + { Node n; n.kind = NodeKind::UTF8; n.name = "name"; n.parentId = baseId; n.offset = 8; n.strLen = 16; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "hex_18"; n.parentId = baseId; n.offset = 24; tree.addNode(n); } { Node n; n.kind = NodeKind::Pointer64; n.name = "owner"; n.parentId = baseId; n.offset = 32; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "hex_28"; n.parentId = baseId; n.offset = 40; tree.addNode(n); } // Cat's own fields after base - { Node n; n.kind = NodeKind::Hex64; n.name = "hex_40"; n.parentId = catId; n.offset = 64; tree.addNode(n); } - { Node n; n.kind = NodeKind::Float; n.name = "whiskerLen"; n.parentId = catId; n.offset = 72; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "hex_30"; n.parentId = catId; n.offset = 48; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "hex_38"; n.parentId = catId; n.offset = 56; tree.addNode(n); } + { Node n; n.kind = NodeKind::Float; n.name = "whiskerLen"; n.parentId = catId; n.offset = 64; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex32; n.name = "hex_44"; n.parentId = catId; n.offset = 68; tree.addNode(n); } + { Node n; n.kind = NodeKind::UInt8; n.name = "lives"; n.parentId = catId; n.offset = 72; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex8; n.name = "hex_49"; n.parentId = catId; n.offset = 73; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex16; n.name = "hex_4A"; n.parentId = catId; n.offset = 74; tree.addNode(n); } { Node n; n.kind = NodeKind::Hex32; n.name = "hex_4C"; n.parentId = catId; n.offset = 76; tree.addNode(n); } - { Node n; n.kind = NodeKind::UInt8; n.name = "lives"; n.parentId = catId; n.offset = 80; tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex8; n.name = "hex_51"; n.parentId = catId; n.offset = 81; tree.addNode(n); } // ── Ball (independent root struct) ── Node ball; @@ -149,13 +155,16 @@ private: int bli = tree.addNode(ball); uint64_t ballId = tree.nodes[bli].id; - { Node n; n.kind = NodeKind::Hex32; n.name = "hex_00"; n.parentId = ballId; n.offset = 0; tree.addNode(n); } - { Node n; n.kind = NodeKind::Float; n.name = "speed"; n.parentId = ballId; n.offset = 4; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "hex_00"; n.parentId = ballId; n.offset = 0; tree.addNode(n); } { Node n; n.kind = NodeKind::Hex64; n.name = "hex_08"; n.parentId = ballId; n.offset = 8; tree.addNode(n); } - { Node n; n.kind = NodeKind::Vec4; n.name = "position"; n.parentId = ballId; n.offset = 16; tree.addNode(n); } - { Node n; n.kind = NodeKind::UInt32; n.name = "color"; n.parentId = ballId; n.offset = 32; tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex32; n.name = "hex_24"; n.parentId = ballId; n.offset = 36; tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex64; n.name = "hex_28"; n.parentId = ballId; n.offset = 40; tree.addNode(n); } + { Node n; n.kind = NodeKind::Float; n.name = "speed"; n.parentId = ballId; n.offset = 16; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex32; n.name = "hex_14"; n.parentId = ballId; n.offset = 20; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "hex_18"; n.parentId = ballId; n.offset = 24; tree.addNode(n); } + { Node n; n.kind = NodeKind::Vec4; n.name = "position"; n.parentId = ballId; n.offset = 32; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "hex_30"; n.parentId = ballId; n.offset = 48; tree.addNode(n); } + { Node n; n.kind = NodeKind::UInt32; n.name = "color"; n.parentId = ballId; n.offset = 56; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex32; n.name = "hex_3C"; n.parentId = ballId; n.offset = 60; tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex64; n.name = "hex_40"; n.parentId = ballId; n.offset = 64; tree.addNode(n); } return tree; } @@ -669,6 +678,147 @@ private slots: QVERIFY(!health->data(Qt::UserRole + 1).isValid()); } + // ═══════════════════════════════════════════════════ + // Feature: Double-click navigation (viewRootId + scroll) + // ═══════════════════════════════════════════════════ + + void testDoubleClick_switchToCollapsedClass() { + // Simulates: Ball is collapsed (hidden). Double-click Ball in workspace + // → uncollapse, set viewRootId, compose shows only Ball with children. + RcxDocument doc; + doc.tree = makeRichTree(); + + // Collapse Ball (3rd root struct) + uint64_t ballId = 0; + for (auto& node : doc.tree.nodes) { + if (node.parentId == 0 && node.kind == NodeKind::Struct + && node.structTypeName == "Ball") { + node.collapsed = true; + ballId = node.id; + break; + } + } + QVERIFY(ballId != 0); + + // Compose with viewRootId=0 should skip collapsed Ball + { + NullProvider prov; + ComposeResult result = compose(doc.tree, prov, 0); + bool foundSpeed = false; + for (const auto& lm : result.meta) { + int ni = lm.nodeIdx; + if (ni >= 0 && ni < doc.tree.nodes.size() + && doc.tree.nodes[ni].name == "speed") + foundSpeed = true; + } + QVERIFY2(!foundSpeed, "Collapsed Ball's children should not appear with viewRootId=0"); + } + + // Simulate double-click: uncollapse Ball + set viewRootId + int bi = doc.tree.indexOfId(ballId); + QVERIFY(bi >= 0); + doc.tree.nodes[bi].collapsed = false; + + // Compose with viewRootId=Ball should show Ball and its children + { + NullProvider prov; + ComposeResult result = compose(doc.tree, prov, ballId); + bool foundSpeed = false, foundPosition = false, foundColor = false; + for (const auto& lm : result.meta) { + int ni = lm.nodeIdx; + if (ni < 0 || ni >= doc.tree.nodes.size()) continue; + const QString& name = doc.tree.nodes[ni].name; + if (name == "speed") foundSpeed = true; + if (name == "position") foundPosition = true; + if (name == "color") foundColor = true; + } + QVERIFY2(foundSpeed, "Ball's speed field should appear"); + QVERIFY2(foundPosition, "Ball's position field should appear"); + QVERIFY2(foundColor, "Ball's color field should appear"); + } + + // Pet/Cat fields should NOT be in the Ball-filtered result + { + NullProvider prov; + ComposeResult result = compose(doc.tree, prov, ballId); + bool foundPetField = false; + for (const auto& lm : result.meta) { + int ni = lm.nodeIdx; + if (ni < 0 || ni >= doc.tree.nodes.size()) continue; + if (doc.tree.nodes[ni].name == "owner") foundPetField = true; + } + QVERIFY2(!foundPetField, "Pet's owner should not appear when viewing Ball"); + } + } + + void testDoubleClick_fieldNavigatesToParentRoot() { + // Simulates: double-click a field inside Ball → walk up to Ball root, + // set viewRootId to Ball, and the field should be in the compose output. + RcxDocument doc; + doc.tree = makeRichTree(); + + // Find Ball's "speed" child + uint64_t ballId = 0, speedId = 0; + for (auto& node : doc.tree.nodes) { + if (node.parentId == 0 && node.structTypeName == "Ball") + ballId = node.id; + } + QVERIFY(ballId != 0); + for (auto& node : doc.tree.nodes) { + if (node.parentId == ballId && node.name == "speed") + speedId = node.id; + } + QVERIFY(speedId != 0); + + // Walk up from speed to find root struct (simulating handler logic) + uint64_t rootId = 0; + uint64_t cur = speedId; + while (cur != 0) { + int idx = doc.tree.indexOfId(cur); + if (idx < 0) break; + if (doc.tree.nodes[idx].parentId == 0) { rootId = cur; break; } + cur = doc.tree.nodes[idx].parentId; + } + QCOMPARE(rootId, ballId); + + // Compose with viewRootId=Ball should contain speed + NullProvider prov; + ComposeResult result = compose(doc.tree, prov, ballId); + bool foundSpeed = false; + for (const auto& lm : result.meta) { + if (lm.nodeId == speedId) { foundSpeed = true; break; } + } + QVERIFY2(foundSpeed, "speed field should be in compose output when viewing its root"); + } + + void testDoubleClick_projectRootShowsAll() { + // Double-click project root clears viewRootId → all non-collapsed roots shown + RcxDocument doc; + doc.tree = makeRichTree(); + + // Collapse Ball + for (auto& node : doc.tree.nodes) { + if (node.parentId == 0 && node.structTypeName == "Ball") + node.collapsed = true; + } + + // viewRootId=0 → Pet and Cat visible, Ball hidden + NullProvider prov; + ComposeResult result = compose(doc.tree, prov, 0); + bool foundOwner = false, foundWhiskerLen = false, foundSpeed = false; + for (const auto& lm : result.meta) { + int ni = lm.nodeIdx; + if (ni < 0 || ni >= doc.tree.nodes.size()) continue; + const QString& name = doc.tree.nodes[ni].name; + if (name == "owner") foundOwner = true; + if (name == "whiskerLen") foundWhiskerLen = true; + if (name == "speed") foundSpeed = true; + } + QVERIFY2(foundOwner, "Pet's owner should appear with viewRootId=0"); + QVERIFY2(foundWhiskerLen, "Cat's whiskerLen should appear with viewRootId=0"); + QVERIFY2(!foundSpeed, "Collapsed Ball's speed should not appear with viewRootId=0"); + } + // ═══════════════════════════════════════════════════ // Integration: Type aliases + compose + generator // ═══════════════════════════════════════════════════