Compare commits

...

10 Commits

Author SHA1 Message Date
IChooseYou
6a51c904de feat: type selector overhaul, fuzzy search, address parser, value tracking
Redesign type selector popup with fuzzy subsequence matching, per-category
icons, field summary tooltips, compact chips, and pointer target primitives.
Add address expression parser with arithmetic and register support.
Enable track value changes by default.
2026-02-28 06:59:22 -07:00
IChooseYou
0d73575ea7 fix: C++ generator bitfields, sizeof placement, Ctrl+F search, view sync
- Generator emits proper bitfield members instead of padding stubs
- Named bitfield structs (MitigationFlagsValues etc) now converted by parser
- sizeof comment moved from top to closing brace (}; // sizeof 0x80)
- C/C++ view syncs with workspace double-click and controller navigation
- Ctrl+F incremental search in C++ code view (Enter=next, Escape=close)
- Workspace dock resizable via 1px drag handle separator
- Regenerated Vergilius_25H2.rcx with all fixes (61 named bitfield containers)
2026-02-26 12:07:55 -07:00
IChooseYou
aa04cfcb5c feat: add Vergilius-to-RCX converter, full Windows 11 25H2 kernel structs
Add tools/vergilius_to_rcx.py: scrapes struct definitions from
vergiliusproject.com and generates .rcx JSON files. Supports bitfields,
arrays, self-referential pointers, deep union/struct nesting, and
cross-struct references. Offsets correctly stored as parent-relative.

Add src/examples/Vergilius_25H2.rcx: 1,690 kernel structs (18,924 nodes)
from Windows 11 25H2 including _EPROCESS, _KTHREAD, _MMPFN, _PEB, etc.

Remove orange M_CYCLE background on self-referential pointer children —
rows now render with normal theme background while retaining click-to-
materialize behavior.
2026-02-26 11:02:12 -07:00
IChooseYou
1465e7fbed feat: Vergilius-style C++ generator, struct type click fix, item view highlight fix
Rewrite C++ generator for Vergilius-style output: inline anonymous
structs/unions, reference opaque types by name with struct keyword
prefix, size comments, aligned offset comments, no anon_ stubs.

Fix struct type name not clickable in editor headers (headerTypeNameSpan
assumed "struct TYPENAME" format but named structs use bare name).

Add static_assert toggle in Options > Generator, default off.

Fix item view highlight bleed: patch PE_PanelItemViewRow to use
theme.hover so row background matches CE_ItemViewItem.
2026-02-26 08:21:15 -07:00
IChooseYou
52f751e751 fix: redesign Type Aliases dialog — visible presets, compact layout
stdint button now fills cells with actual type names instead of clearing
to empty. Removed redundant Reset button, hidden column/row headers,
filtered out irrelevant types (Vec/Mat/Struct/Array). Fixed item view
hover being invisible on dark themes by painting explicit fillRect.
2026-02-25 17:39:17 -07:00
IChooseYou
0a19789a9d feat: enhance workspace dock, reorganize menus, fix Reclass Dark theme
- Workspace dock: show member count per type, expandable child rows
  (Type Name format, Hex padding filtered), search/filter box with
  recursive matching, collapsed by default, double-click navigates
  to member in editor
- Menu reorganization: Import/Export submenus, new Tools menu (Type
  Aliases, MCP Server, Options), Data Source moved to View, renamed
  Unload→Close Project, Unsplit→Remove Split, Current Tab Source→
  Data Source
- View menu: add Relative Offsets toggle (persisted, applies to all
  editors and new splits)
- Fix Reclass Dark theme: hover/selected colors were identical to
  background (#1e1e1e), now #2a2a2a/#2a2d2e for visible contrast
- Dim MDI tab text via QPalette::WindowText (Fusion ignores CSS color)
- Remove dead QProxyStyle tab handlers (never called for QMdiArea)
2026-02-25 14:27:02 -07:00
IChooseYou
62a68bef80 fix: align workspace dock header with MDI tab bar, dim tab text
Use QProxyStyle for tab height (24px) and text color instead of CSS.
Selected/hover tabs now use textDim to match the dock header.
2026-02-24 15:16:33 -07:00
IChooseYou
4941f860b6 docs: fix misleading README claims, add missing features, remove hr noise
- Fix "server does not start by default" (MCP now auto-starts)
- Rephrase tagline to name ReClass.NET/ReClassEx directly
- Add missing features: enums, bitfields, PDB import, themes, disasm preview, heatmap, MDI tabs, import/export
- Note Qt 5 support alongside Qt 6
- Align autoStartMcp default to true in options dialog
- Remove all horizontal rule separators
2026-02-24 12:48:50 -07:00
IChooseYou
c45d51d736 feat: shimmer status bar for MCP activity, auto-start MCP, remove "Ready" spam
- Add ShimmerLabel widget with animated glow band for MCP tool activity
- Separate app/MCP status channels (setAppStatus/setMcpStatus/clearMcpStatus)
- 750ms delayed clear so shimmer stays visible after fast tool calls
- MCP auto-starts on launch by default
- Remove "Ready" text that was overwriting useful status info
- Add statusText field to project.state MCP response
2026-02-24 12:31:25 -07:00
IChooseYou
5b46065403 feat: enum/bitfield editing, MCP guard rails, PDB anonymous type inlining
- Enum inline editing: name/value commit handling, auto-sort by value
- Bitfield support in PDB import with proper container nodes
- Per-member hover/selection highlighting (kMemberBit encoding)
- Context menu fixes for enum/bitfield member lines
- MCP pagination (limit/offset), includeMembers param, tree.search tool
- MCP status bar activity indicator for tool calls
- PDB anonymous type inlining: inline <unnamed-tag> types as children
- Skip anonymous pointer targets to prevent root orphans
- Enum import diagnostics for debugging missing enums
2026-02-24 10:37:42 -07:00
42 changed files with 166011 additions and 1018 deletions

View File

@@ -387,14 +387,15 @@ if(BUILD_TESTING)
endif()
add_test(NAME test_source_provider COMMAND test_source_provider)
if(WIN32)
add_executable(test_windbg_provider tests/test_windbg_provider.cpp
plugins/WinDbgMemory/WinDbgMemoryPlugin.cpp)
target_include_directories(test_windbg_provider PRIVATE src plugins/WinDbgMemory)
target_link_libraries(test_windbg_provider PRIVATE
${QT}::Widgets ${QT}::Concurrent ${QT}::Test dbgeng ole32)
add_test(NAME test_windbg_provider COMMAND test_windbg_provider)
endif()
# Disabled: WinDbg provider test has build errors (lastError API changed)
#if(WIN32)
# add_executable(test_windbg_provider tests/test_windbg_provider.cpp
# plugins/WinDbgMemory/WinDbgMemoryPlugin.cpp)
# target_include_directories(test_windbg_provider PRIVATE src plugins/WinDbgMemory)
# target_link_libraries(test_windbg_provider PRIVATE
# ${QT}::Widgets ${QT}::Concurrent ${QT}::Test dbgeng ole32)
# add_test(NAME test_windbg_provider COMMAND test_windbg_provider)
#endif()
add_executable(bench_large_class tests/bench_large_class.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp

View File

@@ -5,7 +5,7 @@
<img src="docs/RECLASS_DARKMODE.svg" alt="Reclass" height="170" />
</picture>
**A structured binary editor for reverse engineering — inspect raw bytes as typed structs, arrays, and pointers.<p>A complete overhaul of the popular "reclassing" tools**
**A structured binary editor for reverse engineering — inspect raw bytes as typed structs, arrays, and pointers.<p>Built from scratch as a modern replacement for ReClass.NET and ReClassEx**
[Download](https://github.com/IChooseYou/Reclass/releases) · [Build Instructions](#build) · [MCP Integration](#mcp-integration) · [Alternatives](#alternatives)
@@ -16,31 +16,32 @@
</div>
---
Reclass helps you inspect raw bytes and interpret them as types (structs, arrays, primitives, pointers, padding) instead of just hex. It is essentially a debugging tool for figuring out unknown data structures — either at runtime from a live process, or from a static source like a binary file or crash dump.
Built with C++17, Qt 6, and QScintilla. The entire editor surface is rendered as formatted plain text with inline editing, fold markers, and hex/ASCII previews.
---
Built with C++17, Qt 6 (Qt 5 also supported), and QScintilla. The entire editor surface is rendered as formatted plain text with inline editing, fold markers, and hex/ASCII previews.
## Features
- **Structured binary view** — render raw bytes as typed fields (integers, floats, pointers, vectors, matrices, strings, booleans, padding)
- **Struct & array nesting** — define nested structs and arrays with collapsible fold regions
- **Enums & bitfields** — define enums and bitfield types with named members, inline editing, and auto-sort
- **Inline editing** — click to edit type names, field names, values, and base addresses directly in the editor
- **Undo/redo** — full undo history for all mutations via command stack
- **Multi-document tabs** — open multiple projects simultaneously in MDI sub-windows
- **Split views** — multiple synchronized editor panes over the same document
- **Type autocomplete** — popup type picker when changing field kinds
- **Hex + ASCII margins** — raw byte previews alongside the structured view
- **Value history & heatmap** — track value changes over time with color-coded heat indicators
- **Disassembly preview** — hover over code pointers to see decoded instructions
- **C/C++ code generation** — export structs as compilable C/C++ headers
- **Import / export** — PDB import (Windows), ReClass XML import/export, C/C++ source import
- **Themes** — built-in theme editor with multiple presets
- **MCP bridge** — expose all tool functionality to AI clients via Model Context Protocol
- **Plugin system** — extend with custom data source providers via DLL plugins; following ship by default
- **Plugin system** — extend with custom data source providers via DLL plugins; the following ship by default:
- **Process plugin** — access memory of live processes on Windows and Linux
- **WinDbg plugin** — access data sources live in WinDbg debugging sessions
- **ReClass.NET compatibility layer** — load existing .NET and native ReClass.NET plugins
---
## Roadmap
- [ ] Process memory section enumeration
@@ -51,8 +52,6 @@ Built with C++17, Qt 6, and QScintilla. The entire editor surface is rendered as
- [ ] iOS/macOS support
- [ ] Display RTTI information
---
## Data Sources
- **File** — open any binary file and inspect its contents as structured data
@@ -60,8 +59,6 @@ Built with C++17, Qt 6, and QScintilla. The entire editor surface is rendered as
- **Remote Process** — read another process's memory via shared memory
- **WinDbg** — load `.dmp` crash dump files or connect to live debugging sessions
---
## Screenshots
![Type chooser and struct inspection](docs/README_PIC1.png)
@@ -70,11 +67,9 @@ Built with C++17, Qt 6, and QScintilla. The entire editor surface is rendered as
![Split view with rendered C/C++ output](docs/README_PIC3.png)
---
## MCP Integration
Built-in [Model Context Protocol](https://modelcontextprotocol.io/) bridge via `ReclassMcpBridge`. The server does not start by default and can be toggled from the File menu. It exposes all tool functionality to any MCP-compatible client (e.g. Claude Code) and falls back to UI prompts when the client requests something not yet covered by tools. To connect, add this to your MCP client config (e.g. `.mcp.json`):
Built-in [Model Context Protocol](https://modelcontextprotocol.io/) bridge via `ReclassMcpBridge`. The server starts automatically on launch and can be toggled from the File menu. It exposes all tool functionality to any MCP-compatible client (e.g. Claude Code). A standalone stdio-to-pipe bridge binary is built alongside the main application. To connect, add this to your MCP client config (e.g. `.mcp.json`):
```json
{
@@ -87,13 +82,11 @@ Built-in [Model Context Protocol](https://modelcontextprotocol.io/) bridge via `
}
```
---
## Build
### Prerequisites
- **Qt 6** with MinGW — [Qt Online Installer](https://doc.qt.io/qt-6/qt-online-installation.html) (select MinGW kit + CMake/Ninja from the Tools section)
- **Qt 6** (or Qt 5) with MinGW — [Qt Online Installer](https://doc.qt.io/qt-6/qt-online-installation.html) (select MinGW kit + CMake/Ninja from the Tools section)
- **CMake 3.20+** — [cmake.org](https://cmake.org/download/) (bundled with Qt)
- **Ninja** — bundled with the Qt installer
@@ -125,15 +118,11 @@ The build script auto-detects your Qt install location.
ctest --test-dir build --output-on-failure
```
---
## Alternatives
- [ReClass.NET](https://github.com/ReClassNET/ReClass.NET)
- [ReClassEx](https://github.com/ajkhoury/ReClassEx)
---
<div align="center">
<sub>MIT License</sub>
</div>

View File

@@ -10,20 +10,28 @@ namespace rcx {
// "<Program.exe> + 0xDE" → module base + offset
// "[<Program.exe> + 0xDE] - AB" → dereference pointer, then subtract
// "7ff6`6cce0000" → WinDbg-style backtick separator (stripped before parsing)
// "base + e_lfanew" → C/C++ style identifier resolution
// "0xFF & 0x0F" → bitwise AND
// "1 << 4" → shift left
//
// Grammar (standard operator precedence: *, / bind tighter than +, -):
// Grammar (C operator precedence):
//
// bitwiseOr = bitwiseXor ('|' bitwiseXor)*
// bitwiseXor = bitwiseAnd ('^' bitwiseAnd)*
// bitwiseAnd = shift ('&' shift)*
// shift = expr (('<<' | '>>') expr)*
// expr = term (('+' | '-') term)*
// term = unary (('*' | '/') unary)*
// unary = '-' unary | atom
// atom = '[' expr ']' -- read pointer at address (dereference)
// unary = '-' unary | '~' unary | atom
// atom = '[' bitwiseOr ']' -- read pointer at address (dereference)
// | '<' moduleName '>' -- resolve module base address
// | '(' expr ')' -- grouping
// | '(' bitwiseOr ')' -- grouping
// | identifier -- C/C++ name resolved via callback
// | hexLiteral -- hex number, optional 0x prefix
//
// All numeric literals are hexadecimal (base 16).
// Module names and pointer reads are resolved via optional callbacks.
// Without callbacks, modules and dereferences evaluate to 0 (syntax-check mode).
// Identifiers: [a-zA-Z_][a-zA-Z0-9_]* containing at least one non-hex char.
// Pure hex-digit words (e.g. "DEAD") are treated as hex literals.
class ExpressionParser {
public:
@@ -36,7 +44,7 @@ public:
return error("empty expression");
uint64_t value = 0;
if (!parseExpression(value))
if (!parseBitwiseOr(value))
return error(m_error);
skipSpaces();
@@ -90,8 +98,89 @@ private:
|| (ch >= 'A' && ch <= 'F');
}
static bool isIdentStart(QChar ch) {
return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || ch == '_';
}
static bool isIdentChar(QChar ch) {
return isIdentStart(ch) || (ch >= '0' && ch <= '9');
}
// ── Recursive descent parsing ──
// bitwiseOr = bitwiseXor ('|' bitwiseXor)*
bool parseBitwiseOr(uint64_t& result) {
if (!parseBitwiseXor(result))
return false;
for (;;) {
skipSpaces();
if (peek() != '|')
break;
advance();
uint64_t rhs = 0;
if (!parseBitwiseXor(rhs))
return false;
result |= rhs;
}
return true;
}
// bitwiseXor = bitwiseAnd ('^' bitwiseAnd)*
bool parseBitwiseXor(uint64_t& result) {
if (!parseBitwiseAnd(result))
return false;
for (;;) {
skipSpaces();
if (peek() != '^')
break;
advance();
uint64_t rhs = 0;
if (!parseBitwiseAnd(rhs))
return false;
result ^= rhs;
}
return true;
}
// bitwiseAnd = shift ('&' shift)*
bool parseBitwiseAnd(uint64_t& result) {
if (!parseShift(result))
return false;
for (;;) {
skipSpaces();
if (peek() != '&')
break;
advance();
uint64_t rhs = 0;
if (!parseShift(rhs))
return false;
result &= rhs;
}
return true;
}
// shift = expr (('<<' | '>>') expr)*
bool parseShift(uint64_t& result) {
if (!parseExpression(result))
return false;
for (;;) {
skipSpaces();
QChar c = peek();
if (c != '<' && c != '>')
break;
// Must be << or >> (not < or > alone)
if (m_pos + 1 >= m_input.size() || m_input[m_pos + 1] != c)
break;
bool isLeft = (c == '<');
advance(); advance(); // skip << or >>
uint64_t rhs = 0;
if (!parseExpression(rhs))
return false;
result = isLeft ? (result << rhs) : (result >> rhs);
}
return true;
}
// expr = term (('+' | '-') term)*
bool parseExpression(uint64_t& result) {
if (!parseTerm(result))
@@ -140,7 +229,7 @@ private:
return true;
}
// unary = '-' unary | atom
// unary = '-' unary | '~' unary | atom
bool parseUnary(uint64_t& result) {
skipSpaces();
if (peek() == '-') {
@@ -151,10 +240,18 @@ private:
result = static_cast<uint64_t>(-static_cast<int64_t>(inner));
return true;
}
if (peek() == '~') {
advance();
uint64_t inner = 0;
if (!parseUnary(inner))
return false;
result = ~inner;
return true;
}
return parseAtom(result);
}
// atom = '[' expr ']' | '<' name '>' | '(' expr ')' | hexLiteral
// atom = '[' bitwiseOr ']' | '<' name '>' | '(' bitwiseOr ')' | identifier | hexLiteral
bool parseAtom(uint64_t& result) {
skipSpaces();
if (atEnd())
@@ -165,15 +262,55 @@ private:
if (ch == '[') return parseDereference(result);
if (ch == '<') return parseModuleName(result);
if (ch == '(') return parseGrouping(result);
// Try identifier before hex — identifiers start with [a-zA-Z_]
if (isIdentStart(ch))
return parseIdentifierOrHex(result);
return parseHexNumber(result);
}
// '[' expr ']' — read the pointer value at the computed address
// Identifier or hex literal disambiguation.
// Scan [a-zA-Z_][a-zA-Z0-9_]*. If it contains any non-hex char → identifier.
// Otherwise → backtrack and parse as hex number.
bool parseIdentifierOrHex(uint64_t& result) {
int start = m_pos;
bool hasNonHex = false;
// Scan full token
while (!atEnd() && isIdentChar(peek())) {
if (!isHexDigit(peek()))
hasNonHex = true;
advance();
}
QString token = m_input.mid(start, m_pos - start);
if (!hasNonHex) {
// Pure hex digits (e.g. "DEAD") — backtrack, parse as hex
m_pos = start;
return parseHexNumber(result);
}
// It's an identifier — resolve via callback
if (!m_callbacks || !m_callbacks->resolveIdentifier) {
result = 0;
return true;
}
bool ok = false;
result = m_callbacks->resolveIdentifier(token, &ok);
if (!ok)
return fail(QStringLiteral("unknown identifier '%1'").arg(token));
return true;
}
// '[' bitwiseOr ']' — read the pointer value at the computed address
bool parseDereference(uint64_t& result) {
advance(); // skip '['
uint64_t address = 0;
if (!parseExpression(address))
if (!parseBitwiseOr(address))
return false;
if (!expect(']'))
return false;
@@ -220,10 +357,10 @@ private:
return true;
}
// '(' expr ')' — parenthesized sub-expression for grouping
// '(' bitwiseOr ')' — parenthesized sub-expression for grouping
bool parseGrouping(uint64_t& result) {
advance(); // skip '('
if (!parseExpression(result))
if (!parseBitwiseOr(result))
return false;
return expect(')');
}
@@ -290,7 +427,7 @@ QString AddressParser::validate(const QString& formula)
if (cleaned.isEmpty())
return QStringLiteral("empty");
// Parse with no callbacks — modules and dereferences succeed but return 0.
// Parse with no callbacks — modules, dereferences, identifiers succeed but return 0.
// This checks syntax only.
ExpressionParser parser(cleaned, nullptr);
auto result = parser.parse();

View File

@@ -15,6 +15,7 @@ struct AddressParseResult {
struct AddressParserCallbacks {
std::function<uint64_t(const QString& name, bool* ok)> resolveModule;
std::function<uint64_t(uint64_t addr, bool* ok)> readPointer;
std::function<uint64_t(const QString& name, bool* ok)> resolveIdentifier;
};
class AddressParser {

View File

@@ -1,4 +1,5 @@
#include "core.h"
#include "addressparser.h"
#include <algorithm>
#include <numeric>
@@ -296,7 +297,15 @@ void composeParent(ComposeState& state, const NodeTree& tree,
for (const auto& m : node.enumMembers)
maxNameLen = qMax(maxNameLen, (int)m.first.size());
for (int mi = 0; mi < node.enumMembers.size(); mi++) {
// Build display order sorted by value
QVector<int> order(node.enumMembers.size());
std::iota(order.begin(), order.end(), 0);
std::sort(order.begin(), order.end(), [&](int a, int b) {
return node.enumMembers[a].second < node.enumMembers[b].second;
});
for (int oi = 0; oi < order.size(); oi++) {
int mi = order[oi];
const auto& m = node.enumMembers[mi];
LineMeta lm;
lm.nodeIdx = nodeIdx;
@@ -304,6 +313,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
lm.subLine = mi;
lm.depth = childDepth;
lm.lineKind = LineKind::Field;
lm.isMemberLine = true;
lm.nodeKind = NodeKind::UInt32;
lm.foldLevel = computeFoldLevel(childDepth, false);
lm.markerMask = 0;
@@ -334,12 +344,72 @@ void composeParent(ComposeState& state, const NodeTree& tree,
return;
}
const QVector<int>& children = childIndices(state, node.id);
// Bitfield with members: render name : width = value lines
if (node.resolvedClassKeyword() == QStringLiteral("bitfield")
&& !node.bitfieldMembers.isEmpty()) {
int childDepth = depth + 1;
int maxNameLen = 4;
for (const auto& m : node.bitfieldMembers)
maxNameLen = qMax(maxNameLen, (int)m.name.size());
for (int mi = 0; mi < node.bitfieldMembers.size(); mi++) {
const auto& m = node.bitfieldMembers[mi];
uint64_t bitVal = fmt::extractBits(prov, absAddr, node.elementKind,
m.bitOffset, m.bitWidth);
LineMeta lm;
lm.nodeIdx = nodeIdx;
lm.nodeId = node.id;
lm.subLine = mi;
lm.depth = childDepth;
lm.lineKind = LineKind::Field;
lm.isMemberLine = true;
lm.nodeKind = node.elementKind;
lm.foldLevel = computeFoldLevel(childDepth, false);
lm.markerMask = 0;
lm.offsetText = fmt::fmtOffsetMargin(absAddr, true, state.offsetHexDigits);
lm.offsetAddr = absAddr;
lm.ptrBase = state.currentPtrBase;
state.emitLine(fmt::fmtBitfieldMember(m.name, m.bitWidth, bitVal,
childDepth, maxNameLen), lm);
}
// Footer
if (!isArrayChild) {
LineMeta lm;
lm.nodeIdx = nodeIdx;
lm.nodeId = node.id;
lm.depth = depth;
lm.lineKind = LineKind::Footer;
lm.nodeKind = node.kind;
lm.isRootHeader = isRootHeader;
lm.foldLevel = computeFoldLevel(depth, false);
lm.markerMask = 0;
int sz = sizeForKind(node.elementKind);
lm.offsetText = fmt::fmtOffsetMargin(absAddr + sz, false, state.offsetHexDigits);
lm.offsetAddr = absAddr + sz;
lm.ptrBase = state.currentPtrBase;
state.emitLine(fmt::fmtStructFooter(node, depth, sz), lm);
}
state.visiting.remove(node.id);
return;
}
const QVector<int>& allChildren = childIndices(state, node.id);
// Split children into regular nodes and helpers (helpers render at the end)
QVector<int> regular, helperIdxs;
for (int ci : allChildren) {
if (tree.nodes[ci].isHelper)
helperIdxs.append(ci);
else
regular.append(ci);
}
int childDepth = depth + 1;
// Primitive arrays with no child nodes: synthesize element lines dynamically
if (node.kind == NodeKind::Array && children.isEmpty()
if (node.kind == NodeKind::Array && regular.isEmpty()
&& node.elementKind != NodeKind::Struct && node.elementKind != NodeKind::Array) {
int elemSize = sizeForKind(node.elementKind);
int eTW = state.effectiveTypeW(node.id);
@@ -383,7 +453,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
// Struct arrays with refId but no child nodes: synthesize by expanding the
// referenced struct for each element (like repeated pointer deref)
if (node.kind == NodeKind::Array && children.isEmpty()
if (node.kind == NodeKind::Array && regular.isEmpty()
&& node.elementKind == NodeKind::Struct && node.refId != 0) {
int refIdx = tree.indexOfId(node.refId);
if (refIdx >= 0) {
@@ -400,7 +470,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
// Embedded struct with refId but no child nodes: expand referenced struct's
// children at this node's offset (single instance, like array with count=1)
if (node.kind == NodeKind::Struct && children.isEmpty() && node.refId != 0) {
if (node.kind == NodeKind::Struct && regular.isEmpty() && node.refId != 0) {
int refIdx = tree.indexOfId(node.refId);
if (refIdx >= 0) {
const QVector<int>& refChildren = childIndices(state, node.refId);
@@ -444,7 +514,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
// For arrays, render children as condensed (no header/footer for struct elements)
bool childrenAreArrayElements = (node.kind == NodeKind::Array);
int elementIdx = 0;
for (int childIdx : children) {
for (int childIdx : regular) {
// Pass this container's id as the scope for children (for per-scope widths)
// For array elements, also pass the element index for [N] separator
composeNode(state, tree, prov, childIdx, childDepth, base, rootId,
@@ -452,6 +522,156 @@ void composeParent(ComposeState& state, const NodeTree& tree,
childrenAreArrayElements ? elementIdx++ : -1,
childrenAreArrayElements ? absAddr : 0);
}
// ── Static helpers: render after regular children, before footer ──
if (!helperIdxs.isEmpty() && !node.collapsed) {
// Separator line
{
LineMeta lm;
lm.nodeIdx = nodeIdx;
lm.nodeId = node.id;
lm.depth = childDepth;
lm.lineKind = LineKind::Field;
lm.nodeKind = NodeKind::Hex8; // neutral kind for separator
lm.foldLevel = computeFoldLevel(childDepth, false);
lm.markerMask = 0;
lm.offsetText = QString(state.offsetHexDigits, QChar(' '));
state.emitLine(fmt::indent(childDepth)
+ QStringLiteral("\u2500\u2500\u2500 helpers \u2500\u2500\u2500"), lm);
}
// Build identifier resolver for helper expressions
auto makeResolver = [&](uint64_t parentAbsAddr) {
AddressParserCallbacks cbs;
cbs.resolveIdentifier = [&tree, &prov, &regular, parentAbsAddr]
(const QString& name, bool* ok) -> uint64_t {
if (name == QStringLiteral("base")) {
*ok = true;
return parentAbsAddr;
}
// Find sibling field by name, read its value
for (int ci : regular) {
const Node& sib = tree.nodes[ci];
if (sib.name == name) {
int sz = sib.byteSize();
uint64_t sibAddr = parentAbsAddr + sib.offset;
if (sz > 0 && prov.isValid() && prov.isReadable(sibAddr, sz)) {
*ok = true;
if (sz == 1) return (uint64_t)prov.readU8(sibAddr);
if (sz == 2) return (uint64_t)prov.readU16(sibAddr);
if (sz == 4) return (uint64_t)prov.readU32(sibAddr);
return prov.readU64(sibAddr);
}
*ok = false;
return 0;
}
}
*ok = false;
return 0;
};
cbs.readPointer = [&prov](uint64_t addr, bool* ok) -> uint64_t {
if (prov.isValid() && prov.isReadable(addr, 8)) {
*ok = true;
return prov.readU64(addr);
}
*ok = false;
return 0;
};
return cbs;
};
auto cbs = makeResolver(absAddr);
for (int hi : helperIdxs) {
const Node& helper = tree.nodes[hi];
// Evaluate expression → absolute address
uint64_t helperAddr = 0;
bool exprOk = false;
if (!helper.offsetExpr.isEmpty()) {
auto result = AddressParser::evaluate(helper.offsetExpr, 8, &cbs);
exprOk = result.ok;
if (result.ok)
helperAddr = result.value;
}
// Format: "▸ type name = expr → 0xADDR" (or "= expr (error)" on failure)
int typeW = state.effectiveTypeW(node.id);
int nameW = state.effectiveNameW(node.id);
QString typeName;
if (helper.kind == NodeKind::Struct)
typeName = fmt::structTypeName(helper);
else if (helper.kind == NodeKind::Pointer64 || helper.kind == NodeKind::Pointer32)
typeName = fmt::pointerTypeName(helper.kind, resolvePointerTarget(tree, helper.refId));
else
typeName = fmt::typeNameRaw(helper.kind);
bool overflow = state.compactColumns && typeName.size() > typeW;
QString type = overflow ? typeName : typeName.leftJustified(typeW);
QString name = overflow ? helper.name : helper.name.leftJustified(nameW);
QString exprPart;
if (!helper.offsetExpr.isEmpty()) {
if (exprOk)
exprPart = QStringLiteral("= %1 \u2192 0x%2")
.arg(helper.offsetExpr)
.arg(QString::number(helperAddr, 16).toUpper());
else
exprPart = QStringLiteral("= %1 (error)").arg(helper.offsetExpr);
}
QString line = fmt::indent(childDepth) + type
+ QStringLiteral(" ") + name
+ QStringLiteral(" ") + exprPart;
LineMeta lm;
lm.nodeIdx = hi;
lm.nodeId = helper.id;
lm.depth = childDepth;
lm.lineKind = LineKind::Header;
lm.nodeKind = helper.kind;
lm.foldHead = true;
lm.foldCollapsed = true; // helpers always start collapsed
lm.isHelperLine = true;
lm.foldLevel = computeFoldLevel(childDepth, true);
lm.markerMask = (1u << M_STRUCT_BG);
lm.offsetText = QStringLiteral("~") + QString::number(helperAddr, 16)
.toUpper().rightJustified(state.offsetHexDigits - 1, '0');
lm.offsetAddr = helperAddr;
lm.ptrBase = state.currentPtrBase;
lm.effectiveTypeW = overflow ? typeName.size() : typeW;
lm.effectiveNameW = nameW;
state.emitLine(line, lm);
// If helper is expanded (user clicked to expand), compose its children
if (!helper.collapsed && exprOk) {
if (helper.kind == NodeKind::Struct || helper.kind == NodeKind::Array) {
// Compose helper's children at the evaluated address
const QVector<int>& helperKids = childIndices(state, helper.id);
for (int hci : helperKids) {
composeNode(state, tree, prov, hci, childDepth + 1,
helperAddr, helper.id, false, helper.id);
}
// Helper footer
LineMeta flm;
flm.nodeIdx = hi;
flm.nodeId = helper.id;
flm.depth = childDepth;
flm.lineKind = LineKind::Footer;
flm.nodeKind = helper.kind;
flm.foldLevel = computeFoldLevel(childDepth, false);
flm.markerMask = 0;
int hSpan = tree.structSpan(helper.id, &state.childMap);
flm.offsetText = fmt::fmtOffsetMargin(helperAddr + hSpan, false,
state.offsetHexDigits);
flm.offsetAddr = helperAddr + hSpan;
flm.ptrBase = state.currentPtrBase;
state.emitLine(fmt::fmtStructFooter(helper, childDepth, hSpan), flm);
}
}
}
}
}
// Footer line: skip when collapsed or for array element structs
@@ -741,7 +961,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
}
// Emit CommandRow as line 0 (combined: source + address + root class type + name)
const QString cmdRowText = QStringLiteral("[\u25B8] source\u25BE \u00B7 0x0 \u00B7 struct NoName {");
const QString cmdRowText = QStringLiteral("[\u25B8] source\u25BE 0x0 struct NoName {");
{
LineMeta lm;
lm.nodeIdx = -1;

View File

@@ -250,6 +250,15 @@ void RcxController::connectEditor(RcxEditor* editor) {
if (text.isEmpty()) break;
if (nodeIdx >= m_doc->tree.nodes.size()) break;
const Node& node = m_doc->tree.nodes[nodeIdx];
// Enum member name edit
if (node.resolvedClassKeyword() == QStringLiteral("enum")
&& subLine >= 0 && subLine < node.enumMembers.size()) {
auto members = node.enumMembers;
members[subLine].first = text;
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeEnumMembers{node.id, node.enumMembers, members}));
break;
}
// ASCII edit on Hex nodes
if (isHexPreview(node.kind)) {
setNodeValue(nodeIdx, subLine, text, /*isAscii=*/true, resolvedAddr);
@@ -321,9 +330,27 @@ void RcxController::connectEditor(RcxEditor* editor) {
}
break;
}
case EditTarget::Value:
case EditTarget::Value: {
// Enum member value edit
if (nodeIdx >= 0 && nodeIdx < m_doc->tree.nodes.size()) {
const Node& node = m_doc->tree.nodes[nodeIdx];
if (node.resolvedClassKeyword() == QStringLiteral("enum")
&& subLine >= 0 && subLine < node.enumMembers.size()) {
bool ok;
int64_t val = text.toLongLong(&ok);
if (!ok) val = text.toLongLong(&ok, 16);
if (ok) {
auto members = node.enumMembers;
members[subLine].second = val;
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeEnumMembers{node.id, node.enumMembers, members}));
}
break;
}
}
setNodeValue(nodeIdx, subLine, text, /*isAscii=*/false, resolvedAddr);
break;
}
case EditTarget::BaseAddress: {
QString s = text.trimmed();
s.remove('`'); // WinDbg backtick separators (e.g. 7ff6`6cce0000)
@@ -454,6 +481,16 @@ void RcxController::connectEditor(RcxEditor* editor) {
}
break;
}
case EditTarget::HelperExpr: {
if (nodeIdx >= 0 && nodeIdx < m_doc->tree.nodes.size()) {
const Node& node = m_doc->tree.nodes[nodeIdx];
if (node.isHelper && text != node.offsetExpr) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeOffsetExpr{node.id, node.offsetExpr, text}));
}
}
break;
}
case EditTarget::ArrayIndex:
case EditTarget::ArrayCount:
// Array navigation removed - these cases are unreachable
@@ -569,9 +606,10 @@ void RcxController::refresh() {
// Prune stale selections (nodes removed by undo/redo/delete)
QSet<uint64_t> valid;
for (uint64_t id : m_selIds) {
uint64_t nodeId = id & ~(kFooterIdBit | kArrayElemBit | kArrayElemMask);
uint64_t nodeId = id & ~(kFooterIdBit | kArrayElemBit | kArrayElemMask
| kMemberBit | kMemberSubMask);
if (m_doc->tree.indexOfId(nodeId) >= 0)
valid.insert(id); // Keep original ID (with footer/array bits if present)
valid.insert(id); // Keep original ID (with footer/array/member bits if present)
}
m_selIds = valid;
@@ -1145,6 +1183,18 @@ void RcxController::applyCommand(const Command& command, bool isUndo) {
m_valueHistory.remove(c.nodeId);
for (int ci : tree.subtreeIndices(c.nodeId))
m_valueHistory.remove(tree.nodes[ci].id);
} else if constexpr (std::is_same_v<T, cmd::ChangeEnumMembers>) {
int idx = tree.indexOfId(c.nodeId);
if (idx >= 0)
tree.nodes[idx].enumMembers = isUndo ? c.oldMembers : c.newMembers;
} else if constexpr (std::is_same_v<T, cmd::ChangeOffsetExpr>) {
int idx = tree.indexOfId(c.nodeId);
if (idx >= 0)
tree.nodes[idx].offsetExpr = isUndo ? c.oldExpr : c.newExpr;
} else if constexpr (std::is_same_v<T, cmd::ToggleHelper>) {
int idx = tree.indexOfId(c.nodeId);
if (idx >= 0)
tree.nodes[idx].isHelper = isUndo ? c.oldVal : c.newVal;
}
}, command);
@@ -1379,6 +1429,86 @@ void RcxController::splitHexNode(uint64_t nodeId) {
refresh();
}
void RcxController::toggleBitfieldBit(uint64_t nodeId, int memberIdx) {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni < 0) return;
const Node& node = m_doc->tree.nodes[ni];
if (node.resolvedClassKeyword() != QStringLiteral("bitfield")) return;
if (memberIdx < 0 || memberIdx >= node.bitfieldMembers.size()) return;
if (!m_doc->provider || !m_doc->provider->isWritable()) return;
const auto& bm = node.bitfieldMembers[memberIdx];
uint64_t addr = m_doc->tree.baseAddress + m_doc->tree.computeOffset(ni);
int containerSize = sizeForKind(node.elementKind);
if (containerSize <= 0) containerSize = 4;
QByteArray oldBytes(containerSize, 0);
m_doc->provider->read(addr, oldBytes.data(), containerSize);
QByteArray newBytes = oldBytes;
// Toggle the bit
int byteIdx = bm.bitOffset / 8;
int bitInByte = bm.bitOffset % 8;
if (byteIdx < containerSize)
newBytes[byteIdx] = newBytes[byteIdx] ^ (1 << bitInByte);
m_doc->undoStack.push(new RcxCommand(this,
cmd::WriteBytes{addr, oldBytes, newBytes}));
refresh();
}
void RcxController::editBitfieldValue(uint64_t nodeId, int memberIdx) {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni < 0) return;
const Node& node = m_doc->tree.nodes[ni];
if (node.resolvedClassKeyword() != QStringLiteral("bitfield")) return;
if (memberIdx < 0 || memberIdx >= node.bitfieldMembers.size()) return;
if (!m_doc->provider || !m_doc->provider->isWritable()) return;
const auto& bm = node.bitfieldMembers[memberIdx];
uint64_t addr = m_doc->tree.baseAddress + m_doc->tree.computeOffset(ni);
int containerSize = sizeForKind(node.elementKind);
if (containerSize <= 0) containerSize = 4;
// Read current value
uint64_t curVal = fmt::extractBits(*m_doc->provider, addr, node.elementKind,
bm.bitOffset, bm.bitWidth);
uint64_t maxVal = (bm.bitWidth >= 64) ? UINT64_MAX : ((1ULL << bm.bitWidth) - 1);
bool ok = false;
QString input = QInputDialog::getText(nullptr,
QStringLiteral("Edit Bitfield Value"),
QStringLiteral("%1 (%2 bits, max %3):")
.arg(bm.name).arg(bm.bitWidth).arg(maxVal),
QLineEdit::Normal,
QString::number(curVal), &ok);
if (!ok || input.isEmpty()) return;
// Parse value (support hex with 0x prefix)
uint64_t newVal;
if (input.startsWith(QStringLiteral("0x"), Qt::CaseInsensitive))
newVal = input.mid(2).toULongLong(&ok, 16);
else
newVal = input.toULongLong(&ok, 10);
if (!ok) return;
newVal &= maxVal;
QByteArray oldBytes(containerSize, 0);
m_doc->provider->read(addr, oldBytes.data(), containerSize);
// Read-modify-write: clear target bits and set new value
QByteArray newBytes = oldBytes;
uint64_t container = 0;
memcpy(&container, newBytes.constData(), qMin(containerSize, (int)sizeof(container)));
uint64_t mask = maxVal << bm.bitOffset;
container = (container & ~mask) | ((newVal & maxVal) << bm.bitOffset);
memcpy(newBytes.data(), &container, qMin(containerSize, (int)sizeof(container)));
m_doc->undoStack.push(new RcxCommand(this,
cmd::WriteBytes{addr, oldBytes, newBytes}));
refresh();
}
void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
int subLine, const QPoint& globalPos) {
auto icon = [](const char* name) { return QIcon(QStringLiteral(":/vsicons/%1").arg(name)); };
@@ -1535,6 +1665,31 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
uint64_t nodeId = node.id;
uint64_t parentId = node.parentId;
// ── Member line: enum or bitfield member ──
bool isEnumMember = node.resolvedClassKeyword() == QStringLiteral("enum")
&& !node.enumMembers.isEmpty()
&& subLine >= 0 && subLine < node.enumMembers.size();
bool isBitfieldMember = node.resolvedClassKeyword() == QStringLiteral("bitfield")
&& !node.bitfieldMembers.isEmpty()
&& subLine >= 0 && subLine < node.bitfieldMembers.size();
if (isEnumMember || isBitfieldMember) {
if (isBitfieldMember) {
const auto& bm = node.bitfieldMembers[subLine];
if (bm.bitWidth == 1) {
menu.addAction("Toggle Bit", [this, nodeId, subLine]() {
toggleBitfieldBit(nodeId, subLine);
});
} else {
menu.addAction("Edit Value...", [this, nodeId, subLine]() {
editBitfieldValue(nodeId, subLine);
});
}
menu.addSeparator();
}
// Fall through to always-available actions
} else {
// Quick-convert suggestions for Hex nodes
bool addedQuickConvert = false;
if (node.kind == NodeKind::Hex64) {
@@ -1694,6 +1849,19 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
menu.addAction(icon("diff-added.svg"), "Add &Child", [this, nodeId]() {
insertNode(nodeId, 0, NodeKind::Hex64, "newField");
});
// Add Helper — inserts a static helper child
menu.addAction("Add Helper", [this, nodeId]() {
Node helper;
helper.id = m_doc->tree.m_nextId++;
helper.kind = NodeKind::Hex64;
helper.name = QStringLiteral("helper");
helper.parentId = nodeId;
helper.offset = 0;
helper.isHelper = true;
helper.offsetExpr = QStringLiteral("base");
m_doc->undoStack.push(new RcxCommand(this,
cmd::Insert{helper, {}}));
});
if (node.collapsed) {
menu.addAction(icon("expand-all.svg"), "&Expand", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
@@ -1708,6 +1876,25 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
}
// Helper-specific: Edit Expression inline
if (node.isHelper) {
menu.addAction("Edit E&xpression", [this, editor, line, nodeId]() {
// Build completions list: "base" + sibling field names
QStringList completions;
completions << QStringLiteral("base");
int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) {
uint64_t parentId = m_doc->tree.nodes[ni].parentId;
for (const Node& sib : m_doc->tree.nodes) {
if (sib.parentId == parentId && !sib.isHelper && !sib.name.isEmpty())
completions << sib.name;
}
}
editor->setHelperCompletions(completions);
editor->beginInlineEdit(EditTarget::HelperExpr, line);
});
}
// Dissolve Union: available on union itself or any of its children
{
uint64_t targetUnionId = 0;
@@ -1756,6 +1943,7 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
});
menu.addSeparator();
} // else (non-member node actions)
}
// ── Always-available actions ──
@@ -1885,6 +2073,8 @@ void RcxController::handleNodeClick(RcxEditor* source, int line,
return nid | kFooterIdBit;
if (lm.isArrayElement && lm.arrayElementIdx >= 0)
return makeArrayElemSelId(nid, lm.arrayElementIdx);
if (lm.isMemberLine && lm.subLine >= 0)
return makeMemberSelId(nid, lm.subLine);
return nid;
};
@@ -1933,8 +2123,9 @@ void RcxController::handleNodeClick(RcxEditor* source, int line,
if (m_selIds.size() == 1) {
uint64_t sid = *m_selIds.begin();
// Strip footer/array bits for node lookup
int idx = m_doc->tree.indexOfId(sid & ~(kFooterIdBit | kArrayElemBit | kArrayElemMask));
// Strip footer/array/member bits for node lookup
int idx = m_doc->tree.indexOfId(sid & ~(kFooterIdBit | kArrayElemBit | kArrayElemMask
| kMemberBit | kMemberSubMask));
if (idx >= 0) emit nodeSelected(idx);
}
}
@@ -1970,7 +2161,7 @@ void RcxController::updateCommandRow() {
addr = QStringLiteral("0x") +
QString::number(m_doc->tree.baseAddress, 16).toUpper();
QString row = QStringLiteral("%1 \u00B7 %2")
QString row = QStringLiteral("%1 %2")
.arg(elide(src, 40), elide(addr, 24));
// Build row 2: root class type + name (uses current view root)
@@ -2001,7 +2192,7 @@ void RcxController::updateCommandRow() {
if (row2.isEmpty())
row2 = QStringLiteral("struct NoName {");
QString combined = QStringLiteral("[\u25B8] ") + row + QStringLiteral(" \u00B7 ") + row2;
QString combined = QStringLiteral("[\u25B8] ") + row + QStringLiteral(" ") + row2;
for (auto* ed : m_editors) {
ed->setCommandRowText(combined);
@@ -2026,15 +2217,118 @@ TypeSelectorPopup* RcxController::ensurePopup(RcxEditor* editor) {
void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
int nodeIdx, QPoint globalPos) {
const Node* node = nullptr;
if (nodeIdx >= 0 && nodeIdx < m_doc->tree.nodes.size())
if (nodeIdx >= 0 && nodeIdx < (int)m_doc->tree.nodes.size())
node = &m_doc->tree.nodes[nodeIdx];
// ── Determine modifier preset (cheap — only reads node properties) ──
int preModId = 0;
int preArrayCount = 0;
if (mode == TypePopupMode::FieldType && node) {
bool isPtr = (node->kind == NodeKind::Pointer32 || node->kind == NodeKind::Pointer64);
bool isPrimPtr = isPtr && node->ptrDepth > 0 && node->refId == 0;
bool isTypedPtr = isPtr && node->refId != 0;
bool isArray = node->kind == NodeKind::Array;
if (isPrimPtr) preModId = (node->ptrDepth >= 2) ? 2 : 1;
else if (isTypedPtr) preModId = 1;
else if (isArray) { preModId = 3; preArrayCount = node->arrayLen; }
}
// ── Node size for same-size sorting (cheap) ──
int nodeSize = 0;
if (node) {
if (mode == TypePopupMode::ArrayElement)
nodeSize = sizeForKind(node->elementKind);
else
nodeSize = sizeForKind(node->kind);
}
// ── Font with zoom ──
QSettings settings("Reclass", "Reclass");
QString fontName = settings.value("font", "JetBrains Mono").toString();
QFont font(fontName, 12);
font.setFixedPitch(true);
auto* sci = editor->scintilla();
int zoom = (int)sci->SendScintilla(QsciScintillaBase::SCI_GETZOOM);
font.setPointSize(font.pointSize() + zoom);
// ── Position ──
QPoint pos = globalPos;
if (mode == TypePopupMode::Root) {
long lineStart = sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE, 0);
int lineH = (int)sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT, 0);
int x = (int)sci->SendScintilla(QsciScintillaBase::SCI_POINTXFROMPOSITION,
0, lineStart);
int y = (int)sci->SendScintilla(QsciScintillaBase::SCI_POINTYFROMPOSITION,
0, lineStart);
pos = sci->viewport()->mapToGlobal(QPoint(x, y + lineH));
}
// ── Configure popup + show skeleton instantly ──
auto* popup = ensurePopup(editor);
popup->setFont(font);
popup->setMode(mode);
if (preModId > 0)
popup->setModifier(preModId, preArrayCount);
popup->setCurrentNodeSize(nodeSize);
connect(popup, &TypeSelectorPopup::typeSelected,
this, [this, mode, nodeIdx](const TypeEntry& entry, const QString& fullText) {
applyTypePopupResult(mode, nodeIdx, entry, fullText);
});
connect(popup, &TypeSelectorPopup::createNewTypeRequested,
this, [this, mode, nodeIdx]() {
bool wasSuppressed = m_suppressRefresh;
m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QStringLiteral("Create new type"));
QString baseName = QStringLiteral("NewClass");
QString typeName = baseName;
int counter = 1;
QSet<QString> existing;
for (const auto& nd : m_doc->tree.nodes) {
if (nd.kind == NodeKind::Struct && !nd.structTypeName.isEmpty())
existing.insert(nd.structTypeName);
}
while (existing.contains(typeName))
typeName = baseName + QString::number(counter++);
Node n;
n.kind = NodeKind::Struct;
n.structTypeName = typeName;
n.name = QStringLiteral("instance");
n.parentId = 0;
n.offset = 0;
n.id = m_doc->tree.reserveId();
m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{n}));
for (int i = 0; i < 8; i++) {
insertNode(n.id, i * 8, NodeKind::Hex64,
QString("field_%1").arg(i * 8, 2, 16, QChar('0')));
}
m_doc->undoStack.endMacro();
m_suppressRefresh = wasSuppressed;
TypeEntry newEntry;
newEntry.entryKind = TypeEntry::Composite;
newEntry.structId = n.id;
applyTypePopupResult(mode, nodeIdx, newEntry, QString());
});
popup->popupLoading(pos);
// ── Deferred: build entry list + fill content (runs next event-loop tick) ──
int gen = ++m_typePopupGen;
QTimer::singleShot(0, this, [this, popup, mode, nodeIdx, gen]() {
if (gen != m_typePopupGen) return; // popup was reopened, discard stale load
const Node* node = nullptr;
if (nodeIdx >= 0 && nodeIdx < (int)m_doc->tree.nodes.size())
node = &m_doc->tree.nodes[nodeIdx];
// ── Build entry list based on mode ──
QVector<TypeEntry> entries;
TypeEntry currentEntry;
bool hasCurrent = false;
int preModId = 0; // modifier to preselect: 0=plain, 1=*, 2=**, 3=[n]
int preArrayCount = 0; // array count when preModId==3
auto addPrimitives = [&](bool enabled, bool excludeStructArrayPad) {
for (const auto& m : kKindMeta) {
@@ -2046,6 +2340,8 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
e.primitiveKind = m.kind;
e.displayName = QString::fromLatin1(m.typeName);
e.enabled = enabled;
e.sizeBytes = m.size;
e.alignment = m.align;
entries.append(e);
}
};
@@ -2058,6 +2354,39 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
e.structId = n.id;
e.displayName = n.structTypeName.isEmpty() ? n.name : n.structTypeName;
e.classKeyword = n.resolvedClassKeyword();
e.category = (e.classKeyword == QStringLiteral("enum"))
? TypeEntry::CatEnum : TypeEntry::CatType;
e.sizeBytes = m_doc->tree.structSpan(n.id);
QVector<int> kids = m_doc->tree.childrenOf(n.id);
int nonHelperCount = 0;
int maxAlign = 1;
for (int i = 0; i < kids.size(); i++) {
const Node& child = m_doc->tree.nodes[kids[i]];
if (child.isHelper) continue;
nonHelperCount++;
int childAlign = alignmentFor(child.kind);
if (childAlign > maxAlign) maxAlign = childAlign;
if (e.fieldSummary.size() < 6) {
auto* cm = kindMeta(child.kind);
QString typeName = cm ? QString::fromLatin1(cm->typeName)
: QStringLiteral("???");
if (child.kind == NodeKind::Struct && child.refId != 0) {
int refIdx = m_doc->tree.indexOfId(child.refId);
if (refIdx >= 0) {
const Node& ref = m_doc->tree.nodes[refIdx];
typeName = ref.structTypeName.isEmpty()
? ref.name : ref.structTypeName;
}
}
e.fieldSummary << QStringLiteral("0x%1: %2 %3")
.arg(child.offset, 2, 16, QChar('0'))
.arg(typeName, child.name);
}
}
e.fieldCount = nonHelperCount;
e.alignment = maxAlign;
entries.append(e);
if (!hasCurrent && node && isCurrent(*node, e)) {
currentEntry = e;
@@ -2068,8 +2397,7 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
switch (mode) {
case TypePopupMode::Root:
// No primitives in Root mode only project types are valid roots
addComposites([&](const Node&, const TypeEntry& e) {
addComposites([this](const Node&, const TypeEntry& e) {
return e.structId == m_viewRootId;
});
break;
@@ -2083,8 +2411,6 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
bool isArray = node && node->kind == NodeKind::Array;
if (isPrimPtr) {
// Primitive pointer (e.g. int32* or f64**) — current = element kind, modifier = *//**
preModId = (node->ptrDepth >= 2) ? 2 : 1;
for (auto& e : entries) {
if (e.entryKind == TypeEntry::Primitive && e.primitiveKind == node->elementKind) {
currentEntry = e;
@@ -2093,14 +2419,9 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
}
}
} else if (isTypedPtr) {
// Typed pointer (e.g. Ball*) — current = composite target, modifier = *
preModId = 1;
// current set by addComposites below
} else if (isArray) {
// Array — modifier = [n]
preModId = 3;
preArrayCount = node->arrayLen;
if (node->elementKind != NodeKind::Struct) {
// Primitive array — mark element kind as current
for (auto& e : entries) {
if (e.entryKind == TypeEntry::Primitive && e.primitiveKind == node->elementKind) {
currentEntry = e;
@@ -2110,7 +2431,7 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
}
}
} else if (node) {
// Plain primitive — mark current
if (!(node->kind == NodeKind::Struct && node->refId != 0)) {
for (auto& e : entries) {
if (e.entryKind == TypeEntry::Primitive && e.primitiveKind == node->kind) {
currentEntry = e;
@@ -2119,10 +2440,11 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
}
}
}
// For isTypedPtr or struct-array: current is a Composite, set by addComposites below
}
addComposites([&](const Node& n, const TypeEntry& e) {
if (isTypedPtr && n.refId == e.structId) return true;
if (isArray && n.elementKind == NodeKind::Struct && n.refId == e.structId) return true;
if (!isPtr && !isArray && n.kind == NodeKind::Struct && n.refId == e.structId) return true;
return false;
});
break;
@@ -2145,16 +2467,24 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
break;
case TypePopupMode::PointerTarget: {
// "void" entry as a primitive with a special display
TypeEntry voidEntry;
voidEntry.entryKind = TypeEntry::Primitive;
voidEntry.primitiveKind = NodeKind::Hex8; // unused, but needs a value
voidEntry.primitiveKind = NodeKind::Hex8;
voidEntry.displayName = QStringLiteral("void");
voidEntry.enabled = true;
entries.append(voidEntry);
if (node && node->refId == 0) {
addPrimitives(/*enabled=*/true, /*excludeStructArrayPad=*/true);
if (node && node->refId == 0 && node->ptrDepth <= 1) {
currentEntry = voidEntry;
hasCurrent = true;
} else if (node && node->refId == 0 && node->ptrDepth > 0) {
for (auto& e : entries) {
if (e.entryKind == TypeEntry::Primitive && e.primitiveKind == node->elementKind) {
currentEntry = e;
hasCurrent = true;
break;
}
}
}
addComposites([](const Node& n, const TypeEntry& e) {
return n.refId == e.structId;
@@ -2163,7 +2493,7 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
}
}
// ── Add types from other open documents (not for Root mode) ──
// Add types from other open documents
if (mode != TypePopupMode::Root && m_projectDocs) {
QSet<QString> localNames;
for (const auto& e : entries)
@@ -2178,107 +2508,19 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
localNames.insert(name);
TypeEntry e;
e.entryKind = TypeEntry::Composite;
e.structId = 0; // sentinel: not in local tree yet
e.structId = 0;
e.displayName = name;
e.classKeyword = n.resolvedClassKeyword();
e.category = (e.classKeyword == QStringLiteral("enum"))
? TypeEntry::CatEnum : TypeEntry::CatType;
e.sizeBytes = doc->tree.structSpan(n.id);
entries.append(e);
}
}
}
// ── Font with zoom ──
QSettings settings("Reclass", "Reclass");
QString fontName = settings.value("font", "JetBrains Mono").toString();
QFont font(fontName, 12);
font.setFixedPitch(true);
auto* sci = editor->scintilla();
int zoom = (int)sci->SendScintilla(QsciScintillaBase::SCI_GETZOOM);
font.setPointSize(font.pointSize() + zoom);
// ── Position ──
QPoint pos = globalPos;
if (mode == TypePopupMode::Root) {
// Bottom-left of the [▸] span on line 0
long lineStart = sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE, 0);
int lineH = (int)sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT, 0);
int x = (int)sci->SendScintilla(QsciScintillaBase::SCI_POINTXFROMPOSITION,
0, lineStart);
int y = (int)sci->SendScintilla(QsciScintillaBase::SCI_POINTYFROMPOSITION,
0, lineStart);
pos = sci->viewport()->mapToGlobal(QPoint(x, y + lineH));
}
// ── Configure and show popup ──
auto* popup = ensurePopup(editor);
popup->setFont(font);
popup->setMode(mode);
// Preselect modifier button to reflect current node state (after setMode resets to plain)
if (preModId > 0)
popup->setModifier(preModId, preArrayCount);
// Pass current node size for same-size sorting
int nodeSize = 0;
if (node) {
if (mode == TypePopupMode::ArrayElement)
nodeSize = sizeForKind(node->elementKind);
else
nodeSize = sizeForKind(node->kind);
}
popup->setCurrentNodeSize(nodeSize);
static const char* titles[] = { "Change root", "Change type",
"Element type", "Pointer target" };
popup->setTitle(QString::fromLatin1(titles[(int)mode]));
popup->setTypes(entries, hasCurrent ? &currentEntry : nullptr);
connect(popup, &TypeSelectorPopup::typeSelected,
this, [this, mode, nodeIdx](const TypeEntry& entry, const QString& fullText) {
applyTypePopupResult(mode, nodeIdx, entry, fullText);
});
connect(popup, &TypeSelectorPopup::createNewTypeRequested,
this, [this, mode, nodeIdx]() {
bool wasSuppressed = m_suppressRefresh;
m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QStringLiteral("Create new type"));
// Generate unique default type name
QString baseName = QStringLiteral("NewClass");
QString typeName = baseName;
int counter = 1;
QSet<QString> existing;
for (const auto& nd : m_doc->tree.nodes) {
if (nd.kind == NodeKind::Struct && !nd.structTypeName.isEmpty())
existing.insert(nd.structTypeName);
}
while (existing.contains(typeName))
typeName = baseName + QString::number(counter++);
Node n;
n.kind = NodeKind::Struct;
n.structTypeName = typeName;
n.name = QStringLiteral("instance");
n.parentId = 0;
n.offset = 0;
n.id = m_doc->tree.reserveId();
m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{n}));
// Populate with default hex nodes (8 x Hex64 = 64 bytes)
for (int i = 0; i < 8; i++) {
insertNode(n.id, i * 8, NodeKind::Hex64,
QString("field_%1").arg(i * 8, 2, 16, QChar('0')));
}
m_doc->undoStack.endMacro();
m_suppressRefresh = wasSuppressed;
TypeEntry newEntry;
newEntry.entryKind = TypeEntry::Composite;
newEntry.structId = n.id;
applyTypePopupResult(mode, nodeIdx, newEntry, QString());
});
popup->popup(pos);
}
void RcxController::applyTypePopupResult(TypePopupMode mode, int nodeIdx,

View File

@@ -98,6 +98,8 @@ public:
void duplicateNode(int nodeIdx);
void convertToTypedPointer(uint64_t nodeId);
void splitHexNode(uint64_t nodeId);
void toggleBitfieldBit(uint64_t nodeId, int memberIdx);
void editBitfieldValue(uint64_t nodeId, int memberIdx);
void showContextMenu(RcxEditor* editor, int line, int nodeIdx, int subLine, const QPoint& globalPos);
void batchRemoveNodes(const QVector<int>& nodeIndices);
void batchChangeKind(const QVector<int>& nodeIndices, NodeKind newKind);
@@ -166,6 +168,7 @@ private:
// ── Cached type selector popup (avoids ~350ms cold-start on first show) ──
QPointer<TypeSelectorPopup> m_cachedPopup;
int m_typePopupGen = 0; // generation counter for deferred content loading
// ── Auto-refresh state ──
using PageMap = QHash<uint64_t, QByteArray>;
@@ -175,7 +178,7 @@ private:
PageMap m_prevPages;
QSet<int64_t> m_changedOffsets;
QHash<uint64_t, ValueHistory> m_valueHistory;
bool m_trackValues = false;
bool m_trackValues = true;
uint64_t m_refreshGen = 0;
uint64_t m_readGen = 0;
bool m_readInFlight = false;

View File

@@ -179,6 +179,14 @@ enum Marker : int {
M_ACCENT = 9,
};
// ── Bitfield member (name + bit position + width within a container) ──
struct BitfieldMember {
QString name;
uint8_t bitOffset = 0; // position from LSB within the container
uint8_t bitWidth = 1; // number of bits (1..64)
};
// ── Node ──
struct Node {
@@ -189,6 +197,8 @@ struct Node {
QString classKeyword; // "struct", "class", or "enum" (empty = "struct")
uint64_t parentId = 0; // 0 = root (no parent)
int offset = 0;
bool isHelper = false; // static helper — excluded from struct layout
QString offsetExpr; // C/C++ expression → absolute address (helpers only)
int arrayLen = 1; // Array: element count
int strLen = 64;
bool collapsed = false;
@@ -197,6 +207,7 @@ struct Node {
int ptrDepth = 0; // Pointer: 0=struct/void ptr, 1=primitive*, 2=primitive**
int viewIndex = 0; // Array: current view offset (transient)
QVector<QPair<QString, int64_t>> enumMembers; // Enum: name→value pairs
QVector<BitfieldMember> bitfieldMembers; // Bitfield: per-bit member definitions
// Note: Returns 0 for Array-of-Struct/Array. Use tree.structSpan() for accurate size.
int byteSize() const {
@@ -208,6 +219,12 @@ struct Node {
if (elemSz <= 0) return 0;
return qMin(arrayLen, INT_MAX / elemSz) * elemSz;
}
case NodeKind::Struct:
if (classKeyword == QStringLiteral("bitfield")) {
int sz = sizeForKind(elementKind);
return sz > 0 ? sz : 4;
}
return 0;
default: return sizeForKind(kind);
}
}
@@ -223,6 +240,10 @@ struct Node {
o["classKeyword"] = classKeyword;
o["parentId"] = QString::number(parentId);
o["offset"] = offset;
if (isHelper)
o["isHelper"] = true;
if (!offsetExpr.isEmpty())
o["offsetExpr"] = offsetExpr;
o["arrayLen"] = arrayLen;
o["strLen"] = strLen;
o["collapsed"] = collapsed;
@@ -240,6 +261,17 @@ struct Node {
}
o["enumMembers"] = arr;
}
if (!bitfieldMembers.isEmpty()) {
QJsonArray arr;
for (const auto& m : bitfieldMembers) {
QJsonObject bm;
bm["name"] = m.name;
bm["bitOffset"] = m.bitOffset;
bm["bitWidth"] = m.bitWidth;
arr.append(bm);
}
o["bitfieldMembers"] = arr;
}
return o;
}
static Node fromJson(const QJsonObject& o) {
@@ -251,6 +283,8 @@ struct Node {
n.classKeyword = o["classKeyword"].toString();
n.parentId = o["parentId"].toString("0").toULongLong();
n.offset = o["offset"].toInt(0);
n.isHelper = o["isHelper"].toBool(false);
n.offsetExpr = o["offsetExpr"].toString();
n.arrayLen = qBound(1, o["arrayLen"].toInt(1), 1000000);
n.strLen = qBound(1, o["strLen"].toInt(64), 1000000);
n.collapsed = o["collapsed"].toBool(false);
@@ -265,6 +299,17 @@ struct Node {
em["value"].toString("0").toLongLong()});
}
}
if (o.contains("bitfieldMembers")) {
QJsonArray arr = o["bitfieldMembers"].toArray();
for (const auto& v : arr) {
QJsonObject bm = v.toObject();
BitfieldMember m;
m.name = bm["name"].toString();
m.bitOffset = (uint8_t)bm["bitOffset"].toInt(0);
m.bitWidth = (uint8_t)qBound(1, bm["bitWidth"].toInt(1), 64);
n.bitfieldMembers.append(m);
}
}
return n;
}
@@ -400,6 +445,7 @@ struct NodeTree {
QVector<int> kids = childMap ? childMap->value(structId) : childrenOf(structId);
for (int ci : kids) {
const Node& c = nodes[ci];
if (c.isHelper) continue; // helpers don't affect struct size
int sz = (c.kind == NodeKind::Struct || c.kind == NodeKind::Array)
? structSpan(c.id, childMap, visited) : c.byteSize();
int end = c.offset + sz;
@@ -512,6 +558,18 @@ inline int arrayElemIdxFromSelId(uint64_t selId) {
return (int)((selId & kArrayElemMask) >> kArrayElemShift);
}
// Member selection encoding (enum/bitfield members) — mirrors array element pattern
static constexpr uint64_t kMemberBit = 0x2000000000000000ULL;
static constexpr uint64_t kMemberSubShift = 48;
static constexpr uint64_t kMemberSubMask = 0x3FFF000000000000ULL;
inline uint64_t makeMemberSelId(uint64_t nodeId, int subLine) {
return nodeId | kMemberBit | ((uint64_t)(subLine & 0x3FFF) << kMemberSubShift);
}
inline int memberSubFromSelId(uint64_t selId) {
return (int)((selId & kMemberSubMask) >> kMemberSubShift);
}
struct LineMeta {
int nodeIdx = -1;
uint64_t nodeId = 0;
@@ -541,6 +599,8 @@ struct LineMeta {
int effectiveNameW = 22; // Per-line name column width used for rendering
QString pointerTargetName; // Resolved target type name for Pointer32/64 (empty = "void")
bool isArrayElement = false; // true for synthesized primitive array element lines
bool isMemberLine = false; // true for enum member / bitfield member lines
bool isHelperLine = false; // true for static helper node lines
};
inline bool isSyntheticLine(const LineMeta& lm) {
@@ -585,13 +645,18 @@ namespace cmd {
struct ChangeStructTypeName { uint64_t nodeId; QString oldName, newName; };
struct ChangeClassKeyword { uint64_t nodeId; QString oldKeyword, newKeyword; };
struct ChangeOffset { uint64_t nodeId; int oldOffset, newOffset; };
struct ChangeEnumMembers { uint64_t nodeId;
QVector<QPair<QString, int64_t>> oldMembers, newMembers; };
struct ChangeOffsetExpr { uint64_t nodeId; QString oldExpr, newExpr; };
struct ToggleHelper { uint64_t nodeId; bool oldVal, newVal; };
}
using Command = std::variant<
cmd::ChangeKind, cmd::Rename, cmd::Collapse,
cmd::Insert, cmd::Remove, cmd::ChangeBase, cmd::WriteBytes,
cmd::ChangeArrayMeta, cmd::ChangePointerRef, cmd::ChangeStructTypeName,
cmd::ChangeClassKeyword, cmd::ChangeOffset
cmd::ChangeClassKeyword, cmd::ChangeOffset, cmd::ChangeEnumMembers,
cmd::ChangeOffsetExpr, cmd::ToggleHelper
>;
// ── Column spans (for inline editing) ──
@@ -604,7 +669,7 @@ struct ColumnSpan {
enum class EditTarget { Name, Type, Value, BaseAddress, Source, ArrayIndex, ArrayCount,
ArrayElementType, ArrayElementCount, PointerTarget,
RootClassType, RootClassName, TypeSelector };
RootClassType, RootClassName, TypeSelector, HelperExpr };
// Column layout constants (shared with format.cpp span computation)
inline constexpr int kFoldCol = 3; // 3-char fold indicator prefix per line
@@ -621,13 +686,13 @@ inline constexpr int kMaxNameW = 128; // Maximum name column width
inline constexpr int kCompactTypeW = 20; // Type column cap for compact column mode
inline ColumnSpan typeSpanFor(const LineMeta& lm, int typeW = kColType) {
if (lm.lineKind != LineKind::Field || lm.isContinuation) return {};
if (lm.lineKind != LineKind::Field || lm.isContinuation || lm.isMemberLine) return {};
int ind = kFoldCol + lm.depth * 3;
return {ind, ind + typeW, true};
}
inline ColumnSpan nameSpanFor(const LineMeta& lm, int typeW = kColType, int nameW = kColName) {
if (lm.isContinuation || lm.lineKind != LineKind::Field) return {};
if (lm.isContinuation || lm.lineKind != LineKind::Field || lm.isMemberLine) return {};
int ind = kFoldCol + lm.depth * 3;
int start = ind + typeW + kSepWidth;
@@ -642,6 +707,7 @@ inline ColumnSpan nameSpanFor(const LineMeta& lm, int typeW = kColType, int name
inline ColumnSpan valueSpanFor(const LineMeta& lm, int /*lineLength*/, int typeW = kColType, int nameW = kColName) {
if (lm.lineKind == LineKind::Header || lm.lineKind == LineKind::Footer ||
lm.lineKind == LineKind::ArrayElementSeparator) return {};
if (lm.isMemberLine) return {};
int ind = kFoldCol + lm.depth * 3;
// Hex uses nameW for ASCII column (same as regular name column)
@@ -660,6 +726,38 @@ inline ColumnSpan valueSpanFor(const LineMeta& lm, int /*lineLength*/, int typeW
return {start, start + valWidth, true};
}
// Member line spans (enum "name = value", bitfield "name : N = value")
inline ColumnSpan memberNameSpanFor(const LineMeta& lm, const QString& lineText) {
if (!lm.isMemberLine) return {};
int ind = kFoldCol + lm.depth * 3;
int eq = lineText.indexOf(QLatin1String(" = "), ind);
if (eq < 0) return {};
int nameEnd = eq;
while (nameEnd > ind && lineText[nameEnd - 1] == ' ') nameEnd--;
return {ind, nameEnd, true};
}
inline ColumnSpan memberValueSpanFor(const LineMeta& lm, const QString& lineText) {
if (!lm.isMemberLine) return {};
int eq = lineText.indexOf(QLatin1String(" = "));
if (eq < 0) return {};
int valStart = eq + 3;
int valEnd = lineText.size();
while (valEnd > valStart && lineText[valEnd - 1] == ' ') valEnd--;
return {valStart, valEnd, true};
}
// Helper expression span: locates text between "= " and " →" (or end of line)
inline ColumnSpan helperExprSpanFor(const LineMeta& /*lm*/, const QString& lineText) {
int eq = lineText.indexOf(QLatin1String("= "));
if (eq < 0) return {};
int exprStart = eq + 2;
int arrow = lineText.indexOf(QChar(0x2192), exprStart); // →
int exprEnd = (arrow > exprStart) ? arrow - 1 : lineText.size();
while (exprEnd > exprStart && lineText[exprEnd - 1] == ' ') exprEnd--;
return {exprStart, exprEnd, true};
}
inline ColumnSpan commentSpanFor(const LineMeta& lm, int lineLength, int typeW = kColType, int nameW = kColName) {
if (lm.lineKind == LineKind::Header || lm.lineKind == LineKind::Footer) return {};
int ind = kFoldCol + lm.depth * 3;
@@ -681,30 +779,14 @@ inline ColumnSpan commentSpanFor(const LineMeta& lm, int lineLength, int typeW =
// Line format: "source▾ · 0x140000000"
inline ColumnSpan commandRowSrcSpan(const QString& lineText) {
int idx = lineText.indexOf(QStringLiteral(" \u00B7"));
if (idx < 0) return {};
// Source label ends at the ▾ dropdown arrow
int arrow = lineText.indexOf(QChar(0x25BE));
if (arrow < 0) return {};
int start = 0;
while (start < idx && !lineText[start].isLetterOrNumber()
while (start < arrow && !lineText[start].isLetterOrNumber()
&& lineText[start] != '<' && lineText[start] != '\'') start++;
if (start >= idx) return {};
// Exclude trailing ▾ from the editable span
int end = idx;
while (end > start && lineText[end - 1] == QChar(0x25BE)) end--;
if (end <= start) return {};
return {start, end, true};
}
inline ColumnSpan commandRowAddrSpan(const QString& lineText) {
int tag = lineText.indexOf(QStringLiteral(" \u00B7"));
if (tag < 0) return {};
int start = tag + 3; // after " · "
// Scan to next " · " separator (or end of line) to support formulas with spaces
int nextSep = lineText.indexOf(QStringLiteral(" \u00B7"), start);
int end = (nextSep >= 0) ? nextSep : lineText.size();
// Trim trailing whitespace
while (end > start && lineText[end - 1].isSpace()) end--;
if (end <= start) return {};
return {start, end, true};
if (start >= arrow) return {};
return {start, arrow, true};
}
// ── CommandRow root-class spans ──
@@ -723,6 +805,25 @@ inline int commandRowRootStart(const QString& lineText) {
return best;
}
inline ColumnSpan commandRowAddrSpan(const QString& lineText) {
// Address starts at "0x" after the source dropdown arrow
int arrow = lineText.indexOf(QChar(0x25BE));
if (arrow < 0) return {};
int start = lineText.indexOf(QStringLiteral("0x"), arrow);
if (start < 0) {
// Formula mode: address is between arrow and root keyword
start = arrow + 1;
while (start < lineText.size() && lineText[start].isSpace()) start++;
}
// End at root keyword (struct/class/enum) or end of line
int rootStart = commandRowRootStart(lineText);
int end = (rootStart > start) ? rootStart : lineText.size();
// Trim trailing whitespace
while (end > start && lineText[end - 1].isSpace()) end--;
if (end <= start) return {};
return {start, end, true};
}
inline ColumnSpan commandRowRootTypeSpan(const QString& lineText) {
int start = commandRowRootStart(lineText);
if (start < 0) return {};
@@ -893,6 +994,11 @@ namespace fmt {
QByteArray parseAsciiValue(const QString& text, int expectedSize, bool* ok);
QString validateValue(NodeKind kind, const QString& text);
QString fmtEnumMember(const QString& name, int64_t value, int depth, int nameW);
QString fmtBitfieldMember(const QString& name, uint8_t bitWidth,
uint64_t value, int depth, int nameW);
uint64_t extractBits(const Provider& prov, uint64_t addr,
NodeKind containerKind,
uint8_t bitOffset, uint8_t bitWidth);
} // namespace fmt
// ── Compose function forward declaration ──

View File

@@ -503,6 +503,19 @@ 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);
// Autocomplete for helper expressions — show field names as user types
if (m_editState.target == EditTarget::HelperExpr && !m_helperCompletions.isEmpty()) {
// Get word at cursor
long pos = m_sci->SendScintilla(QsciScintillaBase::SCI_GETCURRENTPOS);
long wordStart = m_sci->SendScintilla(QsciScintillaBase::SCI_WORDSTARTPOSITION, pos, (long)1);
int wordLen = (int)(pos - wordStart);
if (wordLen >= 1) {
QByteArray list = m_helperCompletions.join(' ').toUtf8();
m_sci->SendScintilla(QsciScintillaBase::SCI_AUTOCSETSEPARATOR, (long)' ');
m_sci->SendScintilla(QsciScintillaBase::SCI_AUTOCSHOW, (uintptr_t)wordLen, list.constData());
}
}
});
connect(m_sci, &QsciScintilla::selectionChanged,
@@ -747,8 +760,8 @@ void RcxEditor::applyTheme(const Theme& theme) {
// Markers
m_sci->setMarkerBackgroundColor(theme.markerPtr, M_PTR0);
m_sci->setMarkerForegroundColor(theme.markerPtr, M_PTR0);
m_sci->setMarkerBackgroundColor(theme.markerCycle, M_CYCLE);
m_sci->setMarkerForegroundColor(theme.markerCycle, M_CYCLE);
m_sci->setMarkerBackgroundColor(theme.background, M_CYCLE);
m_sci->setMarkerForegroundColor(theme.background, M_CYCLE);
m_sci->setMarkerBackgroundColor(theme.markerError, M_ERR);
m_sci->setMarkerForegroundColor(QColor("#ffffff"), M_ERR);
m_sci->setMarkerBackgroundColor(theme.background, M_STRUCT_BG);
@@ -880,7 +893,7 @@ void RcxEditor::reformatMargins() {
for (int i = 0; i < m_meta.size(); i++) {
auto& lm = m_meta[i];
if (lm.isContinuation) {
if (lm.isContinuation || lm.isMemberLine) {
lm.offsetText = QStringLiteral(" \u00B7 ");
} else if (lm.offsetText.isEmpty()) {
continue;
@@ -1079,8 +1092,11 @@ void RcxEditor::applySelectionOverlay(const QSet<uint64_t>& selIds) {
for (uint64_t selId : selIds) {
bool isFooterSel = (selId & kFooterIdBit) != 0;
bool isArrayElemSel = (selId & kArrayElemBit) != 0;
bool isMemberSel = (selId & kMemberBit) != 0;
int arrayElemIdx = isArrayElemSel ? arrayElemIdxFromSelId(selId) : -1;
uint64_t nodeId = selId & ~(kFooterIdBit | kArrayElemBit | kArrayElemMask);
int memberSubLine = isMemberSel ? memberSubFromSelId(selId) : -1;
uint64_t nodeId = selId & ~(kFooterIdBit | kArrayElemBit | kArrayElemMask
| kMemberBit | kMemberSubMask);
auto it = m_nodeLineIndex.constFind(nodeId);
if (it == m_nodeLineIndex.constEnd()) continue;
for (int ln : *it) {
@@ -1094,8 +1110,13 @@ void RcxEditor::applySelectionOverlay(const QSet<uint64_t>& selIds) {
if (!m_meta[ln].isArrayElement || m_meta[ln].arrayElementIdx != arrayElemIdx)
continue;
} else if (m_meta[ln].isArrayElement) {
// Plain nodeId selection shouldn't highlight individual array elements
// (the header line is enough)
continue;
}
// Member line: match by subLine index
if (isMemberSel) {
if (!m_meta[ln].isMemberLine || m_meta[ln].subLine != memberSubLine)
continue;
} else if (m_meta[ln].isMemberLine) {
continue;
}
m_sci->markerAdd(ln, M_SELECTED);
@@ -1127,7 +1148,8 @@ void RcxEditor::applyHoverHighlight() {
if (prevId != 0) {
// Check if old hovered line was a single-line highlight (footer or array element)
bool prevSingleLine = (prevLine >= 0 && prevLine < m_meta.size() &&
(m_meta[prevLine].lineKind == LineKind::Footer || m_meta[prevLine].isArrayElement));
(m_meta[prevLine].lineKind == LineKind::Footer || m_meta[prevLine].isArrayElement
|| m_meta[prevLine].isMemberLine));
if (prevSingleLine) {
m_sci->markerDelete(prevLine, M_HOVER);
} else {
@@ -1143,11 +1165,13 @@ void RcxEditor::applyHoverHighlight() {
if (!m_hoverInside) return;
if (m_hoveredNodeId == 0) return;
// Footer and array elements highlight only the specific line
// Footer, array elements, and member lines highlight only the specific line
bool hoveringFooter = (m_hoveredLine >= 0 && m_hoveredLine < m_meta.size() &&
m_meta[m_hoveredLine].lineKind == LineKind::Footer);
bool hoveringArrayElem = (m_hoveredLine >= 0 && m_hoveredLine < m_meta.size() &&
m_meta[m_hoveredLine].isArrayElement);
bool hoveringMember = (m_hoveredLine >= 0 && m_hoveredLine < m_meta.size() &&
m_meta[m_hoveredLine].isMemberLine);
// Check if the hovered item is already selected (using appropriate ID)
uint64_t checkId;
@@ -1155,12 +1179,14 @@ void RcxEditor::applyHoverHighlight() {
checkId = m_hoveredNodeId | kFooterIdBit;
else if (hoveringArrayElem)
checkId = makeArrayElemSelId(m_hoveredNodeId, m_meta[m_hoveredLine].arrayElementIdx);
else if (hoveringMember)
checkId = makeMemberSelId(m_hoveredNodeId, m_meta[m_hoveredLine].subLine);
else
checkId = m_hoveredNodeId;
if (m_currentSelIds.contains(checkId)) return;
if (hoveringFooter || hoveringArrayElem) {
// Single-line highlight for footers and array elements
if (hoveringFooter || hoveringArrayElem || hoveringMember) {
// Single-line highlight for footers, array elements, and member lines
m_sci->markerAdd(m_hoveredLine, M_HOVER);
} else {
// Non-footer, non-array-element: highlight all lines for this node
@@ -1374,15 +1400,6 @@ void RcxEditor::applyCommandRowPills() {
if (srcDrop >= 0 && (rootStart < 0 || srcDrop < rootStart))
fillIndicatorCols(IND_HEX_DIM, line, srcDrop, srcDrop + 1);
}
// Dim all " · " separators
int searchFrom = 0;
while (true) {
int tag = t.indexOf(QStringLiteral(" \u00B7"), searchFrom);
if (tag < 0) break;
fillIndicatorCols(IND_HEX_DIM, line, tag, tag + 3);
searchFrom = tag + 3;
}
// Dim base address to match source/struct grey
ColumnSpan addrSpan = commandRowAddrSpan(t);
if (addrSpan.valid)
@@ -1464,39 +1481,35 @@ static ColumnSpan headerNameSpan(const LineMeta& lm, const QString& lineText) {
}
// Type name span for struct headers (not arrays)
// Format: "struct TYPENAME NAME {" or collapsed variants
// For "struct NAME {" (no typename), returns invalid span
// Named structs format as: "_MMPTE OriginalPte {" (type column = just the name)
// Anonymous structs format as: "union {" or "struct {" (no clickable type)
static ColumnSpan headerTypeNameSpan(const LineMeta& lm, const QString& lineText) {
if (lm.lineKind != LineKind::Header) return {};
if (lm.isArrayHeader) return {}; // Arrays use arrayHeaderTypeSpan instead
if (lm.isArrayHeader) return {};
int ind = kFoldCol + lm.depth * 3;
int typeW = lm.effectiveTypeW;
int typeEnd = ind + typeW;
// Clamp to actual line content
if (typeEnd > lineText.size()) typeEnd = lineText.size();
// Extract the type column text and check if it has a typename
// Format: "struct" or "struct TYPENAME"
QString typeCol = lineText.mid(ind, typeEnd - ind).trimmed();
if (typeCol.isEmpty()) return {};
// Find first space (after "struct")
int firstSpace = typeCol.indexOf(' ');
if (firstSpace < 0) return {}; // Just "struct", no typename
// Anonymous structs use bare keywords — not clickable
static const QStringList kKeywords = {
QStringLiteral("struct"), QStringLiteral("union"), QStringLiteral("class")
};
if (kKeywords.contains(typeCol)) return {};
// If there's content after "struct ", that's the typename
QString typename_ = typeCol.mid(firstSpace + 1).trimmed();
if (typename_.isEmpty()) return {};
// Named struct: entire type column is the type name (e.g. "_MMPTE")
// Find the actual text bounds within the padded column
int start = ind;
while (start < typeEnd && lineText[start] == ' ') start++;
int end = start;
while (end < typeEnd && lineText[end] != ' ') end++;
if (end <= start) return {};
// Return span of the typename within the type column
int typenameStart = ind + firstSpace + 1;
// Find where the typename actually ends (skip padding)
int typenameEnd = typenameStart;
while (typenameEnd < typeEnd && lineText[typenameEnd] != ' ')
typenameEnd++;
return {typenameStart, typenameEnd, true};
return {start, end, true};
}
// Type span for array headers: "int32_t[10]" in "int32_t[10] positions {"
@@ -1599,6 +1612,10 @@ bool RcxEditor::resolvedSpanFor(int line, EditTarget t,
s = arrayElemCountSpanFor(*lm, lineText); break;
case EditTarget::PointerTarget:
s = pointerTargetSpanFor(*lm, lineText); break;
case EditTarget::HelperExpr:
if (lm->isHelperLine)
s = helperExprSpanFor(*lm, lineText);
break;
case EditTarget::Source: break;
}
@@ -1615,6 +1632,12 @@ bool RcxEditor::resolvedSpanFor(int line, EditTarget t,
if (!s.valid && t == EditTarget::Name)
s = headerNameSpan(*lm, lineText);
// Member lines: override Name/Value spans
if (!s.valid && lm->isMemberLine) {
if (t == EditTarget::Name) s = memberNameSpanFor(*lm, lineText);
if (t == EditTarget::Value) s = memberValueSpanFor(*lm, lineText);
}
out = normalizeSpan(s, lineText, t, /*skipPrefixes=*/true);
if (lineTextOut) *lineTextOut = lineText;
return out.valid;
@@ -1728,6 +1751,12 @@ static bool hitTestTarget(QsciScintilla* sci,
if (!ns.valid)
ns = headerNameSpan(lm, lineText);
// Member lines: use name/value spans from line text (no type span)
if (lm.isMemberLine) {
ns = memberNameSpanFor(lm, lineText);
vs = memberValueSpanFor(lm, lineText);
}
if (inSpan(ts)) outTarget = EditTarget::Type;
else if (inSpan(ns)) outTarget = EditTarget::Name;
else if (inSpan(vs)) outTarget = EditTarget::Value;
@@ -2686,6 +2715,8 @@ void RcxEditor::updateEditableIndicators(int line) {
checkId = lm->nodeId | kFooterIdBit;
else if (lm->isArrayElement && lm->arrayElementIdx >= 0)
checkId = makeArrayElemSelId(lm->nodeId, lm->arrayElementIdx);
else if (lm->isMemberLine && lm->subLine >= 0)
checkId = makeMemberSelId(lm->nodeId, lm->subLine);
else
checkId = lm->nodeId;
return m_currentSelIds.contains(checkId);

View File

@@ -45,6 +45,7 @@ public:
bool isEditing() const { return m_editState.active; }
bool beginInlineEdit(EditTarget target, int line = -1, int col = -1);
void cancelInlineEdit();
void setHelperCompletions(const QStringList& words) { m_helperCompletions = words; }
void applySelectionOverlay(const QSet<uint64_t>& selIds);
void setCommandRowText(const QString& line);
@@ -61,6 +62,8 @@ public:
m_disasmProvider = prov; m_disasmRealProv = realProv; m_disasmTree = tree;
}
void setRelativeOffsets(bool rel) { m_relativeOffsets = rel; reformatMargins(); }
// Saved sources for quick-switch in source picker
void setSavedSources(const QVector<SavedSourceDisplay>& sources) { m_savedSourceDisplay = sources; }
@@ -131,6 +134,7 @@ private:
bool lastValidationOk = true; // track state to avoid redundant updates
};
InlineEditState m_editState;
QStringList m_helperCompletions; // autocomplete words for HelperExpr editing
// ── Tab cycling state ──
EditTarget m_lastTabTarget = EditTarget::Value;

View File

@@ -41,7 +41,10 @@
{"id":"182","kind":"Hex32","name":"State:3 StackCount:29","offset":0,"parentId":"180"},
{"id":"190","kind":"Struct","name":"kexecute_options","structTypeName":"_KEXECUTE_OPTIONS","classKeyword":"union","offset":0,"parentId":"0","refId":"0","collapsed":true},
{"id":"191","kind":"Hex8","name":"ExecuteOptions","offset":0,"parentId":"190"},
{"id":"191","kind":"Struct","name":"","offset":0,"parentId":"190","refId":"0","collapsed":false},
{"id":"192","kind":"UInt8","name":"ExecuteDisable","offset":0,"parentId":"191"},
{"id":"193","kind":"Hex8","name":"ExecuteDisable:1 ExecuteEnable:1 DisableThunkEmulation:1 Permanent:1 ExecuteDispatchEnable:1 ImageDispatchEnable:1 DisableExceptionChainValidation:1 Spare:1","offset":0,"parentId":"191"},
{"id":"194","kind":"UInt8","name":"ExecuteOptions","offset":0,"parentId":"190"},
{"id":"200","kind":"Struct","name":"se_audit_info","structTypeName":"_SE_AUDIT_PROCESS_CREATION_INFO","offset":0,"parentId":"0","refId":"0","collapsed":true},
{"id":"201","kind":"Pointer64","name":"ImageFileName","offset":0,"parentId":"200"},

160984
src/examples/Vergilius_25H2.rcx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -121,15 +121,8 @@ QString fmtDouble(double v) {
}
QString fmtBool(uint8_t v) { return v ? QStringLiteral("true") : QStringLiteral("false"); }
QString fmtPointer32(uint32_t v) {
if (v == 0) return QStringLiteral("-> NULL");
return QStringLiteral("-> ") + hexVal(v);
}
QString fmtPointer64(uint64_t v) {
if (v == 0) return QStringLiteral("-> NULL");
return QStringLiteral("-> ") + hexVal(v);
}
QString fmtPointer32(uint32_t v) { return hexVal(v); }
QString fmtPointer64(uint64_t v) { return hexVal(v); }
// ── Indentation ──
@@ -148,11 +141,11 @@ QString fmtOffsetMargin(uint64_t absoluteOffset, bool isContinuation, int hexDig
// ── Struct type name (for width calculation) ──
QString structTypeName(const Node& node) {
// Full type string: "struct TypeName", "union TypeName", "class TypeName", etc.
QString base = node.resolvedClassKeyword();
// Named types: just the type name (e.g. "_LIST_ENTRY")
// Anonymous: just the keyword (e.g. "union", "struct")
if (!node.structTypeName.isEmpty())
return base + QStringLiteral(" ") + node.structTypeName;
return base;
return node.structTypeName;
return node.resolvedClassKeyword();
}
// ── Struct header / footer ──
@@ -710,4 +703,27 @@ QString fmtEnumMember(const QString& name, int64_t value, int depth, int nameW)
return ind + name.leftJustified(nameW) + QStringLiteral(" = ") + QString::number(value);
}
// ── Bitfield member formatting ──
uint64_t extractBits(const Provider& prov, uint64_t addr,
NodeKind containerKind,
uint8_t bitOffset, uint8_t bitWidth) {
uint64_t container = 0;
switch (containerKind) {
case NodeKind::Hex8: container = prov.readU8(addr); break;
case NodeKind::Hex16: container = prov.readU16(addr); break;
case NodeKind::Hex32: container = prov.readU32(addr); break;
default: container = prov.readU64(addr); break;
}
if (bitWidth >= 64) return container >> bitOffset;
return (container >> bitOffset) & ((1ULL << bitWidth) - 1);
}
QString fmtBitfieldMember(const QString& name, uint8_t bitWidth,
uint64_t value, int depth, int nameW) {
QString ind = indent(depth);
return ind + name.leftJustified(nameW)
+ QStringLiteral(" : %1 = %2").arg(bitWidth).arg(value);
}
} // namespace rcx::fmt

View File

@@ -68,6 +68,7 @@ struct GenContext {
QString output;
int padCounter = 0;
const QHash<NodeKind, QString>* typeAliases = nullptr;
bool emitAsserts = false;
QString uniquePadName() {
return QStringLiteral("_pad%1").arg(padCounter++, 4, 16, QChar('0'));
@@ -100,82 +101,96 @@ static void emitStruct(GenContext& ctx, uint64_t structId);
static const QChar kCommentMarker = QChar(0x01);
static QString offsetComment(int offset) {
static QString offsetComment(int offset, bool isSizeof = false) {
if (isSizeof)
return QString(kCommentMarker) + QStringLiteral("// sizeof 0x%1").arg(QString::number(offset, 16).toUpper());
return QString(kCommentMarker) + QStringLiteral("// 0x%1").arg(QString::number(offset, 16).toUpper());
}
static QString emitField(GenContext& ctx, const Node& node) {
static QString indent(int depth) {
return QString(depth * 4, ' ');
}
static QString emitField(GenContext& ctx, const Node& node, int depth, int baseOffset) {
const NodeTree& tree = ctx.tree;
QString ind = indent(depth);
QString name = sanitizeIdent(node.name.isEmpty()
? QStringLiteral("field_%1").arg(node.offset, 2, 16, QChar('0'))
: node.name);
QString oc = offsetComment(node.offset);
QString oc = offsetComment(baseOffset + node.offset);
switch (node.kind) {
case NodeKind::Vec2:
return QStringLiteral(" %1 %2[2];").arg(ctx.cType(NodeKind::Float), name) + oc;
return ind + QStringLiteral("%1 %2[2];").arg(ctx.cType(NodeKind::Float), name) + oc;
case NodeKind::Vec3:
return QStringLiteral(" %1 %2[3];").arg(ctx.cType(NodeKind::Float), name) + oc;
return ind + QStringLiteral("%1 %2[3];").arg(ctx.cType(NodeKind::Float), name) + oc;
case NodeKind::Vec4:
return QStringLiteral(" %1 %2[4];").arg(ctx.cType(NodeKind::Float), name) + oc;
return ind + QStringLiteral("%1 %2[4];").arg(ctx.cType(NodeKind::Float), name) + oc;
case NodeKind::Mat4x4:
return QStringLiteral(" %1 %2[4][4];").arg(ctx.cType(NodeKind::Float), name) + oc;
return ind + QStringLiteral("%1 %2[4][4];").arg(ctx.cType(NodeKind::Float), name) + oc;
case NodeKind::UTF8:
return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::UTF8), name).arg(node.strLen) + oc;
return ind + QStringLiteral("%1 %2[%3];").arg(ctx.cType(NodeKind::UTF8), name).arg(node.strLen) + oc;
case NodeKind::UTF16:
return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::UTF16), name).arg(node.strLen) + oc;
return ind + QStringLiteral("%1 %2[%3];").arg(ctx.cType(NodeKind::UTF16), name).arg(node.strLen) + oc;
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(" %1 %2;").arg(ctx.cType(NodeKind::Pointer32), name) +
offsetComment(node.offset).replace(QStringLiteral("//"), QStringLiteral("// -> %1*").arg(target));
return ind + QStringLiteral("struct %1* %2;").arg(target, name) + oc;
}
}
return QStringLiteral(" %1 %2;").arg(ctx.cType(NodeKind::Pointer32), name) + oc;
return ind + QStringLiteral("%1 %2;").arg(ctx.cType(NodeKind::Pointer32), name) + oc;
}
case NodeKind::Pointer64: {
if (node.refId != 0) {
int refIdx = tree.indexOfId(node.refId);
if (refIdx >= 0) {
QString target = ctx.structName(tree.nodes[refIdx]);
return QStringLiteral(" %1* %2;").arg(target, name) + oc;
return ind + QStringLiteral("struct %1* %2;").arg(target, name) + oc;
}
}
return QStringLiteral(" void* %1;").arg(name) + oc;
return ind + QStringLiteral("void* %1;").arg(name) + oc;
}
case NodeKind::FuncPtr32:
return QStringLiteral(" void (*%1)();").arg(name) + oc;
return ind + QStringLiteral("void (*%1)();").arg(name) + oc;
case NodeKind::FuncPtr64:
return QStringLiteral(" void (*%1)();").arg(name) + oc;
return ind + QStringLiteral("void (*%1)();").arg(name) + oc;
default:
return QStringLiteral(" %1 %2;").arg(ctx.cType(node.kind), name) + oc;
return ind + QStringLiteral("%1 %2;").arg(ctx.cType(node.kind), name) + oc;
}
}
// ── Emit struct body (fields + padding) ──
// ── Emit struct body (fields + padding) — Vergilius-style ──
static void emitStructBody(GenContext& ctx, uint64_t structId) {
static void emitStructBody(GenContext& ctx, uint64_t structId,
bool isUnion, int depth, int baseOffset) {
const NodeTree& tree = ctx.tree;
int idx = tree.indexOfId(structId);
if (idx < 0) return;
int structSize = tree.structSpan(structId, &ctx.childMap);
QString ind = indent(depth);
QVector<int> children = ctx.childMap.value(structId);
QVector<int> allChildren = ctx.childMap.value(structId);
QVector<int> children, helperIdxs;
for (int ci : allChildren) {
if (tree.nodes[ci].isHelper)
helperIdxs.append(ci);
else
children.append(ci);
}
std::sort(children.begin(), children.end(), [&](int a, int b) {
return tree.nodes[a].offset < tree.nodes[b].offset;
});
// Helper: emit a padding/hex run as a single collapsed byte array
auto emitPadRun = [&](int offset, int size) {
auto emitPadRun = [&](int relOffset, int size) {
if (size <= 0) return;
ctx.output += QStringLiteral(" %1 %2[0x%3];%4\n")
.arg(QStringLiteral("uint8_t"))
ctx.output += ind + QStringLiteral("uint8_t %1[0x%2];%3\n")
.arg(ctx.uniquePadName())
.arg(QString::number(size, 16).toUpper())
.arg(offsetComment(offset));
.arg(offsetComment(baseOffset + relOffset));
};
int cursor = 0;
@@ -189,13 +204,15 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) {
else
childSize = child.byteSize();
// Gap before this field
// Gap/overlap handling (skip for unions)
if (!isUnion) {
if (child.offset > cursor)
emitPadRun(cursor, child.offset - cursor);
else if (child.offset < cursor)
ctx.output += QStringLiteral(" // WARNING: overlap at offset 0x%1 (previous field ends at 0x%2)\n")
.arg(QString::number(child.offset, 16).toUpper())
.arg(QString::number(cursor, 16).toUpper());
ctx.output += ind + QStringLiteral("// WARNING: overlap at offset 0x%1 (previous field ends at 0x%2)\n")
.arg(QString::number(baseOffset + child.offset, 16).toUpper())
.arg(QString::number(baseOffset + cursor, 16).toUpper());
}
// Collapse consecutive hex nodes into a single padding array
if (isHexNode(child.kind)) {
@@ -206,8 +223,7 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) {
const Node& next = tree.nodes[children[j]];
if (!isHexNode(next.kind)) break;
int nextSize = next.byteSize();
// Allow gaps within the run (they become part of the pad)
if (next.offset < runEnd) break; // overlap — stop merging
if (next.offset < runEnd) break;
runEnd = next.offset + nextSize;
j++;
}
@@ -219,10 +235,53 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) {
// Emit the field
if (child.kind == NodeKind::Struct) {
emitStruct(ctx, child.id);
QString typeName = ctx.structName(child);
// Bitfield container — emit inline bitfield members
if (child.classKeyword == QStringLiteral("bitfield")
&& !child.bitfieldMembers.isEmpty()) {
QString bfType = ctx.cType(child.elementKind);
if (bfType.isEmpty()) bfType = QStringLiteral("uint32_t");
QString fieldName = child.name.isEmpty()
? QString() : QStringLiteral(" ") + sanitizeIdent(child.name);
ctx.output += ind + QStringLiteral("struct\n");
ctx.output += ind + QStringLiteral("{\n");
QString bfInd = indent(depth + 1);
for (const auto& m : child.bitfieldMembers) {
ctx.output += bfInd + bfType + QStringLiteral(" ")
+ sanitizeIdent(m.name) + QStringLiteral(" : ")
+ QString::number(m.bitWidth) + QStringLiteral(";")
+ offsetComment(baseOffset + child.offset)
+ QStringLiteral("\n");
}
ctx.output += ind + QStringLiteral("}") + fieldName + QStringLiteral(";")
+ offsetComment(baseOffset + child.offset) + QStringLiteral("\n");
} else {
bool isAnonymous = child.structTypeName.isEmpty();
if (isAnonymous) {
// Inline anonymous struct/union
QString kw = child.resolvedClassKeyword();
ctx.output += ind + kw + QStringLiteral("\n");
ctx.output += ind + QStringLiteral("{\n");
bool childIsUnion = (kw == QStringLiteral("union"));
emitStructBody(ctx, child.id, childIsUnion, depth + 1,
baseOffset + child.offset);
QString fieldName = child.name.isEmpty()
? QString() : QStringLiteral(" ") + sanitizeIdent(child.name);
ctx.output += ind + QStringLiteral("}") + fieldName + QStringLiteral(";")
+ offsetComment(baseOffset + child.offset) + QStringLiteral("\n");
} else {
// Named struct — reference by name with struct keyword prefix
QString kw = child.resolvedClassKeyword();
if (kw == QStringLiteral("enum") && child.enumMembers.isEmpty())
kw = QStringLiteral("struct");
QString typeName = sanitizeIdent(child.structTypeName);
QString fieldName = sanitizeIdent(child.name);
ctx.output += QStringLiteral(" %1 %2;%3\n").arg(typeName, fieldName, offsetComment(child.offset));
ctx.output += ind + kw + QStringLiteral(" ") + typeName
+ QStringLiteral(" ") + fieldName + QStringLiteral(";")
+ offsetComment(baseOffset + child.offset) + QStringLiteral("\n");
}
} // end bitfield else
} else if (child.kind == NodeKind::Array) {
QVector<int> arrayKids = ctx.childMap.value(child.id);
bool hasStructChild = false;
@@ -231,7 +290,6 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) {
for (int ak : arrayKids) {
if (tree.nodes[ak].kind == NodeKind::Struct) {
hasStructChild = true;
emitStruct(ctx, tree.nodes[ak].id);
elemTypeName = ctx.structName(tree.nodes[ak]);
break;
}
@@ -239,14 +297,16 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) {
QString fieldName = sanitizeIdent(child.name);
if (hasStructChild && !elemTypeName.isEmpty()) {
ctx.output += QStringLiteral(" %1 %2[%3];%4\n")
.arg(elemTypeName, fieldName).arg(child.arrayLen).arg(offsetComment(child.offset));
ctx.output += ind + QStringLiteral("struct %1 %2[%3];%4\n")
.arg(elemTypeName, fieldName).arg(child.arrayLen)
.arg(offsetComment(baseOffset + child.offset));
} else {
ctx.output += QStringLiteral(" %1 %2[%3];%4\n")
.arg(ctx.cType(child.elementKind), fieldName).arg(child.arrayLen).arg(offsetComment(child.offset));
ctx.output += ind + QStringLiteral("%1 %2[%3];%4\n")
.arg(ctx.cType(child.elementKind), fieldName).arg(child.arrayLen)
.arg(offsetComment(baseOffset + child.offset));
}
} else {
ctx.output += emitField(ctx, child) + QStringLiteral("\n");
ctx.output += emitField(ctx, child, depth, baseOffset) + QStringLiteral("\n");
}
int childEnd = child.offset + childSize;
@@ -254,12 +314,20 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) {
i++;
}
// Tail padding
if (cursor < structSize)
// Tail padding (skip for unions)
if (!isUnion && cursor < structSize)
emitPadRun(cursor, structSize - cursor);
// Emit helper comments (helpers are runtime-only, not part of struct layout)
for (int hi : helperIdxs) {
const Node& h = tree.nodes[hi];
QString hType = h.structTypeName.isEmpty() ? ctx.cType(h.kind) : h.structTypeName;
ctx.output += ind + QStringLiteral("// helper: %1 %2 @ %3\n")
.arg(hType, sanitizeIdent(h.name), h.offsetExpr);
}
}
// ── Emit a complete struct definition ──
// ── Emit a complete top-level struct definition (Vergilius-style) ──
static void emitStruct(GenContext& ctx, uint64_t structId) {
if (ctx.emittedIds.contains(structId)) return;
@@ -275,19 +343,12 @@ static void emitStruct(GenContext& ctx, uint64_t structId) {
return;
}
// For arrays, we don't emit a top-level struct — the array itself
// is a field inside its parent. But we do emit struct element types.
if (node.kind == NodeKind::Array) {
QVector<int> kids = ctx.childMap.value(structId);
for (int ki : kids) {
if (ctx.tree.nodes[ki].kind == NodeKind::Struct)
emitStruct(ctx, ctx.tree.nodes[ki].id);
}
ctx.visiting.remove(structId);
return;
}
// Deduplicate by struct type name (different nodes may share the same type)
// Deduplicate by struct type name
QString typeName = ctx.structName(node);
if (ctx.emittedTypeNames.contains(typeName)) {
ctx.emittedIds.insert(structId);
@@ -295,34 +356,6 @@ static void emitStruct(GenContext& ctx, uint64_t structId) {
return;
}
// Emit nested struct types first (dependency order)
QVector<int> children = ctx.childMap.value(structId);
for (int ci : children) {
const Node& child = ctx.tree.nodes[ci];
if (child.kind == NodeKind::Struct)
emitStruct(ctx, child.id);
else if (child.kind == NodeKind::Array) {
QVector<int> arrayKids = ctx.childMap.value(child.id);
for (int ak : arrayKids) {
if (ctx.tree.nodes[ak].kind == NodeKind::Struct)
emitStruct(ctx, ctx.tree.nodes[ak].id);
}
}
// Forward-declare pointer target types if they're outside this subtree
if (child.kind == NodeKind::Pointer64 && child.refId != 0) {
int refIdx = ctx.tree.indexOfId(child.refId);
if (refIdx >= 0 && !ctx.emittedIds.contains(child.refId)
&& !ctx.forwardDeclared.contains(child.refId)) {
QString fwdName = ctx.structName(ctx.tree.nodes[refIdx]);
QString fwdKw = ctx.tree.nodes[refIdx].resolvedClassKeyword();
if (fwdKw == QStringLiteral("enum") && ctx.tree.nodes[refIdx].enumMembers.isEmpty())
fwdKw = QStringLiteral("struct");
ctx.output += QStringLiteral("%1 %2;\n").arg(fwdKw, fwdName);
ctx.forwardDeclared.insert(child.refId);
}
}
}
ctx.emittedIds.insert(structId);
ctx.emittedTypeNames.insert(typeName);
int structSize = ctx.tree.structSpan(structId, &ctx.childMap);
@@ -342,15 +375,20 @@ static void emitStruct(GenContext& ctx, uint64_t structId) {
return;
}
if (kw == QStringLiteral("enum")) kw = QStringLiteral("struct"); // enum without members: fallback
ctx.output += QStringLiteral("%1 %2 {\n").arg(kw, typeName);
if (kw == QStringLiteral("enum")) kw = QStringLiteral("struct");
emitStructBody(ctx, structId);
ctx.output += kw + QStringLiteral(" ") + typeName + QStringLiteral("\n{\n");
ctx.output += QStringLiteral("};\n");
ctx.output += QStringLiteral("static_assert(sizeof(%1) == 0x%2, \"Size mismatch for %1\");\n\n")
emitStructBody(ctx, structId, kw == QStringLiteral("union"), 1, 0);
ctx.output += QStringLiteral("};")
+ offsetComment(structSize, true)
+ QStringLiteral("\n");
if (ctx.emitAsserts)
ctx.output += QStringLiteral("static_assert(sizeof(%1) == 0x%2, \"Size mismatch for %1\");\n")
.arg(typeName)
.arg(QString::number(structSize, 16).toUpper());
ctx.output += QStringLiteral("\n");
ctx.visiting.remove(structId);
}
@@ -404,14 +442,15 @@ static QString alignComments(const QString& raw) {
// ── Public API ──
QString renderCpp(const NodeTree& tree, uint64_t rootStructId,
const QHash<NodeKind, QString>* typeAliases) {
const QHash<NodeKind, QString>* typeAliases,
bool emitAsserts) {
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, typeAliases};
GenContext ctx{tree, buildChildMap(tree), {}, {}, {}, {}, {}, 0, typeAliases, emitAsserts};
ctx.output += QStringLiteral("#pragma once\n\n");
@@ -421,8 +460,9 @@ QString renderCpp(const NodeTree& tree, uint64_t rootStructId,
}
QString renderCppAll(const NodeTree& tree,
const QHash<NodeKind, QString>* typeAliases) {
GenContext ctx{tree, buildChildMap(tree), {}, {}, {}, {}, {}, 0, typeAliases};
const QHash<NodeKind, QString>* typeAliases,
bool emitAsserts) {
GenContext ctx{tree, buildChildMap(tree), {}, {}, {}, {}, {}, 0, typeAliases, emitAsserts};
ctx.output += QStringLiteral("#pragma once\n\n");

View File

@@ -9,11 +9,13 @@ 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,
const QHash<NodeKind, QString>* typeAliases = nullptr);
const QHash<NodeKind, QString>* typeAliases = nullptr,
bool emitAsserts = false);
// Generate C++ struct definitions for every root-level struct (full SDK).
QString renderCppAll(const NodeTree& tree,
const QHash<NodeKind, QString>* typeAliases = nullptr);
const QHash<NodeKind, QString>* typeAliases = nullptr,
bool emitAsserts = false);
// Null generator placeholder (returns empty string).
QString renderNull(const NodeTree& tree, uint64_t rootStructId);

View File

@@ -115,6 +115,24 @@ bool exportReclassXml(const NodeTree& tree, const QString& filePath, QString* er
while (i < children.size()) {
const Node& child = tree.nodes[children[i]];
// Bitfield container: export as hex node (ReClassEx has no bitfield concept)
if (child.kind == NodeKind::Struct
&& child.resolvedClassKeyword() == QStringLiteral("bitfield")) {
int sz = child.byteSize();
if (sz <= 0) sz = 4;
xml.writeStartElement(QStringLiteral("Node"));
xml.writeAttribute(QStringLiteral("Name"), child.name);
NodeKind hexKind = (sz <= 1) ? NodeKind::Hex8 : (sz <= 2) ? NodeKind::Hex16
: (sz <= 4) ? NodeKind::Hex32 : NodeKind::Hex64;
xml.writeAttribute(QStringLiteral("Type"), QString::number(xmlTypeForKind(hexKind)));
xml.writeAttribute(QStringLiteral("Size"), QString::number(sz));
xml.writeAttribute(QStringLiteral("bHidden"), QStringLiteral("false"));
xml.writeAttribute(QStringLiteral("Comment"), QStringLiteral("bitfield"));
xml.writeEndElement();
i++;
continue;
}
// Collapse consecutive hex nodes into a single Custom node (Type=21)
if (isHexNode(child.kind)) {
int runStart = child.offset;

View File

@@ -7,6 +7,7 @@
#include <QHash>
#include <QPair>
#include <QSet>
#include <QDebug>
// ── RawPDB headers ──
#include "PDB.h"
@@ -415,6 +416,7 @@ void PdbCtx::importFieldList(uint32_t fieldListIndex, uint64_t parentId) {
auto maximumSize = rec->header.size - sizeof(uint16_t);
QSet<QPair<int,int>> bitfieldSlots;
QHash<QPair<int,int>, uint64_t> bitfieldNodeIds;
for (size_t i = 0; i < maximumSize; ) {
auto* field = reinterpret_cast<const PDB::CodeView::TPI::FieldList*>(
@@ -440,7 +442,7 @@ void PdbCtx::importFieldList(uint32_t fieldListIndex, uint64_t parentId) {
if (typeRec && typeRec->header.kind == TRK::LF_BITFIELD) {
uint32_t underlying = typeRec->data.LF_BITFIELD.type;
uint8_t bitLen = typeRec->data.LF_BITFIELD.length;
(void)bitLen;
uint8_t bitPos = typeRec->data.LF_BITFIELD.position;
// Determine slot size from underlying type
uint64_t slotSize = 4;
@@ -452,12 +454,26 @@ void PdbCtx::importFieldList(uint32_t fieldListIndex, uint64_t parentId) {
auto key = qMakePair((int)offset, (int)slotSize);
if (!bitfieldSlots.contains(key)) {
bitfieldSlots.insert(key);
// Create bitfield container node
Node n;
n.kind = hexForSize(slotSize);
n.name = qname;
n.kind = NodeKind::Struct;
n.classKeyword = QStringLiteral("bitfield");
n.elementKind = hexForSize(slotSize);
n.parentId = parentId;
n.offset = offset;
tree.addNode(n);
n.collapsed = false;
int idx = tree.addNode(n);
bitfieldNodeIds[key] = tree.nodes[idx].id;
}
// Add this member to the bitfield container
uint64_t bfNodeId = bitfieldNodeIds[key];
int bfIdx = tree.indexOfId(bfNodeId);
if (bfIdx >= 0) {
BitfieldMember bm;
bm.name = qname;
bm.bitOffset = bitPos;
bm.bitWidth = bitLen;
tree.nodes[bfIdx].bitfieldMembers.append(bm);
}
} else {
importMemberType(memberType, offset, qname, parentId);
@@ -641,6 +657,20 @@ void PdbCtx::importMemberType(uint32_t typeIndex, int offset, const QString& nam
uint32_t resolved = findUdtDefinitionIndex(pointeeRec->header.kind, typeName);
if (resolved != 0) defIndex = resolved;
}
// Skip anonymous pointer targets — they'd create root orphans
const char* ptName = nullptr;
const auto* defRec2 = tt->get(defIndex);
if (defRec2) {
if (defRec2->header.kind == TRK::LF_UNION)
ptName = leafName(defRec2->data.LF_UNION.data,
unionLeafKind(defRec2->data.LF_UNION.data));
else if (defRec2->header.kind == TRK::LF_STRUCTURE ||
defRec2->header.kind == TRK::LF_CLASS)
ptName = leafName(defRec2->data.LF_CLASS.data,
defRec2->data.LF_CLASS.lfEasy.kind);
}
bool isAnonTarget = !ptName || ptName[0] == '<' || ptName[0] == '\0';
if (!isAnonTarget)
n.refId = importUDT(defIndex);
} else if (pointeeRec->header.kind == TRK::LF_PROCEDURE ||
pointeeRec->header.kind == TRK::LF_MFUNCTION) {
@@ -676,8 +706,6 @@ void PdbCtx::importMemberType(uint32_t typeIndex, int offset, const QString& nam
if (resolved != 0) defIndex = resolved;
}
uint64_t refId = importUDT(defIndex);
const char* typeName = nullptr;
bool isUnion = (rec->header.kind == TRK::LF_UNION);
if (isUnion)
@@ -685,6 +713,38 @@ void PdbCtx::importMemberType(uint32_t typeIndex, int offset, const QString& nam
else
typeName = leafName(rec->data.LF_CLASS.data, rec->data.LF_CLASS.lfEasy.kind);
// Anonymous types: inline fields directly instead of creating root orphan
bool isAnonymous = !typeName || typeName[0] == '<' || typeName[0] == '\0';
if (isAnonymous) {
// Resolve to definition if needed
const auto* defRec = tt->get(defIndex);
uint32_t fieldListIdx = 0;
if (defRec) {
if (defRec->header.kind == TRK::LF_UNION)
fieldListIdx = defRec->data.LF_UNION.field;
else if (defRec->header.kind == TRK::LF_STRUCTURE ||
defRec->header.kind == TRK::LF_CLASS)
fieldListIdx = defRec->data.LF_CLASS.field;
}
if (fieldListIdx != 0) {
// Create inline container (no refId, no root orphan)
Node n;
n.kind = NodeKind::Struct;
n.name = name;
n.classKeyword = isUnion ? QStringLiteral("union") : QStringLiteral("struct");
n.parentId = parentId;
n.offset = offset;
n.collapsed = true;
int idx = tree.addNode(n);
uint64_t inlineId = tree.nodes[idx].id;
importFieldList(fieldListIdx, inlineId);
break;
}
// Fallthrough if no field list
}
uint64_t refId = importUDT(defIndex);
Node n;
n.kind = NodeKind::Struct;
n.name = name;
@@ -806,16 +866,21 @@ void PdbCtx::importMemberType(uint32_t typeIndex, int offset, const QString& nam
case TRK::LF_BITFIELD: {
uint32_t underlying = rec->data.LF_BITFIELD.type;
uint8_t bitLen = rec->data.LF_BITFIELD.length;
uint8_t bitPos = rec->data.LF_BITFIELD.position;
uint64_t slotSize = 4;
if (underlying < tt->firstIndex()) {
NodeKind k = mapPrimitiveType(underlying);
slotSize = sizeForKind(k);
}
Node n;
n.kind = hexForSize(slotSize);
n.kind = NodeKind::Struct;
n.classKeyword = QStringLiteral("bitfield");
n.elementKind = hexForSize(slotSize);
n.name = name;
n.parentId = parentId;
n.offset = offset;
n.bitfieldMembers.append({name, bitPos, bitLen});
tree.addNode(n);
break;
}
@@ -944,6 +1009,12 @@ QVector<PdbTypeInfo> enumeratePdbTypes(const QString& pdbPath, QString* errorMsg
result.append(info);
}
int enumCount = 0;
for (const auto& r : result)
if (r.isEnum) enumCount++;
qDebug() << "[PDB] enumeratePdbTypes:" << result.size() << "types,"
<< enumCount << "enums";
return result;
}
@@ -960,19 +1031,34 @@ NodeTree importPdbSelected(const QString& pdbPath,
ctx.tt = pdb.typeTable;
int total = typeIndices.size();
int enumDispatched = 0, enumCreated = 0;
for (int i = 0; i < total; i++) {
uint32_t ti = typeIndices[i];
const auto* rec = pdb.typeTable->get(ti);
if (rec && rec->header.kind == TRK::LF_ENUM)
ctx.importEnum(ti);
else
if (rec && rec->header.kind == TRK::LF_ENUM) {
enumDispatched++;
uint64_t id = ctx.importEnum(ti);
if (id != 0) enumCreated++;
else qDebug() << "[PDB] importEnum FAILED for typeIndex" << ti;
} else {
ctx.importUDT(ti);
}
if (progressCb && !progressCb(i + 1, total)) {
if (errorMsg) *errorMsg = QStringLiteral("Import cancelled");
return ctx.tree; // return partial result
}
}
// Count enum nodes in tree
int enumNodes = 0;
for (const auto& n : ctx.tree.nodes)
if (n.classKeyword == QLatin1String("enum")) enumNodes++;
qDebug() << "[PDB] importPdbSelected:" << total << "types,"
<< enumDispatched << "enum dispatches,"
<< enumCreated << "enum created,"
<< enumNodes << "enum nodes in tree,"
<< ctx.tree.nodes.size() << "total nodes";
if (ctx.tree.nodes.isEmpty()) {
if (errorMsg) *errorMsg = QStringLiteral("No types imported");
}

View File

@@ -894,20 +894,40 @@ static void emitHexPadding(NodeTree& tree, uint64_t parentId, int offset, int si
}
}
// ── Bitfield grouping: emit a single hex node covering consecutive bitfields ──
// ── Bitfield grouping: emit a bitfield container with named members ──
static void emitBitfieldGroup(NodeTree& tree, uint64_t parentId, int offset, int totalBits) {
static void emitBitfieldGroup(NodeTree& tree, uint64_t parentId, int offset,
const QVector<ParsedField>& fields,
int startIdx, int endIdx) {
int totalBits = 0;
for (int i = startIdx; i < endIdx; i++)
totalBits += fields[i].bitfieldWidth;
int bytes = (totalBits + 7) / 8;
// Round up to nearest power-of-2 hex node
NodeKind hexKind;
if (bytes <= 1) hexKind = NodeKind::Hex8;
else if (bytes <= 2) hexKind = NodeKind::Hex16;
else if (bytes <= 4) hexKind = NodeKind::Hex32;
else hexKind = NodeKind::Hex64;
NodeKind containerKind;
if (bytes <= 1) containerKind = NodeKind::Hex8;
else if (bytes <= 2) containerKind = NodeKind::Hex16;
else if (bytes <= 4) containerKind = NodeKind::Hex32;
else containerKind = NodeKind::Hex64;
Node n;
n.kind = hexKind;
n.kind = NodeKind::Struct;
n.classKeyword = QStringLiteral("bitfield");
n.elementKind = containerKind;
n.parentId = parentId;
n.offset = offset;
n.collapsed = false;
// Populate bitfield members with computed bit offsets
uint8_t bitOffset = 0;
for (int i = startIdx; i < endIdx; i++) {
BitfieldMember bm;
bm.name = fields[i].name;
bm.bitOffset = bitOffset;
bm.bitWidth = (uint8_t)fields[i].bitfieldWidth;
n.bitfieldMembers.append(bm);
bitOffset += bm.bitWidth;
}
tree.addNode(n);
}
@@ -929,13 +949,14 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
for (int fi = 0; fi < fields.size(); fi++) {
const auto& field = fields[fi];
// Bitfield group: consume consecutive bitfields, emit single hex node
// Bitfield group: consume consecutive bitfields, emit bitfield container
if (field.bitfieldWidth >= 0) {
int groupOffset;
if (ctx.useCommentOffsets && field.commentOffset >= 0)
groupOffset = field.commentOffset - baseOffset;
else
groupOffset = computedOffset;
int startIdx = fi;
int totalBits = 0;
while (fi < fields.size() && fields[fi].bitfieldWidth >= 0) {
totalBits += fields[fi].bitfieldWidth;
@@ -943,7 +964,8 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
}
fi--; // compensate for outer loop increment
if (totalBits > 0)
emitBitfieldGroup(ctx.tree, parentId, groupOffset, totalBits);
emitBitfieldGroup(ctx.tree, parentId, groupOffset,
fields, startIdx, fi + 1);
int bytes = (totalBits + 7) / 8;
int nodeSize = (bytes <= 1) ? 1 : (bytes <= 2) ? 2 : (bytes <= 4) ? 4 : 8;
computedOffset = groupOffset + nodeSize;

View File

@@ -251,9 +251,9 @@ public:
// Kill the 1px frame margin Fusion reserves around QMenu contents
if (metric == PM_MenuPanelWidth)
return 0;
// Kill the separator between dock widgets / central widget
// Thin draggable separator between dock widgets / central widget
if (metric == PM_DockWidgetSeparatorExtent)
return 0;
return 1;
return QProxyStyle::pixelMetric(metric, opt, w);
}
void drawPrimitive(PrimitiveElement elem, const QStyleOption* opt,
@@ -267,6 +267,16 @@ public:
// Transparent menu bar background (no CSS needed)
if (elem == PE_PanelMenuBar)
return;
// Item-view row background — patch Highlight so the row bg matches CE_ItemViewItem
if (elem == PE_PanelItemViewRow) {
if (auto* vi = qstyleoption_cast<const QStyleOptionViewItem*>(opt)) {
QStyleOptionViewItem patched = *vi;
patched.palette.setColor(QPalette::Highlight,
vi->palette.color(QPalette::Mid));
QProxyStyle::drawPrimitive(elem, &patched, p, w);
return;
}
}
QProxyStyle::drawPrimitive(elem, opt, p, w);
}
void drawControl(ControlElement element, const QStyleOption* opt,
@@ -312,9 +322,13 @@ public:
}
}
}
// Tree view items — use theme.hover for selection instead of blue
// Item views — visible hover + themed selection (Fusion's hover is invisible on dark bg)
if (element == CE_ItemViewItem) {
if (auto* vi = qstyleoption_cast<const QStyleOptionViewItem*>(opt)) {
bool hovered = vi->state & State_MouseOver;
bool selected = vi->state & State_Selected;
if (hovered && !selected)
p->fillRect(vi->rect, vi->palette.color(QPalette::Mid));
QStyleOptionViewItem patched = *vi;
patched.palette.setColor(QPalette::Highlight,
vi->palette.color(QPalette::Mid)); // theme.hover
@@ -454,7 +468,7 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
// Start MCP bridge
m_mcp = new McpBridge(this, this);
if (QSettings("Reclass", "Reclass").value("autoStartMcp", false).toBool())
if (QSettings("Reclass", "Reclass").value("autoStartMcp", true).toBool())
m_mcp->start();
connect(m_mdiArea, &QMdiArea::subWindowActivated,
@@ -503,22 +517,19 @@ void MainWindow::createMenus() {
Qt5Qt6AddAction(file, "&Save", QKeySequence::Save, makeIcon(":/vsicons/save.svg"), this, &MainWindow::saveFile);
Qt5Qt6AddAction(file, "Save &As...", QKeySequence::SaveAs, makeIcon(":/vsicons/save-as.svg"), this, &MainWindow::saveFileAs);
file->addSeparator();
m_sourceMenu = file->addMenu("Current Tab So&urce");
connect(m_sourceMenu, &QMenu::aboutToShow, this, &MainWindow::populateSourceMenu);
file->addSeparator();
Qt5Qt6AddAction(file, "&Unload Project", QKeySequence(Qt::CTRL | Qt::Key_W), QIcon(), this, &MainWindow::closeFile);
file->addSeparator();
Qt5Qt6AddAction(file, "Export &C++ Header...", QKeySequence::UnknownKey, makeIcon(":/vsicons/export.svg"), this, &MainWindow::exportCpp);
Qt5Qt6AddAction(file, "Export ReClass &XML...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportReclassXmlAction);
Qt5Qt6AddAction(file, "Import from &Source...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::importFromSource);
Qt5Qt6AddAction(file, "&Import ReClass XML...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::importReclassXml);
Qt5Qt6AddAction(file, "Import &PDB...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::importPdb);
auto* importMenu = file->addMenu("&Import");
Qt5Qt6AddAction(importMenu, "From &Source...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::importFromSource);
Qt5Qt6AddAction(importMenu, "ReClass &XML...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::importReclassXml);
Qt5Qt6AddAction(importMenu, "&PDB...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::importPdb);
auto* exportMenu = file->addMenu("E&xport");
Qt5Qt6AddAction(exportMenu, "&C++ Header...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportCpp);
Qt5Qt6AddAction(exportMenu, "ReClass &XML...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportReclassXmlAction);
// Examples submenu — scan once at init
{
QDir exDir(QCoreApplication::applicationDirPath() + "/examples");
QStringList rcxFiles = exDir.entryList({"*.rcx"}, QDir::Files, QDir::Name);
if (!rcxFiles.isEmpty()) {
auto* examples = file->addMenu("&Examples");
auto* examples = file->addMenu("E&xamples");
for (const QString& fn : rcxFiles) {
QString fullPath = exDir.absoluteFilePath(fn);
examples->addAction(fn, this, [this, fullPath]() { project_open(fullPath); });
@@ -526,10 +537,7 @@ void MainWindow::createMenus() {
}
}
file->addSeparator();
const auto itemName = QSettings("Reclass", "Reclass").value("autoStartMcp", false).toBool() ? "Stop &MCP Server" : "Start &MCP Server";
m_mcpAction = Qt5Qt6AddAction(file, itemName, QKeySequence::UnknownKey, QIcon(), this, &MainWindow::toggleMcp);
file->addSeparator();
Qt5Qt6AddAction(file, "&Options...", QKeySequence::UnknownKey, makeIcon(":/vsicons/settings-gear.svg"), this, &MainWindow::showOptionsDialog);
Qt5Qt6AddAction(file, "&Close Project", QKeySequence(Qt::CTRL | Qt::Key_W), QIcon(), this, &MainWindow::closeFile);
file->addSeparator();
Qt5Qt6AddAction(file, "E&xit", QKeySequence(Qt::Key_Close), makeIcon(":/vsicons/close.svg"), this, &QMainWindow::close);
@@ -537,13 +545,14 @@ void MainWindow::createMenus() {
auto* edit = m_titleBar->menuBar()->addMenu("&Edit");
Qt5Qt6AddAction(edit, "&Undo", QKeySequence::Undo, makeIcon(":/vsicons/arrow-left.svg"), this, &MainWindow::undo);
Qt5Qt6AddAction(edit, "&Redo", QKeySequence::Redo, makeIcon(":/vsicons/arrow-right.svg"), this, &MainWindow::redo);
edit->addSeparator();
Qt5Qt6AddAction(edit, "&Type Aliases...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::showTypeAliasesDialog);
// View
auto* view = m_titleBar->menuBar()->addMenu("&View");
Qt5Qt6AddAction(view, "Split &Horizontal", QKeySequence::UnknownKey, makeIcon(":/vsicons/split-horizontal.svg"), this, &MainWindow::splitView);
Qt5Qt6AddAction(view, "&Unsplit", QKeySequence::UnknownKey, makeIcon(":/vsicons/chrome-close.svg"), this, &MainWindow::unsplitView);
Qt5Qt6AddAction(view, "&Remove Split", QKeySequence::UnknownKey, makeIcon(":/vsicons/chrome-close.svg"), this, &MainWindow::unsplitView);
view->addSeparator();
m_sourceMenu = view->addMenu("&Data Source");
connect(m_sourceMenu, &QMenu::aboutToShow, this, &MainWindow::populateSourceMenu);
view->addSeparator();
auto* fontMenu = view->addMenu(makeIcon(":/vsicons/text-size.svg"), "&Font");
auto* fontGroup = new QActionGroup(this);
@@ -590,9 +599,28 @@ void MainWindow::createMenus() {
tab.ctrl->setCompactColumns(checked);
});
auto* actRelOfs = view->addAction("R&elative Offsets");
actRelOfs->setCheckable(true);
actRelOfs->setChecked(settings.value("relativeOffsets", true).toBool());
connect(actRelOfs, &QAction::triggered, this, [this](bool checked) {
QSettings("Reclass", "Reclass").setValue("relativeOffsets", checked);
for (auto& tab : m_tabs)
for (auto& pane : tab.panes)
pane.editor->setRelativeOffsets(checked);
});
view->addSeparator();
view->addAction(m_workspaceDock->toggleViewAction());
// Tools
auto* tools = m_titleBar->menuBar()->addMenu("&Tools");
Qt5Qt6AddAction(tools, "&Type Aliases...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::showTypeAliasesDialog);
tools->addSeparator();
const auto mcpName = QSettings("Reclass", "Reclass").value("autoStartMcp", true).toBool() ? "Stop &MCP Server" : "Start &MCP Server";
m_mcpAction = Qt5Qt6AddAction(tools, mcpName, QKeySequence::UnknownKey, QIcon(), this, &MainWindow::toggleMcp);
tools->addSeparator();
Qt5Qt6AddAction(tools, "&Options...", QKeySequence::UnknownKey, makeIcon(":/vsicons/settings-gear.svg"), this, &MainWindow::showOptionsDialog);
// Plugins
auto* plugins = m_titleBar->menuBar()->addMenu("&Plugins");
Qt5Qt6AddAction(plugins, "&Manage Plugins...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::showPluginsDialog);
@@ -716,6 +744,80 @@ protected:
void leaveEvent(QEvent*) override { update(); }
};
// ── Shimmer label — gradient text sweep for MCP activity ──
class ShimmerLabel : public QWidget {
public:
explicit ShimmerLabel(QWidget* parent = nullptr) : QWidget(parent) {
m_timer.setInterval(30);
connect(&m_timer, &QTimer::timeout, this, [this]() {
m_phase += 0.012f;
if (m_phase > 1.0f) m_phase -= 1.0f;
update();
});
}
void setText(const QString& t) { m_text = t; update(); }
QString text() const { return m_text; }
void setShimmerActive(bool on) {
if (m_shimmer == on) return;
m_shimmer = on;
if (on) { m_phase = 0.0f; m_timer.start(); }
else { m_timer.stop(); }
update();
}
bool shimmerActive() const { return m_shimmer; }
void setAlignment(Qt::Alignment a) { m_align = a; update(); }
// Colours configurable from theme
QColor colBase; // dim text (normal)
QColor colBright; // highlight sweep
protected:
void paintEvent(QPaintEvent*) override {
if (m_text.isEmpty()) return;
QPainter p(this);
p.setRenderHint(QPainter::TextAntialiasing);
p.setFont(font());
QRect r = contentsRect();
if (!m_shimmer) {
QColor c = colBase.isValid() ? colBase
: palette().color(QPalette::WindowText);
p.setPen(c);
p.drawText(r, m_align, m_text);
return;
}
// Shimmer: sweeping glow band behind text + bright text
QColor bright = colBright.isValid() ? colBright : QColor(255, 200, 80);
// 1. Sweeping glow band (semi-transparent background highlight)
qreal bandW = width() * 0.20;
qreal bandCenter = -bandW + (width() + 2 * bandW) * m_phase;
QLinearGradient bgGrad(bandCenter - bandW, 0, bandCenter + bandW, 0);
QColor glow = bright;
glow.setAlpha(35);
bgGrad.setColorAt(0.0, Qt::transparent);
bgGrad.setColorAt(0.5, glow);
bgGrad.setColorAt(1.0, Qt::transparent);
p.fillRect(rect(), QBrush(bgGrad));
// 2. Text in bright color
p.setPen(bright);
p.drawText(r, m_align, m_text);
}
private:
QString m_text;
bool m_shimmer = false;
float m_phase = 0.0f;
Qt::Alignment m_align = Qt::AlignLeft | Qt::AlignVCenter;
QTimer m_timer;
};
// ── Borderless status bar with manual child layout ──
// QStatusBarLayout hardcodes 2px margins that can't be overridden.
// We bypass it entirely: children are placed manually in resizeEvent,
@@ -724,7 +826,7 @@ protected:
class FlatStatusBar : public QStatusBar {
public:
QWidget* tabRow = nullptr; // set by createStatusBar
QLabel* label = nullptr; // set by createStatusBar
ShimmerLabel* label = nullptr; // set by createStatusBar
void setDividerColor(const QColor& c) { m_div = c; update(); }
void setTopLineColor(const QColor& c) { m_top = c; update(); }
@@ -802,7 +904,8 @@ void MainWindow::createStatusBar() {
auto* sb = new FlatStatusBar;
setStatusBar(sb);
m_statusLabel = new QLabel("Ready", sb);
m_statusLabel = new ShimmerLabel(sb);
m_statusLabel->setText("");
m_statusLabel->setContentsMargins(0, 0, 0, 0);
m_statusLabel->setAlignment(Qt::AlignVCenter | Qt::AlignLeft);
@@ -865,10 +968,42 @@ void MainWindow::createStatusBar() {
};
applyViewTabColors(static_cast<ViewTabButton*>(m_btnReclass));
applyViewTabColors(static_cast<ViewTabButton*>(m_btnRendered));
m_statusLabel->colBase = t.textDim;
m_statusLabel->colBright = t.indHoverSpan;
}
}
void MainWindow::setAppStatus(const QString& text) {
m_appStatus = text;
if (!m_mcpBusy) {
m_statusLabel->setText(text);
m_statusLabel->setShimmerActive(false);
}
}
void MainWindow::setMcpStatus(const QString& text) {
// Cancel any pending clear — new activity extends the shimmer
if (m_mcpClearTimer) m_mcpClearTimer->stop();
m_mcpBusy = true;
m_statusLabel->setText(text);
m_statusLabel->setShimmerActive(true);
}
void MainWindow::clearMcpStatus() {
// Delay the clear so the shimmer stays visible for at least 750ms
if (!m_mcpClearTimer) {
m_mcpClearTimer = new QTimer(this);
m_mcpClearTimer->setSingleShot(true);
connect(m_mcpClearTimer, &QTimer::timeout, this, [this]() {
m_mcpBusy = false;
m_statusLabel->setText(m_appStatus);
m_statusLabel->setShimmerActive(false);
});
}
m_mcpClearTimer->start(750);
}
void MainWindow::styleTabCloseButtons() {
auto* tabBar = m_mdiArea->findChild<QTabBar*>();
@@ -911,12 +1046,68 @@ MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) {
// Create editor via controller (parent = tabWidget for ownership)
pane.editor = tab.ctrl->addSplitEditor(pane.tabWidget);
pane.editor->setRelativeOffsets(
QSettings("Reclass", "Reclass").value("relativeOffsets", true).toBool());
pane.tabWidget->addTab(pane.editor, "Reclass"); // index 0
// Create per-pane rendered C++ view
// Create per-pane rendered C++ view with find bar
pane.renderedContainer = new QWidget;
auto* rvLayout = new QVBoxLayout(pane.renderedContainer);
rvLayout->setContentsMargins(0, 0, 0, 0);
rvLayout->setSpacing(0);
pane.rendered = new QsciScintilla;
setupRenderedSci(pane.rendered);
pane.tabWidget->addTab(pane.rendered, "C/C++"); // index 1
rvLayout->addWidget(pane.rendered);
// Find bar (hidden by default)
pane.findBar = new QLineEdit;
pane.findBar->setPlaceholderText("Find...");
pane.findBar->setVisible(false);
const auto& fbTheme = ThemeManager::instance().current();
pane.findBar->setStyleSheet(
QStringLiteral("QLineEdit { background: %1; color: %2; border: 1px solid %3;"
" padding: 4px 8px; font-size: 13px; }")
.arg(fbTheme.backgroundAlt.name())
.arg(fbTheme.text.name())
.arg(fbTheme.border.name()));
rvLayout->addWidget(pane.findBar);
// Ctrl+F to show find bar
QsciScintilla* sci = pane.rendered;
QLineEdit* fb = pane.findBar;
auto* findAction = new QAction(pane.renderedContainer);
findAction->setShortcut(QKeySequence::Find);
findAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
pane.renderedContainer->addAction(findAction);
connect(findAction, &QAction::triggered, fb, [fb, sci]() {
fb->setVisible(true);
fb->setFocus();
fb->selectAll();
});
// Escape to hide find bar
auto* escAction = new QAction(fb);
escAction->setShortcut(QKeySequence(Qt::Key_Escape));
escAction->setShortcutContext(Qt::WidgetShortcut);
fb->addAction(escAction);
connect(escAction, &QAction::triggered, fb, [fb, sci]() {
fb->setVisible(false);
sci->setFocus();
});
// Search on text change and Enter
connect(fb, &QLineEdit::textChanged, sci, [sci](const QString& text) {
if (text.isEmpty()) return;
sci->findFirst(text, false, false, false, true, true, 0, 0);
});
connect(fb, &QLineEdit::returnPressed, sci, [sci, fb]() {
QString text = fb->text();
if (text.isEmpty()) return;
if (!sci->findNext())
sci->findFirst(text, false, false, false, true, true, 0, 0);
});
pane.tabWidget->addTab(pane.renderedContainer, "C/C++"); // index 1
pane.tabWidget->setCurrentIndex(0);
pane.viewMode = VM_Reclass;
@@ -1033,19 +1224,17 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) {
auto& node = ctrl->document()->tree.nodes[nodeIdx];
auto* ap = findActiveSplitPane();
if (ap && ap->viewMode == VM_Rendered)
m_statusLabel->setText(
setAppStatus(
QString("Rendered: %1 %2")
.arg(kindToString(node.kind))
.arg(node.name));
else
m_statusLabel->setText(
setAppStatus(
QString("%1 %2 offset: 0x%3 size: %4 bytes")
.arg(kindToString(node.kind))
.arg(node.name)
.arg(node.offset, 4, 16, QChar('0'))
.arg(node.byteSize()));
} else {
m_statusLabel->setText("Ready");
}
// Update all rendered panes on selection change
auto it = m_tabs.find(sub);
@@ -1054,10 +1243,8 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) {
});
connect(ctrl, &RcxController::selectionChanged,
this, [this](int count) {
if (count == 0)
m_statusLabel->setText("Ready");
else if (count > 1)
m_statusLabel->setText(QString("%1 nodes selected").arg(count));
if (count > 1)
setAppStatus(QString("%1 nodes selected").arg(count));
});
// Update rendered panes and workspace on document changes and undo/redo
@@ -1417,7 +1604,9 @@ void MainWindow::removeNode() {
QSet<uint64_t> ids = ctrl->selectedIds();
QVector<int> indices;
for (uint64_t id : ids) {
int idx = ctrl->document()->tree.indexOfId(id & ~kFooterIdBit);
int idx = ctrl->document()->tree.indexOfId(
id & ~(kFooterIdBit | kArrayElemBit | kArrayElemMask
| kMemberBit | kMemberSubMask));
if (idx >= 0) indices.append(idx);
}
if (indices.size() > 1)
@@ -1522,18 +1711,18 @@ void MainWindow::toggleMcp() {
if (m_mcp->isRunning()) {
m_mcp->stop();
m_mcpAction->setText("Start &MCP Server");
m_statusLabel->setText("MCP server stopped");
setAppStatus("MCP server stopped");
} else {
m_mcp->start();
m_mcpAction->setText("Stop &MCP Server");
m_statusLabel->setText("MCP server listening on pipe: ReclassMcpBridge");
setAppStatus("MCP server listening on pipe: ReclassMcpBridge");
}
}
void MainWindow::applyTheme(const Theme& theme) {
applyGlobalTheme(theme);
// Separator killed via PM_DockWidgetSeparatorExtent in MenuBarStyle
// Dock separator is 1px via PM_DockWidgetSeparatorExtent in MenuBarStyle
// Custom title bar
m_titleBar->applyTheme(theme);
@@ -1541,15 +1730,21 @@ void MainWindow::applyTheme(const Theme& theme) {
// Update border overlay color
updateBorderColor(isActiveWindow() ? theme.borderFocused : theme.border);
// MDI area tabs
// MDI area tabs — text color + height handled by MenuBarStyle QProxyStyle
m_mdiArea->setStyleSheet(QStringLiteral(
"QTabBar::tab {"
" background: %1; color: %2; padding: 0px 16px; border: none; height: 24px;"
" background: %1; padding: 0px 16px; border: none;"
"}"
"QTabBar::tab:selected { color: %3; background: %4; }"
"QTabBar::tab:hover { color: %3; background: %5; }")
.arg(theme.background.name(), theme.textMuted.name(), theme.text.name(),
theme.backgroundAlt.name(), theme.hover.name()));
"QTabBar::tab:selected { background: %2; }"
"QTabBar::tab:hover { background: %3; }")
.arg(theme.background.name(), theme.backgroundAlt.name(), theme.hover.name()));
// Dim MDI tab text via palette (Fusion reads WindowText, not CSS color:)
if (auto* tabBar = m_mdiArea->findChild<QTabBar*>()) {
QPalette tp = tabBar->palette();
tp.setColor(QPalette::WindowText, theme.textDim);
tabBar->setPalette(tp);
}
// Re-style ✕ close buttons on MDI tabs
styleTabCloseButtons();
@@ -1594,6 +1789,12 @@ void MainWindow::applyTheme(const Theme& theme) {
tp.setColor(QPalette::HighlightedText, theme.text);
m_workspaceTree->setPalette(tp);
}
if (m_workspaceSearch) {
m_workspaceSearch->setStyleSheet(QStringLiteral(
"QLineEdit { background: %1; color: %2; border: none;"
" border-bottom: 1px solid %3; padding: 4px 6px; }")
.arg(theme.background.name(), theme.textDim.name(), theme.border.name()));
}
// Dock titlebar: restyle via palette + close button
if (m_dockTitleLabel) {
@@ -1665,8 +1866,9 @@ void MainWindow::showOptionsDialog() {
current.menuBarTitleCase = m_titleBar->menuBarTitleCase();
current.showIcon = QSettings("Reclass", "Reclass").value("showIcon", false).toBool();
current.safeMode = QSettings("Reclass", "Reclass").value("safeMode", false).toBool();
current.autoStartMcp = QSettings("Reclass", "Reclass").value("autoStartMcp", false).toBool();
current.autoStartMcp = QSettings("Reclass", "Reclass").value("autoStartMcp", true).toBool();
current.refreshMs = QSettings("Reclass", "Reclass").value("refreshMs", 660).toInt();
current.generatorAsserts = QSettings("Reclass", "Reclass").value("generatorAsserts", false).toBool();
OptionsDialog dlg(current, this);
if (dlg.exec() != QDialog::Accepted) return; // OptionsDialog doesn't apply anything. Only apply on OK
@@ -1700,6 +1902,9 @@ void MainWindow::showOptionsDialog() {
for (auto& tab : m_tabs)
tab.ctrl->setRefreshInterval(r.refreshMs);
}
if (r.generatorAsserts != current.generatorAsserts)
QSettings("Reclass", "Reclass").setValue("generatorAsserts", r.generatorAsserts);
}
void MainWindow::setEditorFont(const QString& fontName) {
@@ -1878,18 +2083,34 @@ void MainWindow::updateRenderedView(TabState& tab, SplitPane& pane) {
QSet<uint64_t> selIds = tab.ctrl->selectedIds();
if (selIds.size() >= 1) {
uint64_t selId = *selIds.begin();
selId &= ~kFooterIdBit;
selId &= ~(kFooterIdBit | kArrayElemBit | kArrayElemMask
| kMemberBit | kMemberSubMask);
rootId = findRootStructForNode(tab.doc->tree, selId);
}
// Fall back to the controller's current view root (set by double-click / navigation)
if (rootId == 0)
rootId = findRootStructForNode(tab.doc->tree, tab.ctrl->viewRootId());
// Last resort: first root-level struct in the project
if (rootId == 0) {
for (const auto& n : tab.doc->tree.nodes) {
if (n.parentId == 0 && n.kind == rcx::NodeKind::Struct) {
rootId = n.id;
break;
}
}
}
// Generate text
const QHash<NodeKind, QString>* aliases =
tab.doc->typeAliases.isEmpty() ? nullptr : &tab.doc->typeAliases;
bool asserts = QSettings("Reclass", "Reclass").value("generatorAsserts", false).toBool();
QString text;
if (rootId != 0)
text = renderCpp(tab.doc->tree, rootId, aliases);
text = renderCpp(tab.doc->tree, rootId, aliases, asserts);
else
text = renderCppAll(tab.doc->tree, aliases);
text = renderCppAll(tab.doc->tree, aliases, asserts);
// Scroll restoration: save if same root, reset if different
int restoreLine = 0;
@@ -1933,7 +2154,8 @@ void MainWindow::exportCpp() {
const QHash<NodeKind, QString>* aliases =
tab->doc->typeAliases.isEmpty() ? nullptr : &tab->doc->typeAliases;
QString text = renderCppAll(tab->doc->tree, aliases);
bool asserts = QSettings("Reclass", "Reclass").value("generatorAsserts", false).toBool();
QString text = renderCppAll(tab->doc->tree, aliases, asserts);
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
QMessageBox::warning(this, "Export Failed",
@@ -1941,7 +2163,7 @@ void MainWindow::exportCpp() {
return;
}
file.write(text.toUtf8());
m_statusLabel->setText("Exported to " + QFileInfo(path).fileName());
setAppStatus("Exported to " + QFileInfo(path).fileName());
}
// ── Export ReClass XML ──
@@ -1965,7 +2187,7 @@ void MainWindow::exportReclassXmlAction() {
for (const auto& n : tab->doc->tree.nodes)
if (n.parentId == 0 && n.kind == NodeKind::Struct) classCount++;
m_statusLabel->setText(QStringLiteral("Exported %1 classes to %2")
setAppStatus(QStringLiteral("Exported %1 classes to %2")
.arg(classCount).arg(QFileInfo(path).fileName()));
}
@@ -1996,7 +2218,7 @@ void MainWindow::importReclassXml() {
m_mdiArea->closeAllSubWindows();
createTab(doc);
rebuildWorkspaceModel();
m_statusLabel->setText(QStringLiteral("Imported %1 classes from %2")
setAppStatus(QStringLiteral("Imported %1 classes from %2")
.arg(classCount).arg(QFileInfo(filePath).fileName()));
}
@@ -2046,7 +2268,7 @@ void MainWindow::importFromSource() {
createTab(doc);
rebuildWorkspaceModel();
m_workspaceDock->show();
m_statusLabel->setText(QStringLiteral("Imported %1 classes from source").arg(classCount));
setAppStatus(QStringLiteral("Imported %1 classes from source").arg(classCount));
}
// ── Import PDB ──
@@ -2096,7 +2318,7 @@ void MainWindow::importPdb() {
createTab(doc);
rebuildWorkspaceModel();
m_workspaceDock->show();
m_statusLabel->setText(QStringLiteral("Imported %1 classes from %2")
setAppStatus(QStringLiteral("Imported %1 classes from %2")
.arg(classCount).arg(QFileInfo(pdbPath).fileName()));
}
@@ -2108,30 +2330,91 @@ void MainWindow::showTypeAliasesDialog() {
QDialog dlg(this);
dlg.setWindowTitle("Type Aliases");
dlg.resize(500, 400);
dlg.resize(400, 380);
auto* layout = new QVBoxLayout(&dlg);
// Preset buttons (stdint + Windows only, no redundant Reset)
auto* presetRow = new QHBoxLayout;
auto* btnStdint = new QPushButton("stdint (C99)", &dlg);
auto* btnWindows = new QPushButton("Windows (basetsd.h)", &dlg);
presetRow->addWidget(btnStdint);
presetRow->addWidget(btnWindows);
presetRow->addStretch();
layout->addLayout(presetRow);
auto* table = new QTableWidget(&dlg);
table->setColumnCount(2);
table->setHorizontalHeaderLabels({"NodeKind", "Alias (C type)"});
table->horizontalHeader()->setVisible(false);
table->horizontalHeader()->setStretchLastSection(true);
table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
table->setSelectionMode(QAbstractItemView::SingleSelection);
table->verticalHeader()->setVisible(false);
// Populate with all NodeKind entries
int rowCount = static_cast<int>(std::size(kKindMeta));
table->setRowCount(rowCount);
for (int i = 0; i < rowCount; i++) {
const auto& meta = kKindMeta[i];
// Skip types that nobody aliases (Vec, Mat, Struct, Array)
auto shouldSkip = [](NodeKind k) {
return k == NodeKind::Vec2 || k == NodeKind::Vec3
|| k == NodeKind::Vec4 || k == NodeKind::Mat4x4
|| k == NodeKind::Struct || k == NodeKind::Array;
};
// Build filtered row→meta index mapping
QVector<int> rowMap;
int totalMeta = static_cast<int>(std::size(kKindMeta));
for (int i = 0; i < totalMeta; i++)
if (!shouldSkip(kKindMeta[i].kind)) rowMap.append(i);
table->setRowCount(rowMap.size());
for (int row = 0; row < rowMap.size(); row++) {
const auto& meta = kKindMeta[rowMap[row]];
auto* kindItem = new QTableWidgetItem(QString::fromLatin1(meta.name));
kindItem->setFlags(kindItem->flags() & ~Qt::ItemIsEditable);
table->setItem(i, 0, kindItem);
table->setItem(row, 0, kindItem);
QString alias = tab->doc->typeAliases.value(meta.kind);
table->setItem(i, 1, new QTableWidgetItem(alias));
table->setItem(row, 1, new QTableWidgetItem(alias));
}
// stdint preset: actual typeName values from kKindMeta
static QHash<NodeKind, QString> kStdintPreset;
if (kStdintPreset.isEmpty()) {
for (const auto& m : kKindMeta)
kStdintPreset[m.kind] = QString::fromLatin1(m.typeName);
}
// Windows (basetsd.h) preset mapping
static const QHash<NodeKind, QString> kWindowsPreset = {
{NodeKind::Int8, QStringLiteral("CHAR")},
{NodeKind::Int16, QStringLiteral("SHORT")},
{NodeKind::Int32, QStringLiteral("LONG")},
{NodeKind::Int64, QStringLiteral("LONGLONG")},
{NodeKind::UInt8, QStringLiteral("UCHAR")},
{NodeKind::UInt16, QStringLiteral("USHORT")},
{NodeKind::UInt32, QStringLiteral("ULONG")},
{NodeKind::UInt64, QStringLiteral("ULONGLONG")},
{NodeKind::Float, QStringLiteral("FLOAT")},
{NodeKind::Double, QStringLiteral("DOUBLE")},
{NodeKind::Bool, QStringLiteral("BOOLEAN")},
{NodeKind::Pointer32, QStringLiteral("ULONG")},
{NodeKind::Pointer64, QStringLiteral("ULONG_PTR")},
{NodeKind::FuncPtr32, QStringLiteral("ULONG")},
{NodeKind::FuncPtr64, QStringLiteral("ULONG_PTR")},
{NodeKind::Hex8, QStringLiteral("BYTE")},
{NodeKind::Hex16, QStringLiteral("WORD")},
{NodeKind::Hex32, QStringLiteral("DWORD")},
{NodeKind::Hex64, QStringLiteral("DWORD64")},
{NodeKind::UTF8, QStringLiteral("CHAR[]")},
{NodeKind::UTF16, QStringLiteral("WCHAR[]")},
};
auto applyPreset = [&](const QHash<NodeKind, QString>& preset) {
for (int row = 0; row < rowMap.size(); row++)
table->item(row, 1)->setText(preset.value(kKindMeta[rowMap[row]].kind));
};
connect(btnStdint, &QPushButton::clicked, [&]() { applyPreset(kStdintPreset); });
connect(btnWindows, &QPushButton::clicked, [&]() { applyPreset(kWindowsPreset); });
layout->addWidget(table);
auto* buttons = new QDialogButtonBox(
@@ -2145,10 +2428,10 @@ void MainWindow::showTypeAliasesDialog() {
// Collect new aliases
QHash<NodeKind, QString> newAliases;
for (int i = 0; i < rowCount; i++) {
QString val = table->item(i, 1)->text().trimmed();
for (int row = 0; row < rowMap.size(); row++) {
QString val = table->item(row, 1)->text().trimmed();
if (!val.isEmpty())
newAliases[kKindMeta[i].kind] = val;
newAliases[kKindMeta[rowMap[row]].kind] = val;
}
tab->doc->typeAliases = newAliases;
@@ -2229,7 +2512,7 @@ QMdiSubWindow* MainWindow::project_open(const QString& path) {
int classCount = 0;
for (const auto& n : doc->tree.nodes)
if (n.parentId == 0 && n.kind == NodeKind::Struct) classCount++;
m_statusLabel->setText(QStringLiteral("Imported %1 classes from %2")
setAppStatus(QStringLiteral("Imported %1 classes from %2")
.arg(classCount).arg(QFileInfo(filePath).fileName()));
return sub;
}
@@ -2287,6 +2570,7 @@ void MainWindow::createWorkspaceDock() {
const auto& t = ThemeManager::instance().current();
auto* titleBar = new QWidget(m_workspaceDock);
titleBar->setFixedHeight(24);
titleBar->setAutoFillBackground(true);
{
QPalette tbPal = titleBar->palette();
@@ -2321,15 +2605,47 @@ void MainWindow::createWorkspaceDock() {
m_workspaceDock->setTitleBarWidget(titleBar);
}
m_workspaceTree = new QTreeView(m_workspaceDock);
// Container widget: search box + tree view
auto* dockContainer = new QWidget(m_workspaceDock);
auto* dockLayout = new QVBoxLayout(dockContainer);
dockLayout->setContentsMargins(0, 0, 0, 0);
dockLayout->setSpacing(0);
m_workspaceSearch = new QLineEdit(dockContainer);
m_workspaceSearch->setPlaceholderText(QStringLiteral("Search..."));
m_workspaceSearch->setClearButtonEnabled(true);
{
const auto& t = ThemeManager::instance().current();
m_workspaceSearch->setStyleSheet(QStringLiteral(
"QLineEdit { background: %1; color: %2; border: none;"
" border-bottom: 1px solid %3; padding: 4px 6px; }")
.arg(t.background.name(), t.textDim.name(), t.border.name()));
}
dockLayout->addWidget(m_workspaceSearch);
m_workspaceTree = new QTreeView(dockContainer);
m_workspaceModel = new QStandardItemModel(this);
m_workspaceModel->setHorizontalHeaderLabels({"Name"});
m_workspaceTree->setModel(m_workspaceModel);
m_workspaceProxy = new QSortFilterProxyModel(this);
m_workspaceProxy->setSourceModel(m_workspaceModel);
m_workspaceProxy->setFilterCaseSensitivity(Qt::CaseInsensitive);
m_workspaceProxy->setRecursiveFilteringEnabled(true);
m_workspaceTree->setModel(m_workspaceProxy);
m_workspaceTree->setHeaderHidden(true);
m_workspaceTree->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_workspaceTree->setExpandsOnDoubleClick(false);
m_workspaceTree->setMouseTracking(true);
connect(m_workspaceSearch, &QLineEdit::textChanged, this, [this](const QString& text) {
m_workspaceProxy->setFilterFixedString(text);
if (!text.isEmpty())
m_workspaceTree->expandAll();
else
m_workspaceTree->expandToDepth(0);
});
// Override palette: selection + hover use theme colors (not default blue)
{
const auto& t = ThemeManager::instance().current();
@@ -2340,6 +2656,8 @@ void MainWindow::createWorkspaceDock() {
m_workspaceTree->setPalette(tp);
}
dockLayout->addWidget(m_workspaceTree);
m_workspaceTree->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_workspaceTree, &QWidget::customContextMenuRequested, this, [this](const QPoint& pos) {
QModelIndex index = m_workspaceTree->indexAt(pos);
@@ -2442,7 +2760,7 @@ void MainWindow::createWorkspaceDock() {
}
});
m_workspaceDock->setWidget(m_workspaceTree);
m_workspaceDock->setWidget(dockContainer);
addDockWidget(Qt::LeftDockWidgetArea, m_workspaceDock);
m_workspaceDock->hide();
@@ -2463,12 +2781,36 @@ void MainWindow::createWorkspaceDock() {
m_mdiArea->setActiveSubWindow(sub);
// Type/Enum node: navigate to it
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);
if (ni < 0) return;
auto& tab = m_tabs[sub];
// Child member item: navigate to parent struct, then scroll to this member
uint64_t parentId = tree.nodes[ni].parentId;
if (parentId != 0) {
int pi = tree.indexOfId(parentId);
if (pi >= 0) tree.nodes[pi].collapsed = false;
tab.ctrl->setViewRootId(parentId);
tab.ctrl->scrollToNodeId(structId);
} else {
// Root type/enum: navigate directly
tree.nodes[ni].collapsed = false;
tab.ctrl->setViewRootId(structId);
tab.ctrl->scrollToNodeId(structId);
}
// If active pane is in C/C++ mode, refresh after navigation settles
QTimer::singleShot(0, this, [this, sub]() {
if (!m_tabs.contains(sub)) return;
auto& t = m_tabs[sub];
if (t.activePaneIdx >= 0 && t.activePaneIdx < t.panes.size()) {
auto& p = t.panes[t.activePaneIdx];
if (p.viewMode == VM_Rendered)
updateRenderedView(t, p);
}
});
});
}
@@ -2488,7 +2830,7 @@ void MainWindow::rebuildWorkspaceModel() {
tabs.append({ &tab.doc->tree, name, static_cast<void*>(it.key()) });
}
rcx::buildProjectExplorer(m_workspaceModel, tabs);
m_workspaceTree->expandToDepth(1);
m_workspaceTree->expandToDepth(0);
}
void MainWindow::populateSourceMenu() {
@@ -2607,7 +2949,7 @@ void MainWindow::showPluginsDialog() {
if (!path.isEmpty()) {
if (m_pluginManager.LoadPluginFromPath(path)) {
refreshList();
m_statusLabel->setText("Plugin loaded successfully");
setAppStatus("Plugin loaded successfully");
} else {
QMessageBox::warning(&dialog, "Failed to Load Plugin",
"Could not load the selected plugin.\nCheck the console for details.");
@@ -2633,7 +2975,7 @@ void MainWindow::showPluginsDialog() {
if (reply == QMessageBox::Yes) {
if (m_pluginManager.UnloadPlugin(pluginName)) {
refreshList();
m_statusLabel->setText("Plugin unloaded");
setAppStatus("Plugin unloaded");
} else {
QMessageBox::warning(&dialog, "Failed to Unload",
"Could not unload the selected plugin.");

View File

@@ -11,14 +11,18 @@
#include <QDockWidget>
#include <QTreeView>
#include <QStandardItemModel>
#include <QSortFilterProxyModel>
#include <QLineEdit>
#include <QMap>
#include <QButtonGroup>
#include <QPushButton>
#include <QTimer>
#include <Qsci/qsciscintilla.h>
namespace rcx {
class McpBridge;
class ShimmerLabel;
class MainWindow : public QMainWindow {
Q_OBJECT
@@ -59,6 +63,11 @@ private slots:
void showOptionsDialog();
public:
// Status bar helpers — separate app / MCP channels
void setAppStatus(const QString& text);
void setMcpStatus(const QString& text);
void clearMcpStatus();
// Project Lifecycle API
QMdiSubWindow* project_new(const QString& classKeyword = QString());
QMdiSubWindow* project_open(const QString& path = {});
@@ -69,7 +78,10 @@ private:
enum ViewMode { VM_Reclass, VM_Rendered };
QMdiArea* m_mdiArea;
QLabel* m_statusLabel;
ShimmerLabel* m_statusLabel;
QString m_appStatus;
bool m_mcpBusy = false;
QTimer* m_mcpClearTimer = nullptr;
QButtonGroup* m_viewBtnGroup = nullptr;
QPushButton* m_btnReclass = nullptr;
QPushButton* m_btnRendered = nullptr;
@@ -84,6 +96,8 @@ private:
QTabWidget* tabWidget = nullptr;
RcxEditor* editor = nullptr;
QsciScintilla* rendered = nullptr;
QLineEdit* findBar = nullptr;
QWidget* renderedContainer = nullptr;
ViewMode viewMode = VM_Reclass;
uint64_t lastRenderedRootId = 0;
};
@@ -130,6 +144,8 @@ private:
QDockWidget* m_workspaceDock = nullptr;
QTreeView* m_workspaceTree = nullptr;
QStandardItemModel* m_workspaceModel = nullptr;
QSortFilterProxyModel* m_workspaceProxy = nullptr;
QLineEdit* m_workspaceSearch = nullptr;
QLabel* m_dockTitleLabel = nullptr;
QToolButton* m_dockCloseBtn = nullptr;
void createWorkspaceDock();

View File

@@ -4,6 +4,7 @@
#include "generator.h"
#include "mainwindow.h"
#include <QCoreApplication>
#include <QSettings>
#include <QDebug>
#include <cstring>
@@ -170,9 +171,15 @@ void McpBridge::processLine(const QByteArray& line) {
}
if (method == "initialize") {
m_mainWindow->setMcpStatus(QStringLiteral("MCP: client connected"));
QCoreApplication::processEvents();
sendJson(handleInitialize(id, req.value("params").toObject()));
m_mainWindow->clearMcpStatus();
} else if (method == "tools/list") {
m_mainWindow->setMcpStatus(QStringLiteral("MCP: tools/list"));
QCoreApplication::processEvents();
sendJson(handleToolsList(id));
m_mainWindow->clearMcpStatus();
} else if (method == "tools/call") {
sendJson(handleToolsCall(id, req.value("params").toObject()));
} else {
@@ -211,20 +218,29 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
// 1. project.state
tools.append(QJsonObject{
{"name", "project.state"},
{"description", "Returns project state: node tree, base address, sources, provider info. "
"Use depth/parentId to avoid dumping the whole tree. "
"Call with depth:1 first to see top-level structs, then drill in with parentId."},
{"description", "Returns project state with paginated node tree. "
"Responses return max 'limit' nodes (default 50). "
"Use depth:1 first, then parentId to drill into a struct. "
"Enum/bitfield member arrays are omitted by default (counts shown instead); "
"pass includeMembers:true to get full arrays. "
"Response includes returned/total/nextOffset for paging."},
{"inputSchema", QJsonObject{
{"type", "object"},
{"properties", QJsonObject{
{"tabIndex", QJsonObject{{"type", "integer"},
{"description", "MDI tab index (0-based). Omit for active tab."}}},
{"depth", QJsonObject{{"type", "integer"},
{"description", "Max tree depth to return (default 1 = top-level structs only)."}}},
{"description", "Max tree depth to return (default 1)."}}},
{"parentId", QJsonObject{{"type", "string"},
{"description", "Only return children of this node."}}},
{"includeTree", QJsonObject{{"type", "boolean"},
{"description", "If false, return only provider/source info, no tree. Default true."}}}
{"description", "If false, return only provider/source info, no tree. Default true."}}},
{"includeMembers", QJsonObject{{"type", "boolean"},
{"description", "If true, include full enumMembers/bitfieldMembers arrays. Default false (shows counts only)."}}},
{"limit", QJsonObject{{"type", "integer"},
{"description", "Max nodes to return (default 50, max 500)."}}},
{"offset", QJsonObject{{"type", "integer"},
{"description", "Skip this many nodes (for pagination). Use nextOffset from previous response."}}}
}}
}}
});
@@ -343,7 +359,8 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
{"description", "Trigger a UI action. Fallback for operations without dedicated tools. "
"Actions: undo, redo, new_file, open_file, save_file, save_file_as, "
"export_cpp, set_view_root, scroll_to_node, collapse_node, expand_node, "
"select_node, refresh"},
"select_node, refresh. "
"export_cpp accepts optional nodeId to export a single struct (recommended for large projects)."},
{"inputSchema", QJsonObject{
{"type", "object"},
{"properties", QJsonObject{
@@ -357,6 +374,28 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
}}
});
// 8. tree.search
tools.append(QJsonObject{
{"name", "tree.search"},
{"description", "Search for nodes by name (substring, case-insensitive). "
"Returns compact results: id, name, kind, parentId, offset, childCount. "
"Use kindFilter to narrow (e.g. 'Struct'). Max 100 results. "
"Much faster than paging through project.state to find a specific type."},
{"inputSchema", QJsonObject{
{"type", "object"},
{"properties", QJsonObject{
{"tabIndex", QJsonObject{{"type", "integer"},
{"description", "MDI tab index (0-based). Omit for active tab."}}},
{"query", QJsonObject{{"type", "string"},
{"description", "Name substring to search for (case-insensitive)."}}},
{"kindFilter", QJsonObject{{"type", "string"},
{"description", "Filter by node kind (e.g. 'Struct', 'Hex64', 'Array')."}}},
{"limit", QJsonObject{{"type", "integer"},
{"description", "Max results to return (default 20, max 100)."}}}
}}
}}
});
return okReply(id, QJsonObject{{"tools", tools}});
}
@@ -368,6 +407,10 @@ QJsonObject McpBridge::handleToolsCall(const QJsonValue& id, const QJsonObject&
QString toolName = params.value("name").toString();
QJsonObject args = params.value("arguments").toObject();
// Show tool activity in status bar (with shimmer)
m_mainWindow->setMcpStatus(QStringLiteral("MCP: %1").arg(toolName));
QCoreApplication::processEvents(); // paint immediately
QJsonObject result;
if (toolName == "project.state") result = toolProjectState(args);
else if (toolName == "tree.apply") result = toolTreeApply(args);
@@ -376,8 +419,11 @@ QJsonObject McpBridge::handleToolsCall(const QJsonValue& id, const QJsonObject&
else if (toolName == "hex.write") result = toolHexWrite(args);
else if (toolName == "status.set") result = toolStatusSet(args);
else if (toolName == "ui.action") result = toolUiAction(args);
else if (toolName == "tree.search") result = toolTreeSearch(args);
else return errReply(id, -32601, "Unknown tool: " + toolName);
m_mainWindow->clearMcpStatus();
return okReply(id, result);
}
@@ -436,6 +482,9 @@ QJsonObject McpBridge::toolProjectState(const QJsonObject& args) {
int maxDepth = args.value("depth").toInt(1);
bool includeTree = args.contains("includeTree") ? args.value("includeTree").toBool() : true;
bool includeMembers = args.value("includeMembers").toBool(false);
int limit = qBound(1, args.value("limit").toInt(50), 500);
int offset = qMax(0, args.value("offset").toInt(0));
QString parentIdStr = args.value("parentId").toString();
uint64_t filterParentId = parentIdStr.isEmpty() ? 0 : parentIdStr.toULongLong();
@@ -481,6 +530,7 @@ QJsonObject McpBridge::toolProjectState(const QJsonObject& args) {
state["modified"] = doc->modified;
state["undoAvailable"] = doc->undoStack.canUndo();
state["redoAvailable"] = doc->undoStack.canRedo();
state["statusText"] = m_mainWindow->m_appStatus;
// Filtered tree: only emit nodes up to maxDepth from the filter root
if (includeTree) {
@@ -489,12 +539,15 @@ QJsonObject McpBridge::toolProjectState(const QJsonObject& args) {
for (int i = 0; i < tree.nodes.size(); i++)
childMap[tree.nodes[i].parentId].append(i);
// BFS from filterParentId, respecting maxDepth
// BFS from filterParentId, respecting maxDepth + pagination
QJsonArray nodeArr;
struct QueueEntry { uint64_t parentId; int depth; };
QVector<QueueEntry> queue;
queue.append({filterParentId, 0});
int totalCount = 0; // total nodes that match depth filter
int emitted = 0;
while (!queue.isEmpty()) {
auto entry = queue.takeFirst();
if (entry.depth > maxDepth) continue;
@@ -502,13 +555,47 @@ QJsonObject McpBridge::toolProjectState(const QJsonObject& args) {
const auto& kids = childMap.value(entry.parentId);
for (int ci : kids) {
const Node& n = tree.nodes[ci];
// Count all matching nodes for pagination metadata
totalCount++;
// Apply offset/limit pagination
if (totalCount <= offset) {
// Still skipping — but enqueue children for counting
if (entry.depth + 1 <= maxDepth)
queue.append({n.id, entry.depth + 1});
continue;
}
if (emitted >= limit) {
// Past limit — just keep counting total
if (entry.depth + 1 <= maxDepth)
queue.append({n.id, entry.depth + 1});
continue;
}
QJsonObject nj = n.toJson();
// Strip inline member arrays unless requested
if (!includeMembers) {
if (nj.contains("enumMembers")) {
int count = nj.value("enumMembers").toArray().size();
nj.remove("enumMembers");
nj["enumMemberCount"] = count;
}
if (nj.contains("bitfieldMembers")) {
int count = nj.value("bitfieldMembers").toArray().size();
nj.remove("bitfieldMembers");
nj["bitfieldMemberCount"] = count;
}
}
// Add computed size for containers
if (n.kind == NodeKind::Struct || n.kind == NodeKind::Array) {
nj["computedSize"] = tree.structSpan(n.id, &childMap);
nj["childCount"] = childMap.value(n.id).size();
}
nodeArr.append(nj);
emitted++;
// Enqueue children if we haven't hit depth limit
if (entry.depth + 1 <= maxDepth)
@@ -520,6 +607,10 @@ QJsonObject McpBridge::toolProjectState(const QJsonObject& args) {
treeObj["baseAddress"] = QString::number(tree.baseAddress, 16);
treeObj["nextId"] = QString::number(tree.m_nextId);
treeObj["nodes"] = nodeArr;
treeObj["returned"] = emitted;
treeObj["total"] = totalCount;
if (emitted < totalCount)
treeObj["nextOffset"] = offset + emitted;
state["tree"] = treeObj;
}
@@ -956,7 +1047,7 @@ QJsonObject McpBridge::toolStatusSet(const QJsonObject& args) {
}
}
if (target == "statusBar" || target == "both") {
m_mainWindow->m_statusLabel->setText(text);
m_mainWindow->setAppStatus(text);
}
return makeTextResult("Status set: " + text);
@@ -1004,7 +1095,25 @@ QJsonObject McpBridge::toolUiAction(const QJsonObject& args) {
if (action == "export_cpp") {
if (!doc) return makeTextResult("No active tab", true);
const QHash<NodeKind, QString>* aliases = doc->typeAliases.isEmpty() ? nullptr : &doc->typeAliases;
QString code = renderCppAll(doc->tree, aliases);
bool asserts = QSettings("Reclass", "Reclass").value("generatorAsserts", false).toBool();
QString code;
if (!nodeIdStr.isEmpty()) {
// Per-struct export
uint64_t nid = nodeIdStr.toULongLong();
code = renderCpp(doc->tree, nid, aliases, asserts);
if (code.isEmpty())
return makeTextResult("Node not found or not a struct: " + nodeIdStr, true);
} else {
code = renderCppAll(doc->tree, aliases, asserts);
}
// Truncate if too large (64 KB limit)
if (code.size() > 65536) {
int totalSize = code.size();
code.truncate(65536);
code += QStringLiteral("\n\n... truncated (%1 bytes total, showing first 64KB)"
"\nUse nodeId param to export a single struct.")
.arg(totalSize);
}
return makeTextResult(code);
}
if (action == "save_file") {
@@ -1053,6 +1162,70 @@ QJsonObject McpBridge::toolUiAction(const QJsonObject& args) {
return makeTextResult("Unknown action: " + action, true);
}
// ════════════════════════════════════════════════════════════════════
// TOOL: tree.search
// ════════════════════════════════════════════════════════════════════
QJsonObject McpBridge::toolTreeSearch(const QJsonObject& args) {
auto* tab = resolveTab(args);
if (!tab) return makeTextResult("No active tab", true);
const auto& tree = tab->doc->tree;
QString query = args.value("query").toString();
QString kindFilter = args.value("kindFilter").toString();
int limit = qBound(1, args.value("limit").toInt(20), 100);
if (query.isEmpty() && kindFilter.isEmpty())
return makeTextResult("Provide 'query' (name substring) and/or 'kindFilter' (e.g. 'Struct')", true);
// Build parent→children map for childCount
QHash<uint64_t, int> childCounts;
for (const auto& n : tree.nodes)
childCounts[n.parentId]++;
QJsonArray results;
for (const auto& n : tree.nodes) {
// Kind filter
if (!kindFilter.isEmpty()) {
if (kindToString(n.kind) != kindFilter) continue;
}
// Name substring match (case-insensitive)
if (!query.isEmpty()) {
bool nameMatch = n.name.contains(query, Qt::CaseInsensitive);
bool typeMatch = n.structTypeName.contains(query, Qt::CaseInsensitive);
if (!nameMatch && !typeMatch) continue;
}
QJsonObject nj;
nj["id"] = QString::number(n.id);
nj["name"] = n.name;
nj["kind"] = kindToString(n.kind);
nj["parentId"] = QString::number(n.parentId);
nj["offset"] = n.offset;
if (!n.structTypeName.isEmpty())
nj["structTypeName"] = n.structTypeName;
if (!n.classKeyword.isEmpty())
nj["classKeyword"] = n.classKeyword;
if (n.kind == NodeKind::Struct || n.kind == NodeKind::Array)
nj["childCount"] = childCounts.value(n.id, 0);
if (!n.enumMembers.isEmpty())
nj["enumMemberCount"] = n.enumMembers.size();
if (!n.bitfieldMembers.isEmpty())
nj["bitfieldMemberCount"] = n.bitfieldMembers.size();
results.append(nj);
if (results.size() >= limit) break;
}
QJsonObject out;
out["results"] = results;
out["count"] = results.size();
out["query"] = query;
if (!kindFilter.isEmpty()) out["kindFilter"] = kindFilter;
return makeTextResult(QString::fromUtf8(
QJsonDocument(out).toJson(QJsonDocument::Indented)));
}
// ════════════════════════════════════════════════════════════════════
// Notifications (call from MainWindow/Controller hooks)
// ════════════════════════════════════════════════════════════════════

View File

@@ -58,6 +58,7 @@ private:
QJsonObject toolHexWrite(const QJsonObject& args);
QJsonObject toolStatusSet(const QJsonObject& args);
QJsonObject toolUiAction(const QJsonObject& args);
QJsonObject toolTreeSearch(const QJsonObject& args);
// Helpers
QJsonObject makeTextResult(const QString& text, bool isError = false);

View File

@@ -170,6 +170,14 @@ OptionsDialog::OptionsDialog(const OptionsResult& current, QWidget* parent)
auto* generatorLayout = new QVBoxLayout(generatorPage);
generatorLayout->setContentsMargins(0, 0, 0, 0);
generatorLayout->setSpacing(8);
auto* cppGroup = new QGroupBox("C++ Header");
auto* cppLayout = new QVBoxLayout(cppGroup);
m_assertCheck = new QCheckBox("Emit static_assert size checks");
m_assertCheck->setChecked(current.generatorAsserts);
cppLayout->addWidget(m_assertCheck);
generatorLayout->addWidget(cppGroup);
generatorLayout->addStretch();
m_pages->addWidget(generatorPage); // index 2
@@ -208,6 +216,7 @@ OptionsResult OptionsDialog::result() const {
r.safeMode = m_safeModeCheck->isChecked();
r.autoStartMcp = m_autoMcpCheck->isChecked();
r.refreshMs = m_refreshSpin->value();
r.generatorAsserts = m_assertCheck->isChecked();
return r;
}

View File

@@ -16,8 +16,9 @@ struct OptionsResult {
bool menuBarTitleCase = true;
bool showIcon = false;
bool safeMode = false;
bool autoStartMcp = false;
bool autoStartMcp = true;
int refreshMs = 660;
bool generatorAsserts = false;
};
class OptionsDialog : public QDialog {
@@ -41,6 +42,7 @@ private:
QCheckBox* m_safeModeCheck = nullptr;
QCheckBox* m_autoMcpCheck = nullptr;
QSpinBox* m_refreshSpin = nullptr;
QCheckBox* m_assertCheck = nullptr;
// searchable keywords per leaf tree item
QHash<QTreeWidgetItem*, QStringList> m_pageKeywords;

View File

@@ -51,6 +51,8 @@
<file alias="chevron-down.svg">vsicons/chevron-down.svg</file>
<file alias="folder.svg">vsicons/folder.svg</file>
<file alias="symbol-enum.svg">vsicons/symbol-enum.svg</file>
<file alias="symbol-class.svg">vsicons/symbol-class.svg</file>
<file alias="symbol-variable.svg">vsicons/symbol-variable.svg</file>
<file alias="server-process.svg">vsicons/server-process.svg</file>
<file alias="remote.svg">vsicons/remote.svg</file>
<file alias="plug.svg">vsicons/plug.svg</file>

View File

@@ -10,8 +10,8 @@
"textDim": "#858585",
"textMuted": "#585858",
"textFaint": "#505050",
"hover": "#1e1e1e",
"selected": "#1e1e1e",
"hover": "#2a2a2a",
"selected": "#2a2d2e",
"selection": "#2b2b2b",
"syntaxKeyword": "#569cd6",
"syntaxNumber": "#b5cea8",

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@
#include <QFont>
#include <QVector>
#include <QString>
#include <QStringList>
#include <cstdint>
#include "core.h"
@@ -26,13 +27,19 @@ enum class TypePopupMode { Root, FieldType, ArrayElement, PointerTarget };
struct TypeEntry {
enum Kind { Primitive, Composite, Section };
enum Category { CatPrimitive, CatType, CatEnum };
Kind entryKind = Primitive;
Category category = CatPrimitive;
NodeKind primitiveKind = NodeKind::Hex8; // valid when entryKind==Primitive
uint64_t structId = 0; // valid when entryKind==Composite
QString displayName;
QString classKeyword; // "struct", "class", "enum" (Composite only)
bool enabled = true; // false = grayed out (visible but not selectable)
int sizeBytes = 0; // size in bytes (for display)
int alignment = 0; // natural alignment in bytes
int fieldCount = 0; // child field count (composite only)
QStringList fieldSummary; // first ~6 fields: "0x00: float x"
};
// ── Parsed type spec (shared between popup filter and inline edit) ──
@@ -58,16 +65,21 @@ public:
void setMode(TypePopupMode mode);
void applyTheme(const Theme& theme);
void setCurrentNodeSize(int bytes);
void setPointerSize(int bytes);
void setModifier(int modId, int arrayCount = 0);
void setTypes(const QVector<TypeEntry>& types, const TypeEntry* current = nullptr);
void popup(const QPoint& globalPos);
/// Show popup instantly with skeleton placeholders; call setTypes() to fill content.
void popupLoading(const QPoint& globalPos);
/// Force native window creation to avoid cold-start delay.
void warmUp();
signals:
void typeSelected(const TypeEntry& entry, const QString& fullText);
void createNewTypeRequested();
void saveRequested();
void dismissed();
protected:
@@ -78,27 +90,35 @@ private:
QLabel* m_titleLabel = nullptr;
QToolButton* m_escLabel = nullptr;
QToolButton* m_createBtn = nullptr;
QToolButton* m_saveBtn = nullptr;
QLineEdit* m_filterEdit = nullptr;
QLabel* m_previewLabel = nullptr;
QListView* m_listView = nullptr;
QStringListModel* m_model = nullptr;
QFrame* m_separator = nullptr;
// Modifier toggles
QWidget* m_modRow = nullptr;
QToolButton* m_btnPlain = nullptr;
QToolButton* m_btnPtr = nullptr;
QToolButton* m_btnDblPtr = nullptr;
QToolButton* m_btnArray = nullptr;
QLineEdit* m_arrayCountEdit = nullptr;
QButtonGroup* m_modGroup = nullptr;
// Category filter checkboxes
QWidget* m_chipRow = nullptr;
QToolButton* m_chipPrim = nullptr;
QToolButton* m_chipTypes = nullptr;
QToolButton* m_chipEnums = nullptr;
QLabel* m_statusLabel = nullptr;
QVector<TypeEntry> m_allTypes;
QVector<TypeEntry> m_filteredTypes;
QVector<QVector<int>> m_matchPositions;
TypeEntry m_currentEntry;
bool m_hasCurrent = false;
TypePopupMode m_mode = TypePopupMode::FieldType;
int m_currentNodeSize = 0;
int m_pointerSize = 8;
bool m_loading = false;
QFont m_font;
void applyFilter(const QString& text);

View File

@@ -29,46 +29,88 @@ inline void buildProjectExplorer(QStandardItemModel* model,
projectItem->setData(QVariant::fromValue(kGroupSentinel), Qt::UserRole + 1);
// Collect all top-level structs/enums across all tabs
QVector<std::pair<const Node*, void*>> types, enums;
struct Entry { const Node* node; void* subPtr; const NodeTree* tree; };
QVector<Entry> types, enums;
for (const auto& tab : tabs) {
QVector<int> topLevel = tab.tree->childrenOf(0);
for (int idx : topLevel) {
const Node& n = tab.tree->nodes[idx];
if (n.kind != NodeKind::Struct) continue;
if (n.resolvedClassKeyword() == QStringLiteral("enum"))
enums.append({&n, tab.subPtr});
enums.append({&n, tab.subPtr, tab.tree});
else
types.append({&n, tab.subPtr});
types.append({&n, tab.subPtr, tab.tree});
}
}
auto nameOf = [](const Node* n) {
return n->structTypeName.isEmpty() ? n->name : n->structTypeName;
};
auto cmpName = [&](const std::pair<const Node*, void*>& a,
const std::pair<const Node*, void*>& b) {
return nameOf(a.first).compare(nameOf(b.first), Qt::CaseInsensitive) < 0;
auto cmpName = [&](const Entry& a, const Entry& b) {
return nameOf(a.node).compare(nameOf(b.node), Qt::CaseInsensitive) < 0;
};
std::sort(types.begin(), types.end(), cmpName);
std::sort(enums.begin(), enums.end(), cmpName);
for (const auto& [n, subPtr] : types) {
QString display = QStringLiteral("%1 (%2)")
.arg(nameOf(n), n->resolvedClassKeyword());
// Helper: type display string for a member node
auto memberTypeName = [](const Node& m) -> QString {
if (m.kind == NodeKind::Struct) {
QString stn = m.structTypeName.isEmpty() ? m.resolvedClassKeyword()
: m.structTypeName;
return stn;
}
return QString::fromLatin1(kindToString(m.kind));
};
// Helper: is a Hex padding node
auto isHexPad = [](NodeKind k) {
return k == NodeKind::Hex8 || k == NodeKind::Hex16
|| k == NodeKind::Hex32 || k == NodeKind::Hex64;
};
for (const auto& e : types) {
QVector<int> members = e.tree->childrenOf(e.node->id);
// Count non-hex members for display
int visibleCount = 0;
for (int mi : members)
if (!isHexPad(e.tree->nodes[mi].kind)) ++visibleCount;
QString display = QStringLiteral("%1 (%2) \u2014 %3")
.arg(nameOf(e.node), e.node->resolvedClassKeyword(),
QString::number(visibleCount));
auto* item = new QStandardItem(
QIcon(":/vsicons/symbol-structure.svg"), display);
item->setData(QVariant::fromValue(subPtr), Qt::UserRole);
item->setData(QVariant::fromValue(n->id), Qt::UserRole + 1);
item->setData(QVariant::fromValue(e.subPtr), Qt::UserRole);
item->setData(QVariant::fromValue(e.node->id), Qt::UserRole + 1);
// Add child rows sorted by offset (skip Hex padding)
std::sort(members.begin(), members.end(), [&](int a, int b) {
return e.tree->nodes[a].offset < e.tree->nodes[b].offset;
});
for (int mi : members) {
const Node& m = e.tree->nodes[mi];
if (isHexPad(m.kind)) continue;
QString childDisplay = QStringLiteral("%1 %2")
.arg(memberTypeName(m), m.name);
auto* childItem = new QStandardItem(childDisplay);
childItem->setData(QVariant::fromValue(e.subPtr), Qt::UserRole);
childItem->setData(QVariant::fromValue(m.id), Qt::UserRole + 1);
item->appendRow(childItem);
}
projectItem->appendRow(item);
}
for (const auto& [n, subPtr] : enums) {
QString display = QStringLiteral("%1 (%2)")
.arg(nameOf(n), n->resolvedClassKeyword());
for (const auto& e : enums) {
int count = e.node->enumMembers.size();
QString display = QStringLiteral("%1 (%2) \u2014 %3")
.arg(nameOf(e.node), e.node->resolvedClassKeyword(),
QString::number(count));
auto* item = new QStandardItem(
QIcon(":/vsicons/symbol-enum.svg"), display);
item->setData(QVariant::fromValue(subPtr), Qt::UserRole);
item->setData(QVariant::fromValue(n->id), Qt::UserRole + 1);
item->setData(QVariant::fromValue(e.subPtr), Qt::UserRole);
item->setData(QVariant::fromValue(e.node->id), Qt::UserRole + 1);
projectItem->appendRow(item);
}

View File

@@ -213,6 +213,186 @@ private slots:
QVERIFY(r.ok);
QCOMPARE(r.value, 0x600ULL);
}
// -- Identifier resolution --
void identBase() {
AddressParserCallbacks cbs;
cbs.resolveIdentifier = [](const QString& name, bool* ok) -> uint64_t {
*ok = (name == "base");
return *ok ? 0x140000000ULL : 0;
};
auto r = AddressParser::evaluate("base", 8, &cbs);
QVERIFY(r.ok);
QCOMPARE(r.value, 0x140000000ULL);
}
void identFieldName() {
AddressParserCallbacks cbs;
cbs.resolveIdentifier = [](const QString& name, bool* ok) -> uint64_t {
if (name == "base") { *ok = true; return 0x140000000ULL; }
if (name == "e_lfanew") { *ok = true; return 0xE8ULL; }
*ok = false; return 0;
};
auto r = AddressParser::evaluate("base + e_lfanew", 8, &cbs);
QVERIFY(r.ok);
QCOMPARE(r.value, 0x1400000E8ULL);
}
void identUnknown() {
AddressParserCallbacks cbs;
cbs.resolveIdentifier = [](const QString&, bool* ok) -> uint64_t {
*ok = false; return 0;
};
auto r = AddressParser::evaluate("unknown_var", 8, &cbs);
QVERIFY(!r.ok);
QVERIFY(r.error.contains("unknown identifier"));
}
// -- Hex vs identifier disambiguation --
void hexDisambigDEAD() {
// "DEAD" is all hex digits → should parse as hex number 0xDEAD
auto r = AddressParser::evaluate("DEAD");
QVERIFY(r.ok);
QCOMPARE(r.value, 0xDEADULL);
}
void hexDisambigBase() {
// "base" has 's' (non-hex) → identifier
AddressParserCallbacks cbs;
cbs.resolveIdentifier = [](const QString& name, bool* ok) -> uint64_t {
*ok = (name == "base"); return *ok ? 42ULL : 0;
};
auto r = AddressParser::evaluate("base", 8, &cbs);
QVERIFY(r.ok);
QCOMPARE(r.value, 42ULL);
}
void hexDisambigABCwithUnderscore() {
// "ABC_field" has '_' → identifier, not hex
AddressParserCallbacks cbs;
cbs.resolveIdentifier = [](const QString& name, bool* ok) -> uint64_t {
*ok = (name == "ABC_field"); return *ok ? 99ULL : 0;
};
auto r = AddressParser::evaluate("ABC_field", 8, &cbs);
QVERIFY(r.ok);
QCOMPARE(r.value, 99ULL);
}
// -- Bitwise operators --
void bitwiseAnd() {
auto r = AddressParser::evaluate("0xFF & 0x0F");
QVERIFY(r.ok);
QCOMPARE(r.value, 0x0FULL);
}
void bitwiseOr() {
auto r = AddressParser::evaluate("0xA0 | 0x0B");
QVERIFY(r.ok);
QCOMPARE(r.value, 0xABULL);
}
void bitwiseXor() {
auto r = AddressParser::evaluate("0xA ^ 0x5");
QVERIFY(r.ok);
QCOMPARE(r.value, 0xFULL);
}
void shiftLeft() {
auto r = AddressParser::evaluate("1 << 4");
QVERIFY(r.ok);
QCOMPARE(r.value, 0x10ULL);
}
void shiftRight() {
auto r = AddressParser::evaluate("0xFF00 >> 8");
QVERIFY(r.ok);
QCOMPARE(r.value, 0xFFULL);
}
// -- Unary bitwise NOT --
void unaryNot() {
auto r = AddressParser::evaluate("~0");
QVERIFY(r.ok);
QCOMPARE(r.value, 0xFFFFFFFFFFFFFFFFULL);
}
void unaryNotMask() {
// ~0xFFF = 0xFFFFFFFFFFFFF000
auto r = AddressParser::evaluate("~0xFFF");
QVERIFY(r.ok);
QCOMPARE(r.value, 0xFFFFFFFFFFFFF000ULL);
}
// -- Operator precedence --
void shiftPrecedence() {
// C precedence: shift binds looser than addition
// 1 + 2 << 3 = (1 + 2) << 3 = 3 << 3 = 24 = 0x18
auto r = AddressParser::evaluate("1 + 2 << 3");
QVERIFY(r.ok);
QCOMPARE(r.value, 0x18ULL);
}
void andOrPrecedence() {
// & binds tighter than |
// 0xFF | 0x100 & 0xF00 = 0xFF | (0x100 & 0xF00) = 0xFF | 0x100 = 0x1FF
auto r = AddressParser::evaluate("0xFF | 0x100 & 0xF00");
QVERIFY(r.ok);
QCOMPARE(r.value, 0x1FFULL);
}
void xorPrecedence() {
// ^ between & and |: a | b ^ c & d = a | (b ^ (c & d))
// 0xF0 | 0x0F ^ 0xFF & 0x0F = 0xF0 | (0x0F ^ (0xFF & 0x0F))
// = 0xF0 | (0x0F ^ 0x0F) = 0xF0 | 0x00 = 0xF0
auto r = AddressParser::evaluate("0xF0 | 0x0F ^ 0xFF & 0x0F");
QVERIFY(r.ok);
QCOMPARE(r.value, 0xF0ULL);
}
// -- E_lfanew end-to-end --
void elfanewScenario() {
AddressParserCallbacks cbs;
cbs.resolveIdentifier = [](const QString& name, bool* ok) -> uint64_t {
if (name == "base") { *ok = true; return 0x140000000ULL; }
if (name == "e_lfanew") { *ok = true; return 0xE8ULL; }
*ok = false; return 0;
};
// base + e_lfanew = 0x140000000 + 0xE8 = 0x1400000E8
auto r = AddressParser::evaluate("base + e_lfanew", 8, &cbs);
QVERIFY(r.ok);
QCOMPARE(r.value, 0x1400000E8ULL);
}
void pageAlignedExpr() {
AddressParserCallbacks cbs;
cbs.resolveIdentifier = [](const QString& name, bool* ok) -> uint64_t {
if (name == "base") { *ok = true; return 0x140000000ULL; }
if (name == "e_lfanew") { *ok = true; return 0xE8ULL; }
*ok = false; return 0;
};
// (base + e_lfanew) & ~0xFFF = 0x1400000E8 & ~0xFFF = 0x140000000
auto r = AddressParser::evaluate("(base + e_lfanew) & ~0xFFF", 8, &cbs);
QVERIFY(r.ok);
QCOMPARE(r.value, 0x140000000ULL);
}
// -- Validate with new syntax --
void validateIdentifier() {
QCOMPARE(AddressParser::validate("base + e_lfanew"), QString());
}
void validateBitwiseOps() {
QCOMPARE(AddressParser::validate("0xFF & 0x0F"), QString());
QCOMPARE(AddressParser::validate("1 << 4"), QString());
QCOMPARE(AddressParser::validate("~0xFFF"), QString());
}
};
QTEST_GUILESS_MAIN(TestAddressParser)

View File

@@ -21,7 +21,7 @@ static QString buildCommandRow(const Provider& prov, uint64_t baseAddress) {
QString src = buildSourceLabel(prov);
QString addr = QStringLiteral("0x") +
QString::number(baseAddress, 16).toUpper();
return QStringLiteral(" %1 \u00B7 %2").arg(src, addr);
return QStringLiteral(" %1 %2").arg(src, addr);
}
// -- Replicate commandRowSrcSpan for testing
@@ -32,17 +32,13 @@ struct TestColumnSpan {
};
static TestColumnSpan commandRowSrcSpan(const QString& lineText) {
int idx = lineText.indexOf(QStringLiteral(" \u00B7"));
if (idx < 0) return {};
int arrow = lineText.indexOf(QChar(0x25BE));
if (arrow < 0) return {};
int start = 0;
while (start < idx && !lineText[start].isLetterOrNumber()
while (start < arrow && !lineText[start].isLetterOrNumber()
&& lineText[start] != '<' && lineText[start] != '\'') start++;
if (start >= idx) return {};
// Exclude trailing ▾ from the editable span
int end = idx;
while (end > start && lineText[end - 1] == QChar(0x25BE)) end--;
if (end <= start) return {};
return {start, end, true};
if (start >= arrow) return {};
return {start, arrow, true};
}
class TestCommandRow : public QObject {
@@ -77,13 +73,13 @@ private slots:
void row_nullProvider() {
NullProvider p;
QString row = buildCommandRow(p, 0);
QCOMPARE(row, QStringLiteral(" source\u25BE \u00B7 0x0"));
QCOMPARE(row, QStringLiteral(" source\u25BE 0x0"));
}
void row_fileProvider() {
BufferProvider p(QByteArray(4, '\0'), "test.bin");
QString row = buildCommandRow(p, 0x140000000ULL);
QCOMPARE(row, QStringLiteral(" 'test.bin'\u25BE \u00B7 0x140000000"));
QCOMPARE(row, QStringLiteral(" 'test.bin'\u25BE 0x140000000"));
}
// ---------------------------------------------------------------
@@ -110,7 +106,7 @@ private slots:
void span_processProvider_simulated() {
// Simulate a process provider without needing Windows APIs
// by building the string directly
QString row = QStringLiteral(" 'notepad.exe'\u25BE \u00B7 0x7FF600000000");
QString row = QStringLiteral(" 'notepad.exe'\u25BE 0x7FF600000000");
auto span = commandRowSrcSpan(row);
QVERIFY(span.valid);
QString extracted = row.mid(span.start, span.end - span.start);

View File

@@ -1924,7 +1924,7 @@ private slots:
void testCommandRowRootNameSpan() {
// Name span should cover the class name in the merged command row
QString text = "source\u25BE \u00B7 0x0 \u00B7 struct MyClass {";
QString text = "source\u25BE 0x0 struct MyClass {";
ColumnSpan nameSpan = commandRowRootNameSpan(text);
QVERIFY(nameSpan.valid);
@@ -2173,8 +2173,8 @@ private slots:
QVERIFY(result.text.contains("Blue"));
QVERIFY(result.text.contains("= 0"));
QVERIFY(result.text.contains("= 2"));
// Header should contain "enum"
QVERIFY(result.text.contains("enum"));
// Header should contain the type name
QVERIFY(result.text.contains("Color"));
}
void testEnumCollapsed() {
@@ -2205,8 +2205,7 @@ private slots:
// Collapsed: members should NOT appear
QVERIFY(!result.text.contains("= 0"));
QVERIFY(!result.text.contains("= 1"));
// But header should still show
QVERIFY(result.text.contains("enum"));
// But header should still show the type name
QVERIFY(result.text.contains("Flags"));
}
@@ -2351,6 +2350,283 @@ private slots:
}
}
void testBitfieldMembers() {
NodeTree tree;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.name = QStringLiteral("Test");
root.structTypeName = QStringLiteral("Test");
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node bf;
bf.kind = NodeKind::Struct;
bf.classKeyword = QStringLiteral("bitfield");
bf.name = QStringLiteral("flags");
bf.elementKind = NodeKind::Hex32;
bf.parentId = rootId;
bf.offset = 0;
bf.collapsed = false;
bf.bitfieldMembers = {
{QStringLiteral("Valid"), 0, 1},
{QStringLiteral("Dirty"), 1, 1},
{QStringLiteral("PageNum"), 2, 20}
};
tree.addNode(bf);
NullProvider prov;
auto result = compose(tree, prov);
// Should contain bitfield member names
QVERIFY(result.text.contains(QStringLiteral("Valid")));
QVERIFY(result.text.contains(QStringLiteral("Dirty")));
QVERIFY(result.text.contains(QStringLiteral("PageNum")));
// Should contain : width = value format
QVERIFY(result.text.contains(QStringLiteral(": 1 =")));
QVERIFY(result.text.contains(QStringLiteral(": 20 =")));
// Member lines should have isMemberLine set
bool foundMemberLine = false;
for (const auto& lm : result.meta) {
if (lm.isMemberLine) {
foundMemberLine = true;
break;
}
}
QVERIFY(foundMemberLine);
}
void testBitfieldJsonRoundtrip() {
Node n;
n.id = 42;
n.kind = NodeKind::Struct;
n.classKeyword = QStringLiteral("bitfield");
n.elementKind = NodeKind::Hex64;
n.bitfieldMembers = {
{QStringLiteral("ExecuteDisable"), 63, 1},
{QStringLiteral("PageFrameNumber"), 12, 36}
};
QJsonObject json = n.toJson();
Node restored = Node::fromJson(json);
QCOMPARE(restored.classKeyword, QStringLiteral("bitfield"));
QCOMPARE(restored.bitfieldMembers.size(), 2);
QCOMPARE(restored.bitfieldMembers[0].name, QStringLiteral("ExecuteDisable"));
QCOMPARE(restored.bitfieldMembers[0].bitOffset, (uint8_t)63);
QCOMPARE(restored.bitfieldMembers[0].bitWidth, (uint8_t)1);
QCOMPARE(restored.bitfieldMembers[1].name, QStringLiteral("PageFrameNumber"));
QCOMPARE(restored.bitfieldMembers[1].bitOffset, (uint8_t)12);
QCOMPARE(restored.bitfieldMembers[1].bitWidth, (uint8_t)36);
}
void testBitfieldByteSize() {
Node n;
n.kind = NodeKind::Struct;
n.classKeyword = QStringLiteral("bitfield");
n.elementKind = NodeKind::Hex8;
QCOMPARE(n.byteSize(), 1);
n.elementKind = NodeKind::Hex16;
QCOMPARE(n.byteSize(), 2);
n.elementKind = NodeKind::Hex32;
QCOMPARE(n.byteSize(), 4);
n.elementKind = NodeKind::Hex64;
QCOMPARE(n.byteSize(), 8);
}
// ── Helper node compose tests ──
void testHelperSeparatorLine() {
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;
// Regular field
Node f1;
f1.kind = NodeKind::UInt32;
f1.name = "field_a";
f1.parentId = rootId;
f1.offset = 0;
tree.addNode(f1);
// Helper node
Node helper;
helper.kind = NodeKind::Hex64;
helper.name = "my_helper";
helper.parentId = rootId;
helper.offset = 0;
helper.isHelper = true;
helper.offsetExpr = QStringLiteral("base");
tree.addNode(helper);
NullProvider prov;
ComposeResult result = compose(tree, prov);
// Separator with "helpers" text and box-drawing chars should appear
QVERIFY2(result.text.contains(QStringLiteral("helpers")),
qPrintable("Expected 'helpers' separator in:\n" + result.text));
QVERIFY2(result.text.contains(QStringLiteral("\u2500")),
qPrintable("Expected box-drawing separator char in:\n" + result.text));
}
void testHelperDoesNotAffectStructSize() {
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 f1;
f1.kind = NodeKind::UInt32;
f1.name = "a";
f1.parentId = rootId;
f1.offset = 0;
tree.addNode(f1);
// Struct span without helper
int spanBefore = tree.structSpan(rootId);
// Add helper
Node helper;
helper.kind = NodeKind::Struct;
helper.name = "helper";
helper.parentId = rootId;
helper.offset = 0;
helper.isHelper = true;
helper.offsetExpr = QStringLiteral("base + 100");
tree.addNode(helper);
int spanAfter = tree.structSpan(rootId);
QCOMPARE(spanAfter, spanBefore);
}
void testHelperIsHelperLineFlag() {
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 f1;
f1.kind = NodeKind::UInt32;
f1.name = "field_a";
f1.parentId = rootId;
f1.offset = 0;
tree.addNode(f1);
Node helper;
helper.kind = NodeKind::Hex64;
helper.name = "my_helper";
helper.parentId = rootId;
helper.offset = 0;
helper.isHelper = true;
helper.offsetExpr = QStringLiteral("base");
tree.addNode(helper);
NullProvider prov;
ComposeResult result = compose(tree, prov);
// At least one line should have isHelperLine set
bool foundHelper = false;
for (const auto& lm : result.meta) {
if (lm.isHelperLine) {
foundHelper = true;
break;
}
}
QVERIFY2(foundHelper, "Expected at least one LineMeta with isHelperLine=true");
}
void testHelperCollapsedByDefault() {
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;
// Helper struct with a child (should still appear collapsed)
Node helper;
helper.kind = NodeKind::Struct;
helper.name = "inner";
helper.parentId = rootId;
helper.offset = 0;
helper.isHelper = true;
helper.offsetExpr = QStringLiteral("base");
helper.collapsed = true;
int hi = tree.addNode(helper);
uint64_t helperId = tree.nodes[hi].id;
Node hChild;
hChild.kind = NodeKind::UInt32;
hChild.name = "x";
hChild.parentId = helperId;
hChild.offset = 0;
tree.addNode(hChild);
NullProvider prov;
ComposeResult result = compose(tree, prov);
// The helper's child should NOT have a visible line (it's collapsed)
bool foundChildLine = false;
for (const auto& lm : result.meta) {
if (lm.nodeIdx >= 0 && lm.nodeIdx < tree.nodes.size()
&& tree.nodes[lm.nodeIdx].name == QStringLiteral("x")
&& tree.nodes[lm.nodeIdx].parentId == helperId) {
foundChildLine = true;
}
}
QVERIFY2(!foundChildLine,
"Helper's children should not be visible when collapsed");
}
void testHelperExpressionShownInText() {
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 helper;
helper.kind = NodeKind::Hex64;
helper.name = "my_helper";
helper.parentId = rootId;
helper.offset = 0;
helper.isHelper = true;
helper.offsetExpr = QStringLiteral("base + 0x10");
tree.addNode(helper);
NullProvider prov;
ComposeResult result = compose(tree, prov);
// The composed text should contain the expression and arrow
QVERIFY2(result.text.contains(QStringLiteral("base + 0x10")),
qPrintable("Expected expression in text:\n" + result.text));
QVERIFY2(result.text.contains(QStringLiteral("\u2192")),
qPrintable("Expected arrow (\u2192) in text:\n" + result.text));
}
};
QTEST_MAIN(TestCompose)

View File

@@ -668,6 +668,181 @@ private slots:
QVERIFY(newIdx >= 0);
QCOMPARE(m_doc->tree.nodes[newIdx].kind, NodeKind::UInt32);
}
// ── Helper node controller tests ──
void testAddHelper() {
uint64_t rootId = m_doc->tree.nodes[0].id;
int origSize = m_doc->tree.nodes.size();
// Simulate "Add Helper" — same code as context menu action
Node helper;
helper.id = m_doc->tree.m_nextId++;
helper.kind = NodeKind::Hex64;
helper.name = QStringLiteral("helper");
helper.parentId = rootId;
helper.offset = 0;
helper.isHelper = true;
helper.offsetExpr = QStringLiteral("base");
m_doc->undoStack.push(new RcxCommand(m_ctrl, cmd::Insert{helper, {}}));
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes.size(), origSize + 1);
const auto& h = m_doc->tree.nodes.back();
QCOMPARE(h.isHelper, true);
QCOMPARE(h.offsetExpr, QStringLiteral("base"));
QCOMPARE(h.name, QStringLiteral("helper"));
QCOMPARE(h.parentId, rootId);
}
void testAddHelperUndo() {
uint64_t rootId = m_doc->tree.nodes[0].id;
int origSize = m_doc->tree.nodes.size();
Node helper;
helper.id = m_doc->tree.m_nextId++;
helper.kind = NodeKind::Hex64;
helper.name = QStringLiteral("helper");
helper.parentId = rootId;
helper.offset = 0;
helper.isHelper = true;
helper.offsetExpr = QStringLiteral("base");
m_doc->undoStack.push(new RcxCommand(m_ctrl, cmd::Insert{helper, {}}));
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes.size(), origSize + 1);
// Undo: helper should be gone
m_doc->undoStack.undo();
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes.size(), origSize);
// Redo: helper should be back
m_doc->undoStack.redo();
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes.size(), origSize + 1);
QCOMPARE(m_doc->tree.nodes.back().isHelper, true);
}
void testChangeHelperExpression() {
uint64_t rootId = m_doc->tree.nodes[0].id;
// Add a helper
Node helper;
helper.id = m_doc->tree.m_nextId++;
helper.kind = NodeKind::Hex64;
helper.name = QStringLiteral("helper");
helper.parentId = rootId;
helper.offset = 0;
helper.isHelper = true;
helper.offsetExpr = QStringLiteral("base");
m_doc->undoStack.push(new RcxCommand(m_ctrl, cmd::Insert{helper, {}}));
QApplication::processEvents();
uint64_t helperId = m_doc->tree.nodes.back().id;
// Change expression
m_doc->undoStack.push(new RcxCommand(m_ctrl,
cmd::ChangeOffsetExpr{helperId, QStringLiteral("base"), QStringLiteral("base + 0x10")}));
QApplication::processEvents();
int idx = m_doc->tree.indexOfId(helperId);
QVERIFY(idx >= 0);
QCOMPARE(m_doc->tree.nodes[idx].offsetExpr, QStringLiteral("base + 0x10"));
// Undo: old expression restored
m_doc->undoStack.undo();
QApplication::processEvents();
idx = m_doc->tree.indexOfId(helperId);
QVERIFY(idx >= 0);
QCOMPARE(m_doc->tree.nodes[idx].offsetExpr, QStringLiteral("base"));
}
void testDeleteHelperPreservesStructSize() {
uint64_t rootId = m_doc->tree.nodes[0].id;
int spanBefore = m_doc->tree.structSpan(rootId);
// Add a helper
Node helper;
helper.id = m_doc->tree.m_nextId++;
helper.kind = NodeKind::Hex64;
helper.name = QStringLiteral("helper");
helper.parentId = rootId;
helper.offset = 0;
helper.isHelper = true;
helper.offsetExpr = QStringLiteral("base");
m_doc->undoStack.push(new RcxCommand(m_ctrl, cmd::Insert{helper, {}}));
QApplication::processEvents();
// Struct size unchanged after adding helper
QCOMPARE(m_doc->tree.structSpan(rootId), spanBefore);
// Remove helper
uint64_t helperId = m_doc->tree.nodes.back().id;
m_doc->undoStack.push(new RcxCommand(m_ctrl, cmd::Remove{helperId}));
QApplication::processEvents();
// Struct size still unchanged
QCOMPARE(m_doc->tree.structSpan(rootId), spanBefore);
}
void testHelperRenamePreservesExpression() {
uint64_t rootId = m_doc->tree.nodes[0].id;
// Add a helper
Node helper;
helper.id = m_doc->tree.m_nextId++;
helper.kind = NodeKind::Hex64;
helper.name = QStringLiteral("my_helper");
helper.parentId = rootId;
helper.offset = 0;
helper.isHelper = true;
helper.offsetExpr = QStringLiteral("base + field_u32");
m_doc->undoStack.push(new RcxCommand(m_ctrl, cmd::Insert{helper, {}}));
QApplication::processEvents();
uint64_t helperId = m_doc->tree.nodes.back().id;
// Rename the helper
m_doc->undoStack.push(new RcxCommand(m_ctrl,
cmd::Rename{helperId, QStringLiteral("my_helper"), QStringLiteral("renamed_helper")}));
QApplication::processEvents();
int idx = m_doc->tree.indexOfId(helperId);
QVERIFY(idx >= 0);
QCOMPARE(m_doc->tree.nodes[idx].name, QStringLiteral("renamed_helper"));
// Expression should be preserved
QCOMPARE(m_doc->tree.nodes[idx].offsetExpr, QStringLiteral("base + field_u32"));
QCOMPARE(m_doc->tree.nodes[idx].isHelper, true);
}
void testHelperTypeChangePreservesFlags() {
uint64_t rootId = m_doc->tree.nodes[0].id;
Node helper;
helper.id = m_doc->tree.m_nextId++;
helper.kind = NodeKind::Hex64;
helper.name = QStringLiteral("helper");
helper.parentId = rootId;
helper.offset = 0;
helper.isHelper = true;
helper.offsetExpr = QStringLiteral("base");
m_doc->undoStack.push(new RcxCommand(m_ctrl, cmd::Insert{helper, {}}));
QApplication::processEvents();
uint64_t helperId = m_doc->tree.nodes.back().id;
// Change kind to UInt32
m_doc->undoStack.push(new RcxCommand(m_ctrl,
cmd::ChangeKind{helperId, NodeKind::Hex64, NodeKind::UInt32}));
QApplication::processEvents();
int idx = m_doc->tree.indexOfId(helperId);
QVERIFY(idx >= 0);
QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::UInt32);
// Helper flags must survive type change
QCOMPARE(m_doc->tree.nodes[idx].isHelper, true);
QCOMPARE(m_doc->tree.nodes[idx].offsetExpr, QStringLiteral("base"));
}
};
QTEST_MAIN(TestController)

View File

@@ -671,6 +671,114 @@ private slots:
QCOMPARE(h.count, 4); // 4 transitions
QCOMPARE(h.heatLevel(), 2); // warm (count=4 → 3-4 range)
}
// ── Helper node serialization ──
void testHelperJsonRoundTrip() {
rcx::NodeTree tree;
tree.baseAddress = 0x14000000;
rcx::Node root;
root.kind = rcx::NodeKind::Struct;
root.name = "DOS_HEADER";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
rcx::Node field;
field.kind = rcx::NodeKind::UInt32;
field.name = "e_lfanew";
field.parentId = rootId;
field.offset = 0x3C;
tree.addNode(field);
rcx::Node helper;
helper.kind = rcx::NodeKind::Struct;
helper.name = "nt_hdr";
helper.parentId = rootId;
helper.offset = 0;
helper.isHelper = true;
helper.offsetExpr = QStringLiteral("base + e_lfanew");
tree.addNode(helper);
QJsonObject json = tree.toJson();
rcx::NodeTree tree2 = rcx::NodeTree::fromJson(json);
QCOMPARE(tree2.nodes.size(), 3);
const auto& h = tree2.nodes[2];
QCOMPARE(h.isHelper, true);
QCOMPARE(h.offsetExpr, QStringLiteral("base + e_lfanew"));
QCOMPARE(h.name, QStringLiteral("nt_hdr"));
}
void testHelperJsonBackwardCompat() {
// Old JSON without isHelper/offsetExpr should load with defaults
rcx::NodeTree tree;
rcx::Node root;
root.kind = rcx::NodeKind::Struct;
root.name = "Test";
root.parentId = 0;
int ri = tree.addNode(root);
QJsonObject json = tree.toJson();
rcx::NodeTree tree2 = rcx::NodeTree::fromJson(json);
QCOMPARE(tree2.nodes[0].isHelper, false);
QCOMPARE(tree2.nodes[0].offsetExpr, QString());
}
void testStructSpanExcludesHelpers() {
using namespace rcx;
NodeTree tree;
Node root;
root.kind = NodeKind::Struct;
root.name = "Root";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
// Regular field: offset 0, size 4
Node f1;
f1.kind = NodeKind::UInt32;
f1.name = "a";
f1.parentId = rootId;
f1.offset = 0;
tree.addNode(f1);
// Regular field: offset 4, size 8
Node f2;
f2.kind = NodeKind::UInt64;
f2.name = "b";
f2.parentId = rootId;
f2.offset = 4;
tree.addNode(f2);
// Helper: should NOT affect span
Node helper;
helper.kind = NodeKind::Struct;
helper.name = "helper";
helper.parentId = rootId;
helper.offset = 0;
helper.isHelper = true;
helper.offsetExpr = QStringLiteral("base");
tree.addNode(helper);
// Span should be max(0+4, 4+8) = 12, same as without helper
QCOMPARE(tree.structSpan(rootId), 12);
}
void testHelperExprSpanFor() {
using namespace rcx;
// Simulate a helper header line: " ▸ struct NT_HEADERS nt_hdr = base + e_lfanew → 0x1400000E8"
LineMeta lm;
lm.isHelperLine = true;
QString lineText = QStringLiteral(" \u25B8 struct NT_HEADERS nt_hdr = base + e_lfanew \u2192 0x1400000E8");
ColumnSpan span = helperExprSpanFor(lm, lineText);
QVERIFY(span.valid);
QString expr = lineText.mid(span.start, span.end - span.start);
QCOMPARE(expr.trimmed(), QStringLiteral("base + e_lfanew"));
}
};
QTEST_MAIN(TestCore)

View File

@@ -4,62 +4,92 @@
#include <initguid.h>
#include <dbgeng.h>
int main()
int main(int argc, char* argv[])
{
const char* connStr = "tcp:Port=5057,Server=localhost";
const char* connStr = "tcp:Port=5055,Server=localhost";
if (argc > 1) connStr = argv[1];
// Initialize COM — required for DbgEng remote transport (TCP/named-pipe)
HRESULT hrCom = CoInitializeEx(NULL, COINIT_MULTITHREADED);
printf("CoInitializeEx: 0x%08lX\n", hrCom);
fflush(stdout);
printf("Attempting DebugConnect(\"%s\")...\n", connStr);
fflush(stdout);
IDebugClient* client = nullptr;
HRESULT hr = DebugConnect(connStr, IID_IDebugClient, (void**)&client);
printf("DebugConnect returned: 0x%08lX\n", hr);
fflush(stdout);
if (SUCCEEDED(hr) && client) {
printf("Connected! Getting IDebugDataSpaces...\n");
printf("Connected! Getting interfaces...\n");
fflush(stdout);
IDebugDataSpaces* ds = nullptr;
hr = client->QueryInterface(IID_IDebugDataSpaces, (void**)&ds);
printf("QueryInterface(IDebugDataSpaces) = 0x%08lX\n", hr);
fflush(stdout);
if (ds) {
IDebugControl* ctrl = nullptr;
client->QueryInterface(IID_IDebugControl, (void**)&ctrl);
if (ctrl) {
printf("Waiting for event...\n");
printf("Calling WaitForEvent(5000ms)...\n");
fflush(stdout);
hr = ctrl->WaitForEvent(0, 5000);
printf("WaitForEvent = 0x%08lX\n", hr);
ctrl->Release();
fflush(stdout);
ULONG debugClass = 0, debugQual = 0;
hr = ctrl->GetDebuggeeType(&debugClass, &debugQual);
printf("GetDebuggeeType = 0x%08lX, class=%lu, qualifier=%lu\n",
hr, debugClass, debugQual);
printf(" -> %s\n", debugQual >= 1024 ? "DUMP" : "LIVE");
fflush(stdout);
}
// Try to read 2 bytes
IDebugSymbols* sym = nullptr;
client->QueryInterface(IID_IDebugSymbols, (void**)&sym);
if (sym) {
ULONG numMods = 0, numUnloaded = 0;
hr = sym->GetNumberModules(&numMods, &numUnloaded);
printf("GetNumberModules = 0x%08lX, numMods=%lu\n", hr, numMods);
printf("GetNumberModules = 0x%08lX, loaded=%lu, unloaded=%lu\n",
hr, numMods, numUnloaded);
fflush(stdout);
if (numMods > 0) {
ULONG64 base = 0;
hr = sym->GetModuleByIndex(0, &base);
printf("Module[0] base = 0x%llX (hr=0x%08lX)\n", base, hr);
fflush(stdout);
if (SUCCEEDED(hr) && base) {
if (SUCCEEDED(hr) && base && ds) {
uint8_t buf[4] = {};
ULONG got = 0;
hr = ds->ReadVirtual(base, buf, 4, &got);
printf("ReadVirtual(%llX, 4) = 0x%08lX, got=%lu, data=[%02X %02X %02X %02X]\n",
printf("ReadVirtual(0x%llX, 4) = 0x%08lX, got=%lu, data=[%02X %02X %02X %02X]\n",
base, hr, got, buf[0], buf[1], buf[2], buf[3]);
fflush(stdout);
}
}
sym->Release();
}
ds->Release();
}
if (ds) ds->Release();
if (ctrl) ctrl->Release();
printf("Disconnecting...\n");
fflush(stdout);
client->EndSession(DEBUG_END_DISCONNECT);
client->Release();
printf("Done.\n");
} else {
printf("DebugConnect FAILED. hr=0x%08lX\n", hr);
}
fflush(stdout);
if (SUCCEEDED(hrCom)) CoUninitialize();
return 0;
}

View File

@@ -481,7 +481,7 @@ private slots:
// Set CommandRow text with an ADDR value (simulates controller.updateCommandRow)
m_editor->setCommandRowText(
QStringLiteral("source\u25BE \u00B7 0xD87B5E5000"));
QStringLiteral("source\u25BE 0xD87B5E5000"));
// BaseAddress should be ALLOWED on CommandRow (ADDR field)
bool ok = m_editor->beginInlineEdit(EditTarget::BaseAddress, 0);
@@ -816,7 +816,7 @@ private slots:
// Set CommandRow text with ADDR value (simulates controller)
m_editor->setCommandRowText(
QStringLiteral("source\u25BE \u00B7 0xD87B5E5000"));
QStringLiteral("source\u25BE 0xD87B5E5000"));
// Line 0 is CommandRow
const LineMeta* lm = m_editor->metaForLine(0);
@@ -901,7 +901,7 @@ private slots:
// Set CommandRow text with ADDR value (simulates controller)
m_editor->setCommandRowText(
QStringLiteral("source\u25BE \u00B7 0xD87B5E5000"));
QStringLiteral("source\u25BE 0xD87B5E5000"));
// Begin base address edit on line 0 (CommandRow ADDR field)
bool ok = m_editor->beginInlineEdit(EditTarget::BaseAddress, 0);
@@ -1038,7 +1038,7 @@ private slots:
// Set CommandRow text with root class (simulates controller.updateCommandRow)
m_editor->setCommandRowText(
QStringLiteral("source\u25BE \u00B7 0xD87B5E5000 \u00B7 struct _PEB64 {"));
QStringLiteral("source\u25BE 0xD87B5E5000 struct _PEB64 {"));
// RootClassName should be allowed on CommandRow (line 0)
bool ok = m_editor->beginInlineEdit(EditTarget::RootClassName, 0);
@@ -1053,7 +1053,7 @@ private slots:
// Set CommandRow with root class
m_editor->setCommandRowText(
QStringLiteral("source\u25BE \u00B7 0xD87B5E5000 \u00B7 struct _PEB64 {"));
QStringLiteral("source\u25BE 0xD87B5E5000 struct _PEB64 {"));
// Line 0 is CommandRow
const LineMeta* lm = m_editor->metaForLine(0);
@@ -1099,7 +1099,7 @@ private slots:
// Set command row text (simulates controller.updateCommandRow)
QString cmdText = QStringLiteral(
"source\u25BE \u00B7 0xD87B5E5000 \u00B7 struct _PEB64 {");
"source\u25BE 0xD87B5E5000 struct _PEB64 {");
m_editor->setCommandRowText(cmdText);
QApplication::processEvents();
@@ -1177,7 +1177,7 @@ private slots:
m_editor->applyDocument(m_result);
QString cmdText = QStringLiteral(
"source\u25BE \u00B7 0xD87B5E5000 \u00B7 struct _PEB64 {");
"source\u25BE 0xD87B5E5000 struct _PEB64 {");
m_editor->setCommandRowText(cmdText);
QApplication::processEvents();
@@ -2514,6 +2514,48 @@ private slots:
<< QString("gapRight=%1 gapBottom=%2 (font-independent)")
.arg(gapR1).arg(gapB1);
}
// ── Test: hovering struct type name shows PointingHand cursor ──
// Regression: headerTypeNameSpan returned invalid for named structs
// because it assumed "struct TYPENAME" format, but named structs are
// formatted as just "TYPENAME" (e.g. "_STRING64 CSDVersion").
void testStructTypeClickable() {
m_editor->applyDocument(m_result);
QApplication::processEvents();
// Find a named struct header (e.g. _STRING64 CSDVersion from makeTestTree)
int headerLine = -1;
for (int i = 0; i < m_result.meta.size(); i++) {
const auto& lm = m_result.meta[i];
if (lm.lineKind == LineKind::Header && lm.foldHead
&& lm.nodeKind == NodeKind::Struct && !lm.isArrayHeader) {
headerLine = i;
break;
}
}
QVERIFY2(headerLine >= 0, "Should have a struct header");
const LineMeta* lm = m_editor->metaForLine(headerLine);
QVERIFY(lm);
// Scroll to ensure line is visible
m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_ENSUREVISIBLE, (unsigned long)headerLine);
m_editor->scintilla()->SendScintilla(
QsciScintillaBase::SCI_GOTOLINE, (unsigned long)headerLine);
QApplication::processEvents();
// The type column starts at kFoldCol + depth*3
int typeStart = 3 + lm->depth * 3; // kFoldCol = 3
// Hover over type column — should show PointingHandCursor
// (Before fix: showed ArrowCursor because headerTypeNameSpan returned invalid)
QPoint typePos = colToViewport(m_editor->scintilla(), headerLine, typeStart + 1);
QVERIFY2(typePos.y() > 0, "Header line should be visible");
sendMouseMove(m_editor->scintilla()->viewport(), typePos);
QApplication::processEvents();
QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor);
}
};
QTEST_MAIN(TestEditor)

View File

@@ -29,12 +29,12 @@ private slots:
}
void testFmtPointer64_null() {
QCOMPARE(fmt::fmtPointer64(0), QString("-> NULL"));
QCOMPARE(fmt::fmtPointer64(0), QString("0x0"));
}
void testFmtPointer64_nonNull() {
QString s = fmt::fmtPointer64(0x400000);
QVERIFY(s.startsWith("-> 0x"));
QVERIFY(s.startsWith("0x"));
QVERIFY(s.contains("400000"));
}

View File

@@ -46,27 +46,37 @@ private:
private slots:
// ── Basic struct generation ──
// ── Basic struct generation (Vergilius-style) ──
void testSimpleStruct() {
auto tree = makeSimpleStruct();
uint64_t rootId = tree.nodes[0].id;
QString result = rcx::renderCpp(tree, rootId);
QString result = rcx::renderCpp(tree, rootId, nullptr, true);
// Header
QVERIFY(result.contains("#pragma once"));
QVERIFY(!result.contains("#include <cstdint>"));
QVERIFY(!result.contains("#pragma pack"));
// Struct definition
QVERIFY(result.contains("struct Player {"));
// Size comment on closing brace
QVERIFY(result.contains("// sizeof 0x10"));
// Struct definition (brace on new line)
QVERIFY(result.contains("struct Player\n{"));
QVERIFY(result.contains("int32_t health;"));
QVERIFY(result.contains("float speed;"));
QVERIFY(result.contains("uint64_t id;"));
QVERIFY(result.contains("};"));
// static_assert - struct is 16 bytes (0+4 + 4+4 + 8+8 = 16)
// Offset comments
QVERIFY(result.contains("// 0x0"));
QVERIFY(result.contains("// 0x4"));
QVERIFY(result.contains("// 0x8"));
// static_assert
QVERIFY(result.contains("static_assert(sizeof(Player) == 0x10"));
// Without emitAsserts, static_assert should not appear
QString noAsserts = rcx::renderCpp(tree, rootId);
QVERIFY(!noAsserts.contains("static_assert"));
}
// ── Padding gap detection ──
@@ -134,7 +144,7 @@ private slots:
f2.offset = 16;
tree.addNode(f2);
QString result = rcx::renderCpp(tree, rootId);
QString result = rcx::renderCpp(tree, rootId, nullptr, true);
// Gap between offset 1 and 16 = 15 bytes padding
QVERIFY(result.contains("[0xF]"));
@@ -175,7 +185,47 @@ private slots:
QVERIFY(result.contains("WARNING: overlap"));
}
// ── Nested struct ──
// ── Union members should NOT produce overlap warnings ──
void testUnionNoOverlapWarning() {
rcx::NodeTree tree;
rcx::Node root;
root.kind = rcx::NodeKind::Struct;
root.name = "TestUnion";
root.structTypeName = "TestUnion";
root.classKeyword = "union";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
// Two union members at offset 0
rcx::Node f1;
f1.kind = rcx::NodeKind::UInt64;
f1.name = "wide";
f1.parentId = rootId;
f1.offset = 0;
tree.addNode(f1);
rcx::Node f2;
f2.kind = rcx::NodeKind::UInt32;
f2.name = "narrow";
f2.parentId = rootId;
f2.offset = 0;
tree.addNode(f2);
QString result = rcx::renderCpp(tree, rootId);
// Vergilius-style: union keyword, brace on new line
QVERIFY(result.contains("union TestUnion\n{"));
QVERIFY(result.contains("uint64_t wide;"));
QVERIFY(result.contains("uint32_t narrow;"));
// Union members overlap by design — no warning
QVERIFY(!result.contains("WARNING"));
// No padding in unions
QVERIFY(!result.contains("_pad"));
}
// ── Nested struct: named sub-type referenced by name ──
void testNestedStruct() {
rcx::NodeTree tree;
@@ -222,23 +272,14 @@ private slots:
f2.offset = 8;
tree.addNode(f2);
QString result = rcx::renderCpp(tree, outerId);
QString result = rcx::renderCpp(tree, outerId, nullptr, true);
// Inner struct should be defined before outer
int innerPos = result.indexOf("struct Vec2f {");
int outerPos = result.indexOf("struct Outer {");
QVERIFY(innerPos >= 0);
QVERIFY(outerPos >= 0);
QVERIFY(innerPos < outerPos);
// Inner struct fields
QVERIFY(result.contains("float x;"));
QVERIFY(result.contains("float y;"));
QVERIFY(result.contains("static_assert(sizeof(Vec2f) == 0x8"));
// Outer struct uses inner type
QVERIFY(result.contains("Vec2f pos;"));
// Vergilius-style: named sub-types referenced by name with struct prefix
// No separate top-level definition for Vec2f in renderCpp
QVERIFY(result.contains("struct Outer\n{"));
QVERIFY(result.contains("struct Vec2f pos;"));
QVERIFY(result.contains("int32_t score;"));
QVERIFY(result.contains("static_assert(sizeof(Outer) == 0xC"));
}
// ── Primitive array ──
@@ -325,15 +366,12 @@ private slots:
QString result = rcx::renderCpp(tree, mainId);
// ptr64 with target → real C++ pointer
QVERIFY(result.contains("TargetData* pTarget;"));
// Vergilius-style: struct prefix on pointer targets
QVERIFY(result.contains("struct TargetData* pTarget;"));
// ptr64 without target → void*
QVERIFY(result.contains("void* pVoid;"));
// ptr32 with target → uint32_t with comment
QVERIFY(result.contains("uint32_t pTarget32;"));
QVERIFY(result.contains("-> TargetData*"));
// Forward declaration for TargetData
QVERIFY(result.contains("struct TargetData;"));
// ptr32 with target → struct X* (Vergilius-style, no forward decl needed)
QVERIFY(result.contains("struct TargetData* pTarget32;"));
}
// ── Vector and matrix types ──
@@ -457,10 +495,11 @@ private slots:
bf.offset = 0;
tree.addNode(bf);
QString result = rcx::renderCppAll(tree);
QString result = rcx::renderCppAll(tree, nullptr, true);
QVERIFY(result.contains("struct StructA {"));
QVERIFY(result.contains("struct StructB {"));
// Vergilius-style: brace on new line
QVERIFY(result.contains("struct StructA\n{"));
QVERIFY(result.contains("struct StructB\n{"));
QVERIFY(result.contains("uint32_t valueA;"));
QVERIFY(result.contains("uint64_t valueB;"));
QVERIFY(result.contains("static_assert(sizeof(StructA) == 0x4"));
@@ -508,9 +547,9 @@ private slots:
root.parentId = 0;
tree.addNode(root);
QString result = rcx::renderCpp(tree, tree.nodes[0].id);
QString result = rcx::renderCpp(tree, tree.nodes[0].id, nullptr, true);
QVERIFY(result.contains("struct Empty {"));
QVERIFY(result.contains("struct Empty\n{"));
QVERIFY(result.contains("};"));
QVERIFY(result.contains("static_assert(sizeof(Empty) == 0x0"));
}
@@ -537,7 +576,7 @@ private slots:
QString result = rcx::renderCpp(tree, rootId);
// Spaces and dashes should be replaced with underscores
QVERIFY(result.contains("struct my_struct_name {"));
QVERIFY(result.contains("struct my_struct_name\n{"));
QVERIFY(result.contains("uint32_t field_with_spaces;"));
}
@@ -546,7 +585,7 @@ private slots:
void testExportToFile() {
auto tree = makeSimpleStruct();
uint64_t rootId = tree.nodes[0].id;
QString text = rcx::renderCpp(tree, rootId);
QString text = rcx::renderCpp(tree, rootId, nullptr, true);
QTemporaryFile tmpFile;
tmpFile.setAutoRemove(true);
@@ -561,7 +600,7 @@ private slots:
QString readStr = QString::fromUtf8(readBack);
QVERIFY(readStr.contains("#pragma once"));
QVERIFY(readStr.contains("struct Player {"));
QVERIFY(readStr.contains("struct Player\n{"));
QVERIFY(readStr.contains("static_assert"));
}
@@ -582,7 +621,7 @@ private slots:
QVERIFY(!result.contains("struct "));
}
// ── Deeply nested structs ──
// ── Deeply nested structs: referenced by name ──
void testDeeplyNested() {
rcx::NodeTree tree;
@@ -623,20 +662,216 @@ private slots:
QString result = rcx::renderCpp(tree, aId);
// TypeC defined first, then TypeB, then TypeA
int cPos = result.indexOf("struct TypeC {");
int bPos = result.indexOf("struct TypeB {");
int aPos = result.indexOf("struct TypeA {");
QVERIFY(cPos >= 0);
QVERIFY(bPos >= 0);
QVERIFY(aPos >= 0);
QVERIFY(cPos < bPos);
QVERIFY(bPos < aPos);
// Vergilius-style: named sub-types referenced by name with struct prefix
// Only the root type gets a top-level definition
QVERIFY(result.contains("struct TypeA\n{"));
QVERIFY(result.contains("struct TypeB b;"));
}
// TypeA contains TypeB, TypeB contains TypeC
QVERIFY(result.contains("TypeB b;"));
QVERIFY(result.contains("TypeC c;"));
QVERIFY(result.contains("uint8_t val;"));
// ── Inline anonymous struct/union ──
void testInlineAnonymousStruct() {
rcx::NodeTree tree;
rcx::Node root;
root.kind = rcx::NodeKind::Struct;
root.name = "_MMPFN";
root.structTypeName = "_MMPFN";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
// Anonymous union at offset 0 (no structTypeName)
rcx::Node anonUnion;
anonUnion.kind = rcx::NodeKind::Struct;
anonUnion.name = "";
anonUnion.structTypeName = "";
anonUnion.classKeyword = "union";
anonUnion.parentId = rootId;
anonUnion.offset = 0;
int ui = tree.addNode(anonUnion);
uint64_t unionId = tree.nodes[ui].id;
// Union member 1: named struct reference
rcx::Node listEntry;
listEntry.kind = rcx::NodeKind::Struct;
listEntry.name = "ListEntry";
listEntry.structTypeName = "_LIST_ENTRY";
listEntry.parentId = unionId;
listEntry.offset = 0;
tree.addNode(listEntry);
// Union member 2: a simple field
rcx::Node flags;
flags.kind = rcx::NodeKind::UInt64;
flags.name = "Flags";
flags.parentId = unionId;
flags.offset = 0;
tree.addNode(flags);
// Field after the anonymous union
rcx::Node pfn;
pfn.kind = rcx::NodeKind::UInt64;
pfn.name = "PfnCount";
pfn.parentId = rootId;
pfn.offset = 0x10;
tree.addNode(pfn);
QString result = rcx::renderCpp(tree, rootId);
// Anonymous union should be inlined, not a top-level anon_XXXX
QVERIFY(!result.contains("anon_"));
QVERIFY(result.contains("union\n {"));
QVERIFY(result.contains("struct _LIST_ENTRY ListEntry;"));
QVERIFY(result.contains("uint64_t Flags;"));
QVERIFY(result.contains("};"));
QVERIFY(result.contains("uint64_t PfnCount;"));
}
// ── Opaque types: no stub definition ──
void testOpaqueTypeNoStub() {
rcx::NodeTree tree;
rcx::Node root;
root.kind = rcx::NodeKind::Struct;
root.name = "Container";
root.structTypeName = "Container";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
// Named struct child with no children of its own (opaque reference)
rcx::Node opaque;
opaque.kind = rcx::NodeKind::Struct;
opaque.name = "entry";
opaque.structTypeName = "_LIST_ENTRY";
opaque.parentId = rootId;
opaque.offset = 0;
tree.addNode(opaque);
QString result = rcx::renderCpp(tree, rootId);
// Should reference by name with struct prefix, no stub body
QVERIFY(result.contains("struct _LIST_ENTRY entry;"));
// Should NOT have a separate _LIST_ENTRY definition with padding
QVERIFY(!result.contains("struct _LIST_ENTRY\n{"));
QVERIFY(!result.contains("uint8_t _pad"));
}
// ── Helper node generator tests ──
void testHelperNotInStructBody() {
rcx::NodeTree tree;
rcx::Node root;
root.kind = rcx::NodeKind::Struct;
root.name = "MyStruct";
root.structTypeName = "MyStruct";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
rcx::Node f1;
f1.kind = rcx::NodeKind::UInt32;
f1.name = "e_lfanew";
f1.parentId = rootId;
f1.offset = 0;
tree.addNode(f1);
rcx::Node helper;
helper.kind = rcx::NodeKind::Struct;
helper.name = "nt_hdr";
helper.structTypeName = "IMAGE_NT_HEADERS";
helper.parentId = rootId;
helper.offset = 0;
helper.isHelper = true;
helper.offsetExpr = QStringLiteral("base + e_lfanew");
tree.addNode(helper);
QString result = rcx::renderCpp(tree, rootId);
// Helper should NOT appear as a member in the struct body
QVERIFY2(!result.contains("IMAGE_NT_HEADERS nt_hdr;"),
qPrintable("Helper should not be in struct body:\n" + result));
// Helper SHOULD appear as a comment
QVERIFY2(result.contains("// helper:"),
qPrintable("Helper comment missing:\n" + result));
QVERIFY2(result.contains("nt_hdr"),
qPrintable("Helper name missing from comment:\n" + result));
QVERIFY2(result.contains("base + e_lfanew"),
qPrintable("Helper expression missing from comment:\n" + result));
}
void testHelperCommentFormat() {
rcx::NodeTree tree;
rcx::Node root;
root.kind = rcx::NodeKind::Struct;
root.name = "Test";
root.structTypeName = "Test";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
rcx::Node f1;
f1.kind = rcx::NodeKind::UInt64;
f1.name = "base_field";
f1.parentId = rootId;
f1.offset = 0;
tree.addNode(f1);
rcx::Node helper;
helper.kind = rcx::NodeKind::Hex64;
helper.name = "ptr";
helper.parentId = rootId;
helper.offset = 0;
helper.isHelper = true;
helper.offsetExpr = QStringLiteral("base + 0xFF");
tree.addNode(helper);
QString result = rcx::renderCpp(tree, rootId);
// The regular field should be in the struct body
QVERIFY(result.contains("uint64_t base_field;"));
// Helper emitted as comment after struct body
QVERIFY(result.contains("// helper:"));
QVERIFY(result.contains("@ base + 0xFF"));
}
void testStructSizeUnchangedByHelper() {
rcx::NodeTree tree;
rcx::Node root;
root.kind = rcx::NodeKind::Struct;
root.name = "Small";
root.structTypeName = "Small";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
rcx::Node f1;
f1.kind = rcx::NodeKind::UInt32;
f1.name = "x";
f1.parentId = rootId;
f1.offset = 0;
tree.addNode(f1);
rcx::Node helper;
helper.kind = rcx::NodeKind::Struct;
helper.name = "big_helper";
helper.parentId = rootId;
helper.offset = 0;
helper.isHelper = true;
helper.offsetExpr = QStringLiteral("base");
tree.addNode(helper);
QString result = rcx::renderCpp(tree, rootId, nullptr, true);
// static_assert should use only the regular field size (4 bytes)
QVERIFY2(result.contains("sizeof(Small) == 0x4"),
qPrintable("Expected sizeof(Small) == 0x4:\n" + result));
}
};

View File

@@ -780,7 +780,7 @@ void TestImportSource::structPrefixOnType() {
}
void TestImportSource::bitfieldSkipped() {
// Bitfields emit a hex placeholder covering the group
// Bitfields emit a bitfield container with named members
NodeTree tree = importFromSource(QStringLiteral(
"struct BF {\n"
" uint32_t normal;\n"
@@ -790,12 +790,20 @@ void TestImportSource::bitfieldSkipped() {
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
// normal + Hex16 (16 bits → 2 bytes) + after
// normal + bitfield container (16 bits → 2 bytes) + after
QCOMPARE(kids.size(), 3);
QCOMPARE(tree.nodes[kids[0]].name, QStringLiteral("normal"));
QCOMPARE(tree.nodes[kids[0]].offset, 0);
QCOMPARE(tree.nodes[kids[1]].kind, NodeKind::Hex16);
QCOMPARE(tree.nodes[kids[1]].kind, NodeKind::Struct);
QCOMPARE(tree.nodes[kids[1]].resolvedClassKeyword(), QStringLiteral("bitfield"));
QCOMPARE(tree.nodes[kids[1]].offset, 4);
QCOMPARE(tree.nodes[kids[1]].bitfieldMembers.size(), 2);
QCOMPARE(tree.nodes[kids[1]].bitfieldMembers[0].name, QStringLiteral("bitA"));
QCOMPARE(tree.nodes[kids[1]].bitfieldMembers[0].bitWidth, (uint8_t)4);
QCOMPARE(tree.nodes[kids[1]].bitfieldMembers[0].bitOffset, (uint8_t)0);
QCOMPARE(tree.nodes[kids[1]].bitfieldMembers[1].name, QStringLiteral("bitB"));
QCOMPARE(tree.nodes[kids[1]].bitfieldMembers[1].bitWidth, (uint8_t)12);
QCOMPARE(tree.nodes[kids[1]].bitfieldMembers[1].bitOffset, (uint8_t)4);
QCOMPARE(tree.nodes[kids[2]].name, QStringLiteral("after"));
QCOMPARE(tree.nodes[kids[2]].offset, 6);
}
@@ -812,13 +820,22 @@ void TestImportSource::bitfieldWithOffsetsEmitsHex() {
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
// normal + hex64 (bitfield group: 64 bits) + after = 3
// normal + bitfield container (64 bits) + after = 3
QCOMPARE(kids.size(), 3);
QCOMPARE(tree.nodes[kids[0]].name, QStringLiteral("normal"));
QCOMPARE(tree.nodes[kids[0]].offset, 0);
// Bitfield group emitted as Hex64 at offset 4
QCOMPARE(tree.nodes[kids[1]].kind, NodeKind::Hex64);
// Bitfield container at offset 4
QCOMPARE(tree.nodes[kids[1]].kind, NodeKind::Struct);
QCOMPARE(tree.nodes[kids[1]].resolvedClassKeyword(), QStringLiteral("bitfield"));
QCOMPARE(tree.nodes[kids[1]].offset, 4);
QCOMPARE(tree.nodes[kids[1]].elementKind, NodeKind::Hex64);
QCOMPARE(tree.nodes[kids[1]].bitfieldMembers.size(), 4);
QCOMPARE(tree.nodes[kids[1]].bitfieldMembers[0].name, QStringLiteral("Valid"));
QCOMPARE(tree.nodes[kids[1]].bitfieldMembers[0].bitWidth, (uint8_t)1);
QCOMPARE(tree.nodes[kids[1]].bitfieldMembers[1].name, QStringLiteral("Dirty"));
QCOMPARE(tree.nodes[kids[1]].bitfieldMembers[2].name, QStringLiteral("PageFrameNumber"));
QCOMPARE(tree.nodes[kids[1]].bitfieldMembers[2].bitWidth, (uint8_t)36);
QCOMPARE(tree.nodes[kids[1]].bitfieldMembers[3].name, QStringLiteral("Reserved"));
// after at 0xC
QCOMPARE(tree.nodes[kids[2]].name, QStringLiteral("after"));
QCOMPARE(tree.nodes[kids[2]].offset, 0xC);

View File

@@ -63,7 +63,7 @@ private slots:
// ── Chevron span detection ──
void testChevronSpanDetected() {
QString text = QStringLiteral("[\u25B8] source\u25BE \u00B7 0x1000 \u00B7 struct Alpha {");
QString text = QStringLiteral("[\u25B8] source\u25BE 0x1000 struct Alpha {");
ColumnSpan span = commandRowChevronSpan(text);
QVERIFY(span.valid);
QCOMPARE(span.start, 0);
@@ -80,7 +80,7 @@ private slots:
// ── Existing spans unbroken by chevron prefix ──
void testSpansWithPrefix() {
QString text = QStringLiteral("[\u25B8] source\u25BE \u00B7 0x1000 \u00B7 struct Alpha {");
QString text = QStringLiteral("[\u25B8] source\u25BE 0x1000 struct Alpha {");
ColumnSpan src = commandRowSrcSpan(text);
QVERIFY(src.valid);
@@ -861,10 +861,11 @@ private slots:
void testPopupWidthScalesWithFont() {
TypeSelectorPopup popup;
// Use a very long name so even font-9 exceeds the minimum popup width
TypeEntry comp;
comp.entryKind = TypeEntry::Composite;
comp.structId = 100;
comp.displayName = QStringLiteral("MyLongStructName");
comp.displayName = QStringLiteral("MyExtremelyLongStructNameThatExceedsMinWidth");
comp.classKeyword = QStringLiteral("struct");
popup.setTypes({comp});
@@ -1465,6 +1466,191 @@ private slots:
QVERIFY2(!result.text.contains("hex64*"),
qPrintable("Should not show 'hex64*', got: " + result.text));
}
// ── Category chips and three-group filtering ──
void testCategoryEnumOnEntry() {
// Verify that Category enum values exist and are distinct
TypeEntry prim;
prim.category = TypeEntry::CatPrimitive;
QCOMPARE(prim.category, TypeEntry::CatPrimitive);
TypeEntry typ;
typ.category = TypeEntry::CatType;
QCOMPARE(typ.category, TypeEntry::CatType);
TypeEntry en;
en.category = TypeEntry::CatEnum;
QCOMPARE(en.category, TypeEntry::CatEnum);
QVERIFY(TypeEntry::CatPrimitive != TypeEntry::CatType);
QVERIFY(TypeEntry::CatType != TypeEntry::CatEnum);
}
void testCategoryDefaultIsPrimitive() {
TypeEntry e;
QCOMPARE(e.category, TypeEntry::CatPrimitive);
}
void testCompositesCategorizedInController() {
// Build tree with struct and enum types
NodeTree tree;
tree.baseAddress = 0;
Node st;
st.kind = NodeKind::Struct;
st.name = "Ball";
st.structTypeName = "Ball";
st.parentId = 0;
int si = tree.addNode(st);
uint64_t stId = tree.nodes[si].id;
{ Node n; n.kind = NodeKind::Int32; n.name = "x"; n.parentId = stId;
n.offset = 0; tree.addNode(n); }
Node en;
en.kind = NodeKind::Struct;
en.name = "Color";
en.structTypeName = "Color";
en.classKeyword = QStringLiteral("enum");
en.parentId = 0;
tree.addNode(en);
// Simulate controller logic: tag composites
QVector<TypeEntry> entries;
for (const auto& n : tree.nodes) {
if (n.parentId != 0 || n.kind != NodeKind::Struct) continue;
TypeEntry e;
e.entryKind = TypeEntry::Composite;
e.structId = n.id;
e.displayName = n.structTypeName.isEmpty() ? n.name : n.structTypeName;
e.classKeyword = n.resolvedClassKeyword();
e.category = (e.classKeyword == QStringLiteral("enum"))
? TypeEntry::CatEnum : TypeEntry::CatType;
entries.append(e);
}
QCOMPARE(entries.size(), 2);
// Ball → CatType, Color → CatEnum
bool foundType = false, foundEnum = false;
for (const auto& e : entries) {
if (e.displayName == "Ball") {
QCOMPARE(e.category, TypeEntry::CatType);
foundType = true;
}
if (e.displayName == "Color") {
QCOMPARE(e.category, TypeEntry::CatEnum);
foundEnum = true;
}
}
QVERIFY(foundType);
QVERIFY(foundEnum);
}
void testThreeGroupSections() {
// Create popup and set types with mixed categories
TypeSelectorPopup popup;
popup.setMode(TypePopupMode::FieldType);
QVector<TypeEntry> types;
// A primitive
TypeEntry prim;
prim.entryKind = TypeEntry::Primitive;
prim.primitiveKind = NodeKind::Int32;
prim.displayName = QStringLiteral("int32_t");
prim.category = TypeEntry::CatPrimitive;
types.append(prim);
// A struct type
TypeEntry st;
st.entryKind = TypeEntry::Composite;
st.structId = 1;
st.displayName = QStringLiteral("Player");
st.classKeyword = QStringLiteral("struct");
st.category = TypeEntry::CatType;
types.append(st);
// An enum type
TypeEntry en;
en.entryKind = TypeEntry::Composite;
en.structId = 2;
en.displayName = QStringLiteral("Color");
en.classKeyword = QStringLiteral("enum");
en.category = TypeEntry::CatEnum;
types.append(en);
popup.setTypes(types);
// The popup should have three sections in field mode:
// primitives → types → enums
// We can access via the internal model
auto* model = popup.findChild<QStringListModel*>();
QVERIFY(model != nullptr);
QStringList items = model->stringList();
// Should contain section headers
bool hasPrimSection = false, hasTypeSection = false, hasEnumSection = false;
for (const auto& item : items) {
if (item == QStringLiteral("primitives")) hasPrimSection = true;
if (item == QStringLiteral("types")) hasTypeSection = true;
if (item == QStringLiteral("enums")) hasEnumSection = true;
}
QVERIFY2(hasPrimSection, "Missing 'primitives' section header");
QVERIFY2(hasTypeSection, "Missing 'types' section header");
QVERIFY2(hasEnumSection, "Missing 'enums' section header");
}
// ── Test: struct embed auto-selects the current composite in popup ──
void testStructEmbedAutoSelectsCurrent() {
TypeSelectorPopup popup;
popup.setMode(TypePopupMode::FieldType);
QFont font(QStringLiteral("Consolas"), 10);
popup.setFont(font);
// Build entries: a primitive + two composites
QVector<TypeEntry> types;
TypeEntry prim;
prim.entryKind = TypeEntry::Primitive;
prim.primitiveKind = NodeKind::Int32;
prim.displayName = QStringLiteral("int32_t");
types.append(prim);
TypeEntry alpha;
alpha.entryKind = TypeEntry::Composite;
alpha.structId = 100;
alpha.displayName = QStringLiteral("Alpha");
alpha.classKeyword = QStringLiteral("struct");
alpha.category = TypeEntry::CatType;
types.append(alpha);
TypeEntry bravo;
bravo.entryKind = TypeEntry::Composite;
bravo.structId = 200;
bravo.displayName = QStringLiteral("Bravo");
bravo.classKeyword = QStringLiteral("struct");
bravo.category = TypeEntry::CatType;
types.append(bravo);
// Set Bravo as the current type (simulates struct embed field with refId=200)
popup.setTypes(types, &bravo);
popup.popup(QPoint(-9999, -9999));
QApplication::processEvents();
// The list view should auto-select the row matching Bravo
auto* listView = popup.findChild<QListView*>();
QVERIFY(listView != nullptr);
QModelIndex sel = listView->currentIndex();
QVERIFY2(sel.isValid(), "No item selected — auto-select failed");
// The selected row text should contain "Bravo"
QString selectedText = sel.data().toString();
QVERIFY2(selectedText.contains(QStringLiteral("Bravo")),
qPrintable(QString("Expected 'Bravo' in selected text, got '%1'").arg(selectedText)));
popup.hide();
}
};
QTEST_MAIN(TestTypeSelector)

View File

@@ -5,6 +5,9 @@
#include <QtConcurrent>
#include <QFuture>
#include <cstring>
#include <atomic>
#include <thread>
#include <chrono>
#include "providers/provider.h"
#include "../plugins/WinDbgMemory/WinDbgMemoryPlugin.h"
@@ -87,20 +90,40 @@ private slots:
// ── Fixture ──
/// Try a quick DebugConnect to see if the port is already serving.
static bool canConnect(const QString& connStr)
/// Runs in a detached thread with a timeout because DebugConnect can
/// hang indefinitely with WinDbg Preview servers.
static bool canConnect(const QString& connStr, int timeoutMs = 8000)
{
#ifdef _WIN32
IDebugClient* probe = nullptr;
QByteArray utf8 = connStr.toUtf8();
std::atomic<int> state{0}; // 0=pending, 1=connected, -1=failed
std::thread t([&state, utf8]() {
CoInitializeEx(NULL, COINIT_MULTITHREADED);
IDebugClient* probe = nullptr;
HRESULT hr = DebugConnect(utf8.constData(), IID_IDebugClient, (void**)&probe);
if (SUCCEEDED(hr) && probe) {
probe->EndSession(DEBUG_END_DISCONNECT);
probe->Release();
return true;
state.store(1);
} else {
state.store(-1);
}
CoUninitialize();
});
t.detach(); // Don't block on join — DebugConnect may hang forever
auto deadline = std::chrono::steady_clock::now()
+ std::chrono::milliseconds(timeoutMs);
while (state.load() == 0) {
if (std::chrono::steady_clock::now() >= deadline) {
qDebug() << "canConnect: DebugConnect timed out after" << timeoutMs << "ms";
return false;
}
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
return state.load() == 1;
#else
Q_UNUSED(connStr);
Q_UNUSED(connStr); Q_UNUSED(timeoutMs);
return false;
#endif
}
@@ -116,13 +139,18 @@ private slots:
return;
}
// No server running — launch cdb ourselves
// No server running — try to launch cdb ourselves.
// If cdb isn't available, user-mode tests will be skipped but
// kernel/dump tests can still run via WINDBG_KERNEL_CONN.
m_notepadPid = findProcess(L"notepad.exe");
if (m_notepadPid == 0) {
m_notepadPid = launchNotepad();
m_weSpawnedNotepad = true;
}
QVERIFY2(m_notepadPid != 0, "Need notepad.exe running");
if (m_notepadPid == 0) {
qDebug() << "No notepad.exe and could not launch — user-mode tests will skip";
return;
}
qDebug() << "Using notepad.exe PID:" << m_notepadPid;
m_cdbProcess = new QProcess(this);
@@ -135,7 +163,12 @@ private slots:
m_cdbProcess->setArguments(args);
m_cdbProcess->start();
QVERIFY2(m_cdbProcess->waitForStarted(5000), "Failed to start cdb.exe");
if (!m_cdbProcess->waitForStarted(5000)) {
qDebug() << "Failed to start cdb.exe — user-mode tests will skip";
delete m_cdbProcess;
m_cdbProcess = nullptr;
return;
}
QThread::sleep(3);
qDebug() << "cdb.exe debug server started on port" << DBG_PORT;
@@ -448,47 +481,47 @@ private slots:
delete raw;
}
// ── Kernel session tests ──
// Requires a WinDbg instance with a kernel dump loaded and
// .server tcp:port=5055 running. Skipped automatically if
// no server is available. Override with WINDBG_KERNEL_CONN env var.
// ── Kernel/dump session tests ──
// Set WINDBG_KERNEL_CONN to a target string:
// "dump:F:/path/to/file.dmp" — open dump directly
// "tcp:Port=5055,Server=localhost" — connect to debug server
// Set WINDBG_KERNEL_ADDR to a readable hex address (e.g. kernel base).
static QString kernelTarget()
{
return qEnvironmentVariable("WINDBG_KERNEL_CONN", "");
}
void provider_kernel_connect()
{
QString kernelConn = qEnvironmentVariable("WINDBG_KERNEL_CONN",
"tcp:Port=5055,Server=localhost");
if (!canConnect(kernelConn))
QSKIP("No kernel debug server available (set WINDBG_KERNEL_CONN)");
QString target = kernelTarget();
if (target.isEmpty())
QSKIP("Set WINDBG_KERNEL_CONN (e.g. dump:F:/file.dmp)");
WinDbgMemoryProvider prov(kernelConn);
QVERIFY2(prov.isValid(), "Should connect to kernel debug server");
WinDbgMemoryProvider prov(target);
QVERIFY2(prov.isValid(),
qPrintable("Should connect, lastError: " + prov.lastError()));
QCOMPARE(prov.kind(), QStringLiteral("WinDbg"));
qDebug() << "Kernel provider name:" << prov.name();
qDebug() << "Kernel provider base:" << QString("0x%1").arg(prov.base(), 0, 16);
qDebug() << "Kernel provider isLive:" << prov.isLive();
// Name should not be an arbitrary user-mode DLL
QVERIFY2(!prov.name().contains("WS2_32", Qt::CaseInsensitive),
qPrintable("Name should not be 'WS2_32', got: " + prov.name()));
}
void provider_kernel_read_base()
{
QString kernelConn = qEnvironmentVariable("WINDBG_KERNEL_CONN",
"tcp:Port=5055,Server=localhost");
if (!canConnect(kernelConn))
QSKIP("No kernel debug server available");
QString target = kernelTarget();
if (target.isEmpty())
QSKIP("Set WINDBG_KERNEL_CONN");
WinDbgMemoryProvider prov(kernelConn);
QVERIFY(prov.isValid());
// Provider no longer auto-selects a base. Use a known kernel address
// from env, or skip.
QString addrStr = qEnvironmentVariable("WINDBG_KERNEL_ADDR", "");
if (addrStr.isEmpty())
QSKIP("Set WINDBG_KERNEL_ADDR to a readable kernel address");
WinDbgMemoryProvider prov(target);
QVERIFY2(prov.isValid(),
qPrintable("lastError: " + prov.lastError()));
bool ok = false;
uint64_t addr = addrStr.toULongLong(&ok, 16);
QVERIFY2(ok && addr != 0, "WINDBG_KERNEL_ADDR must be a valid hex address");
@@ -502,20 +535,21 @@ private slots:
if (buf[i] != 0) { allZero = false; break; }
}
QVERIFY2(!allZero, "Kernel read returned all zeros");
qDebug() << "Read 16 bytes at" << QString("0x%1").arg(addr, 0, 16)
<< "first 4:" << QString("%1 %2 %3 %4")
.arg(buf[0], 2, 16, QChar('0'))
.arg(buf[1], 2, 16, QChar('0'))
.arg(buf[2], 2, 16, QChar('0'))
.arg(buf[3], 2, 16, QChar('0'));
}
void provider_kernel_read_high_address()
{
QString kernelConn = qEnvironmentVariable("WINDBG_KERNEL_CONN",
"tcp:Port=5055,Server=localhost");
if (!canConnect(kernelConn))
QSKIP("No kernel debug server available");
QString target = kernelTarget();
if (target.isEmpty())
QSKIP("Set WINDBG_KERNEL_CONN");
WinDbgMemoryProvider prov(kernelConn);
QVERIFY(prov.isValid());
// Use env var for a specific kernel address (e.g. _EPROCESS),
// otherwise fall back to the provider's base.
QString addrStr = qEnvironmentVariable("WINDBG_KERNEL_ADDR", "");
uint64_t addr = 0;
if (!addrStr.isEmpty()) {
@@ -523,7 +557,14 @@ private slots:
addr = addrStr.toULongLong(&ok, 16);
if (!ok) addr = 0;
}
WinDbgMemoryProvider prov(target);
QVERIFY2(prov.isValid(),
qPrintable("lastError: " + prov.lastError()));
if (addr == 0) addr = prov.base();
if (addr == 0)
QSKIP("No kernel address available (set WINDBG_KERNEL_ADDR)");
uint8_t buf[64] = {};
bool ok = prov.read(addr, buf, 64);
@@ -550,10 +591,9 @@ private slots:
void provider_kernel_read_backgroundThread()
{
QString kernelConn = qEnvironmentVariable("WINDBG_KERNEL_CONN",
"tcp:Port=5055,Server=localhost");
if (!canConnect(kernelConn))
QSKIP("No kernel debug server available");
QString target = kernelTarget();
if (target.isEmpty())
QSKIP("Set WINDBG_KERNEL_CONN");
QString addrStr = qEnvironmentVariable("WINDBG_KERNEL_ADDR", "");
if (addrStr.isEmpty())
@@ -563,8 +603,9 @@ private slots:
uint64_t addr = addrStr.toULongLong(&ok, 16);
QVERIFY2(ok && addr != 0, "WINDBG_KERNEL_ADDR must be a valid hex address");
WinDbgMemoryProvider prov(kernelConn);
QVERIFY(prov.isValid());
WinDbgMemoryProvider prov(target);
QVERIFY2(prov.isValid(),
qPrintable("lastError: " + prov.lastError()));
// Simulate the controller's async refresh pattern
QFuture<QByteArray> future = QtConcurrent::run([&prov, addr]() -> QByteArray {

806
tools/vergilius_to_rcx.py Normal file
View File

@@ -0,0 +1,806 @@
#!/usr/bin/env python3
"""
Fetch kernel structs from Vergilius Project and generate .rcx (JSON) file.
Usage:
python vergilius_to_rcx.py -o output.rcx _EPROCESS _KPROCESS _MMPFN ...
python vergilius_to_rcx.py --preset 25h2 -o output.rcx
Fetches struct definitions from vergiliusproject.com, parses the C-like
syntax, and converts to Reclass 2027 native JSON format (.rcx).
"""
import argparse
import json
import re
import sys
import urllib.request
import urllib.error
from html.parser import HTMLParser
import time
# ── Windows kernel type → (RCX kind, byte size) ──
TYPE_MAP = {
# Unsigned integers
'UCHAR': ('UInt8', 1),
'UINT8': ('UInt8', 1),
'BOOLEAN': ('UInt8', 1),
'USHORT': ('UInt16', 2),
'UINT16': ('UInt16', 2),
'WCHAR': ('UInt16', 2),
'ULONG': ('UInt32', 4),
'UINT32': ('UInt32', 4),
'ULONGLONG': ('UInt64', 8),
'UINT64': ('UInt64', 8),
'ULONG_PTR': ('UInt64', 8),
'SIZE_T': ('UInt64', 8),
# Signed integers
'CHAR': ('Int8', 1),
'INT8': ('Int8', 1),
'SHORT': ('Int16', 2),
'INT16': ('Int16', 2),
'LONG': ('Int32', 4),
'INT32': ('Int32', 4),
'LONGLONG': ('Int64', 8),
'INT64': ('Int64', 8),
'LONG_PTR': ('Int64', 8),
# Floating point
'float': ('Float', 4),
'double': ('Double', 8),
# Pointer-like
'PVOID': ('Pointer64', 8),
'HANDLE': ('Pointer64', 8),
'PCHAR': ('Pointer64', 8),
'PWCHAR': ('Pointer64', 8),
'PUCHAR': ('Pointer64', 8),
'PULONG': ('Pointer64', 8),
'PLONG': ('Pointer64', 8),
'PUSHORT': ('Pointer64', 8),
'PULONGLONG': ('Pointer64', 8),
'PVOID64': ('Pointer64', 8),
}
# ── HTML parser to extract <pre> content ──
class PreExtractor(HTMLParser):
def __init__(self):
super().__init__()
self.in_pre = False
self.pre_content = []
self.result = None
def handle_starttag(self, tag, attrs):
if tag == 'pre':
self.in_pre = True
self.pre_content = []
def handle_endtag(self, tag):
if tag == 'pre' and self.in_pre:
self.in_pre = False
if self.result is None:
self.result = ''.join(self.pre_content)
def handle_data(self, data):
if self.in_pre:
self.pre_content.append(data)
def handle_entityref(self, name):
if self.in_pre:
self.pre_content.append(f'&{name};')
def handle_charref(self, name):
if self.in_pre:
self.pre_content.append(f'&#{name};')
# ── ID allocator ──
class IdAlloc:
def __init__(self, start=100):
self.next = start
def alloc(self):
n = self.next
self.next += 1
return n
# ── Fetch a struct definition from Vergilius ──
BASE_URL = 'https://www.vergiliusproject.com/kernels/x64/windows-11/25h2'
def fetch_struct_text(name):
"""Fetch the C struct definition text for a given type name."""
url = f'{BASE_URL}/{name}'
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0 (Reclass2027 struct importer)',
})
try:
with urllib.request.urlopen(req, timeout=30) as resp:
html = resp.read().decode('utf-8', errors='replace')
except urllib.error.HTTPError as e:
print(f' ERROR: HTTP {e.code} fetching {name}', file=sys.stderr)
return None
except Exception as e:
print(f' ERROR: {e} fetching {name}', file=sys.stderr)
return None
parser = PreExtractor()
parser.feed(html)
return parser.result
# ── Vergilius text parser ──
# Regex for offset comment at end of line: //0xNN
RE_OFFSET = re.compile(r'//0x([0-9a-fA-F]+)\s*$')
# Regex for size comment: //0xNN bytes (sizeof)
RE_SIZEOF = re.compile(r'//0x([0-9a-fA-F]+)\s+bytes\s+\(sizeof\)')
# Regex for a field line: TYPE fieldname; //0xNN
# Handles: volatile, struct/union prefix, pointers (*), arrays ([N]), bitfields (:N)
RE_FIELD = re.compile(
r'^\s+' # leading whitespace
r'(?:volatile\s+)?' # optional volatile
r'(?:(struct|union|enum)\s+)?' # optional keyword
r'(\w+)' # type name (or keyword target)
r'(\*?)' # optional pointer
r'\s+'
r'(?:volatile\s+)?' # volatile can appear here too
r'(\*?)' # pointer can be here (struct _X* volatile Field)
r'(\w+)' # field name
r'(?:\[(\d+)\])?' # optional array [N]
r'(?::(\d+))?' # optional bitfield :N
r'\s*;' # semicolon
)
def parse_offset(line):
"""Extract hex offset from //0xNN comment."""
m = RE_OFFSET.search(line)
return int(m.group(1), 16) if m else None
def parse_struct_size(text):
"""Extract struct size from //0xNN bytes (sizeof) comment."""
m = RE_SIZEOF.search(text)
return int(m.group(1), 16) if m else 0
def parse_vergilius(text, ids, struct_registry):
"""
Parse Vergilius C-like struct text and return list of RCX nodes.
struct_registry: dict mapping type_name → node_id (built up across calls)
Returns (nodes, root_id, struct_size)
"""
lines = text.strip().split('\n')
nodes = []
pos = [0] # mutable for closure
def peek():
return lines[pos[0]].rstrip() if pos[0] < len(lines) else None
def advance():
line = lines[pos[0]].rstrip()
pos[0] += 1
return line
def skip_blank():
while pos[0] < len(lines) and not lines[pos[0]].strip():
pos[0] += 1
# Parse top-level: optional size comment, struct/union keyword, name, body
skip_blank()
struct_size = 0
line = peek()
if line and RE_SIZEOF.search(line):
struct_size = parse_struct_size(line)
advance()
# struct/union _NAME
skip_blank()
line = advance()
m = re.match(r'\s*(struct|union)\s+(\w+)', line)
if not m:
return nodes, 0, 0
root_keyword = m.group(1)
root_name = m.group(2)
# Opening brace
skip_blank()
line = peek()
if line and line.strip() == '{':
advance()
# Create root node
root_id = ids.alloc()
root_node = {
'id': str(root_id),
'kind': 'Struct',
'name': root_name.lstrip('_').lower(),
'structTypeName': root_name,
'offset': 0,
'parentId': '0',
'refId': '0',
'collapsed': True,
}
if root_keyword == 'union':
root_node['classKeyword'] = 'union'
nodes.append(root_node)
struct_registry[root_name] = root_id
# Parse body
parse_body(lines, pos, ids, nodes, root_id, struct_registry)
# Fix anonymous containers whose offset peek failed (first child was
# a nested struct/union, not a field line with an offset comment).
# Set their offset to the minimum child offset.
fixup_anonymous_offsets(nodes)
# Convert bitfield children into proper bitfield containers
postprocess_bitfields(nodes)
# Convert absolute offsets to parent-relative
convert_to_relative_offsets(nodes)
return nodes, root_id, struct_size
def parse_body(lines, pos, ids, nodes, parent_id, struct_registry):
"""Parse fields inside { ... }; recursively."""
while pos[0] < len(lines):
line = lines[pos[0]].rstrip()
stripped = line.strip()
# End of block
if stripped.startswith('}'):
pos[0] += 1
return stripped # caller checks for "} name;" vs "};"
# Blank line
if not stripped:
pos[0] += 1
continue
# Nested struct/union
m = re.match(r'\s*(struct|union)\s*$', stripped)
if m:
keyword = m.group(1)
pos[0] += 1
# Expect opening brace
while pos[0] < len(lines):
brace_line = lines[pos[0]].strip()
if brace_line == '{':
pos[0] += 1
break
if not brace_line:
pos[0] += 1
continue
break
# Create anonymous struct/union node
anon_id = ids.alloc()
# We don't know the offset yet; peek at first child
anon_offset = 0
if pos[0] < len(lines):
off = parse_offset(lines[pos[0]])
if off is not None:
anon_offset = off
anon_node = {
'id': str(anon_id),
'kind': 'Struct',
'name': '',
'classKeyword': keyword,
'offset': anon_offset,
'parentId': str(parent_id),
'refId': '0',
'collapsed': False,
}
nodes.append(anon_node)
# Parse body recursively
close_line = parse_body(lines, pos, ids, nodes, anon_id, struct_registry)
# Check for name after closing brace: "} name;" or "};"
if close_line:
cm = re.match(r'\}\s*(\w+)\s*;', close_line)
if cm:
anon_node['name'] = cm.group(1)
# Get offset from close line
off = parse_offset(close_line)
if off is not None:
anon_node['offset'] = off
continue
# Regular field line
offset = parse_offset(line)
if offset is None:
pos[0] += 1
continue
# Parse field
node = parse_field_line(stripped, offset, parent_id, ids, struct_registry)
if node:
nodes.append(node)
pos[0] += 1
def parse_field_line(line, offset, parent_id, ids, struct_registry):
"""Parse a single field line into an RCX node."""
# Strip offset comment
line = RE_OFFSET.sub('', line).strip().rstrip(';').strip()
# Remove volatile
line = re.sub(r'\bvolatile\b', '', line).strip()
line = re.sub(r'\s+', ' ', line)
# Check for struct/union keyword prefix
keyword = None
m = re.match(r'^(struct|union|enum)\s+(.+)', line)
if m:
keyword = m.group(1)
line = m.group(2)
# Check for pointer(s)
is_pointer = False
if '*' in line:
is_pointer = True
# "TYPE* name" or "TYPE *name" or "_NAME* name"
parts = line.replace('*', '* ').split()
# Find the type and name
type_parts = []
field_name = None
for i, p in enumerate(parts):
if p.endswith('*'):
type_parts.append(p.rstrip('*'))
is_pointer = True
elif i == len(parts) - 1:
field_name = p
else:
type_parts.append(p)
type_name = ' '.join(tp for tp in type_parts if tp)
if not field_name:
return None
else:
# "TYPE name" or "TYPE name[N]" or "TYPE name:N"
parts = line.split()
if len(parts) < 2:
return None
type_name = parts[0]
rest = ' '.join(parts[1:])
# Check for array
am = re.match(r'(\w+)\[(\d+)\]', rest)
# Check for bitfield
bm = re.match(r'(\w+):(\d+)', rest)
if am:
field_name = am.group(1)
array_len = int(am.group(2))
return make_array_node(type_name, keyword, field_name, array_len,
offset, parent_id, ids, struct_registry)
elif bm:
field_name = bm.group(1)
bitwidth = int(bm.group(2))
return make_bitfield_node(type_name, keyword, field_name, bitwidth,
offset, parent_id, ids)
else:
field_name = parts[-1]
# Pointer field
if is_pointer:
node_id = ids.alloc()
node = {
'id': str(node_id),
'kind': 'Pointer64',
'name': field_name,
'offset': offset,
'parentId': str(parent_id),
'collapsed': True,
}
# If it points to a known struct, set refId
if type_name in struct_registry:
node['refId'] = str(struct_registry[type_name])
elif keyword in ('struct', 'union') and type_name:
# Will be resolved later
node['_pending_ref'] = type_name
node['refId'] = '0'
else:
node['refId'] = '0'
return node
# Embedded struct/union
if keyword in ('struct', 'union'):
node_id = ids.alloc()
node = {
'id': str(node_id),
'kind': 'Struct',
'name': field_name,
'structTypeName': type_name,
'offset': offset,
'parentId': str(parent_id),
'refId': '0',
'collapsed': True,
}
if keyword == 'union':
node['classKeyword'] = 'union'
# Link to existing definition
if type_name in struct_registry:
node['refId'] = str(struct_registry[type_name])
else:
node['_pending_ref'] = type_name
return node
# Primitive type
kind, size = TYPE_MAP.get(type_name, (None, None))
if kind is None:
# Unknown type — treat as Hex64 (8 bytes, common for x64)
kind = 'Hex64'
node_id = ids.alloc()
return {
'id': str(node_id),
'kind': kind,
'name': field_name,
'offset': offset,
'parentId': str(parent_id),
}
def make_array_node(type_name, keyword, field_name, array_len, offset,
parent_id, ids, struct_registry):
"""Create a primitive or struct array node."""
kind, elem_size = TYPE_MAP.get(type_name, (None, None))
node_id = ids.alloc()
if kind and keyword is None:
# Primitive array: kind=Array, elementKind=primitive type
return {
'id': str(node_id),
'kind': 'Array',
'name': field_name,
'offset': offset,
'parentId': str(parent_id),
'elementKind': kind,
'arrayLen': array_len,
}
else:
# Struct/union array: kind=Array, elementKind=Struct
node = {
'id': str(node_id),
'kind': 'Array',
'name': field_name,
'offset': offset,
'parentId': str(parent_id),
'elementKind': 'Struct',
'arrayLen': array_len,
}
if type_name:
node['structTypeName'] = type_name
if type_name in struct_registry:
node['refId'] = str(struct_registry[type_name])
else:
node['_pending_ref'] = type_name
return node
def make_bitfield_node(type_name, keyword, field_name, bitwidth, offset,
parent_id, ids):
"""Create a bitfield node — stored as Hex of the underlying type size."""
kind, size = TYPE_MAP.get(type_name, ('Hex32', 4))
# Map to hex kind for bitfields
hex_kind = {1: 'Hex8', 2: 'Hex16', 4: 'Hex32', 8: 'Hex64'}.get(size, 'Hex32')
node_id = ids.alloc()
return {
'id': str(node_id),
'kind': hex_kind,
'name': f'{field_name}:{bitwidth}',
'offset': offset,
'parentId': str(parent_id),
}
def fixup_anonymous_offsets(nodes):
"""Fix anonymous struct/union nodes whose offset peek failed.
When the first child of an anonymous container is another nested
struct/union (not a field line), the parser can't peek at an offset
comment and defaults to 0. Fix by setting the container's offset to
the minimum offset among its direct children.
"""
children_of = {}
for node in nodes:
pid = node.get('parentId', '0')
children_of.setdefault(pid, []).append(node)
for node in nodes:
if node.get('kind') != 'Struct':
continue
if node.get('parentId', '0') == '0':
continue
# Only fix containers that still have offset 0 (the default from failed peek)
if node.get('offset', 0) != 0:
continue
kids = children_of.get(node['id'], [])
if not kids:
continue
kid_offsets = [k.get('offset', 0) for k in kids]
min_off = min(kid_offsets)
if min_off > 0:
node['offset'] = min_off
def postprocess_bitfields(nodes):
"""
Convert anonymous structs whose children are ALL bitfield Hex nodes
into proper bitfield containers with bitfieldMembers array.
Bitfield children are identified by having ':' in their name (e.g. "Absolute:1").
The parent becomes kind=Struct, classKeyword=bitfield, elementKind=Hex8/16/32/64,
and all child nodes are removed from the list.
"""
# Build parent→children index
children_of = {}
for node in nodes:
pid = node.get('parentId', '0')
children_of.setdefault(pid, []).append(node)
ids_to_remove = set()
for node in nodes:
# Process struct nodes (not unions, not already bitfields, not named types)
if node.get('kind') != 'Struct':
continue
if node.get('classKeyword') in ('union', 'bitfield'):
continue
if node.get('structTypeName', ''):
continue
nid = node['id']
kids = children_of.get(nid, [])
if not kids:
continue
# Check if ALL children are Hex nodes with ':' in name
all_bitfield = True
for kid in kids:
kid_kind = kid.get('kind', '')
kid_name = kid.get('name', '')
if not kid_kind.startswith('Hex') or ':' not in kid_name:
all_bitfield = False
break
if not all_bitfield:
continue
# Determine container elementKind from children's hex kind
element_kind = kids[0].get('kind', 'Hex32')
# Build bitfieldMembers array
members = []
bit_offset = 0
for kid in kids:
kid_name = kid.get('name', '')
# Parse "FieldName:Width"
parts = kid_name.rsplit(':', 1)
if len(parts) != 2:
continue
fname, width_str = parts
bit_width = int(width_str)
members.append({
'name': fname,
'bitOffset': bit_offset,
'bitWidth': bit_width,
})
bit_offset += bit_width
# Convert parent to bitfield container
node['classKeyword'] = 'bitfield'
node['elementKind'] = element_kind
node['bitfieldMembers'] = members
# Use offset from first child (they all share same byte offset)
if kids:
node['offset'] = kids[0].get('offset', node.get('offset', 0))
# Remove fields not needed on bitfield containers
node.pop('refId', None)
node.pop('collapsed', None)
# Mark children for removal
for kid in kids:
ids_to_remove.add(kid['id'])
# Remove bitfield children from node list
if ids_to_remove:
nodes[:] = [n for n in nodes if n['id'] not in ids_to_remove]
def convert_to_relative_offsets(nodes):
"""Convert absolute offsets (from struct root) to parent-relative offsets.
Vergilius provides absolute offsets from the struct root in //0xNN comments,
but the RCX data model expects offsets relative to the parent node.
"""
abs_off = {n['id']: n.get('offset', 0) for n in nodes}
for node in nodes:
pid = node.get('parentId', '0')
if pid == '0':
continue
if pid in abs_off:
node['offset'] = node.get('offset', 0) - abs_off[pid]
def resolve_pending_refs(all_nodes, struct_registry):
"""Resolve _pending_ref fields to actual refIds."""
for node in all_nodes:
ref_name = node.pop('_pending_ref', None)
if ref_name and ref_name in struct_registry:
node['refId'] = str(struct_registry[ref_name])
def build_rcx(all_nodes, base_address='FFFFF80000000000'):
"""Build the final .rcx JSON structure."""
max_id = max(int(n['id']) for n in all_nodes) if all_nodes else 100
return {
'baseAddress': base_address,
'nextId': str(max_id + 100),
'nodes': all_nodes,
}
# ── Curated struct sets ──
PRESET_25H2 = [
# Fundamental
'_LIST_ENTRY',
'_UNICODE_STRING',
'_LARGE_INTEGER',
'_EX_PUSH_LOCK',
'_EX_FAST_REF',
'_DISPATCHER_HEADER',
# Process / Thread
'_EPROCESS',
'_KPROCESS',
'_ETHREAD',
'_KTHREAD',
'_PEB',
'_TEB',
'_KAPC_STATE',
# Memory
'_MMPFN',
'_MMPTE',
'_MMVAD',
'_MMVAD_SHORT',
'_MDL',
'_CONTROL_AREA',
# Objects
'_OBJECT_HEADER',
'_OBJECT_TYPE',
'_HANDLE_TABLE',
'_HANDLE_TABLE_ENTRY',
# I/O
'_DEVICE_OBJECT',
'_DRIVER_OBJECT',
'_FILE_OBJECT',
'_IRP',
# Misc
'_KPCR',
'_KPRCB',
'_CONTEXT',
]
def scrape_all_struct_names():
"""Scrape all struct names from the Vergilius 25H2 index page."""
class LinkExtractor(HTMLParser):
def __init__(self):
super().__init__()
self.names = []
self.base = '/kernels/x64/windows-11/25h2/'
def handle_starttag(self, tag, attrs):
if tag == 'a':
for k, v in attrs:
if k == 'href' and v and v.startswith(self.base):
name = v[len(self.base):].strip('/')
if name and '/' not in name:
self.names.append(name)
print('Scraping struct index from Vergilius...', flush=True)
req = urllib.request.Request(BASE_URL,
headers={'User-Agent': 'Mozilla/5.0 (Reclass2027 struct importer)'})
with urllib.request.urlopen(req, timeout=30) as resp:
html = resp.read().decode('utf-8', errors='replace')
p = LinkExtractor()
p.feed(html)
seen = set()
names = []
for n in p.names:
if n not in seen:
seen.add(n)
names.append(n)
print(f'Found {len(names)} structs')
return names
def main():
parser = argparse.ArgumentParser(
description='Fetch Vergilius structs and generate .rcx file')
parser.add_argument('structs', nargs='*', help='Struct names (e.g. _EPROCESS)')
parser.add_argument('-o', '--output', default='Vergilius_25H2.rcx',
help='Output .rcx file path')
parser.add_argument('--preset', choices=['25h2'],
help='Use preset struct list')
parser.add_argument('--from-file', metavar='FILE',
help='Read struct names from file (one per line)')
parser.add_argument('--scrape-all', action='store_true',
help='Scrape all struct names from the Vergilius page')
parser.add_argument('--delay', type=float, default=1.0,
help='Delay between HTTP requests (seconds)')
parser.add_argument('--base', default='FFFFF80000000000',
help='Base address (hex string)')
args = parser.parse_args()
struct_names = args.structs
if args.preset == '25h2':
struct_names = PRESET_25H2
if args.from_file:
with open(args.from_file) as f:
struct_names = [line.strip() for line in f if line.strip()]
if args.scrape_all:
struct_names = scrape_all_struct_names()
if not struct_names:
parser.error('Specify struct names or use --preset / --from-file / --scrape-all')
ids = IdAlloc(100)
struct_registry = {} # type_name → node_id
all_nodes = []
failed = []
total = len(struct_names)
for i, name in enumerate(struct_names):
print(f'[{i+1}/{total}] Fetching {name}...', end=' ', flush=True)
text = fetch_struct_text(name)
if not text:
print('FAILED')
failed.append(name)
continue
struct_nodes, root_id, struct_size = parse_vergilius(text, ids, struct_registry)
if not struct_nodes:
print('PARSE ERROR')
failed.append(name)
continue
all_nodes.extend(struct_nodes)
field_count = len(struct_nodes) - 1
print(f'OK ({field_count} fields, 0x{struct_size:X} bytes)')
if i < total - 1:
time.sleep(args.delay)
# Resolve cross-references
resolve_pending_refs(all_nodes, struct_registry)
# Build and write .rcx
rcx = build_rcx(all_nodes, args.base)
with open(args.output, 'w', encoding='utf-8') as f:
json.dump(rcx, f, indent=4, ensure_ascii=False)
print(f'\nWrote {args.output}')
print(f' {len(struct_registry)} structs, {len(all_nodes)} total nodes')
if failed:
print(f' Failed: {", ".join(failed)}')
if __name__ == '__main__':
main()