diff --git a/CMakeLists.txt b/CMakeLists.txt index 758aa55..adc929f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -27,6 +27,7 @@ add_executable(ReclassX src/processpicker.ui src/resources.qrc src/core.h + src/workspace_model.h src/providers/buffer_provider.h src/providers/null_provider.h src/providers/process_provider.h src/providers/provider.h src/providers/snapshot_provider.h ) @@ -153,4 +154,13 @@ if(BUILD_TESTING) Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test QScintilla::QScintilla dbghelp psapi) add_test(NAME test_context_menu COMMAND test_context_menu) + + add_executable(test_new_features tests/test_new_features.cpp + src/generator.cpp src/compose.cpp src/format.cpp src/controller.cpp + src/editor.cpp src/processpicker.cpp src/processpicker.ui) + target_include_directories(test_new_features PRIVATE src) + target_link_libraries(test_new_features PRIVATE + Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test + QScintilla::QScintilla dbghelp psapi) + add_test(NAME test_new_features COMMAND test_new_features) endif() diff --git a/src/compose.cpp b/src/compose.cpp index 46b7258..4e17610 100644 --- a/src/compose.cpp +++ b/src/compose.cpp @@ -384,7 +384,7 @@ void composeNode(ComposeState& state, const NodeTree& tree, } // anonymous namespace -ComposeResult compose(const NodeTree& tree, const Provider& prov) { +ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId) { ComposeState state; // Precompute parent→children map @@ -504,6 +504,12 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov) { }); for (int idx : roots) { + // 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 ff25482..7b573d0 100644 --- a/src/controller.cpp +++ b/src/controller.cpp @@ -21,6 +21,14 @@ namespace rcx { +static thread_local const RcxDocument* s_composeDoc = nullptr; + +static QString docTypeNameProvider(NodeKind k) { + if (s_composeDoc) return s_composeDoc->resolveTypeName(k); + auto* m = kindMeta(k); + return m ? QString::fromLatin1(m->typeName) : QStringLiteral("???"); +} + static QString elide(QString s, int max) { if (max <= 0) return {}; if (s.size() <= max) return s; @@ -63,12 +71,21 @@ RcxDocument::RcxDocument(QObject* parent) }); } -ComposeResult RcxDocument::compose() const { - return rcx::compose(tree, *provider); +ComposeResult RcxDocument::compose(uint64_t viewRootId) const { + return rcx::compose(tree, *provider, viewRootId); } bool RcxDocument::save(const QString& path) { QJsonObject json = tree.toJson(); + + // Save type aliases + if (!typeAliases.isEmpty()) { + QJsonObject aliasObj; + for (auto it = typeAliases.begin(); it != typeAliases.end(); ++it) + aliasObj[kindToString(it.key())] = it.value(); + json["typeAliases"] = aliasObj; + } + QJsonDocument jdoc(json); QFile file(path); if (!file.open(QIODevice::WriteOnly)) @@ -86,7 +103,19 @@ bool RcxDocument::load(const QString& path) { return false; undoStack.clear(); QJsonDocument jdoc = QJsonDocument::fromJson(file.readAll()); - tree = NodeTree::fromJson(jdoc.object()); + QJsonObject root = jdoc.object(); + tree = NodeTree::fromJson(root); + + // Load type aliases + typeAliases.clear(); + QJsonObject aliasObj = root["typeAliases"].toObject(); + for (auto it = aliasObj.begin(); it != aliasObj.end(); ++it) { + NodeKind k = kindFromString(it.key()); + QString v = it.value().toString(); + if (!v.isEmpty()) + typeAliases[k] = v; + } + filePath = path; modified = false; emit documentChanged(); @@ -125,6 +154,7 @@ void RcxCommand::redo() { m_ctrl->applyCommand(m_cmd, false); } RcxController::RcxController(RcxDocument* doc, QWidget* parent) : QObject(parent), m_doc(doc) { + fmt::setTypeNameProvider(docTypeNameProvider); connect(m_doc, &RcxDocument::documentChanged, this, &RcxController::refresh); setupAutoRefresh(); } @@ -469,12 +499,28 @@ void RcxController::connectEditor(RcxEditor* editor) { this, [this]() { refresh(); }); } +void RcxController::setViewRootId(uint64_t id) { + if (m_viewRootId == id) return; + m_viewRootId = id; + refresh(); +} + +void RcxController::scrollToNodeId(uint64_t nodeId) { + if (auto* editor = primaryEditor()) + editor->scrollToNodeId(nodeId); +} + void RcxController::refresh() { + // Bracket compose with thread-local doc pointer for type name resolution + s_composeDoc = m_doc; + // Compose against snapshot provider if active, otherwise real provider if (m_snapshotProv) - m_lastResult = rcx::compose(m_doc->tree, *m_snapshotProv); + m_lastResult = rcx::compose(m_doc->tree, *m_snapshotProv, m_viewRootId); else - m_lastResult = m_doc->compose(); + m_lastResult = m_doc->compose(m_viewRootId); + + s_composeDoc = nullptr; // Mark lines whose node data changed since last refresh if (!m_changedOffsets.isEmpty()) { diff --git a/src/controller.h b/src/controller.h index 60bf74e..94010ec 100644 --- a/src/controller.h +++ b/src/controller.h @@ -28,8 +28,17 @@ public: QString filePath; QString dataPath; bool modified = false; + QHash typeAliases; - ComposeResult compose() const; + QString resolveTypeName(NodeKind kind) const { + auto it = typeAliases.find(kind); + if (it != typeAliases.end() && !it.value().isEmpty()) + return it.value(); + auto* m = kindMeta(kind); + return m ? QString::fromLatin1(m->typeName) : QStringLiteral("???"); + } + + ComposeResult compose(uint64_t viewRootId = 0) const; bool save(const QString& path); bool load(const QString& path); void loadData(const QString& binaryPath); @@ -96,6 +105,10 @@ public: void applySelectionOverlays(); QSet selectedIds() const { return m_selIds; } + void setViewRootId(uint64_t id); + uint64_t viewRootId() const { return m_viewRootId; } + void scrollToNodeId(uint64_t nodeId); + RcxDocument* document() const { return m_doc; } void setEditorFont(const QString& fontName); @@ -110,6 +123,7 @@ private: QSet m_selIds; int m_anchorLine = -1; bool m_suppressRefresh = false; + uint64_t m_viewRootId = 0; // ── Saved sources for quick-switch ── QVector m_savedSources; diff --git a/src/core.h b/src/core.h index 0a43b99..89694ab 100644 --- a/src/core.h +++ b/src/core.h @@ -747,6 +747,6 @@ namespace fmt { // ── Compose function forward declaration ── -ComposeResult compose(const NodeTree& tree, const Provider& prov); +ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId = 0); } // namespace rcx diff --git a/src/editor.cpp b/src/editor.cpp index 6d9866d..b7550d1 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -524,6 +524,16 @@ int RcxEditor::currentNodeIndex() const { return lm ? lm->nodeIdx : -1; } +void RcxEditor::scrollToNodeId(uint64_t nodeId) { + for (int i = 0; i < m_meta.size(); i++) { + if (m_meta[i].nodeId == nodeId && m_meta[i].lineKind != LineKind::Footer) { + m_sci->setCursorPosition(i, 0); + m_sci->ensureLineVisible(i); + return; + } + } +} + // ── Column span computation ── ColumnSpan RcxEditor::typeSpan(const LineMeta& lm, int typeW) { return typeSpanFor(lm, typeW); } diff --git a/src/editor.h b/src/editor.h index 7b62282..8034182 100644 --- a/src/editor.h +++ b/src/editor.h @@ -28,6 +28,7 @@ public: QsciScintilla* scintilla() const { return m_sci; } const LineMeta* metaForLine(int line) const; int currentNodeIndex() const; + void scrollToNodeId(uint64_t nodeId); // ── Column span computation ── static ColumnSpan typeSpan(const LineMeta& lm, int typeW = kColType); diff --git a/src/generator.cpp b/src/generator.cpp index f07739a..e1cd49c 100644 --- a/src/generator.cpp +++ b/src/generator.cpp @@ -66,11 +66,22 @@ struct GenContext { QSet forwardDeclared; // forward-declared type IDs QString output; int padCounter = 0; + const QHash* typeAliases = nullptr; QString uniquePadName() { return QStringLiteral("_pad%1").arg(padCounter++, 4, 16, QChar('0')); } + // Resolve the C type name for a primitive, consulting aliases first + QString cType(NodeKind kind) const { + if (typeAliases) { + auto it = typeAliases->find(kind); + if (it != typeAliases->end() && !it.value().isEmpty()) + return it.value(); + } + return cTypeName(kind); + } + // Resolve the canonical type name for a struct/array node QString structName(const Node& n) const { if (!n.structTypeName.isEmpty()) return sanitizeIdent(n.structTypeName); @@ -92,28 +103,28 @@ static QString emitField(GenContext& ctx, const Node& node) { switch (node.kind) { case NodeKind::Vec2: - return QStringLiteral(" float %1[2];").arg(name); + return QStringLiteral(" %1 %2[2];").arg(ctx.cType(NodeKind::Float), name); case NodeKind::Vec3: - return QStringLiteral(" float %1[3];").arg(name); + return QStringLiteral(" %1 %2[3];").arg(ctx.cType(NodeKind::Float), name); case NodeKind::Vec4: - return QStringLiteral(" float %1[4];").arg(name); + return QStringLiteral(" %1 %2[4];").arg(ctx.cType(NodeKind::Float), name); case NodeKind::Mat4x4: - return QStringLiteral(" float %1[4][4];").arg(name); + return QStringLiteral(" %1 %2[4][4];").arg(ctx.cType(NodeKind::Float), name); case NodeKind::UTF8: - return QStringLiteral(" char %1[%2];").arg(name).arg(node.strLen); + return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::UTF8), name).arg(node.strLen); case NodeKind::UTF16: - return QStringLiteral(" wchar_t %1[%2];").arg(name).arg(node.strLen); + return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::UTF16), name).arg(node.strLen); case NodeKind::Padding: - return QStringLiteral(" uint8_t %1[%2];").arg(name).arg(qMax(1, node.arrayLen)); + return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::Padding), name).arg(qMax(1, node.arrayLen)); case NodeKind::Pointer32: { if (node.refId != 0) { int refIdx = tree.indexOfId(node.refId); if (refIdx >= 0) { QString target = ctx.structName(tree.nodes[refIdx]); - return QStringLiteral(" uint32_t %1; // -> %2*").arg(name, target); + return QStringLiteral(" %1 %2; // -> %3*").arg(ctx.cType(NodeKind::Pointer32), name, target); } } - return QStringLiteral(" uint32_t %1;").arg(name); + return QStringLiteral(" %1 %2;").arg(ctx.cType(NodeKind::Pointer32), name); } case NodeKind::Pointer64: { if (node.refId != 0) { @@ -126,7 +137,7 @@ static QString emitField(GenContext& ctx, const Node& node) { return QStringLiteral(" void* %1;").arg(name); } default: - return QStringLiteral(" %1 %2;").arg(cTypeName(node.kind), name); + return QStringLiteral(" %1 %2;").arg(ctx.cType(node.kind), name); } } @@ -157,7 +168,8 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) { // Gap before this field if (child.offset > cursor) { int gap = child.offset - cursor; - ctx.output += QStringLiteral(" uint8_t %1[0x%2];\n") + ctx.output += QStringLiteral(" %1 %2[0x%3];\n") + .arg(ctx.cType(NodeKind::Padding)) .arg(ctx.uniquePadName()) .arg(QString::number(gap, 16).toUpper()); } else if (child.offset < cursor) { @@ -195,7 +207,7 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) { .arg(elemTypeName, fieldName).arg(child.arrayLen); } else { ctx.output += QStringLiteral(" %1 %2[%3];\n") - .arg(cTypeName(child.elementKind), fieldName).arg(child.arrayLen); + .arg(ctx.cType(child.elementKind), fieldName).arg(child.arrayLen); } } else { ctx.output += emitField(ctx, child) + QStringLiteral("\n"); @@ -208,7 +220,8 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) { // Tail padding if (cursor < structSize) { int gap = structSize - cursor; - ctx.output += QStringLiteral(" uint8_t %1[0x%2];\n") + ctx.output += QStringLiteral(" %1 %2[0x%3];\n") + .arg(ctx.cType(NodeKind::Padding)) .arg(ctx.uniquePadName()) .arg(QString::number(gap, 16).toUpper()); } @@ -321,14 +334,15 @@ static QString nodePath(const NodeTree& tree, uint64_t nodeId) { // ── Public API ── -QString renderCpp(const NodeTree& tree, uint64_t rootStructId) { +QString renderCpp(const NodeTree& tree, uint64_t rootStructId, + const QHash* typeAliases) { int idx = tree.indexOfId(rootStructId); if (idx < 0) return {}; const Node& root = tree.nodes[idx]; if (root.kind != NodeKind::Struct) return {}; - GenContext ctx{tree, buildChildMap(tree), {}, {}, {}, {}, {}, 0}; + GenContext ctx{tree, buildChildMap(tree), {}, {}, {}, {}, {}, 0, typeAliases}; int rootSize = tree.structSpan(rootStructId, &ctx.childMap); QString typeName = ctx.structName(root); @@ -345,8 +359,9 @@ QString renderCpp(const NodeTree& tree, uint64_t rootStructId) { return ctx.output; } -QString renderCppAll(const NodeTree& tree) { - GenContext ctx{tree, buildChildMap(tree), {}, {}, {}, {}, {}, 0}; +QString renderCppAll(const NodeTree& tree, + const QHash* typeAliases) { + GenContext ctx{tree, buildChildMap(tree), {}, {}, {}, {}, {}, 0, typeAliases}; ctx.output += QStringLiteral("// Generated by ReclassX\n"); ctx.output += QStringLiteral("// Full SDK export\n\n"); diff --git a/src/generator.h b/src/generator.h index 6928855..fcf31ac 100644 --- a/src/generator.h +++ b/src/generator.h @@ -1,16 +1,19 @@ #pragma once #include "core.h" #include +#include #include namespace rcx { // Generate C++ struct definitions for a single root struct and all // nested/referenced types reachable from it. -QString renderCpp(const NodeTree& tree, uint64_t rootStructId); +QString renderCpp(const NodeTree& tree, uint64_t rootStructId, + const QHash* typeAliases = nullptr); // Generate C++ struct definitions for every root-level struct (full SDK). -QString renderCppAll(const NodeTree& tree); +QString renderCppAll(const NodeTree& tree, + const QHash* typeAliases = nullptr); // Null generator placeholder (returns empty string). QString renderNull(const NodeTree& tree, uint64_t rootStructId); diff --git a/src/main.cpp b/src/main.cpp index 63d4804..28fb2dd 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -25,6 +25,15 @@ #include #include #include +#include +#include +#include +#include "workspace_model.h" +#include +#include +#include +#include +#include #include #include @@ -148,6 +157,14 @@ private slots: void about(); void setEditorFont(const QString& fontName); void exportCpp(); + void showTypeAliasesDialog(); + +public: + // Project Lifecycle API + QMdiSubWindow* project_new(); + QMdiSubWindow* project_open(const QString& path = {}); + bool project_save(QMdiSubWindow* sub = nullptr, bool saveAs = false); + void project_close(QMdiSubWindow* sub = nullptr); private: enum ViewMode { VM_Reclass, VM_Rendered }; @@ -184,6 +201,13 @@ private: void syncRenderMenuState(); uint64_t findRootStructForNode(const NodeTree& tree, uint64_t nodeId) const; void setupRenderedSci(QsciScintilla* sci); + + // Workspace dock + QDockWidget* m_workspaceDock = nullptr; + QTreeView* m_workspaceTree = nullptr; + QStandardItemModel* m_workspaceModel = nullptr; + void createWorkspaceDock(); + void rebuildWorkspaceModel(); }; MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { @@ -198,11 +222,13 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) { createMenus(); createStatusBar(); + createWorkspaceDock(); connect(m_mdiArea, &QMdiArea::subWindowActivated, this, [this](QMdiSubWindow*) { updateWindowTitle(); syncRenderMenuState(); + rebuildWorkspaceModel(); }); } @@ -244,6 +270,8 @@ void MainWindow::createMenus() { auto* edit = menuBar()->addMenu("&Edit"); edit->addAction(makeIcon(":/vsicons/arrow-left.svg"), "&Undo", QKeySequence::Undo, this, &MainWindow::undo); edit->addAction(makeIcon(":/vsicons/arrow-right.svg"), "&Redo", QKeySequence::Redo, this, &MainWindow::redo); + edit->addSeparator(); + edit->addAction(makeIcon(":/vsicons/symbol-structure.svg"), "&Type Aliases...", this, &MainWindow::showTypeAliasesDialog); // View auto* view = menuBar()->addMenu("&View"); @@ -319,6 +347,7 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) { it->doc->deleteLater(); m_tabs.erase(it); } + rebuildWorkspaceModel(); }); connect(ctrl, &RcxController::nodeSelected, @@ -354,7 +383,7 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) { m_statusLabel->setText(QString("%1 nodes selected").arg(count)); }); - // Update rendered view on document changes and undo/redo + // Update rendered view and workspace on document changes and undo/redo connect(doc, &RcxDocument::documentChanged, this, [this, sub]() { auto it = m_tabs.find(sub); @@ -362,6 +391,7 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) { QTimer::singleShot(0, this, [this, sub]() { auto it2 = m_tabs.find(sub); if (it2 != m_tabs.end()) updateRenderedView(*it2); + rebuildWorkspaceModel(); }); }); connect(&doc->undoStack, &QUndoStack::indexChanged, @@ -375,31 +405,12 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) { }); ctrl->refresh(); + rebuildWorkspaceModel(); return sub; } void MainWindow::newFile() { - auto* doc = new RcxDocument(this); - - QByteArray data(16, '\0'); - doc->loadData(data); - doc->tree.baseAddress = 0x00400000; - - Node root; - root.kind = NodeKind::Struct; - root.name = "Entity"; - root.structTypeName = "Entity"; - root.parentId = 0; - root.offset = 0; - int ri = doc->tree.addNode(root); - uint64_t rootId = doc->tree.nodes[ri].id; - - { Node n; n.kind = NodeKind::Int32; n.name = "health"; n.parentId = rootId; n.offset = 0; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Int32; n.name = "armor"; n.parentId = rootId; n.offset = 4; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Float; n.name = "speed"; n.parentId = rootId; n.offset = 8; doc->tree.addNode(n); } - { Node n; n.kind = NodeKind::Hex32; n.name = "flags"; n.parentId = rootId; n.offset = 12; doc->tree.addNode(n); } - - createTab(doc); + project_new(); } void MainWindow::selfTest() { @@ -420,22 +431,97 @@ void MainWindow::selfTest() { hProc, base, kTestDataSize, "ReclassX.exe"); doc->tree.baseAddress = base; - Node root; - root.kind = NodeKind::Struct; - root.name = "MyClass"; - root.structTypeName = "MyClass"; - root.parentId = 0; - root.offset = 0; - int ri = doc->tree.addNode(root); - uint64_t rootId = doc->tree.nodes[ri].id; + // ── 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; - for (int i = 0; i < 16; i++) { - Node n; - n.kind = NodeKind::Hex64; - n.name = QStringLiteral("field_%1").arg(i); - n.parentId = rootId; - n.offset = i * 8; - doc->tree.addNode(n); + { 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); } + } + + // ── 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); @@ -443,35 +529,15 @@ void MainWindow::selfTest() { } void MainWindow::openFile() { - QString path = QFileDialog::getOpenFileName(this, - "Open Definition", {}, "ReclassX (*.rcx);;JSON (*.json);;All (*)"); - if (path.isEmpty()) return; - - auto* doc = new RcxDocument(this); - if (!doc->load(path)) { - QMessageBox::warning(this, "Error", "Failed to load: " + path); - delete doc; - return; - } - createTab(doc); + project_open(); } void MainWindow::saveFile() { - auto* tab = activeTab(); - if (!tab) return; - if (tab->doc->filePath.isEmpty()) { saveFileAs(); return; } - tab->doc->save(tab->doc->filePath); - updateWindowTitle(); + project_save(nullptr, false); } void MainWindow::saveFileAs() { - auto* tab = activeTab(); - if (!tab) return; - QString path = QFileDialog::getSaveFileName(this, - "Save Definition", {}, "ReclassX (*.rcx);;JSON (*.json)"); - if (path.isEmpty()) return; - tab->doc->save(path); - updateWindowTitle(); + project_save(nullptr, true); } void MainWindow::loadBinary() { @@ -576,12 +642,12 @@ void MainWindow::about() { void MainWindow::setEditorFont(const QString& fontName) { QSettings settings("ReclassX", "ReclassX"); settings.setValue("font", fontName); + QFont f(fontName, 12); + f.setFixedPitch(true); for (auto& state : m_tabs) { state.ctrl->setEditorFont(fontName); // Also update the rendered view font if (state.rendered) { - QFont f(fontName, 12); - f.setFixedPitch(true); state.rendered->setFont(f); if (auto* lex = state.rendered->lexer()) { lex->setFont(f); @@ -591,6 +657,9 @@ void MainWindow::setEditorFont(const QString& fontName) { state.rendered->setMarginsFont(f); } } + // Sync workspace tree font + if (m_workspaceTree) + m_workspaceTree->setFont(f); } RcxController* MainWindow::activeController() const { @@ -732,11 +801,13 @@ void MainWindow::updateRenderedView(TabState& tab) { } // Generate text + const QHash* aliases = + tab.doc->typeAliases.isEmpty() ? nullptr : &tab.doc->typeAliases; QString text; if (rootId != 0) - text = renderCpp(tab.doc->tree, rootId); + text = renderCpp(tab.doc->tree, rootId, aliases); else - text = renderCppAll(tab.doc->tree); + text = renderCppAll(tab.doc->tree, aliases); // Scroll restoration: save if same root, reset if different int restoreLine = 0; @@ -773,7 +844,9 @@ void MainWindow::exportCpp() { "Export C++ Header", {}, "C++ Header (*.h);;All Files (*)"); if (path.isEmpty()) return; - QString text = renderCppAll(tab->doc->tree); + const QHash* aliases = + tab->doc->typeAliases.isEmpty() ? nullptr : &tab->doc->typeAliases; + QString text = renderCppAll(tab->doc->tree, aliases); QFile file(path); if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { QMessageBox::warning(this, "Export Failed", @@ -784,6 +857,222 @@ void MainWindow::exportCpp() { m_statusLabel->setText("Exported to " + QFileInfo(path).fileName()); } +// ── Type Aliases Dialog ── + +void MainWindow::showTypeAliasesDialog() { + auto* tab = activeTab(); + if (!tab) return; + + QDialog dlg(this); + dlg.setWindowTitle("Type Aliases"); + dlg.resize(500, 400); + + auto* layout = new QVBoxLayout(&dlg); + + auto* table = new QTableWidget(&dlg); + table->setColumnCount(2); + table->setHorizontalHeaderLabels({"NodeKind", "Alias (C type)"}); + table->horizontalHeader()->setStretchLastSection(true); + table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + table->setSelectionMode(QAbstractItemView::SingleSelection); + + // Populate with all NodeKind entries + int rowCount = static_cast(std::size(kKindMeta)); + table->setRowCount(rowCount); + for (int i = 0; i < rowCount; i++) { + const auto& meta = kKindMeta[i]; + auto* kindItem = new QTableWidgetItem(QString::fromLatin1(meta.name)); + kindItem->setFlags(kindItem->flags() & ~Qt::ItemIsEditable); + table->setItem(i, 0, kindItem); + + QString alias = tab->doc->typeAliases.value(meta.kind); + table->setItem(i, 1, new QTableWidgetItem(alias)); + } + + layout->addWidget(table); + + auto* buttons = new QDialogButtonBox( + QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dlg); + layout->addWidget(buttons); + + connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept); + connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject); + + if (dlg.exec() != QDialog::Accepted) return; + + // Collect new aliases + QHash newAliases; + for (int i = 0; i < rowCount; i++) { + QString val = table->item(i, 1)->text().trimmed(); + if (!val.isEmpty()) + newAliases[kKindMeta[i].kind] = val; + } + + tab->doc->typeAliases = newAliases; + tab->doc->modified = true; + tab->ctrl->refresh(); + updateWindowTitle(); +} + +// ── Project Lifecycle API ── + +QMdiSubWindow* MainWindow::project_new() { + auto* doc = new RcxDocument(this); + + QByteArray data(16, '\0'); + doc->loadData(data); + doc->tree.baseAddress = 0x00400000; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Entity"; + root.structTypeName = "Entity"; + root.parentId = 0; + root.offset = 0; + int ri = doc->tree.addNode(root); + uint64_t rootId = doc->tree.nodes[ri].id; + + { Node n; n.kind = NodeKind::Int32; n.name = "health"; n.parentId = rootId; n.offset = 0; doc->tree.addNode(n); } + { Node n; n.kind = NodeKind::Int32; n.name = "armor"; n.parentId = rootId; n.offset = 4; doc->tree.addNode(n); } + { Node n; n.kind = NodeKind::Float; n.name = "speed"; n.parentId = rootId; n.offset = 8; doc->tree.addNode(n); } + { Node n; n.kind = NodeKind::Hex32; n.name = "flags"; n.parentId = rootId; n.offset = 12; doc->tree.addNode(n); } + + auto* sub = createTab(doc); + rebuildWorkspaceModel(); + return sub; +} + +QMdiSubWindow* MainWindow::project_open(const QString& path) { + QString filePath = path; + if (filePath.isEmpty()) { + filePath = QFileDialog::getOpenFileName(this, + "Open Definition", {}, "ReclassX (*.rcx);;JSON (*.json);;All (*)"); + if (filePath.isEmpty()) return nullptr; + } + + auto* doc = new RcxDocument(this); + if (!doc->load(filePath)) { + QMessageBox::warning(this, "Error", "Failed to load: " + filePath); + delete doc; + return nullptr; + } + auto* sub = createTab(doc); + rebuildWorkspaceModel(); + return sub; +} + +bool MainWindow::project_save(QMdiSubWindow* sub, bool saveAs) { + if (!sub) sub = m_mdiArea->activeSubWindow(); + if (!sub || !m_tabs.contains(sub)) return false; + auto& tab = m_tabs[sub]; + + if (saveAs || tab.doc->filePath.isEmpty()) { + QString path = QFileDialog::getSaveFileName(this, + "Save Definition", {}, "ReclassX (*.rcx);;JSON (*.json)"); + if (path.isEmpty()) return false; + tab.doc->save(path); + } else { + tab.doc->save(tab.doc->filePath); + } + updateWindowTitle(); + return true; +} + +void MainWindow::project_close(QMdiSubWindow* sub) { + if (!sub) sub = m_mdiArea->activeSubWindow(); + if (!sub) return; + sub->close(); + rebuildWorkspaceModel(); +} + +// ── Workspace Dock ── + +void MainWindow::createWorkspaceDock() { + m_workspaceDock = new QDockWidget("Workspace", this); + m_workspaceDock->setObjectName("WorkspaceDock"); + m_workspaceDock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea); + + m_workspaceTree = new QTreeView(m_workspaceDock); + m_workspaceModel = new QStandardItemModel(this); + m_workspaceModel->setHorizontalHeaderLabels({"Name"}); + m_workspaceTree->setModel(m_workspaceModel); + m_workspaceTree->setHeaderHidden(true); + m_workspaceTree->setEditTriggers(QAbstractItemView::NoEditTriggers); + + // Match editor font + { + QSettings settings("ReclassX", "ReclassX"); + QString fontName = settings.value("font", "Consolas").toString(); + QFont f(fontName, 12); + f.setFixedPitch(true); + m_workspaceTree->setFont(f); + } + + m_workspaceDock->setWidget(m_workspaceTree); + addDockWidget(Qt::LeftDockWidgetArea, m_workspaceDock); + + connect(m_workspaceTree, &QTreeView::doubleClicked, this, [this](const QModelIndex& index) { + // Data roles: UserRole=QMdiSubWindow*, UserRole+1=structId, UserRole+2=nodeId + auto subVar = index.data(Qt::UserRole); + if (!subVar.isValid()) return; + + auto* sub = static_cast(subVar.value()); + if (!sub || !m_tabs.contains(sub)) return; + + m_mdiArea->setActiveSubWindow(sub); + + auto structIdVar = index.data(Qt::UserRole + 1); + auto nodeIdVar = index.data(Qt::UserRole + 2); + + if (structIdVar.isValid()) { + // Double-clicked a struct: set as view root + uint64_t structId = structIdVar.toULongLong(); + auto& tree = m_tabs[sub].doc->tree; + int ni = tree.indexOfId(structId); + if (ni >= 0) tree.nodes[ni].collapsed = false; + m_tabs[sub].ctrl->setViewRootId(structId); + m_tabs[sub].ctrl->scrollToNodeId(structId); + } else if (nodeIdVar.isValid()) { + // Double-clicked a field: find its root struct, set as view root, scroll to field + uint64_t nodeId = nodeIdVar.toULongLong(); + auto& tree = m_tabs[sub].doc->tree; + // Walk up to find root struct + uint64_t rootId = 0; + uint64_t cur = nodeId; + while (cur != 0) { + int idx = tree.indexOfId(cur); + if (idx < 0) break; + if (tree.nodes[idx].parentId == 0) { rootId = cur; break; } + cur = tree.nodes[idx].parentId; + } + if (rootId != 0) { + int ri = tree.indexOfId(rootId); + if (ri >= 0) tree.nodes[ri].collapsed = false; + m_tabs[sub].ctrl->setViewRootId(rootId); + } + m_tabs[sub].ctrl->scrollToNodeId(nodeId); + } else if (!index.parent().isValid()) { + // Double-clicked project root: clear view root to show all + m_tabs[sub].ctrl->setViewRootId(0); + } + }); +} + +void MainWindow::rebuildWorkspaceModel() { + m_workspaceModel->clear(); + + auto* sub = m_mdiArea->activeSubWindow(); + if (!sub || !m_tabs.contains(sub)) return; + + TabState& tab = m_tabs[sub]; + QString tabName = tab.doc->filePath.isEmpty() + ? "Untitled" : QFileInfo(tab.doc->filePath).fileName(); + + buildWorkspaceModel(m_workspaceModel, tab.doc->tree, tabName, + static_cast(sub)); + m_workspaceTree->expandAll(); +} + } // namespace rcx // ── Entry point ── diff --git a/src/workspace_model.h b/src/workspace_model.h new file mode 100644 index 0000000..d5b7c80 --- /dev/null +++ b/src/workspace_model.h @@ -0,0 +1,64 @@ +#pragma once +#include "core.h" +#include +#include +#include + +namespace rcx { + +// Recursively add children of parentId as tree items under parentItem. +inline void addWorkspaceChildren(QStandardItem* parentItem, + const NodeTree& tree, + uint64_t parentId, + void* subPtr) { + QVector children = tree.childrenOf(parentId); + std::sort(children.begin(), children.end(), [&](int a, int b) { + return tree.nodes[a].offset < tree.nodes[b].offset; + }); + + for (int idx : children) { + const Node& node = tree.nodes[idx]; + + // Skip hex preview nodes — they are padding/filler, not meaningful fields + if (isHexNode(node.kind)) continue; + + QString display; + if (node.kind == NodeKind::Struct) { + QString typeName = node.structTypeName.isEmpty() + ? node.name : node.structTypeName; + display = QStringLiteral("%1 (%2)") + .arg(typeName, node.resolvedClassKeyword()); + } else { + display = QStringLiteral("%1 (%2)") + .arg(node.name, QString::fromLatin1(kindToString(node.kind))); + } + + auto* item = new QStandardItem(display); + item->setData(QVariant::fromValue(subPtr), Qt::UserRole); + if (node.kind == NodeKind::Struct) + item->setData(QVariant::fromValue(node.id), Qt::UserRole + 1); + item->setData(QVariant::fromValue(node.id), Qt::UserRole + 2); // nodeId for scroll + + if (node.kind == NodeKind::Struct) + addWorkspaceChildren(item, tree, node.id, subPtr); + + parentItem->appendRow(item); + } +} + +inline void buildWorkspaceModel(QStandardItemModel* model, + const NodeTree& tree, + const QString& projectName, + void* subPtr = nullptr) { + model->clear(); + model->setHorizontalHeaderLabels({QStringLiteral("Name")}); + + auto* projectItem = new QStandardItem(projectName); + projectItem->setData(QVariant::fromValue(subPtr), Qt::UserRole); + + addWorkspaceChildren(projectItem, tree, 0, subPtr); + + model->appendRow(projectItem); +} + +} // namespace rcx diff --git a/tests/test_new_features.cpp b/tests/test_new_features.cpp new file mode 100644 index 0000000..201d2f3 --- /dev/null +++ b/tests/test_new_features.cpp @@ -0,0 +1,716 @@ +#include +#include +#include +#include +#include +#include "core.h" +#include "generator.h" +#include "controller.h" +#include "workspace_model.h" + +using namespace rcx; + +class TestNewFeatures : public QObject { + Q_OBJECT + +private: + NodeTree makeSimpleTree() { + NodeTree tree; + tree.baseAddress = 0; + + Node root; + root.kind = NodeKind::Struct; + root.name = "Player"; + root.structTypeName = "Player"; + root.parentId = 0; + root.offset = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + Node f1; + f1.kind = NodeKind::Int32; + f1.name = "health"; + f1.parentId = rootId; + f1.offset = 0; + tree.addNode(f1); + + Node f2; + f2.kind = NodeKind::Float; + f2.name = "speed"; + f2.parentId = rootId; + f2.offset = 4; + tree.addNode(f2); + + return tree; + } + + NodeTree makeTwoRootTree() { + NodeTree tree; + tree.baseAddress = 0; + + // Root struct A + Node a; + a.kind = NodeKind::Struct; + a.name = "Alpha"; + a.structTypeName = "Alpha"; + a.parentId = 0; + a.offset = 0; + int ai = tree.addNode(a); + uint64_t aId = tree.nodes[ai].id; + + Node af; + af.kind = NodeKind::UInt32; + af.name = "flagsA"; + af.parentId = aId; + af.offset = 0; + tree.addNode(af); + + // Root struct B + Node b; + b.kind = NodeKind::Struct; + b.name = "Bravo"; + b.structTypeName = "Bravo"; + b.parentId = 0; + b.offset = 0x100; + int bi = tree.addNode(b); + uint64_t bId = tree.nodes[bi].id; + + Node bf; + bf.kind = NodeKind::UInt64; + bf.name = "flagsB"; + bf.parentId = bId; + bf.offset = 0; + tree.addNode(bf); + + return tree; + } + + NodeTree makeRichTree() { + NodeTree tree; + tree.baseAddress = 0x00400000; + + // ── Pet (root struct) ── + Node pet; + pet.kind = NodeKind::Struct; + pet.name = "Pet"; + pet.structTypeName = "Pet"; + pet.parentId = 0; + pet.offset = 0; + 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::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); } + { Node n; n.kind = NodeKind::Hex64; n.name = "hex_38"; n.parentId = petId; n.offset = 56; tree.addNode(n); } + + // ── Cat (root struct, "inherits" Pet via nested struct) ── + Node cat; + cat.kind = NodeKind::Struct; + cat.name = "Cat"; + cat.structTypeName = "Cat"; + cat.parentId = 0; + cat.offset = 0; + int ci = tree.addNode(cat); + uint64_t catId = tree.nodes[ci].id; + + // base = embedded Pet (nested struct child at offset 0) + Node base; + base.kind = NodeKind::Struct; + base.name = "base"; + base.structTypeName = "Pet"; + base.parentId = catId; + base.offset = 0; + int bi = tree.addNode(base); + 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::Pointer64; n.name = "owner"; n.parentId = baseId; n.offset = 32; 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::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; + ball.kind = NodeKind::Struct; + ball.name = "Ball"; + ball.structTypeName = "Ball"; + ball.parentId = 0; + ball.offset = 0; + 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_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); } + + return tree; + } + +private slots: + + // ═══════════════════════════════════════════════════ + // Feature 1: Type Aliases + // ═══════════════════════════════════════════════════ + + void testResolveTypeName_noAlias() { + RcxDocument doc; + // No aliases set — should return default type name + QString name = doc.resolveTypeName(NodeKind::Int32); + QCOMPARE(name, QString("int32_t")); + + name = doc.resolveTypeName(NodeKind::Float); + QCOMPARE(name, QString("float")); + + name = doc.resolveTypeName(NodeKind::Hex64); + QCOMPARE(name, QString("Hex64")); + } + + void testResolveTypeName_withAlias() { + RcxDocument doc; + doc.typeAliases[NodeKind::Int32] = "DWORD"; + doc.typeAliases[NodeKind::Float] = "FLOAT"; + + QCOMPARE(doc.resolveTypeName(NodeKind::Int32), QString("DWORD")); + QCOMPARE(doc.resolveTypeName(NodeKind::Float), QString("FLOAT")); + // Non-aliased types still return default + QCOMPARE(doc.resolveTypeName(NodeKind::UInt64), QString("uint64_t")); + } + + void testResolveTypeName_emptyAlias() { + RcxDocument doc; + doc.typeAliases[NodeKind::Int32] = ""; // empty alias should be ignored + QCOMPARE(doc.resolveTypeName(NodeKind::Int32), QString("int32_t")); + } + + void testTypeAliases_saveLoad() { + // Save a document with type aliases, reload, verify aliases persist + QTemporaryFile tmpFile; + tmpFile.setAutoRemove(true); + QVERIFY(tmpFile.open()); + QString path = tmpFile.fileName(); + tmpFile.close(); + + // Create document with aliases and save + { + RcxDocument doc; + doc.tree = makeSimpleTree(); + doc.typeAliases[NodeKind::Int32] = "DWORD"; + doc.typeAliases[NodeKind::Float] = "FLOAT"; + QVERIFY(doc.save(path)); + } + + // Reload and check aliases + { + RcxDocument doc; + QVERIFY(doc.load(path)); + QCOMPARE(doc.typeAliases.size(), 2); + QCOMPARE(doc.typeAliases.value(NodeKind::Int32), QString("DWORD")); + QCOMPARE(doc.typeAliases.value(NodeKind::Float), QString("FLOAT")); + } + } + + void testTypeAliases_saveLoadEmpty() { + // Save without aliases, reload, verify no aliases + QTemporaryFile tmpFile; + tmpFile.setAutoRemove(true); + QVERIFY(tmpFile.open()); + QString path = tmpFile.fileName(); + tmpFile.close(); + + { + RcxDocument doc; + doc.tree = makeSimpleTree(); + QVERIFY(doc.save(path)); + } + + { + RcxDocument doc; + QVERIFY(doc.load(path)); + QVERIFY(doc.typeAliases.isEmpty()); + } + } + + void testTypeAliases_jsonFormat() { + // Verify the JSON format of saved aliases + QTemporaryFile tmpFile; + tmpFile.setAutoRemove(true); + QVERIFY(tmpFile.open()); + QString path = tmpFile.fileName(); + tmpFile.close(); + + RcxDocument doc; + doc.tree = makeSimpleTree(); + doc.typeAliases[NodeKind::UInt32] = "UINT"; + QVERIFY(doc.save(path)); + + // Read raw JSON + QFile file(path); + QVERIFY(file.open(QIODevice::ReadOnly)); + QJsonDocument jdoc = QJsonDocument::fromJson(file.readAll()); + QJsonObject root = jdoc.object(); + + QVERIFY(root.contains("typeAliases")); + QJsonObject aliases = root["typeAliases"].toObject(); + QCOMPARE(aliases["UInt32"].toString(), QString("UINT")); + } + + void testGenerator_typeAliases() { + // Generator should use aliases for field types + auto tree = makeSimpleTree(); + uint64_t rootId = tree.nodes[0].id; + + QHash aliases; + aliases[NodeKind::Int32] = "LONG"; + aliases[NodeKind::Float] = "FLOAT"; + + QString result = renderCpp(tree, rootId, &aliases); + + QVERIFY(result.contains("LONG health;")); + QVERIFY(result.contains("FLOAT speed;")); + // struct keyword itself should not be aliased + QVERIFY(result.contains("struct Player {")); + } + + void testGenerator_typeAliases_null() { + // With nullptr aliases, should behave like before + auto tree = makeSimpleTree(); + uint64_t rootId = tree.nodes[0].id; + + QString result = renderCpp(tree, rootId, nullptr); + QVERIFY(result.contains("int32_t health;")); + QVERIFY(result.contains("float speed;")); + } + + void testGenerator_typeAliases_padding() { + // Padding gap and tail padding should use aliased uint8_t + NodeTree tree; + Node root; + root.kind = NodeKind::Struct; + root.name = "PadTest"; + root.structTypeName = "PadTest"; + root.parentId = 0; + int ri = tree.addNode(root); + uint64_t rootId = tree.nodes[ri].id; + + Node f1; + f1.kind = NodeKind::UInt32; + f1.name = "a"; + f1.parentId = rootId; + f1.offset = 0; + tree.addNode(f1); + + Node f2; + f2.kind = NodeKind::UInt32; + f2.name = "b"; + f2.parentId = rootId; + f2.offset = 8; // gap of 4 bytes at offset 4 + tree.addNode(f2); + + QHash aliases; + aliases[NodeKind::Padding] = "BYTE"; + + QString result = renderCpp(tree, rootId, &aliases); + // Padding gap should use the alias + QVERIFY(result.contains("BYTE _pad")); + } + + void testGenerator_typeAliases_array() { + // Array element type should use alias + NodeTree tree; + Node root; + root.kind = NodeKind::Struct; + root.name = "ArrTest"; + root.structTypeName = "ArrTest"; + 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.arrayLen = 16; + arr.elementKind = NodeKind::UInt32; + tree.addNode(arr); + + QHash aliases; + aliases[NodeKind::UInt32] = "DWORD"; + + QString result = renderCpp(tree, rootId, &aliases); + QVERIFY(result.contains("DWORD data[16];")); + } + + void testGenerator_renderCppAll_typeAliases() { + auto tree = makeTwoRootTree(); + + QHash aliases; + aliases[NodeKind::UInt32] = "DWORD"; + aliases[NodeKind::UInt64] = "QWORD"; + + QString result = renderCppAll(tree, &aliases); + QVERIFY(result.contains("DWORD flagsA;")); + QVERIFY(result.contains("QWORD flagsB;")); + } + + // ═══════════════════════════════════════════════════ + // Feature 3: Per-Window View Root Class + // ═══════════════════════════════════════════════════ + + void testCompose_viewRootId_zero() { + // viewRootId=0 should show all roots (same as default) + auto tree = makeTwoRootTree(); + + NullProvider prov; + ComposeResult result = compose(tree, prov, 0); + + // Should have content from both structs + QStringList lines = result.text.split('\n'); + bool foundFlagsA = false, foundFlagsB = false; + for (const QString& l : lines) { + if (l.contains("flagsA")) foundFlagsA = true; + if (l.contains("flagsB")) foundFlagsB = true; + } + QVERIFY2(foundFlagsA, "viewRootId=0 should include Alpha struct"); + QVERIFY2(foundFlagsB, "viewRootId=0 should include Bravo struct"); + } + + void testCompose_viewRootId_filter() { + // viewRootId set to Alpha's id should only show Alpha's fields + auto tree = makeTwoRootTree(); + uint64_t alphaId = tree.nodes[0].id; + + NullProvider prov; + ComposeResult result = compose(tree, prov, alphaId); + + QStringList lines = result.text.split('\n'); + bool foundFlagsA = false, foundFlagsB = false; + for (const QString& l : lines) { + if (l.contains("flagsA")) foundFlagsA = true; + if (l.contains("flagsB")) foundFlagsB = true; + } + QVERIFY2(foundFlagsA, "viewRootId=Alpha should include Alpha's fields"); + QVERIFY2(!foundFlagsB, "viewRootId=Alpha should NOT include Bravo's fields"); + } + + void testCompose_viewRootId_otherRoot() { + // viewRootId set to Bravo's id should only show Bravo's fields + auto tree = makeTwoRootTree(); + uint64_t bravoId = tree.nodes[2].id; // Bravo is the 3rd node (index 2) + + NullProvider prov; + ComposeResult result = compose(tree, prov, bravoId); + + QStringList lines = result.text.split('\n'); + bool foundFlagsA = false, foundFlagsB = false; + for (const QString& l : lines) { + if (l.contains("flagsA")) foundFlagsA = true; + if (l.contains("flagsB")) foundFlagsB = true; + } + QVERIFY2(!foundFlagsA, "viewRootId=Bravo should NOT include Alpha's fields"); + QVERIFY2(foundFlagsB, "viewRootId=Bravo should include Bravo's fields"); + } + + void testCompose_viewRootId_invalid() { + // viewRootId pointing to non-existent node: should show nothing (only command rows) + auto tree = makeTwoRootTree(); + + NullProvider prov; + ComposeResult result = compose(tree, prov, 99999); + + // Only command rows + QCOMPARE(result.meta.size(), 2); + QCOMPARE(result.meta[0].lineKind, LineKind::CommandRow); + QCOMPARE(result.meta[1].lineKind, LineKind::CommandRow2); + } + + void testCompose_viewRootId_singleRoot() { + // Single root tree with viewRootId set to that root — should work normally + auto tree = makeSimpleTree(); + uint64_t rootId = tree.nodes[0].id; + + NullProvider prov; + ComposeResult full = compose(tree, prov, 0); + ComposeResult filtered = compose(tree, prov, rootId); + + // Both should have same number of lines (only one root anyway) + QCOMPARE(full.meta.size(), filtered.meta.size()); + } + + void testDocument_compose_viewRootId() { + // Test RcxDocument::compose passes viewRootId through + RcxDocument doc; + doc.tree = makeTwoRootTree(); + uint64_t alphaId = doc.tree.nodes[0].id; + + ComposeResult fullResult = doc.compose(0); + ComposeResult filtered = doc.compose(alphaId); + + // Filtered should have fewer lines than full + QVERIFY(filtered.meta.size() < fullResult.meta.size()); + + // Filtered should have Alpha's fields + bool foundFlagsA = false; + for (const QString& l : filtered.text.split('\n')) { + if (l.contains("flagsA")) foundFlagsA = true; + } + QVERIFY(foundFlagsA); + } + + // ═══════════════════════════════════════════════════ + // Feature 2: Project Lifecycle API (document-level) + // ═══════════════════════════════════════════════════ + + void testDocument_saveLoadPreservesData() { + // Verify save/load round-trip preserves tree + aliases + baseAddress + QTemporaryFile tmpFile; + tmpFile.setAutoRemove(true); + QVERIFY(tmpFile.open()); + QString path = tmpFile.fileName(); + tmpFile.close(); + + { + RcxDocument doc; + doc.tree = makeTwoRootTree(); + doc.tree.baseAddress = 0xDEADBEEF; + doc.typeAliases[NodeKind::Int32] = "INT"; + QVERIFY(doc.save(path)); + } + + { + RcxDocument doc; + QVERIFY(doc.load(path)); + QCOMPARE(doc.tree.baseAddress, (uint64_t)0xDEADBEEF); + QCOMPARE(doc.tree.nodes.size(), 4); // 2 roots + 2 fields + QCOMPARE(doc.typeAliases.value(NodeKind::Int32), QString("INT")); + QCOMPARE(doc.filePath, path); + QVERIFY(!doc.modified); + } + } + + void testDocument_saveCreatesFile() { + QTemporaryFile tmpFile; + tmpFile.setAutoRemove(true); + QVERIFY(tmpFile.open()); + QString path = tmpFile.fileName(); + tmpFile.close(); + + RcxDocument doc; + doc.tree = makeSimpleTree(); + QVERIFY(doc.save(path)); + QCOMPARE(doc.filePath, path); + QVERIFY(!doc.modified); + + // Verify file exists and is valid JSON + QFile file(path); + QVERIFY(file.open(QIODevice::ReadOnly)); + QJsonDocument jdoc = QJsonDocument::fromJson(file.readAll()); + QVERIFY(!jdoc.isNull()); + QVERIFY(jdoc.object().contains("nodes")); + } + + void testDocument_loadInvalidPath() { + RcxDocument doc; + QVERIFY(!doc.load("/nonexistent/path/file.rcx")); + } + + // ═══════════════════════════════════════════════════ + // Integration: Type aliases + compose + generator + // ═══════════════════════════════════════════════════ + + // ═══════════════════════════════════════════════════ + // Feature 4: Workspace Model + // ═══════════════════════════════════════════════════ + + void testWorkspace_simpleTree() { + auto tree = makeSimpleTree(); + QStandardItemModel model; + buildWorkspaceModel(&model, tree, "TestProject.rcx"); + + // 1 top-level item (the project) + QCOMPARE(model.rowCount(), 1); + QStandardItem* project = model.item(0); + QCOMPARE(project->text(), QString("TestProject.rcx")); + + // Project has 1 child: the Player struct + QCOMPARE(project->rowCount(), 1); + QStandardItem* player = project->child(0); + QVERIFY(player->text().contains("Player")); + QVERIFY(player->text().contains("struct")); + + // Player struct has 2 children: health, speed + QCOMPARE(player->rowCount(), 2); + QVERIFY(player->child(0)->text().contains("health")); + QVERIFY(player->child(1)->text().contains("speed")); + } + + void testWorkspace_twoRootTree() { + auto tree = makeTwoRootTree(); + QStandardItemModel model; + buildWorkspaceModel(&model, tree, "TwoRoot.rcx"); + + QCOMPARE(model.rowCount(), 1); + QStandardItem* project = model.item(0); + + // 2 root struct children: Alpha and Bravo + QCOMPARE(project->rowCount(), 2); + QVERIFY(project->child(0)->text().contains("Alpha")); + QVERIFY(project->child(1)->text().contains("Bravo")); + + // Each has 1 field child + QCOMPARE(project->child(0)->rowCount(), 1); + QVERIFY(project->child(0)->child(0)->text().contains("flagsA")); + QCOMPARE(project->child(1)->rowCount(), 1); + QVERIFY(project->child(1)->child(0)->text().contains("flagsB")); + } + + void testWorkspace_richTree_rootCount() { + auto tree = makeRichTree(); + QStandardItemModel model; + buildWorkspaceModel(&model, tree, "Rich.rcx"); + + QStandardItem* project = model.item(0); + QCOMPARE(project->rowCount(), 3); // Pet, Cat, Ball + } + + void testWorkspace_richTree_petChildren() { + auto tree = makeRichTree(); + QStandardItemModel model; + buildWorkspaceModel(&model, tree, "Rich.rcx"); + + QStandardItem* pet = model.item(0)->child(0); + QVERIFY(pet->text().contains("Pet")); + // Pet has 2 non-hex children: name (UTF8), owner (Pointer64) + QCOMPARE(pet->rowCount(), 2); + QVERIFY(pet->child(0)->text().contains("name")); + QVERIFY(pet->child(1)->text().contains("owner")); + } + + void testWorkspace_richTree_catNesting() { + auto tree = makeRichTree(); + QStandardItemModel model; + buildWorkspaceModel(&model, tree, "Rich.rcx"); + + QStandardItem* cat = model.item(0)->child(1); + QVERIFY(cat->text().contains("Cat")); + + // Find the nested "Pet" struct child (base) + QStandardItem* base = nullptr; + for (int i = 0; i < cat->rowCount(); i++) { + if (cat->child(i)->text().contains("Pet") && + cat->child(i)->text().contains("struct")) { + base = cat->child(i); + break; + } + } + QVERIFY2(base != nullptr, "Cat should have a nested Pet struct child"); + + // base has structId set + QVERIFY(base->data(Qt::UserRole + 1).isValid()); + + // base should have its own children (name + owner) + QCOMPARE(base->rowCount(), 2); + } + + void testWorkspace_richTree_ballChildren() { + auto tree = makeRichTree(); + QStandardItemModel model; + buildWorkspaceModel(&model, tree, "Rich.rcx"); + + QStandardItem* ball = model.item(0)->child(2); + QVERIFY(ball->text().contains("Ball")); + + // Ball has 3 non-hex children: speed, position, color + QCOMPARE(ball->rowCount(), 3); + QVERIFY(ball->child(0)->text().contains("speed")); + QVERIFY(ball->child(1)->text().contains("position")); + QVERIFY(ball->child(2)->text().contains("color")); + } + + void testWorkspace_emptyTree() { + NodeTree tree; + QStandardItemModel model; + buildWorkspaceModel(&model, tree, "Empty.rcx"); + + QCOMPARE(model.rowCount(), 1); + QCOMPARE(model.item(0)->rowCount(), 0); + } + + void testWorkspace_structIdRole() { + auto tree = makeSimpleTree(); + QStandardItemModel model; + buildWorkspaceModel(&model, tree, "Test.rcx"); + + QStandardItem* project = model.item(0); + // Project item should NOT have structId + QVERIFY(!project->data(Qt::UserRole + 1).isValid()); + + // Player struct should have structId + QStandardItem* player = project->child(0); + QVERIFY(player->data(Qt::UserRole + 1).isValid()); + QVERIFY(player->data(Qt::UserRole + 1).toULongLong() > 0); + + // health field should NOT have structId + QStandardItem* health = player->child(0); + QVERIFY(!health->data(Qt::UserRole + 1).isValid()); + } + + // ═══════════════════════════════════════════════════ + // Integration: Type aliases + compose + generator + // ═══════════════════════════════════════════════════ + + void testAliasesPreservedThroughSaveReloadCompose() { + // Full workflow: set aliases, save, reload, compose + generate + QTemporaryFile tmpFile; + tmpFile.setAutoRemove(true); + QVERIFY(tmpFile.open()); + QString path = tmpFile.fileName(); + tmpFile.close(); + + auto tree = makeSimpleTree(); + + // Save with aliases + { + RcxDocument doc; + doc.tree = tree; + doc.typeAliases[NodeKind::Int32] = "my_int32"; + doc.typeAliases[NodeKind::Float] = "my_float"; + QVERIFY(doc.save(path)); + } + + // Reload and verify compose + generate work + { + RcxDocument doc; + QVERIFY(doc.load(path)); + + // Compose should succeed + ComposeResult result = doc.compose(); + QVERIFY(result.meta.size() > 0); + + // Generator should use aliases + uint64_t rootId = doc.tree.nodes[0].id; + const QHash* aliases = + doc.typeAliases.isEmpty() ? nullptr : &doc.typeAliases; + QString cpp = renderCpp(doc.tree, rootId, aliases); + QVERIFY(cpp.contains("my_int32 health;")); + QVERIFY(cpp.contains("my_float speed;")); + } + } +}; + +QTEST_MAIN(TestNewFeatures) +#include "test_new_features.moc"