Compare commits

...

6 Commits

Author SHA1 Message Date
IChooseYou
483f87cfbd feat: type hints green [bracketed] notation, workspace cleanup, unique naming
- Type inference hints now show value-first with bracketed type in comment
  green: "0x7ff718570000 [ptr64]", "6, 16 [int32_t×2]"
- Raise hint threshold to strong-only (score >= 75%)
- Remove Bool inference, widen Int16 range to ±16384
- Workspace: remove dead WorkspaceProxy, fix null deref, debounce search,
  cache icons, add pinning support
- Unique naming: UnnamedClass0/UnnamedEnum1 with global counter
- Footer buttons: +10h +100h +1000h replacing +1024
- MCP: project lifecycle API, snapshot provider fix
2026-03-09 10:39:22 -06:00
IChooseYou
a21e5a07a8 feat: replace +1024 footer button with +10h +100h +1000h granular grow
- Three hex-sized grow buttons: +10h (16B), +100h (256B), +1000h (4096B)
- Single-space gaps between buttons for tighter layout
- All click, hover, cursor, and pill styling updated
- Enum +10 button unchanged and correctly disambiguated

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 16:38:03 -06:00
IChooseYou
25afbe373b feat: status bar format, tab titles with source, taller tabs, pill hover, source switch base fix
- Status bar: show StructName.field +0xOFFSET with dimmed offset suffix
- Status bar: sync font to global editor font (JetBrains Mono 10pt)
- Dock tab title: include active source name (StructName — source.exe)
- Dock tabs +10% height (28→31), pane tabs (24→26), workspace title (26→29)
- Footer pills (+1024, Trim, +10): add visual hover highlight via IND_HOVER_SPAN
- Fix source switch keeping old base address for plugin providers
2026-03-08 16:29:12 -06:00
IChooseYou
6a4cb47ed4 fix: kill Fusion outline on QScintilla, type inference hints, workspace styling
- Suppress PE_Frame on QsciScintilla in MenuBarStyle to eliminate the
  1px dark (#171717) Fusion outline around the editor area
- Add --screenshot flag for automated pixel regression testing
- Add type inference engine (typeinfer.h) with hex pattern analysis
- Show inferred type hints on hex nodes in compose output
- Style workspace tree corner/header widgets to match theme
- Fix integer overflow in compose.cpp array element addressing
- Fix integer overflow in core.h structSpan calculation
- Add bounds check on activePaneIdx in controller
- Use QPointer for deferred dock lambda safety
- Workspace delegate uses icon Normal/Disabled for viewed state
2026-03-08 10:26:12 -06:00
IChooseYou
431e2b90c9 perf: TypeSelector — zero-alloc fuzzy scorer, warm popup 75% faster
Stack arrays + pre-lowered QChars in fuzzyScore eliminate all heap
allocations in the hot path. applyFilter uses indices instead of
deep-copying TypeEntry. popup() width estimated from cached max name
length. QListView: uniform sizes, batched layout, cached sizeHint.

Benchmark (5000 structs): warm popup 27ms→7ms, filter 5ms→1.7ms.
2026-03-08 08:33:21 -06:00
IChooseYou
43365c1aff fix: close project actually destroys dock, editor perf single-pass line attributes
- Set WA_DeleteOnClose on doc docks so all close paths trigger cleanup
- Create fresh empty class when last project closes
- Add splitDockWidget/resizeDocks to project_new() so workspace doesn't eat editor space
- Merge applyMarginText, applyMarkers, applyFoldLevels into single-pass applyLineAttributes
- Cache line texts for heatmap/symbol coloring passes (avoid redundant Scintilla IPC)
- Zero-alloc scroll width scan replaces QString::split
2026-03-08 08:13:36 -06:00
30 changed files with 2428 additions and 406894 deletions

View File

@@ -314,6 +314,11 @@ if(BUILD_TESTING)
target_link_libraries(test_core PRIVATE ${QT}::Core ${QT}::Test)
add_test(NAME test_core COMMAND test_core)
add_executable(test_typeinfer tests/test_typeinfer.cpp)
target_include_directories(test_typeinfer PRIVATE src)
target_link_libraries(test_typeinfer PRIVATE ${QT}::Core ${QT}::Test)
add_test(NAME test_typeinfer COMMAND test_typeinfer)
add_executable(test_format tests/test_format.cpp src/format.cpp src/addressparser.cpp)
target_include_directories(test_format PRIVATE src)
target_link_libraries(test_format PRIVATE ${QT}::Core ${QT}::Test)
@@ -423,20 +428,6 @@ if(BUILD_TESTING)
endif()
add_test(NAME test_controller COMMAND test_controller)
add_executable(test_validation tests/test_validation.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
target_include_directories(test_validation PRIVATE src third_party/fadec)
target_link_libraries(test_validation PRIVATE
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
QScintilla::QScintilla)
if(WIN32)
target_link_libraries(test_validation PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
endif()
add_test(NAME test_validation COMMAND test_validation)
add_executable(test_context_menu tests/test_context_menu.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
@@ -483,20 +474,6 @@ if(BUILD_TESTING)
QScintilla::QScintilla)
add_test(NAME test_rendered_view COMMAND test_rendered_view)
add_executable(test_new_features tests/test_new_features.cpp
src/generator.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
src/editor.cpp src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
target_include_directories(test_new_features PRIVATE src third_party/fadec)
target_link_libraries(test_new_features PRIVATE
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
QScintilla::QScintilla)
if(WIN32)
target_link_libraries(test_new_features PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
endif()
add_test(NAME test_new_features COMMAND test_new_features)
add_executable(test_type_selector tests/test_type_selector.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp

View File

@@ -1,4 +1,5 @@
#include "core.h"
#include "typeinfer.h"
#include "addressparser.h"
#include <algorithm>
#include <numeric>
@@ -7,6 +8,49 @@ namespace rcx {
namespace {
// ── Value preview for type hints ──
// Formats raw bytes as the suggested type using existing fmt:: functions.
static QString formatPreview(const uint8_t* data, int len, const TypeSuggestion& s) {
using namespace detail;
if (s.kinds.isEmpty()) return {};
NodeKind k = s.kinds[0];
if (s.kinds.size() == 1) {
switch (k) {
case NodeKind::Float: return fmt::fmtFloat(loadF32(data));
case NodeKind::Double: return fmt::fmtDouble(loadF64(data));
case NodeKind::Int32: return fmt::fmtInt32((int32_t)loadU32(data));
case NodeKind::UInt32: return fmt::fmtUInt32(loadU32(data));
case NodeKind::Int16: return fmt::fmtInt16((int16_t)loadU16(data));
case NodeKind::UInt16: return fmt::fmtUInt16(loadU16(data));
case NodeKind::Int64: return fmt::fmtInt64((int64_t)loadU64(data));
case NodeKind::UInt64: return fmt::fmtUInt64(loadU64(data));
case NodeKind::Pointer64: return fmt::fmtPointer64(loadU64(data));
case NodeKind::Pointer32: return fmt::fmtPointer32(loadU32(data));
case NodeKind::Bool: return fmt::fmtBool(data[0]);
case NodeKind::UTF8: {
int n = std::min(len, 8);
QString s;
for (int i = 0; i < n && data[i] >= 0x20 && data[i] <= 0x7E; ++i)
s += QLatin1Char(data[i]);
return s.isEmpty() ? QString() : (QStringLiteral("\"") + s + QStringLiteral("\""));
}
default: return {};
}
}
// Split: show each part
int partSz = len / s.kinds.size();
QStringList parts;
for (int i = 0; i < s.kinds.size(); ++i) {
TypeSuggestion sub;
sub.kinds = {s.kinds[i]};
sub.score = s.score;
sub.strength = s.strength;
parts << formatPreview(data + i * partSz, partSz, sub);
}
return parts.join(QStringLiteral(", "));
}
// Scintilla fold constants (avoid including Scintilla headers in core)
constexpr int SC_FOLDLEVELBASE = 0x400;
constexpr int SC_FOLDLEVELHEADERFLAG = 0x2000;
@@ -26,6 +70,7 @@ struct ComposeState {
bool compactColumns = false; // compact column mode: cap type width, overflow long types
bool treeLines = false; // draw Unicode tree connectors in indentation
bool braceWrap = false; // opening brace on its own line
bool typeHints = false; // show type inference hints on hex nodes
QVector<bool> siblingStack; // per-depth: true = more siblings follow at this level
uint64_t currentPtrBase = 0; // absolute addr of current pointer expansion target
@@ -208,6 +253,29 @@ void composeLeaf(ComposeState& state, const NodeTree& tree,
QString lineText = fmt::fmtNodeLine(node, prov, absAddr, depth, sub,
/*comment=*/{}, typeW, nameW, ptrTypeOverride,
state.compactColumns);
// Type inference hint for hex nodes (when enabled)
if (state.typeHints && isHexNode(node.kind) && sub == 0) {
const int sz = sizeForKind(node.kind);
QByteArray b = prov.isReadable(absAddr, sz)
? prov.readBytes(absAddr, sz) : QByteArray(sz, '\0');
auto suggestions = inferTypes(
reinterpret_cast<const uint8_t*>(b.constData()), sz);
if (!suggestions.isEmpty() && suggestions[0].strength >= 3) {
lm.typeHintStart = lineText.size() + 2; // after " " gap
lm.typeHintKinds = suggestions[0].kinds;
QString typeName = formatHint(suggestions[0]);
QString preview = formatPreview(
reinterpret_cast<const uint8_t*>(b.constData()), sz, suggestions[0]);
// Value-first with bracketed type: "0x7ff718570000 [ptr64]"
if (!preview.isEmpty())
lm.typeHint = preview + QStringLiteral(" [") + typeName + QStringLiteral("]");
else
lm.typeHint = QStringLiteral("[") + typeName + QStringLiteral("]");
lineText += QStringLiteral(" ") + lm.typeHint;
}
}
state.emitLine(lineText, std::move(lm));
}
}
@@ -469,7 +537,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
int eNW = state.effectiveNameW(node.id);
for (int i = 0; i < node.arrayLen; i++) {
state.setTreeSibling(childDepth, i < node.arrayLen - 1);
uint64_t elemAddr = absAddr + i * elemSize;
uint64_t elemAddr = absAddr + (uint64_t)i * elemSize;
// Type override: "float[0]", "uint32_t[1]", etc.
QString elemTypeStr = fmt::typeNameRaw(node.elementKind)
@@ -478,7 +546,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
Node elem;
elem.kind = node.elementKind;
elem.name = QString(); // no name for array elements
elem.offset = node.offset + i * elemSize;
elem.offset = node.offset + (int)((uint64_t)i * elemSize);
elem.parentId = node.id;
elem.id = 0;
@@ -971,11 +1039,13 @@ void composeNode(ComposeState& state, const NodeTree& tree,
} // anonymous namespace
ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId,
bool compactColumns, bool treeLines, bool braceWrap) {
bool compactColumns, bool treeLines, bool braceWrap,
bool typeHints) {
ComposeState state;
state.compactColumns = compactColumns;
state.treeLines = treeLines;
state.braceWrap = braceWrap;
state.typeHints = typeHints;
// Precompute parent→children map
for (int i = 0; i < tree.nodes.size(); i++)

View File

@@ -73,8 +73,8 @@ RcxDocument::RcxDocument(QObject* parent)
}
ComposeResult RcxDocument::compose(uint64_t viewRootId, bool compactColumns,
bool treeLines, bool braceWrap) const {
return rcx::compose(tree, *provider, viewRootId, compactColumns, treeLines, braceWrap);
bool treeLines, bool braceWrap, bool typeHints) const {
return rcx::compose(tree, *provider, viewRootId, compactColumns, treeLines, braceWrap, typeHints);
}
bool RcxDocument::save(const QString& path) {
@@ -190,9 +190,10 @@ RcxEditor* RcxController::addSplitEditor(QWidget* parent) {
// Eagerly pre-warm the type popup so first click isn't slow (~350ms cold start).
if (!m_cachedPopup) {
QTimer::singleShot(0, this, [this, editor]() {
if (!m_cachedPopup && !m_editors.isEmpty())
ensurePopup(editor);
QPointer<RcxEditor> safeEditor = editor;
QTimer::singleShot(0, this, [this, safeEditor]() {
if (!m_cachedPopup && !m_editors.isEmpty() && safeEditor)
ensurePopup(safeEditor);
});
}
return editor;
@@ -200,7 +201,7 @@ RcxEditor* RcxController::addSplitEditor(QWidget* parent) {
void RcxController::removeSplitEditor(RcxEditor* editor) {
m_editors.removeOne(editor);
// Caller (MainWindow) owns the parent QTabWidget and handles widget destruction.
editor->disconnect(this);
}
void RcxController::connectEditor(RcxEditor* editor) {
@@ -246,6 +247,67 @@ void RcxController::connectEditor(RcxEditor* editor) {
}
});
// Footer "+1024" button
connect(editor, &RcxEditor::appendBytesRequested,
this, [this](uint64_t structId, int byteCount) {
int hex64Count = byteCount / 8;
int remainBytes = byteCount % 8;
m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QStringLiteral("Append %1 bytes").arg(byteCount));
for (int i = 0; i < hex64Count; i++)
insertNode(structId, -1, NodeKind::Hex64,
QStringLiteral("field_%1").arg(i));
for (int i = 0; i < remainBytes; i++)
insertNode(structId, -1, NodeKind::Hex8,
QStringLiteral("field_%1").arg(hex64Count + i));
m_doc->undoStack.endMacro();
m_suppressRefresh = false;
refresh();
});
// Footer "Trim" button — remove trailing hex nodes from end of struct
connect(editor, &RcxEditor::trimHexRequested,
this, [this](uint64_t structId) {
QVector<int> children = m_doc->tree.childrenOf(structId);
if (children.isEmpty()) return;
// Sort by offset descending to find trailing hex nodes
std::sort(children.begin(), children.end(), [&](int a, int b) {
return m_doc->tree.nodes[a].offset > m_doc->tree.nodes[b].offset;
});
// Collect trailing hex nodes to remove
QVector<int> toRemove;
for (int ci : children) {
const Node& n = m_doc->tree.nodes[ci];
if (!isHexNode(n.kind)) break;
toRemove.append(ci);
}
if (toRemove.isEmpty()) return;
m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QStringLiteral("Trim %1 trailing hex nodes").arg(toRemove.size()));
for (int ni : toRemove)
removeNode(ni);
m_doc->undoStack.endMacro();
m_suppressRefresh = false;
refresh();
});
// Footer "+10" button — append enum members sequentially from highest value
connect(editor, &RcxEditor::appendEnumMembersRequested,
this, [this](uint64_t enumId, int count) {
int ni = m_doc->tree.indexOfId(enumId);
if (ni < 0) return;
auto members = m_doc->tree.nodes[ni].enumMembers;
int64_t nextVal = members.isEmpty() ? 0 : members.last().second + 1;
auto oldMembers = members;
for (int i = 0; i < count; i++)
members.append({QStringLiteral("Member%1").arg(nextVal + i), nextVal + i});
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeEnumMembers{enumId, oldMembers, members}));
});
// Inline editing signals
connect(editor, &RcxEditor::inlineEditCommitted,
this, [this](int nodeIdx, int subLine, EditTarget target, const QString& text,
@@ -548,9 +610,9 @@ void RcxController::refresh() {
// Compose against snapshot provider if active, otherwise real provider
if (m_snapshotProv)
m_lastResult = rcx::compose(m_doc->tree, *m_snapshotProv, m_viewRootId, m_compactColumns, m_treeLines, m_braceWrap);
m_lastResult = rcx::compose(m_doc->tree, *m_snapshotProv, m_viewRootId, m_compactColumns, m_treeLines, m_braceWrap, m_typeHints);
else
m_lastResult = m_doc->compose(m_viewRootId, m_compactColumns, m_treeLines, m_braceWrap);
m_lastResult = m_doc->compose(m_viewRootId, m_compactColumns, m_treeLines, m_braceWrap, m_typeHints);
s_composeDoc = nullptr;
@@ -1850,6 +1912,40 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
// Fall through to always-available actions
} else {
// ── Inference-based quick convert (from type hints) ──
if (isHexNode(node.kind) && line >= 0 && line < m_lastResult.meta.size()) {
const auto& lm = m_lastResult.meta[line];
if (!lm.typeHintKinds.isEmpty()) {
NodeKind suggested = lm.typeHintKinds[0];
if (lm.typeHintKinds.size() == 1) {
auto* m = kindMeta(suggested);
QString label = QStringLiteral("Convert to %1").arg(QString::fromLatin1(m->typeName));
menu.addAction(label, [this, nodeId, suggested]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) changeNodeKind(ni, suggested);
});
} else {
auto* m = kindMeta(lm.typeHintKinds[0]);
QString label = QStringLiteral("Split into %1\u00D7%2")
.arg(QString::fromLatin1(m->typeName))
.arg(lm.typeHintKinds.size());
menu.addAction(label, [this, nodeId, kinds = lm.typeHintKinds]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni < 0) return;
changeNodeKind(ni, kinds[0]);
for (int k = 1; k < kinds.size(); ++k) {
ni = m_doc->tree.indexOfId(nodeId);
if (ni < 0) break;
int next = ni + 1;
if (next < m_doc->tree.nodes.size() && isHexNode(m_doc->tree.nodes[next].kind))
changeNodeKind(next, kinds[k]);
}
});
}
menu.addSeparator();
}
}
// ── Quick-convert suggestions (top-level for fast access) ──
bool addedQuickConvert = false;
if (node.kind == NodeKind::Hex64) {
@@ -3130,8 +3226,8 @@ void RcxController::switchToSavedSource(int idx) {
// Restore formula before attach so it can be re-evaluated against the new provider
m_doc->tree.baseAddressFormula = entry.baseAddressFormula;
attachViaPlugin(entry.kind, entry.providerTarget);
// Restore saved base address (user may have navigated away from provider default)
if (entry.baseAddress != 0 && entry.baseAddressFormula.isEmpty())
// Restore saved base address — always override with saved value on source switch
if (entry.baseAddressFormula.isEmpty())
m_doc->tree.baseAddress = entry.baseAddress;
}
}
@@ -3313,6 +3409,11 @@ void RcxController::setBraceWrap(bool v) {
refresh();
}
void RcxController::setTypeHints(bool v) {
m_typeHints = v;
refresh();
}
void RcxController::setupAutoRefresh() {
int ms = QSettings("Reclass", "Reclass").value("refreshMs", 660).toInt();
m_refreshTimer = new QTimer(this);

View File

@@ -41,7 +41,8 @@ public:
}
ComposeResult compose(uint64_t viewRootId = 0, bool compactColumns = false,
bool treeLines = false, bool braceWrap = false) const;
bool treeLines = false, bool braceWrap = false,
bool typeHints = false) const;
bool save(const QString& path);
bool load(const QString& path);
void loadData(const QString& binaryPath);
@@ -131,6 +132,8 @@ public:
void setCompactColumns(bool v);
void setTreeLines(bool v);
void setBraceWrap(bool v);
void setTypeHints(bool v);
bool typeHints() const { return m_typeHints; }
void resetProvider();
// MCP bridge accessors
@@ -171,6 +174,7 @@ private:
bool m_compactColumns = false;
bool m_treeLines = false;
bool m_braceWrap = false;
bool m_typeHints = false;
uint64_t m_viewRootId = 0;
// ── Saved sources for quick-switch ──

View File

@@ -450,8 +450,8 @@ struct NodeTree {
if (c.isStatic) continue; // static fields 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;
if (end > maxEnd) maxEnd = end;
int64_t end = (int64_t)c.offset + sz;
if (end > maxEnd) maxEnd = (int)qMin(end, (int64_t)INT_MAX);
}
// Embedded struct reference: no own children but refId points to a struct definition
@@ -570,13 +570,13 @@ static constexpr int kCommandRowLine = 0;
static constexpr int kFirstDataLine = 1;
static constexpr uint64_t kFooterIdBit = 0x8000000000000000ULL;
static constexpr uint64_t kArrayElemBit = 0x4000000000000000ULL; // marks array element selection
static constexpr uint64_t kArrayElemShift = 48; // bits 48-61 hold element index
static constexpr uint64_t kArrayElemMask = 0x3FFF000000000000ULL; // 14 bits → max 16383 elements
static constexpr uint64_t kArrayElemShift = 42; // bits 42-61 hold element index
static constexpr uint64_t kArrayElemMask = 0x3FFFFC0000000000ULL; // 20 bits → max 1048575 elements
// Encode an array element selection ID: nodeId | kArrayElemBit | (elemIdx << 48)
// Encode an array element selection ID: nodeId | kArrayElemBit | (elemIdx << 42)
inline uint64_t makeArrayElemSelId(uint64_t nodeId, int elemIdx) {
Q_ASSERT(elemIdx >= 0);
return nodeId | kArrayElemBit | ((uint64_t)(elemIdx & 0x3FFF) << kArrayElemShift);
return nodeId | kArrayElemBit | ((uint64_t)(elemIdx & 0xFFFFF) << kArrayElemShift);
}
inline int arrayElemIdxFromSelId(uint64_t selId) {
return (int)((selId & kArrayElemMask) >> kArrayElemShift);
@@ -584,11 +584,11 @@ inline int arrayElemIdxFromSelId(uint64_t selId) {
// 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;
static constexpr uint64_t kMemberSubShift = 42;
static constexpr uint64_t kMemberSubMask = 0x3FFFFC0000000000ULL;
inline uint64_t makeMemberSelId(uint64_t nodeId, int subLine) {
return nodeId | kMemberBit | ((uint64_t)(subLine & 0x3FFF) << kMemberSubShift);
return nodeId | kMemberBit | ((uint64_t)(subLine & 0xFFFFF) << kMemberSubShift);
}
inline int memberSubFromSelId(uint64_t selId) {
return (int)((selId & kMemberSubMask) >> kMemberSubShift);
@@ -625,6 +625,9 @@ struct LineMeta {
bool isArrayElement = false; // true for synthesized primitive array element lines
bool isMemberLine = false; // true for enum member / bitfield member lines
bool isStaticLine = false; // true for static field node lines
QString typeHint; // Type inference hint text (e.g. "Float×2") — only set for hex nodes when hints enabled
int typeHintStart = -1; // Character offset where hint text starts in line text (-1 = none)
QVector<NodeKind> typeHintKinds; // Suggested kinds from inference (empty = no hint)
};
inline bool isSyntheticLine(const LineMeta& lm) {
@@ -1037,6 +1040,6 @@ namespace fmt {
ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId = 0,
bool compactColumns = false, bool treeLines = false,
bool braceWrap = false);
bool braceWrap = false, bool typeHints = false);
} // namespace rcx

View File

@@ -32,17 +32,14 @@ namespace rcx {
// Forward declaration (defined below, after RcxEditor constructor)
static QString getLineText(QsciScintilla* sci, int line);
// ── Value history popup (styled like TypeSelectorPopup) ──
// ── Base class for all hover popups ──
class ValueHistoryPopup : public QFrame {
class HoverPopup : public QFrame {
protected:
uint64_t m_nodeId = 0;
bool m_hasButtons = false;
QStringList m_values;
QVector<QLabel*> m_labels;
std::function<void(const QString&)> m_onSet;
std::function<void(QMouseEvent*)> m_onMouseMove;
public:
explicit ValueHistoryPopup(QWidget* parent)
explicit HoverPopup(QWidget* parent)
: QFrame(parent, Qt::ToolTip | Qt::FramelessWindowHint)
{
setAttribute(Qt::WA_DeleteOnClose, false);
@@ -53,9 +50,129 @@ public:
}
uint64_t nodeId() const { return m_nodeId; }
void setOnMouseMove(std::function<void(QMouseEvent*)> fn) { m_onMouseMove = std::move(fn); }
void showAt(const QPoint& globalPos, int lineHeight = 0) {
QSize sz = sizeHint();
QRect screen = QApplication::screenAt(globalPos)
? QApplication::screenAt(globalPos)->availableGeometry()
: QRect(0, 0, 1920, 1080);
int x = qMin(globalPos.x(), screen.right() - sz.width());
int y = globalPos.y();
if (y + sz.height() > screen.bottom())
y = globalPos.y() - sz.height() - lineHeight - 4;
move(x, y);
if (!isVisible()) show();
}
virtual void dismiss() {
if (isVisible()) hide();
m_nodeId = 0;
}
protected:
void mouseMoveEvent(QMouseEvent* e) override {
if (m_onMouseMove) m_onMouseMove(e);
else QFrame::mouseMoveEvent(e);
}
void applyThemePalette(const Theme& t) {
QPalette pal;
pal.setColor(QPalette::Window, t.backgroundAlt);
pal.setColor(QPalette::WindowText, t.text);
setPalette(pal);
}
void styleSeparator(const Theme& t) {
for (auto* child : findChildren<QFrame*>()) {
if (child->frameShape() == QFrame::HLine) {
QPalette sp;
sp.setColor(QPalette::WindowText, t.border);
child->setPalette(sp);
break;
}
}
}
};
// ── Title + body popup (used for disasm/hex-dump and struct preview) ──
class TitleBodyPopup : public HoverPopup {
QString m_body;
QLabel* m_titleLabel = nullptr;
QLabel* m_bodyLabel = nullptr;
public:
explicit TitleBodyPopup(QWidget* parent) : HoverPopup(parent) {
auto* vbox = new QVBoxLayout(this);
vbox->setContentsMargins(8, 6, 8, 6);
vbox->setSpacing(2);
m_titleLabel = new QLabel;
QFont bold = m_titleLabel->font();
bold.setBold(true);
m_titleLabel->setFont(bold);
vbox->addWidget(m_titleLabel);
auto* sep = new QFrame;
sep->setFrameShape(QFrame::HLine);
sep->setFrameShadow(QFrame::Plain);
sep->setFixedHeight(1);
vbox->addWidget(sep);
m_bodyLabel = new QLabel;
m_bodyLabel->setTextFormat(Qt::PlainText);
m_bodyLabel->setWordWrap(false);
vbox->addWidget(m_bodyLabel);
}
void populate(uint64_t nodeId, const QString& title, const QString& body,
const QFont& font, const QColor& bodyColor) {
if (nodeId == m_nodeId && body == m_body && isVisible())
return;
m_nodeId = nodeId;
m_body = body;
const auto& theme = ThemeManager::instance().current();
applyThemePalette(theme);
QFont bold = font;
bold.setBold(true);
m_titleLabel->setFont(bold);
m_titleLabel->setText(title);
m_titleLabel->setStyleSheet(
QStringLiteral("color: %1;").arg(theme.text.name()));
styleSeparator(theme);
m_bodyLabel->setFont(font);
m_bodyLabel->setText(body);
m_bodyLabel->setStyleSheet(
QStringLiteral("color: %1;").arg(bodyColor.name()));
setMaximumWidth(600);
adjustSize();
}
void dismiss() override {
HoverPopup::dismiss();
m_body.clear();
}
};
// ── Value history popup ──
class ValueHistoryPopup : public HoverPopup {
bool m_hasButtons = false;
QStringList m_values;
QVector<QLabel*> m_labels;
std::function<void(const QString&)> m_onSet;
public:
explicit ValueHistoryPopup(QWidget* parent) : HoverPopup(parent) {}
bool hasButtons() const { return m_hasButtons; }
void setOnSet(std::function<void(const QString&)> fn) { m_onSet = std::move(fn); }
void setOnMouseMove(std::function<void(QMouseEvent*)> fn) { m_onMouseMove = std::move(fn); }
protected:
void mouseMoveEvent(QMouseEvent* e) override {
if (!m_hasButtons && m_onMouseMove)
@@ -63,8 +180,8 @@ protected:
else
QFrame::mouseMoveEvent(e);
}
public:
public:
void populate(uint64_t nodeId, const ValueHistory& hist, const QFont& font,
bool showButtons = false) {
QStringList vals;
@@ -93,10 +210,7 @@ public:
qDeleteAll(findChildren<QWidget*>(QString(), Qt::FindDirectChildrenOnly));
const auto& theme = ThemeManager::instance().current();
QPalette pal;
pal.setColor(QPalette::Window, theme.backgroundAlt);
pal.setColor(QPalette::WindowText, theme.text);
setPalette(pal);
applyThemePalette(theme);
auto* vbox = new QVBoxLayout(this);
vbox->setContentsMargins(8, 6, 8, 6);
@@ -169,240 +283,13 @@ public:
adjustSize();
}
void showAt(const QPoint& globalPos, int lineHeight = 0) {
QSize sz = sizeHint();
QRect screen = QApplication::screenAt(globalPos)
? QApplication::screenAt(globalPos)->availableGeometry()
: QRect(0, 0, 1920, 1080);
int x = qMin(globalPos.x(), screen.right() - sz.width());
int y = globalPos.y();
if (y + sz.height() > screen.bottom())
y = globalPos.y() - sz.height() - lineHeight - 4;
move(x, y);
if (!isVisible()) show();
}
void dismiss() {
if (isVisible()) hide();
m_nodeId = 0;
void dismiss() override {
HoverPopup::dismiss();
m_values.clear();
m_labels.clear();
}
};
// ── Disassembly / hex-dump hover popup ──
class DisasmPopup : public QFrame {
uint64_t m_nodeId = 0;
QString m_body;
QLabel* m_titleLabel = nullptr;
QLabel* m_bodyLabel = nullptr;
std::function<void(QMouseEvent*)> m_onMouseMove;
public:
explicit DisasmPopup(QWidget* parent)
: QFrame(parent, Qt::ToolTip | Qt::FramelessWindowHint)
{
setAttribute(Qt::WA_DeleteOnClose, false);
setAttribute(Qt::WA_ShowWithoutActivating, true);
setMouseTracking(true);
setFrameShape(QFrame::NoFrame);
setAutoFillBackground(true);
auto* vbox = new QVBoxLayout(this);
vbox->setContentsMargins(8, 6, 8, 6);
vbox->setSpacing(2);
m_titleLabel = new QLabel;
QFont bold = m_titleLabel->font();
bold.setBold(true);
m_titleLabel->setFont(bold);
vbox->addWidget(m_titleLabel);
auto* sep = new QFrame;
sep->setFrameShape(QFrame::HLine);
sep->setFrameShadow(QFrame::Plain);
sep->setFixedHeight(1);
vbox->addWidget(sep);
m_bodyLabel = new QLabel;
m_bodyLabel->setTextFormat(Qt::PlainText);
m_bodyLabel->setWordWrap(false);
vbox->addWidget(m_bodyLabel);
}
void setOnMouseMove(std::function<void(QMouseEvent*)> fn) { m_onMouseMove = std::move(fn); }
uint64_t nodeId() const { return m_nodeId; }
protected:
void mouseMoveEvent(QMouseEvent* e) override {
if (m_onMouseMove) m_onMouseMove(e);
else QFrame::mouseMoveEvent(e);
}
public:
void populate(uint64_t nodeId, const QString& title, const QString& body,
const QFont& font) {
if (nodeId == m_nodeId && body == m_body && isVisible())
return;
m_nodeId = nodeId;
m_body = body;
const auto& theme = ThemeManager::instance().current();
QPalette pal;
pal.setColor(QPalette::Window, theme.backgroundAlt);
pal.setColor(QPalette::WindowText, theme.text);
setPalette(pal);
QFont bold = font;
bold.setBold(true);
m_titleLabel->setFont(bold);
m_titleLabel->setText(title);
m_titleLabel->setStyleSheet(
QStringLiteral("color: %1;").arg(theme.text.name()));
// Find and style the separator
for (auto* child : findChildren<QFrame*>()) {
if (child->frameShape() == QFrame::HLine) {
QPalette sp;
sp.setColor(QPalette::WindowText, theme.border);
child->setPalette(sp);
break;
}
}
m_bodyLabel->setFont(font);
m_bodyLabel->setText(body);
m_bodyLabel->setStyleSheet(
QStringLiteral("color: %1;").arg(theme.syntaxNumber.name()));
setMaximumWidth(600);
adjustSize();
}
void showAt(const QPoint& globalPos, int lineHeight = 0) {
QSize sz = sizeHint();
QRect screen = QApplication::screenAt(globalPos)
? QApplication::screenAt(globalPos)->availableGeometry()
: QRect(0, 0, 1920, 1080);
int x = qMin(globalPos.x(), screen.right() - sz.width());
int y = globalPos.y();
if (y + sz.height() > screen.bottom())
y = globalPos.y() - sz.height() - lineHeight - 4;
move(x, y);
if (!isVisible()) show();
}
void dismiss() {
if (isVisible()) hide();
m_nodeId = 0;
m_body.clear();
}
};
class StructPreviewPopup : public QFrame {
uint64_t m_nodeId = 0;
QString m_body;
QLabel* m_titleLabel = nullptr;
QLabel* m_bodyLabel = nullptr;
std::function<void(QMouseEvent*)> m_onMouseMove;
public:
explicit StructPreviewPopup(QWidget* parent)
: QFrame(parent, Qt::ToolTip | Qt::FramelessWindowHint)
{
setAttribute(Qt::WA_DeleteOnClose, false);
setAttribute(Qt::WA_ShowWithoutActivating, true);
setMouseTracking(true);
setFrameShape(QFrame::NoFrame);
setAutoFillBackground(true);
auto* vbox = new QVBoxLayout(this);
vbox->setContentsMargins(8, 6, 8, 6);
vbox->setSpacing(2);
m_titleLabel = new QLabel;
QFont bold = m_titleLabel->font();
bold.setBold(true);
m_titleLabel->setFont(bold);
vbox->addWidget(m_titleLabel);
auto* sep = new QFrame;
sep->setFrameShape(QFrame::HLine);
sep->setFrameShadow(QFrame::Plain);
sep->setFixedHeight(1);
vbox->addWidget(sep);
m_bodyLabel = new QLabel;
m_bodyLabel->setTextFormat(Qt::PlainText);
m_bodyLabel->setWordWrap(false);
vbox->addWidget(m_bodyLabel);
}
uint64_t nodeId() const { return m_nodeId; }
void setOnMouseMove(std::function<void(QMouseEvent*)> fn) { m_onMouseMove = std::move(fn); }
protected:
void mouseMoveEvent(QMouseEvent* e) override {
if (m_onMouseMove) m_onMouseMove(e);
else QFrame::mouseMoveEvent(e);
}
public:
void populate(uint64_t nodeId, const QString& title, const QString& body,
const QFont& font) {
if (nodeId == m_nodeId && body == m_body && isVisible())
return;
m_nodeId = nodeId;
m_body = body;
const auto& theme = ThemeManager::instance().current();
QPalette pal;
pal.setColor(QPalette::Window, theme.backgroundAlt);
pal.setColor(QPalette::WindowText, theme.text);
setPalette(pal);
QFont bold = font;
bold.setBold(true);
m_titleLabel->setFont(bold);
m_titleLabel->setText(title);
m_titleLabel->setStyleSheet(
QStringLiteral("color: %1;").arg(theme.text.name()));
for (auto* child : findChildren<QFrame*>()) {
if (child->frameShape() == QFrame::HLine) {
QPalette sp;
sp.setColor(QPalette::WindowText, theme.border);
child->setPalette(sp);
break;
}
}
m_bodyLabel->setFont(font);
m_bodyLabel->setText(body);
m_bodyLabel->setStyleSheet(
QStringLiteral("color: %1;").arg(theme.text.name()));
setMaximumWidth(600);
adjustSize();
}
void showAt(const QPoint& globalPos, int lineHeight = 0) {
QSize sz = sizeHint();
QRect screen = QApplication::screenAt(globalPos)
? QApplication::screenAt(globalPos)->availableGeometry()
: QRect(0, 0, 1920, 1080);
int x = qMin(globalPos.x(), screen.right() - sz.width());
int y = globalPos.y();
if (y + sz.height() > screen.bottom())
y = globalPos.y() - sz.height() - lineHeight - 4;
move(x, y);
if (!isVisible()) show();
}
void dismiss() {
if (isVisible()) hide();
m_nodeId = 0;
m_body.clear();
}
};
static constexpr int IND_EDITABLE = 8;
static constexpr int IND_HEX_DIM = 9;
static constexpr int IND_BASE_ADDR = 10; // Default text color override for command row address
@@ -415,6 +302,7 @@ static constexpr int IND_LOCAL_OFF = 16; // Dim text for inline local offset
static constexpr int IND_HEAT_WARM = 17; // Heatmap level 2 (moderate changes)
static constexpr int IND_HEAT_HOT = 18; // Heatmap level 3 (frequent changes)
static constexpr int IND_FIND = 19; // Search match highlight
static constexpr int IND_TYPE_HINT = 20; // Dimmed type inference hint text on hex nodes
static QString g_fontName = "JetBrains Mono";
@@ -724,6 +612,10 @@ void RcxEditor::setupScintilla() {
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_LOCAL_OFF, 17 /*INDIC_TEXTFORE*/);
// Type inference hint — dimmed text appended to hex lines
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_TYPE_HINT, 17 /*INDIC_TEXTFORE*/);
// Find match highlight — thick underline (avoids box rendering artifacts)
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_FIND, 14 /*INDIC_COMPOSITIONTHICK*/);
@@ -869,6 +761,8 @@ void RcxEditor::applyTheme(const Theme& theme) {
IND_HINT_GREEN, theme.indHintGreen);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_LOCAL_OFF, theme.textFaint);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_TYPE_HINT, theme.indHintGreen);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_FIND, theme.borderFocused);
@@ -973,15 +867,22 @@ void RcxEditor::applyDocument(const ComposeResult& result) {
m_sci->setText(result.text);
m_sci->setReadOnly(true);
// Set horizontal scroll width to match the longest line (ignoring trailing spaces)
// Set horizontal scroll width to match the longest line (ignoring trailing spaces).
// Single-pass scan avoids QString::split() allocation of entire QStringList.
{
int maxLen = 0;
const QStringList lines = result.text.split(QChar('\n'));
for (const auto& line : lines) {
int len = (int)line.size();
while (len > 0 && line[len - 1] == QChar(' ')) --len;
maxLen = std::max(len, maxLen);
int maxLen = 0, curLen = 0, lastNonSpace = 0;
for (int i = 0; i < result.text.size(); i++) {
QChar ch = result.text[i];
if (ch == '\n') {
maxLen = qMax(maxLen, lastNonSpace);
curLen = 0;
lastNonSpace = 0;
} else {
++curLen;
if (ch != ' ') lastNonSpace = curLen;
}
}
maxLen = qMax(maxLen, lastNonSpace);
QFontMetrics fm(editorFont());
int pixelWidth = fm.horizontalAdvance(QString(maxLen, QChar('0')));
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSCROLLWIDTH,
@@ -995,14 +896,56 @@ void RcxEditor::applyDocument(const ComposeResult& result) {
// Force full re-lex to fix stale syntax coloring after edits
m_sci->SendScintilla(QsciScintillaBase::SCI_COLOURISE, (uintptr_t)0, (long)-1);
applyMarginText(result.meta);
applyMarkers(result.meta);
applyFoldLevels(result.meta);
applyLineAttributes(result.meta);
applyHexDimming(result.meta);
applyHeatmapHighlight(result.meta);
applySymbolColoring(result.meta);
// Build line-text cache for indicator passes (avoids redundant Scintilla IPC)
QVector<QString> lineTexts(result.meta.size());
for (int i = 0; i < result.meta.size(); i++) {
const auto& lm = result.meta[i];
if (lm.heatLevel > 0 || isFuncPtr(lm.nodeKind) ||
lm.nodeKind == NodeKind::Pointer32 ||
lm.nodeKind == NodeKind::Pointer64 ||
lm.lineKind == LineKind::Footer ||
lm.typeHintStart >= 0)
lineTexts[i] = getLineText(m_sci, i);
}
applyHeatmapHighlight(result.meta, lineTexts);
applySymbolColoring(result.meta, lineTexts);
applyCommandRowPills();
// Footer buttons — pill styling
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].lineKind != LineKind::Footer) continue;
const QString& ft = lineTexts[i];
// Struct footer: +10h +100h +1000h Trim (search longest first)
int p1000 = ft.indexOf(QStringLiteral("+1000h"));
if (p1000 >= 0)
fillIndicatorCols(IND_CMD_PILL, i, p1000, p1000 + 6);
int p100 = ft.indexOf(QStringLiteral("+100h"));
if (p100 >= 0 && p100 != p1000 + 1)
fillIndicatorCols(IND_CMD_PILL, i, p100, p100 + 5);
int p10 = ft.indexOf(QStringLiteral("+10h"));
if (p10 >= 0 && p10 != p100 && p10 != p1000)
fillIndicatorCols(IND_CMD_PILL, i, p10, p10 + 4);
// Enum footer: +10 (no 'h')
int add10Start = ft.indexOf(QStringLiteral("+10"));
if (add10Start >= 0 && add10Start != p10 && add10Start != p100 && add10Start != p1000)
fillIndicatorCols(IND_CMD_PILL, i, add10Start, add10Start + 3);
int trimStart = ft.indexOf(QStringLiteral("Trim"));
if (trimStart >= 0)
fillIndicatorCols(IND_CMD_PILL, i, trimStart, trimStart + 4);
}
// Apply type inference hint coloring (green, same as comment annotations)
for (int i = 0; i < result.meta.size(); i++) {
const auto& lm = result.meta[i];
if (lm.typeHintStart < 0) continue;
const QString& ft = lineTexts[i];
if (lm.typeHintStart < ft.size())
fillIndicatorCols(IND_TYPE_HINT, i, lm.typeHintStart, ft.size());
}
// Reset hint line - applySelectionOverlay will repaint indicators
m_hintLine = -1;
@@ -1015,9 +958,7 @@ void RcxEditor::applyDocument(const ComposeResult& result) {
if (m_hoveredNodeId != 0 && !m_nodeLineIndex.contains(m_hoveredNodeId)) {
m_hoveredNodeId = 0;
m_hoveredLine = -1;
dismissHistoryPopup();
if (m_disasmPopup) m_disasmPopup->hide();
if (m_structPreviewPopup) m_structPreviewPopup->hide();
dismissAllPopups();
}
// Re-apply hover markers (setText() clears all Scintilla markers).
@@ -1051,22 +992,47 @@ void RcxEditor::applyDocument(const ComposeResult& result) {
}
}
void RcxEditor::applyMarginText(const QVector<LineMeta>& meta) {
if (m_relativeOffsets)
return reformatMargins();
void RcxEditor::applyLineAttributes(const QVector<LineMeta>& meta) {
// Margin text
if (m_relativeOffsets) {
reformatMargins();
} else {
m_sci->clearMarginText(-1);
}
m_sci->clearMarginText(-1);
// Clear markers
for (int m = M_CONT; m <= M_STRUCT_BG; m++)
m_sci->markerDeleteAll(m);
m_sci->markerDeleteAll(M_CMD_ROW);
// Single pass: margin text (absolute mode), markers, fold levels
for (int i = 0; i < meta.size(); i++) {
const auto& lm = meta[i];
if (lm.offsetText.isEmpty()) continue;
QByteArray text = lm.offsetText.toUtf8();
m_sci->SendScintilla(QsciScintillaBase::SCI_MARGINSETTEXT,
(uintptr_t)i, text.constData());
QByteArray styles(text.size(), '\0'); // style 0 = dim
m_sci->SendScintilla(QsciScintillaBase::SCI_MARGINSETSTYLES,
(uintptr_t)i, styles.constData());
// Margin text (only in absolute offset mode; reformatMargins handles relative)
if (!m_relativeOffsets && !lm.offsetText.isEmpty()) {
QByteArray text = lm.offsetText.toUtf8();
m_sci->SendScintilla(QsciScintillaBase::SCI_MARGINSETTEXT,
(uintptr_t)i, text.constData());
QByteArray styles(text.size(), '\0');
m_sci->SendScintilla(QsciScintillaBase::SCI_MARGINSETSTYLES,
(uintptr_t)i, styles.constData());
}
// Markers
if (lm.lineKind == LineKind::CommandRow) {
m_sci->markerAdd(i, M_CMD_ROW);
} else {
uint32_t mask = lm.markerMask;
for (int m = M_CONT; m <= M_STRUCT_BG; m++) {
if (mask & (1u << m))
m_sci->markerAdd(i, m);
}
}
// Fold level
m_sci->SendScintilla(QsciScintillaBase::SCI_SETFOLDLEVEL,
(unsigned long)i, (long)lm.foldLevel);
}
}
@@ -1187,31 +1153,6 @@ void RcxEditor::reformatMargins() {
m_sci->setReadOnly(true);
}
void RcxEditor::applyMarkers(const QVector<LineMeta>& meta) {
for (int m = M_CONT; m <= M_STRUCT_BG; m++) {
m_sci->markerDeleteAll(m);
}
m_sci->markerDeleteAll(M_CMD_ROW);
for (int i = 0; i < meta.size(); i++) {
if (meta[i].lineKind == LineKind::CommandRow) {
m_sci->markerAdd(i, M_CMD_ROW);
continue;
}
uint32_t mask = meta[i].markerMask;
for (int m = M_CONT; m <= M_STRUCT_BG; m++) {
if (mask & (1u << m)) {
m_sci->markerAdd(i, m);
}
}
}
}
void RcxEditor::applyFoldLevels(const QVector<LineMeta>& meta) {
for (int i = 0; i < meta.size(); i++) {
m_sci->SendScintilla(QsciScintillaBase::SCI_SETFOLDLEVEL,
(unsigned long)i, (long)meta[i].foldLevel);
}
}
static inline void lineRangeNoEol(QsciScintilla* sci, int line, long& start, long& len) {
start = sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE, (unsigned long)line);
@@ -1272,6 +1213,7 @@ void RcxEditor::applyHexDimming(const QVector<LineMeta>& meta) {
}
}
}
}
void RcxEditor::applySelectionOverlay(const QSet<uint64_t>& selIds) {
@@ -1448,7 +1390,13 @@ void RcxEditor::showFindBar() {
void RcxEditor::dismissHistoryPopup() {
if (m_historyPopup)
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
static_cast<HoverPopup*>(m_historyPopup)->dismiss();
}
void RcxEditor::dismissAllPopups() {
if (m_historyPopup) static_cast<HoverPopup*>(m_historyPopup)->dismiss();
if (m_disasmPopup) static_cast<HoverPopup*>(m_disasmPopup)->dismiss();
if (m_structPreviewPopup) static_cast<HoverPopup*>(m_structPreviewPopup)->dismiss();
}
void RcxEditor::hideFindBar() {
@@ -1524,7 +1472,8 @@ static QString getLineText(QsciScintilla* sci, int line) {
return text;
}
void RcxEditor::applyHeatmapHighlight(const QVector<LineMeta>& meta) {
void RcxEditor::applyHeatmapHighlight(const QVector<LineMeta>& meta,
const QVector<QString>& lineTexts) {
static constexpr int heatIndicators[] = { IND_HEAT_COLD, IND_HEAT_WARM, IND_HEAT_HOT };
for (int i = 0; i < meta.size(); i++) {
@@ -1546,7 +1495,7 @@ void RcxEditor::applyHeatmapHighlight(const QVector<LineMeta>& meta) {
int activeInd = heatIndicators[qBound(0, heat - 1, 2)];
// Apply heat-level indicator to value span (narrowed for pointer-like nodes)
QString lineText = getLineText(m_sci, i);
const QString& lineText = lineTexts[i];
ColumnSpan vs = narrowPtrValueSpan(lm,
valueSpan(lm, lineText.size(), typeW, nameW), lineText);
if (!vs.valid) continue;
@@ -1561,14 +1510,15 @@ void RcxEditor::applyHeatmapHighlight(const QVector<LineMeta>& meta) {
}
}
void RcxEditor::applySymbolColoring(const QVector<LineMeta>& meta) {
void RcxEditor::applySymbolColoring(const QVector<LineMeta>& meta,
const QVector<QString>& lineTexts) {
for (int i = 0; i < meta.size(); i++) {
const LineMeta& lm = meta[i];
if (!isFuncPtr(lm.nodeKind)
&& lm.nodeKind != NodeKind::Pointer32
&& lm.nodeKind != NodeKind::Pointer64)
continue;
QString lineText = getLineText(m_sci, i);
const QString& lineText = lineTexts[i];
// Find " // " within the value region and color "// sym" portion green
ColumnSpan vs = valueSpan(lm, lineText.size(), lm.effectiveTypeW, lm.effectiveNameW);
if (!vs.valid) continue;
@@ -2111,6 +2061,42 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
emit marginClicked(0, h.line, me->modifiers());
return true;
}
// Footer buttons: +10h/+100h/+1000h, +10 (enum), Trim
if (h.line >= 0 && h.line < m_meta.size()
&& m_meta[h.line].lineKind == LineKind::Footer) {
QString ft = getLineText(m_sci, h.line);
uint64_t nid = m_meta[h.line].nodeId;
// Struct: +1000h (0x1000 = 4096 bytes)
int p1000 = ft.indexOf(QStringLiteral("+1000h"));
if (p1000 >= 0 && h.col >= p1000 && h.col < p1000 + 6) {
emit appendBytesRequested(nid, 0x1000);
return true;
}
// Struct: +100h (0x100 = 256 bytes)
int p100 = ft.indexOf(QStringLiteral("+100h"));
if (p100 >= 0 && p100 != p1000 + 1 && h.col >= p100 && h.col < p100 + 5) {
emit appendBytesRequested(nid, 0x100);
return true;
}
// Struct: +10h (0x10 = 16 bytes)
int p10 = ft.indexOf(QStringLiteral("+10h"));
if (p10 >= 0 && p10 != p100 && p10 != p1000 && h.col >= p10 && h.col < p10 + 4) {
emit appendBytesRequested(nid, 0x10);
return true;
}
// Enum: +10 (10 members)
int add10Start = ft.indexOf(QStringLiteral("+10"));
if (add10Start >= 0 && add10Start != p10 && add10Start != p100 && add10Start != p1000
&& h.col >= add10Start && h.col < add10Start + 3) {
emit appendEnumMembersRequested(nid, 10);
return true;
}
int trimStart = ft.indexOf(QStringLiteral("Trim"));
if (trimStart >= 0 && h.col >= trimStart && h.col < trimStart + 4) {
emit trimHexRequested(nid);
return true;
}
}
// CommandRow: try chevron/ADDR edit or consume
if (h.nodeId == kCommandRowId) {
int tLine, tCol; EditTarget t;
@@ -2486,10 +2472,7 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
m_hoveredLine = -1;
applyHoverHighlight();
// Dismiss hover popups so they get recreated with Set buttons once edit starts
if (m_historyPopup)
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
if (m_structPreviewPopup)
static_cast<StructPreviewPopup*>(m_structPreviewPopup)->dismiss();
dismissAllPopups();
// Clear editable-token color hints (de-emphasize non-active tokens)
clearIndicatorLine(IND_EDITABLE, m_hintLine);
m_hintLine = -1;
@@ -2590,6 +2573,8 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
ColumnSpan cs = commentSpanFor(*lm, 9999, lm->effectiveTypeW, lm->effectiveNameW);
m_editState.commentCol = cs.valid ? cs.start : -1;
m_editState.lastValidationOk = true; // original value is always valid
} else if (target == EditTarget::BaseAddress) {
m_editState.commentCol = norm.end + 2; // command row has no column layout
} else {
m_editState.commentCol = -1;
}
@@ -2603,7 +2588,8 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
// For value editing: extend line with trailing spaces for the edit comment area
// (comment padding is no longer baked into every line to avoid unnecessary scroll width)
if (target == EditTarget::Value && m_editState.commentCol >= 0) {
if ((target == EditTarget::Value || target == EditTarget::BaseAddress)
&& m_editState.commentCol >= 0) {
int commentStart = norm.end + 2;
int neededLen = commentStart + kColComment;
int currentLen = (int)lineText.size();
@@ -2652,6 +2638,8 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
// Show initial edit hint in comment column
if (target == EditTarget::Value)
setEditComment(QStringLiteral("Enter=Save Esc=Cancel"));
else if (target == EditTarget::BaseAddress)
setEditComment(QStringLiteral("e.g. <mod.exe> + 0xFF | [0x1000 + 0x10] | 7ff6`1234ABCD"));
// Note: Type, ArrayElementType, PointerTarget are handled by TypeSelectorPopup
// and exit early above (never reach here).
@@ -3092,25 +3080,19 @@ void RcxEditor::applyHoverCursor() {
}
}
if (!showPopup && m_historyPopup && m_historyPopup->isVisible())
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
static_cast<HoverPopup*>(m_historyPopup)->dismiss();
}
// Always dismiss disasm/preview popups during inline editing
if (m_disasmPopup && m_disasmPopup->isVisible())
static_cast<DisasmPopup*>(m_disasmPopup)->dismiss();
if (m_structPreviewPopup && m_structPreviewPopup->isVisible())
static_cast<StructPreviewPopup*>(m_structPreviewPopup)->dismiss();
if (m_disasmPopup) static_cast<HoverPopup*>(m_disasmPopup)->dismiss();
if (m_structPreviewPopup) static_cast<HoverPopup*>(m_structPreviewPopup)->dismiss();
return;
}
// Mouse left viewport - set Arrow, dismiss popups
// (but not during applyDocument — the Leave is synthetic from setText)
if (!m_hoverInside) {
if (m_historyPopup && !m_applyingDocument)
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
if (m_disasmPopup && !m_applyingDocument)
static_cast<DisasmPopup*>(m_disasmPopup)->dismiss();
if (m_structPreviewPopup && !m_applyingDocument)
static_cast<StructPreviewPopup*>(m_structPreviewPopup)->dismiss();
if (!m_applyingDocument)
dismissAllPopups();
m_sci->viewport()->setCursor(Qt::ArrowCursor);
return;
}
@@ -3211,6 +3193,30 @@ void RcxEditor::applyHoverCursor() {
m_hoverSpanLines.append(h.line);
}
// Apply hover span on footer pills (+10h/+100h/+1000h, +10, Trim)
if (h.line >= 0 && h.line < m_meta.size()
&& m_meta[h.line].lineKind == LineKind::Footer) {
QString ft = getLineText(m_sci, h.line);
auto tryPill = [&](const QString& text, int pos) {
if (pos >= 0 && h.col >= pos && h.col < pos + text.size()) {
fillIndicatorCols(IND_HOVER_SPAN, h.line, pos, pos + text.size());
m_hoverSpanLines.append(h.line);
}
};
int p1000 = ft.indexOf(QStringLiteral("+1000h"));
tryPill(QStringLiteral("+1000h"), p1000);
int p100 = ft.indexOf(QStringLiteral("+100h"));
if (p100 >= 0 && p100 != p1000 + 1)
tryPill(QStringLiteral("+100h"), p100);
int p10 = ft.indexOf(QStringLiteral("+10h"));
if (p10 >= 0 && p10 != p100 && p10 != p1000)
tryPill(QStringLiteral("+10h"), p10);
int add10Start = ft.indexOf(QStringLiteral("+10"));
if (add10Start >= 0 && add10Start != p10 && add10Start != p100 && add10Start != p1000)
tryPill(QStringLiteral("+10"), add10Start);
tryPill(QStringLiteral("Trim"), ft.indexOf(QStringLiteral("Trim")));
}
// Value history popup on hover (read-only, no buttons)
// Skip FuncPtr and void-Pointer nodes — they use the disasm popup instead.
{
@@ -3266,7 +3272,7 @@ void RcxEditor::applyHoverCursor() {
}
}
if (!showPopup && m_historyPopup && m_historyPopup->isVisible())
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
static_cast<HoverPopup*>(m_historyPopup)->dismiss();
}
// Disasm / hex-dump popup on hover for FuncPtr and void Pointer nodes
@@ -3331,8 +3337,8 @@ void RcxEditor::applyHoverCursor() {
}
if (!body.isEmpty()) {
if (!m_disasmPopup) {
m_disasmPopup = new DisasmPopup(this);
static_cast<DisasmPopup*>(m_disasmPopup)->setOnMouseMove([this](QMouseEvent* e) {
m_disasmPopup = new TitleBodyPopup(this);
static_cast<TitleBodyPopup*>(m_disasmPopup)->setOnMouseMove([this](QMouseEvent* e) {
QPoint gp = e->globalPosition().toPoint();
QPoint vp = m_sci->viewport()->mapFromGlobal(gp);
m_lastHoverPos = vp;
@@ -3350,10 +3356,11 @@ void RcxEditor::applyHoverCursor() {
applyHoverCursor();
});
}
auto* popup = static_cast<DisasmPopup*>(
auto* popup = static_cast<TitleBodyPopup*>(
m_disasmPopup);
popup->populate(lm.nodeId, title, body,
editorFont());
editorFont(),
ThemeManager::instance().current().syntaxNumber);
long linePos = m_sci->SendScintilla(
QsciScintillaBase::SCI_POSITIONFROMLINE,
(unsigned long)h.line);
@@ -3374,7 +3381,7 @@ void RcxEditor::applyHoverCursor() {
showDisasm = true;
// Dismiss value history popup to avoid fighting
if (m_historyPopup && m_historyPopup->isVisible())
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
static_cast<HoverPopup*>(m_historyPopup)->dismiss();
}
}
}
@@ -3383,7 +3390,7 @@ void RcxEditor::applyHoverCursor() {
}
}
if (!showDisasm && m_disasmPopup && m_disasmPopup->isVisible())
static_cast<DisasmPopup*>(m_disasmPopup)->dismiss();
static_cast<HoverPopup*>(m_disasmPopup)->dismiss();
}
// Struct preview popup for collapsed typed pointers
@@ -3418,8 +3425,8 @@ void RcxEditor::applyHoverCursor() {
}
if (!body.isEmpty()) {
if (!m_structPreviewPopup) {
m_structPreviewPopup = new StructPreviewPopup(this);
static_cast<StructPreviewPopup*>(m_structPreviewPopup)->setOnMouseMove([this](QMouseEvent* e) {
m_structPreviewPopup = new TitleBodyPopup(this);
static_cast<TitleBodyPopup*>(m_structPreviewPopup)->setOnMouseMove([this](QMouseEvent* e) {
QPoint gp = e->globalPosition().toPoint();
QPoint vp = m_sci->viewport()->mapFromGlobal(gp);
m_lastHoverPos = vp;
@@ -3437,9 +3444,10 @@ void RcxEditor::applyHoverCursor() {
applyHoverCursor();
});
}
auto* popup = static_cast<StructPreviewPopup*>(m_structPreviewPopup);
auto* popup = static_cast<TitleBodyPopup*>(m_structPreviewPopup);
popup->populate(lm.nodeId,
lm.pointerTargetName, body, editorFont());
lm.pointerTargetName, body, editorFont(),
ThemeManager::instance().current().text);
long linePos = m_sci->SendScintilla(
QsciScintillaBase::SCI_POSITIONFROMLINE,
(unsigned long)h.line);
@@ -3458,14 +3466,14 @@ void RcxEditor::applyHoverCursor() {
popup->showAt(anchor, lh);
showPreview = true;
if (m_historyPopup && m_historyPopup->isVisible())
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
static_cast<HoverPopup*>(m_historyPopup)->dismiss();
}
}
}
}
}
if (!showPreview && m_structPreviewPopup && m_structPreviewPopup->isVisible())
static_cast<StructPreviewPopup*>(m_structPreviewPopup)->dismiss();
static_cast<HoverPopup*>(m_structPreviewPopup)->dismiss();
}
// Determine cursor shape based on interaction type
@@ -3473,6 +3481,25 @@ void RcxEditor::applyHoverCursor() {
if (h.inFoldCol) {
desired = Qt::PointingHandCursor; // fold toggle = button
} else if (h.line >= 0 && h.line < m_meta.size()
&& m_meta[h.line].lineKind == LineKind::Footer) {
QString ft = getLineText(m_sci, h.line);
int p1000 = ft.indexOf(QStringLiteral("+1000h"));
if (p1000 >= 0 && h.col >= p1000 && h.col < p1000 + 6)
desired = Qt::PointingHandCursor;
int p100 = ft.indexOf(QStringLiteral("+100h"));
if (p100 >= 0 && p100 != p1000 + 1 && h.col >= p100 && h.col < p100 + 5)
desired = Qt::PointingHandCursor;
int p10 = ft.indexOf(QStringLiteral("+10h"));
if (p10 >= 0 && p10 != p100 && p10 != p1000 && h.col >= p10 && h.col < p10 + 4)
desired = Qt::PointingHandCursor;
int add10Start = ft.indexOf(QStringLiteral("+10"));
if (add10Start >= 0 && add10Start != p10 && add10Start != p100 && add10Start != p1000
&& h.col >= add10Start && h.col < add10Start + 3)
desired = Qt::PointingHandCursor;
int trimStart = ft.indexOf(QStringLiteral("Trim"));
if (trimStart >= 0 && h.col >= trimStart && h.col < trimStart + 4)
desired = Qt::PointingHandCursor;
} else if (tokenHit) {
// Check if mouse is actually over trimmed text content (not column padding)
NormalizedSpan trimmed;

View File

@@ -37,6 +37,7 @@ public:
void scrollToNodeId(uint64_t nodeId);
void showFindBar();
void dismissHistoryPopup();
void dismissAllPopups();
// ── Column span computation ──
static ColumnSpan typeSpan(const LineMeta& lm, int typeW = kColType);
@@ -85,6 +86,9 @@ signals:
void typePickerRequested(EditTarget target, int nodeIdx, QPoint globalPos);
void insertAboveRequested(int nodeIdx, NodeKind kind);
void relativeOffsetsChanged(bool relative);
void appendBytesRequested(uint64_t structId, int byteCount);
void trimHexRequested(uint64_t structId);
void appendEnumMembersRequested(uint64_t enumId, int count);
protected:
bool eventFilter(QObject* obj, QEvent* event) override;
@@ -155,8 +159,8 @@ private:
// ── Value history ref (owned by controller) ──
const QHash<uint64_t, ValueHistory>* m_valueHistory = nullptr;
QWidget* m_historyPopup = nullptr; // ValueHistoryPopup (file-local class in editor.cpp)
QWidget* m_disasmPopup = nullptr; // DisasmPopup (file-local class in editor.cpp)
QWidget* m_structPreviewPopup = nullptr; // StructPreviewPopup (file-local class in editor.cpp)
QWidget* m_disasmPopup = nullptr; // TitleBodyPopup (file-local class in editor.cpp)
QWidget* m_structPreviewPopup = nullptr; // TitleBodyPopup (file-local class in editor.cpp)
const Provider* m_disasmProvider = nullptr; // snapshot or real — for reading tree data
const Provider* m_disasmRealProv = nullptr; // real process provider — for reading code at arbitrary addresses
const NodeTree* m_disasmTree = nullptr;
@@ -179,13 +183,11 @@ private:
void setupMarkers();
void allocateMarginStyles();
void applyMarginText(const QVector<LineMeta>& meta);
void applyLineAttributes(const QVector<LineMeta>& meta);
void reformatMargins();
void applyMarkers(const QVector<LineMeta>& meta);
void applyFoldLevels(const QVector<LineMeta>& meta);
void applyHexDimming(const QVector<LineMeta>& meta);
void applyHeatmapHighlight(const QVector<LineMeta>& meta);
void applySymbolColoring(const QVector<LineMeta>& meta);
void applyHeatmapHighlight(const QVector<LineMeta>& meta, const QVector<QString>& lineTexts);
void applySymbolColoring(const QVector<LineMeta>& meta, const QVector<QString>& lineTexts);
void applyBaseAddressColoring(const QVector<LineMeta>& meta);
void applyCommandRowPills();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -163,8 +163,13 @@ QString fmtStructHeader(const Node& node, int depth, bool collapsed, int colType
return ind + type + SEP + node.name + SEP + suffix;
}
QString fmtStructFooter(const Node& /*node*/, int depth, int /*totalSize*/) {
return indent(depth) + QStringLiteral("};");
QString fmtStructFooter(const Node& node, int depth, int /*totalSize*/) {
QString footer = indent(depth) + QStringLiteral("};");
if (node.resolvedClassKeyword() == QStringLiteral("enum"))
footer += QStringLiteral(" +10");
else
footer += QStringLiteral(" +10h +100h +1000h Trim");
return footer;
}
// ── Array header ──
@@ -656,8 +661,10 @@ QString validateValue(NodeKind kind, const QString& text) {
QString digits = hasHexPrefix ? s.mid(2) : s;
if (hasHexPrefix || isHexKind) {
// Hex mode: only 0-9, a-f, A-F
// Hex mode: only 0-9, a-f, A-F (spaces allowed for multi-byte hex kinds)
bool isMultiByteHex = (kind >= NodeKind::Hex16 && kind <= NodeKind::Hex64);
for (QChar c : digits) {
if (c == ' ' && isMultiByteHex) continue;
if (!c.isDigit() && !(c >= 'a' && c <= 'f') && !(c >= 'A' && c <= 'F'))
return QStringLiteral("invalid hex '%1'").arg(c);
}

View File

@@ -72,6 +72,7 @@ static QHash<QString, TypeInfo> buildTypeTable(int ptrSize = 8) {
t[QStringLiteral("USHORT")] = {NodeKind::UInt16, 2};
t[QStringLiteral("SHORT")] = {NodeKind::Int16, 2};
t[QStringLiteral("WCHAR")] = {NodeKind::UInt16, 2};
t[QStringLiteral("TCHAR")] = {NodeKind::UInt16, 2};
t[QStringLiteral("DWORD")] = {NodeKind::UInt32, 4};
t[QStringLiteral("ULONG")] = {NodeKind::UInt32, 4};
t[QStringLiteral("UINT")] = {NodeKind::UInt32, 4};
@@ -1187,6 +1188,16 @@ static int structTypeSize(const QString& typeName, const BuildContext& ctx) {
return 0;
}
// Compute total array elements from multi-dimensional sizes, capped to prevent overflow.
static int clampedArrayElements(const QVector<int>& dims, int maxElements = 1000000) {
int64_t total = 1;
for (int dim : dims) {
total *= (dim > 0 ? dim : 1);
if (total > maxElements) return maxElements;
}
return (int)total;
}
static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
const QVector<ParsedField>& fields) {
int computedOffset = 0;
@@ -1275,8 +1286,7 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
// Array of pointers: PVOID arr[N]
if (!field.arraySizes.isEmpty()) {
int totalElements = 1;
for (int dim : field.arraySizes) totalElements *= (dim > 0 ? dim : 1);
int totalElements = clampedArrayElements(field.arraySizes);
Node n;
n.kind = NodeKind::Array;
@@ -1314,8 +1324,7 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
int elemSize = 4;
NodeKind elemKind = NodeKind::UInt32;
if (!field.arraySizes.isEmpty()) {
int totalElements = 1;
for (int dim : field.arraySizes) totalElements *= (dim > 0 ? dim : 1);
int totalElements = clampedArrayElements(field.arraySizes);
Node n;
n.kind = NodeKind::Array;
n.name = field.name;
@@ -1366,7 +1375,8 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
if (firstDim <= 0) firstDim = 1;
if (baseKind == NodeKind::Int8 && field.arraySizes.size() == 1 &&
field.typeName == QStringLiteral("char") && firstDim <= 128) {
(field.typeName == QStringLiteral("char") ||
field.typeName == QStringLiteral("CHAR"))) {
Node n;
n.kind = NodeKind::UTF8;
n.name = field.name;
@@ -1379,8 +1389,9 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
}
if (baseKind == NodeKind::UInt16 && field.arraySizes.size() == 1 &&
(field.typeName == QStringLiteral("wchar_t") || field.typeName == QStringLiteral("WCHAR")) &&
firstDim <= 128) {
(field.typeName == QStringLiteral("wchar_t") ||
field.typeName == QStringLiteral("WCHAR") ||
field.typeName == QStringLiteral("TCHAR"))) {
Node n;
n.kind = NodeKind::UTF16;
n.name = field.name;
@@ -1417,8 +1428,7 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
ctx.tree.addNode(n); computedOffset = fieldOffset + 64; continue;
}
int totalElements = 1;
for (int dim : field.arraySizes) totalElements *= (dim > 0 ? dim : 1);
int totalElements = clampedArrayElements(field.arraySizes);
Node n;
n.kind = NodeKind::Array;
@@ -1437,8 +1447,7 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
int elemSize = structTypeSize(field.typeName, ctx);
if (!field.arraySizes.isEmpty()) {
int totalElements = 1;
for (int dim : field.arraySizes) totalElements *= (dim > 0 ? dim : 1);
int totalElements = clampedArrayElements(field.arraySizes);
Node n;
n.kind = NodeKind::Array;
@@ -1575,6 +1584,13 @@ NodeTree importFromSource(const QString& sourceCode, QString* errorMsg, int poin
buildFields(ctx, structId, 0, ps.fields);
// Union: all direct children overlap at offset 0
if (ps.keyword == QStringLiteral("union")) {
QVector<int> children = tree.childrenOf(structId);
for (int ci : children)
tree.nodes[ci].offset = 0;
}
// Apply static_assert size: add tail padding if needed
auto sizeIt = parser.sizeAsserts.find(ps.name);
if (sizeIt != parser.sizeAsserts.end()) {

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@
#include "pluginmanager.h"
#include "scannerpanel.h"
#include "startpage.h"
#include "workspace_model.h"
#include <QMainWindow>
#include <QLabel>
#include <QSplitter>
@@ -68,6 +69,7 @@ private slots:
public:
// Status bar helpers — separate app / MCP channels
void setAppStatus(const QString& text);
void setAppStatus(const QString& text, const QString& dimSuffix);
void setMcpStatus(const QString& text);
void clearMcpStatus();
@@ -83,6 +85,7 @@ private:
QWidget* m_centralPlaceholder;
ShimmerLabel* m_statusLabel;
QString m_appStatus;
QString m_appStatusDim;
bool m_mcpBusy = false;
QTimer* m_mcpClearTimer = nullptr;
TitleBarWidget* m_titleBar = nullptr;
@@ -117,7 +120,15 @@ private:
QMap<QDockWidget*, TabState> m_tabs;
QVector<QDockWidget*> m_docDocks; // ordered list for tabByIndex
QDockWidget* m_activeDocDock = nullptr; // tracks active document dock
QDockWidget* m_sentinelDock = nullptr; // hidden dock to bootstrap tab bar creation
QVector<RcxDocument*> m_allDocs; // all open docs, shared with controllers
bool m_closingAll = false; // guards spurious project_new during batch close
bool m_tabBarShowGuard = false; // prevents recursion in event filter re-show
struct ClosingGuard {
bool& flag;
ClosingGuard(bool& f) : flag(f) { flag = true; }
~ClosingGuard() { flag = false; }
};
void rebuildAllDocs();
void createMenus();
@@ -134,6 +145,7 @@ private:
TabState* tabByIndex(int index);
int tabCount() const { return m_tabs.size(); }
QDockWidget* createTab(RcxDocument* doc);
QString tabTitle(const TabState& tab) const;
void setupDockTabBars();
void updateWindowTitle();
void closeAllDocDocks();
@@ -161,10 +173,12 @@ private:
QLabel* m_dockTitleLabel = nullptr;
QToolButton* m_dockCloseBtn = nullptr;
DockGripWidget* m_dockGrip = nullptr;
QSet<uint64_t> m_pinnedIds;
void createWorkspaceDock();
void rebuildWorkspaceModel(); // debounced — safe to call frequently
void rebuildWorkspaceModelNow(); // immediate rebuild
QTimer* m_workspaceRebuildTimer = nullptr;
QTimer* m_workspaceSearchTimer = nullptr;
void updateBorderColor(const QColor& color);
// Scanner dock

View File

@@ -10,13 +10,24 @@
namespace rcx {
static constexpr int kMaxReadBuffer = 10 * 1024 * 1024; // 10 MB
// ════════════════════════════════════════════════════════════════════
// Construction / lifecycle
// ════════════════════════════════════════════════════════════════════
McpBridge::McpBridge(MainWindow* mainWindow, QObject* parent)
: QObject(parent), m_mainWindow(mainWindow)
{}
{
m_notifyTimer = new QTimer(this);
m_notifyTimer->setSingleShot(true);
m_notifyTimer->setInterval(100);
connect(m_notifyTimer, &QTimer::timeout, this, [this]() {
if (m_client && m_initialized)
sendNotification("notifications/resources/updated",
QJsonObject{{"uri", "project://tree"}});
});
}
McpBridge::~McpBridge() {
stop();
@@ -84,15 +95,24 @@ void McpBridge::onNewConnection() {
void McpBridge::onReadyRead() {
m_readBuffer.append(m_client->readAll());
// Newline-delimited JSON framing
if (m_readBuffer.size() > kMaxReadBuffer) {
qWarning() << "[MCP] Read buffer exceeded 10MB, disconnecting client";
m_client->disconnectFromServer();
return;
}
// Newline-delimited JSON framing (cursor approach avoids quadratic shifting)
int consumed = 0;
while (true) {
int idx = m_readBuffer.indexOf('\n');
int idx = m_readBuffer.indexOf('\n', consumed);
if (idx < 0) break;
QByteArray line = m_readBuffer.left(idx).trimmed();
m_readBuffer.remove(0, idx + 1);
QByteArray line = m_readBuffer.mid(consumed, idx - consumed).trimmed();
consumed = idx + 1;
if (!line.isEmpty())
processLine(line);
}
if (consumed > 0)
m_readBuffer.remove(0, consumed);
}
void McpBridge::onDisconnected() {
@@ -153,6 +173,7 @@ QJsonObject McpBridge::makeTextResult(const QString& text, bool isError) {
// ════════════════════════════════════════════════════════════════════
void McpBridge::processLine(const QByteArray& line) {
try {
qDebug() << "[MCP] <<" << line.trimmed().left(200);
auto doc = QJsonDocument::fromJson(line);
if (!doc.isObject()) {
@@ -172,12 +193,10 @@ 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") {
@@ -185,6 +204,14 @@ void McpBridge::processLine(const QByteArray& line) {
} else {
sendJson(errReply(id, -32601, "Method not found: " + method));
}
} catch (const std::exception& e) {
qWarning() << "[MCP] Exception:" << e.what();
sendJson(errReply(QJsonValue(), -32603,
QStringLiteral("Internal error: %1").arg(e.what())));
} catch (...) {
qWarning() << "[MCP] Unknown exception";
sendJson(errReply(QJsonValue(), -32603, "Internal error"));
}
}
// ════════════════════════════════════════════════════════════════════
@@ -476,7 +503,7 @@ QJsonObject McpBridge::handleToolsCall(const QJsonValue& id, const QJsonObject&
// Show tool activity in status bar (with shimmer)
m_mainWindow->setMcpStatus(QStringLiteral("MCP: %1").arg(toolName));
QCoreApplication::processEvents(); // paint immediately
QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents);
QJsonObject result;
if (toolName == "project.state") result = toolProjectState(args);
@@ -501,11 +528,15 @@ QJsonObject McpBridge::handleToolsCall(const QJsonValue& id, const QJsonObject&
// ════════════════════════════════════════════════════════════════════
QString McpBridge::resolvePlaceholder(const QString& ref,
const QHash<QString, uint64_t>& placeholderMap) {
const QHash<QString, uint64_t>& placeholderMap,
bool* ok) {
if (ok) *ok = true;
if (ref.startsWith('$')) {
auto it = placeholderMap.find(ref);
if (it != placeholderMap.end())
return QString::number(it.value());
if (ok) *ok = false;
return ref; // unresolved placeholder
}
return ref; // not a placeholder — return as-is
}
@@ -514,26 +545,36 @@ QString McpBridge::resolvePlaceholder(const QString& ref,
// Smart tab resolution
// ════════════════════════════════════════════════════════════════════
MainWindow::TabState* McpBridge::resolveTab(const QJsonObject& args) {
MainWindow::TabState* McpBridge::resolveTab(const QJsonObject& args, int* resolvedIndex) {
if (resolvedIndex) *resolvedIndex = -1;
// 1) Explicit tab index from args
if (args.contains("tabIndex")) {
int idx = args.value("tabIndex").toInt();
auto* t = m_mainWindow->tabByIndex(idx);
if (t) return t;
if (t) { if (resolvedIndex) *resolvedIndex = idx; return t; }
}
// 2) Active sub-window (user clicked on it)
auto* t = m_mainWindow->activeTab();
if (t) return t;
if (t) {
if (resolvedIndex) {
for (int i = 0; i < m_mainWindow->tabCount(); i++) {
if (m_mainWindow->tabByIndex(i) == t) { *resolvedIndex = i; break; }
}
}
return t;
}
// 3) Fall back to first available tab
if (m_mainWindow->tabCount() > 0) {
t = m_mainWindow->tabByIndex(0);
if (t) return t;
if (t) { if (resolvedIndex) *resolvedIndex = 0; return t; }
}
// 4) No tabs at all — auto-create a project
m_mainWindow->project_new();
if (resolvedIndex) *resolvedIndex = 0;
return m_mainWindow->tabByIndex(0);
}
@@ -725,8 +766,11 @@ QJsonObject McpBridge::toolTreeApply(const QJsonObject& args) {
QStringList skippedOps;
for (int i = 0; i < ops.size(); i++) {
// Safety valve: keep paint events flowing for large batches
if (i % 100 == 0 && ops.size() > 200)
if (i % 100 == 0 && ops.size() > 200) {
m_mainWindow->setMcpStatus(
QStringLiteral("MCP: tree.apply %1/%2").arg(i).arg(ops.size()));
QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents, 5);
}
QJsonObject op = ops[i].toObject();
QString opType = op.value("op").toString();
@@ -736,15 +780,29 @@ QJsonObject McpBridge::toolTreeApply(const QJsonObject& args) {
n.id = placeholders.value(QStringLiteral("$%1").arg(i), tree.reserveId());
n.kind = kindFromString(op.value("kind").toString("Hex64"));
n.name = op.value("name").toString();
QString pid = resolvePlaceholder(op.value("parentId").toString("0"), placeholders);
bool pidOk;
QString pid = resolvePlaceholder(op.value("parentId").toString("0"), placeholders, &pidOk);
if (!pidOk) {
skippedOps.append(QStringLiteral("op[%1]: unresolved placeholder for parentId").arg(i));
continue;
}
n.parentId = pid.toULongLong();
if (n.parentId != 0 && tree.indexOfId(n.parentId) < 0) {
skippedOps.append(QStringLiteral("op[%1]: parentId '%2' not found").arg(i).arg(pid));
continue;
}
n.offset = op.value("offset").toInt(0);
n.structTypeName = op.value("structTypeName").toString();
n.classKeyword = op.value("classKeyword").toString();
n.strLen = op.value("strLen").toInt(64);
n.strLen = qBound(1, op.value("strLen").toInt(64), 1000000);
n.elementKind = kindFromString(op.value("elementKind").toString("UInt8"));
n.arrayLen = op.value("arrayLen").toInt(1);
QString refStr = resolvePlaceholder(op.value("refId").toString("0"), placeholders);
n.arrayLen = qBound(1, op.value("arrayLen").toInt(1), 1000000);
bool refOk;
QString refStr = resolvePlaceholder(op.value("refId").toString("0"), placeholders, &refOk);
if (!refOk) {
skippedOps.append(QStringLiteral("op[%1]: unresolved placeholder for refId").arg(i));
continue;
}
n.refId = refStr.toULongLong();
// Auto-place: offset -1 means "after last sibling"
@@ -870,7 +928,7 @@ QJsonObject McpBridge::toolTreeApply(const QJsonObject& args) {
int idx = tree.indexOfId(nid.toULongLong());
if (idx >= 0) {
NodeKind newElemKind = kindFromString(op.value("elementKind").toString());
int newLen = op.value("arrayLen").toInt(1);
int newLen = qBound(1, op.value("arrayLen").toInt(1), 1000000);
doc->undoStack.push(new RcxCommand(ctrl,
cmd::ChangeArrayMeta{tree.nodes[idx].id,
tree.nodes[idx].elementKind, newElemKind,
@@ -1383,8 +1441,7 @@ QJsonObject McpBridge::toolProcessInfo(const QJsonObject& args) {
void McpBridge::notifyTreeChanged() {
if (!m_client || !m_initialized) return;
sendNotification("notifications/resources/updated",
QJsonObject{{"uri", "project://tree"}});
m_notifyTimer->start(); // debounce 100ms
}
void McpBridge::notifyDataChanged() {

View File

@@ -7,6 +7,7 @@
#include <QJsonArray>
#include <QJsonDocument>
#include <QByteArray>
#include <QTimer>
namespace rcx {
@@ -34,6 +35,7 @@ private:
QByteArray m_readBuffer;
bool m_initialized = false;
bool m_slowMode = false;
QTimer* m_notifyTimer = nullptr;
// JSON-RPC plumbing
void onNewConnection();
@@ -65,10 +67,11 @@ private:
// Helpers
QJsonObject makeTextResult(const QString& text, bool isError = false);
QString resolvePlaceholder(const QString& ref,
const QHash<QString, uint64_t>& placeholderMap);
const QHash<QString, uint64_t>& placeholderMap,
bool* ok = nullptr);
// Smart tab resolution: tabIndex arg → activeTab → first tab → auto-create
MainWindow::TabState* resolveTab(const QJsonObject& args);
MainWindow::TabState* resolveTab(const QJsonObject& args, int* resolvedIndex = nullptr);
};
} // namespace rcx

View File

@@ -53,6 +53,7 @@ public:
bool isReadable(uint64_t addr, int len) const override {
if (len <= 0) return (len == 0);
uint64_t end = addr + static_cast<uint64_t>(len);
if (end < addr) return false; // overflow
for (uint64_t p = addr & kPageMask; p < end; p += kPageSize) {
if (!m_pages.contains(p)) return false;
}

View File

@@ -50,6 +50,7 @@
<file alias="settings-gear.svg">vsicons/settings-gear.svg</file>
<file alias="chevron-down.svg">vsicons/chevron-down.svg</file>
<file alias="chevron-right.svg">vsicons/chevron-right.svg</file>
<file alias="chevron-left.svg">vsicons/chevron-left.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>

View File

@@ -702,7 +702,7 @@ void ScannerPanel::onCellEdited(int row, int col) {
m_statusLabel->setText(QStringLiteral("Wrote %1 byte%2 to 0x%3")
.arg(bytes.size())
.arg(bytes.size() == 1 ? "" : "s")
.arg(addr, 0, 16, QLatin1Char('0')).toUpper());
.arg(QString::number(addr, 16).toUpper()));
// Re-read and update cache
m_resultTable->blockSignals(true);
int readSize = (m_lastScanMode == 1) ? valueSize() : 16;

505
src/typeinfer.h Normal file
View File

@@ -0,0 +1,505 @@
#pragma once
#include <QVector>
#include <cmath>
#include <cstdint>
#include <cstring>
#include "core.h"
namespace rcx {
// ── Hints from value history (optional, improves accuracy) ──
struct InferHints {
const uint8_t* minObserved = nullptr; // raw bytes, same len as data
const uint8_t* maxObserved = nullptr;
bool monotonic = false; // value only increases or only decreases
bool neverChanged = false; // identical across all samples
int sampleCount = 0; // 0 = no history
int ptrSize = 8;
};
// ── Suggestion result ──
struct TypeSuggestion {
QVector<NodeKind> kinds; // size==1: convert, size>1: uniform split
int score = 0; // 0-100 feature ratio (passed / checked × 100)
int strength = 0; // 0=hidden, 1=weak, 2=moderate, 3=strong
};
// ── Public API ──
QVector<TypeSuggestion> inferTypes(
const uint8_t* data, int len,
const InferHints& hints = {},
int maxResults = 3);
// Format top suggestion as short type label (e.g. "ptr64", "int32_t×2")
inline QString formatHint(const TypeSuggestion& s) {
if (s.kinds.isEmpty()) return {};
const char* name = kindMeta(s.kinds[0])->typeName;
return (s.kinds.size() == 1)
? QString::fromLatin1(name)
: QStringLiteral("%1\u00D7%2").arg(QString::fromLatin1(name)).arg(s.kinds.size());
}
// ── Implementation (header-only) ──
namespace detail {
inline uint32_t loadU32(const uint8_t* p) {
uint32_t v; std::memcpy(&v, p, 4); return v;
}
inline uint64_t loadU64(const uint8_t* p) {
uint64_t v; std::memcpy(&v, p, 8); return v;
}
inline uint16_t loadU16(const uint8_t* p) {
uint16_t v; std::memcpy(&v, p, 2); return v;
}
inline float loadF32(const uint8_t* p) {
float v; std::memcpy(&v, p, 4); return v;
}
inline double loadF64(const uint8_t* p) {
double v; std::memcpy(&v, p, 8); return v;
}
inline bool allZero(const uint8_t* p, int n) {
for (int i = 0; i < n; ++i) if (p[i]) return false;
return true;
}
inline int popcount32(uint32_t v) {
#if defined(__GNUC__) || defined(__clang__)
return __builtin_popcount(v);
#else
int c = 0; while (v) { v &= v - 1; ++c; } return c;
#endif
}
inline bool isPrintable(uint8_t c) {
return c >= 0x20 && c <= 0x7E;
}
// ── Float feature checker ──
// Returns features passed out of features checked (as pair)
struct FeatureResult { int passed; int checked; };
inline bool isGoodFloat(uint32_t bits) {
uint32_t exp = (bits >> 23) & 0xFF;
if (exp == 0xFF) return false; // inf/nan
if (exp == 0 && (bits & 0x7FFFFF)) return false; // denormal
float f; std::memcpy(&f, &bits, 4);
double af = std::fabs((double)f);
return f == 0.0f || (af >= 1e-6 && af <= 1e7);
}
inline FeatureResult countFloatFeatures(uint32_t cur,
const uint8_t* minP, const uint8_t* maxP,
const InferHints& h) {
int passed = 0, checked = 4;
float f; std::memcpy(&f, &cur, 4);
// Feature 1: finite
passed += std::isfinite((double)f) ? 1 : 0;
// Feature 2: non-denormal (exponent > 0 or value is ±0)
uint32_t exp = (cur >> 23) & 0xFF;
passed += (exp > 0 || (cur & 0x7FFFFFFF) == 0) ? 1 : 0;
// Feature 3: reasonable range
double af = std::fabs((double)f);
passed += (f == 0.0f || (af >= 1e-6 && af <= 1e7)) ? 1 : 0;
// Feature 4: has fractional part (not just a reinterpreted integer)
float ip; double frac = std::fabs((double)std::modf(f, &ip));
passed += (frac > 0.0001) ? 1 : 0;
if (h.sampleCount > 0 && minP && maxP) {
checked += 4;
uint32_t minBits = loadU32(minP), maxBits = loadU32(maxP);
// Feature 5-6: min/max are also valid floats
passed += isGoodFloat(minBits) ? 1 : 0;
passed += isGoodFloat(maxBits) ? 1 : 0;
// Feature 7: field changes
passed += (minBits != maxBits) ? 1 : 0;
// Feature 8: range is game-plausible
float fmin, fmax;
std::memcpy(&fmin, &minBits, 4);
std::memcpy(&fmax, &maxBits, 4);
double range = std::fabs((double)fmax - (double)fmin);
passed += (range < 1e6) ? 1 : 0;
}
return {passed, checked};
}
// ── Integer feature checker ──
inline FeatureResult countIntFeatures(uint32_t val,
const uint8_t* minP, const uint8_t* maxP,
const InferHints& h) {
// Hard reject: zero and sentinel are never useful integers
if (val == 0 || val == 0xFFFFFFFF)
return {0, 3};
int passed = 0, checked = 3;
int32_t sv = (int32_t)val;
// Feature 1: non-zero and not sentinel (always passes after hard reject)
passed += 1;
// Feature 2: small absolute value
passed += (val <= 1000000u || (uint32_t)(sv + 1000000) <= 2000000u) ? 1 : 0;
// Feature 3: fits int16 range
passed += (sv >= -32768 && sv <= 32767) ? 1 : 0;
if (h.sampleCount > 0 && minP && maxP) {
checked += 3;
uint32_t minV = loadU32(minP), maxV = loadU32(maxP);
// Feature 4: min/max in reasonable range
passed += (minV <= 1000000u && maxV <= 1000000u) ? 1 : 0;
// Feature 5: monotonic (counter/timer)
passed += h.monotonic ? 1 : 0;
// Feature 6: field varies
passed += (minV != maxV) ? 1 : 0;
}
return {passed, checked};
}
// ── Flags feature checker ──
inline FeatureResult countFlagFeatures(uint32_t val,
const uint8_t* minP, const uint8_t* maxP,
const InferHints& h) {
int passed = 0, checked = 2;
int pc = popcount32(val);
// Feature 1: sparse bits (1-3 set)
passed += (pc >= 1 && pc <= 3) ? 1 : 0;
// Feature 2: not a small sequential integer (flags are usually not 1,2,3...)
passed += (val > 256 || (val & (val - 1)) != 0) ? 1 : 0;
if (h.sampleCount > 0 && minP && maxP) {
checked += 3;
uint32_t minV = loadU32(minP), maxV = loadU32(maxP);
// Feature 3: XOR of min/max has low popcount (specific bits toggle)
passed += (popcount32(minV ^ maxV) <= 4) ? 1 : 0;
// Feature 4: field varies
passed += (minV != maxV) ? 1 : 0;
// Feature 5: max is superset of min bits
passed += ((minV & maxV) == minV) ? 1 : 0;
}
return {passed, checked};
}
// ── Pointer feature checker ──
inline FeatureResult countPtrFeatures64(uint64_t val) {
// Hard reject: common sentinel values are never pointers
if (val == 0 || val == 0xFFFFFFFFFFFFFFFFULL || val == 0x00000000FFFFFFFFULL)
return {0, 6};
int passed = 0, checked = 6;
// Feature 1: canonical 48-bit address (sign-extended from bit 47)
passed += (val <= 0x00007FFFFFFFFFFFULL
|| val >= 0xFFFF800000000000ULL) ? 1 : 0;
// Feature 2: aligned to 8 (heap/vtable allocations)
passed += ((val & 7) == 0) ? 1 : 0;
// Feature 3: above null guard pages (real addresses >= 64KB)
passed += (val >= 0x10000) ? 1 : 0;
// Feature 4: has upper 32 bits (real 64-bit address, not a small constant)
passed += ((val >> 32) != 0) ? 1 : 0;
// Feature 5: above 4GB (in real 64-bit address space, not a 32-bit value)
passed += (val > 0x100000000ULL) ? 1 : 0;
// Feature 6: user-mode address range (not kernel 0xFFFF800000000000+)
passed += (val < 0xFFFF800000000000ULL) ? 1 : 0;
return {passed, checked};
}
inline FeatureResult countPtrFeatures32(uint32_t val) {
int passed = 0, checked = 3;
// Feature 1: non-zero and not sentinel
passed += (val != 0 && val != 0xFFFFFFFF) ? 1 : 0;
// Feature 2: aligned to 4
passed += ((val & 3) == 0) ? 1 : 0;
// Feature 3: above null guard pages (>= 64KB)
passed += (val >= 0x10000) ? 1 : 0;
return {passed, checked};
}
// ── String feature checker ──
inline FeatureResult countStringFeatures(const uint8_t* data, int len) {
if (len < 2) return {0, 4};
int printable = 0, letters = 0, consecutive = 0, maxConsec = 0;
for (int i = 0; i < len; ++i) {
if (isPrintable(data[i])) {
printable++;
consecutive++;
maxConsec = std::max(maxConsec, consecutive);
if ((data[i] >= 'A' && data[i] <= 'Z') || (data[i] >= 'a' && data[i] <= 'z'))
letters++;
} else {
consecutive = 0;
}
}
double ratio = (double)printable / len;
int passed = 0, checked = 4;
passed += (maxConsec >= 4) ? 1 : 0;
passed += (ratio > 0.75) ? 1 : 0;
passed += (letters >= 1) ? 1 : 0;
passed += (ratio > 0.90) ? 1 : 0;
return {passed, checked};
}
// ── Int16 feature checker ──
inline FeatureResult countInt16Features(uint16_t val,
const uint8_t* minP, const uint8_t* maxP,
const InferHints& h) {
int passed = 0, checked = 2;
int16_t sv = (int16_t)val;
passed += (val != 0) ? 1 : 0;
passed += (sv >= -16384 && sv <= 16384) ? 1 : 0;
if (h.sampleCount > 0 && minP && maxP) {
checked += 2;
uint16_t minV = loadU16(minP), maxV = loadU16(maxP);
passed += (minV <= 4096 && maxV <= 4096) ? 1 : 0;
passed += (minV != maxV) ? 1 : 0;
}
return {passed, checked};
}
// ── Score from feature result ──
inline int featureScore(FeatureResult r) {
if (r.checked == 0) return 0;
return (r.passed * 100) / r.checked;
}
inline int strengthFromScore(int score) {
if (score >= 75) return 3;
if (score >= 50) return 2;
if (score >= 25) return 1;
return 0;
}
// ── Candidate accumulator ──
struct Candidate {
QVector<NodeKind> kinds;
int score;
};
inline void addCandidate(QVector<Candidate>& out, NodeKind k, int score) {
if (score >= 25) out.append({{k}, score});
}
inline void addSplitCandidate(QVector<Candidate>& out, NodeKind k, int count, int score) {
if (score >= 25) {
QVector<NodeKind> kinds(count, k);
out.append({std::move(kinds), score});
}
}
// ── Try whole-width interpretations ──
inline void tryWhole8(const uint8_t* data, const InferHints& h, QVector<Candidate>& out) {
uint64_t u64 = loadU64(data);
// Pointer64
if (h.ptrSize == 8)
addCandidate(out, NodeKind::Pointer64, featureScore(countPtrFeatures64(u64)));
// Double
{
double d; std::memcpy(&d, data, 8);
uint64_t exp = (u64 >> 52) & 0x7FF;
int passed = 0, checked = 3;
passed += std::isfinite(d) ? 1 : 0;
passed += (exp > 0 || (u64 & 0x7FFFFFFFFFFFFFFFull) == 0) ? 1 : 0;
double ad = std::fabs(d);
passed += (d == 0.0 || (ad >= 1e-6 && ad <= 1e12)) ? 1 : 0;
addCandidate(out, NodeKind::Double, featureScore({passed, checked}));
}
// UTF8
addCandidate(out, NodeKind::UTF8, featureScore(countStringFeatures(data, 8)));
// UInt64 / Int64
{
int passed = 0, checked = 4;
// Feature 1: fits in 32 bits (small constant, not an address)
passed += (u64 <= 0xFFFFFFFFull) ? 1 : 0;
// Feature 2: upper 32 bits are zero (confirms it's a small value, not a pointer)
passed += ((u64 >> 32) == 0) ? 1 : 0;
// Feature 3: non-zero
passed += (u64 != 0) ? 1 : 0;
// Feature 4: monotonic or very small (< 0x10000)
passed += (h.monotonic || u64 < 0x10000) ? 1 : 0;
addCandidate(out, NodeKind::UInt64, featureScore({passed, checked}));
}
}
inline void tryWhole4(const uint8_t* data, const uint8_t* minP, const uint8_t* maxP,
const InferHints& h, QVector<Candidate>& out) {
uint32_t u32 = loadU32(data);
// Float
addCandidate(out, NodeKind::Float, featureScore(countFloatFeatures(u32, minP, maxP, h)));
// Int32
addCandidate(out, NodeKind::Int32, featureScore(countIntFeatures(u32, minP, maxP, h)));
// UInt32
addCandidate(out, NodeKind::UInt32, featureScore(countIntFeatures(u32, minP, maxP, h)));
// Flags (only if sparse bits)
addCandidate(out, NodeKind::UInt32, featureScore(countFlagFeatures(u32, minP, maxP, h)));
// Pointer32
if (h.ptrSize == 4)
addCandidate(out, NodeKind::Pointer32, featureScore(countPtrFeatures32(u32)));
}
inline void tryWhole2(const uint8_t* data, const uint8_t* minP, const uint8_t* maxP,
const InferHints& h, QVector<Candidate>& out) {
uint16_t u16 = loadU16(data);
int scoreI = featureScore(countInt16Features(u16, minP, maxP, h));
addCandidate(out, NodeKind::Int16, scoreI);
addCandidate(out, NodeKind::UInt16, scoreI);
}
inline void tryWhole1(const uint8_t* data, QVector<Candidate>& out) {
uint8_t v = data[0];
int score = (v == 0 || v == 1) ? 50 : 25;
addCandidate(out, NodeKind::UInt8, score);
}
// ── Try uniform splits ──
inline void trySplitUniform(const uint8_t* data, int len,
const InferHints& h,
QVector<Candidate>& out) {
// 8 → 2×4
if (len == 8) {
const uint8_t* minA = h.minObserved;
const uint8_t* minB = h.minObserved ? h.minObserved + 4 : nullptr;
const uint8_t* maxA = h.maxObserved;
const uint8_t* maxB = h.maxObserved ? h.maxObserved + 4 : nullptr;
bool zA = allZero(data, 4), zB = allZero(data + 4, 4);
// Float×2: both halves must be good floats and at least one non-zero
if (!zA || !zB) {
uint32_t bitsA = loadU32(data), bitsB = loadU32(data + 4);
bool fA = zA || isGoodFloat(bitsA);
bool fB = zB || isGoodFloat(bitsB);
if (fA && fB) {
auto rA = zA ? FeatureResult{2, 4} : countFloatFeatures(bitsA, minA, maxA, h);
auto rB = zB ? FeatureResult{2, 4} : countFloatFeatures(bitsB, minB, maxB, h);
int score = std::min(featureScore(rA), featureScore(rB));
addSplitCandidate(out, NodeKind::Float, 2, score);
}
}
// Int32×2: both halves, at least one non-zero
if (!zA || !zB) {
auto rA = zA ? FeatureResult{1, 3} : countIntFeatures(loadU32(data), minA, maxA, h);
auto rB = zB ? FeatureResult{1, 3} : countIntFeatures(loadU32(data + 4), minB, maxB, h);
int score = std::min(featureScore(rA), featureScore(rB));
addSplitCandidate(out, NodeKind::Int32, 2, score);
}
// UInt32×2
if (!zA || !zB) {
auto rA = zA ? FeatureResult{1, 3} : countIntFeatures(loadU32(data), minA, maxA, h);
auto rB = zB ? FeatureResult{1, 3} : countIntFeatures(loadU32(data + 4), minB, maxB, h);
int score = std::min(featureScore(rA), featureScore(rB));
addSplitCandidate(out, NodeKind::UInt32, 2, score);
}
}
// 8 → 4×2 or 4 → 2×2
int halfLen = len / 2;
if (halfLen == 2) {
int minScore = 100;
int count = len / 2;
bool anyNonZero = false;
for (int i = 0; i < count; ++i) {
const uint8_t* part = data + i * 2;
if (!allZero(part, 2)) anyNonZero = true;
const uint8_t* mp = h.minObserved ? h.minObserved + i * 2 : nullptr;
const uint8_t* xp = h.maxObserved ? h.maxObserved + i * 2 : nullptr;
int s = featureScore(countInt16Features(loadU16(part), mp, xp, h));
minScore = std::min(minScore, s);
}
if (anyNonZero) {
addSplitCandidate(out, NodeKind::Int16, count, minScore);
addSplitCandidate(out, NodeKind::UInt16, count, minScore);
}
}
}
// ── Prune and rank ──
inline QVector<TypeSuggestion> pruneAndRank(QVector<Candidate>& cands, int maxResults) {
// Sort descending by score
std::sort(cands.begin(), cands.end(), [](const Candidate& a, const Candidate& b) {
return a.score > b.score;
});
// Dedup: keep highest-scoring per unique kinds vector
QVector<Candidate> deduped;
for (const auto& c : cands) {
bool dup = false;
for (const auto& d : deduped) {
if (d.kinds == c.kinds) { dup = true; break; }
}
if (!dup) deduped.append(c);
}
// Dominance: if top >= 1.5× second, keep only top
if (deduped.size() >= 2 && deduped[0].score >= deduped[1].score * 3 / 2)
deduped.resize(1);
else if (deduped.size() > maxResults)
deduped.resize(maxResults);
QVector<TypeSuggestion> result;
result.reserve(deduped.size());
for (const auto& c : deduped) {
int str = strengthFromScore(c.score);
if (str > 0)
result.append({c.kinds, c.score, str});
}
return result;
}
} // namespace detail
// ── Entry point ──
inline QVector<TypeSuggestion> inferTypes(
const uint8_t* data, int len,
const InferHints& hints,
int maxResults)
{
using namespace detail;
if (!data || len <= 0) return {};
if (allZero(data, len)) return {}; // NULL → skip entirely
QVector<Candidate> cands;
cands.reserve(12);
// Whole-width candidates
if (len >= 8) tryWhole8(data, hints, cands);
if (len == 4) tryWhole4(data, hints.minObserved, hints.maxObserved, hints, cands);
if (len == 2) tryWhole2(data, hints.minObserved, hints.maxObserved, hints, cands);
if (len == 1) tryWhole1(data, cands);
// Uniform splits (compete directly with whole-width candidates)
if (len >= 4)
trySplitUniform(data, len, hints, cands);
return pruneAndRank(cands, maxResults);
}
} // namespace rcx

View File

@@ -57,51 +57,73 @@ TypeSpec parseTypeSpec(const QString& text) {
}
// ── Fuzzy scorer: subsequence match with word-boundary bonuses ──
// Hot path — uses stack arrays and pre-lowered QChars to avoid heap allocs.
static constexpr int kMaxFuzzyLen = 64;
static int fuzzyScore(const QString& pattern, const QString& text,
QVector<int>* outPositions = nullptr) {
int pLen = pattern.size(), tLen = text.size();
if (pLen == 0) return 1;
if (pLen > tLen) return 0;
if (pLen > kMaxFuzzyLen || tLen > 256) {
// Fallback: prefix match only for very long names
if (text.startsWith(pattern, Qt::CaseInsensitive)) return 1;
return 0;
}
// Quick subsequence reject
// Pre-compute lowercase chars on the stack
QChar pLow[kMaxFuzzyLen];
for (int i = 0; i < pLen; i++) pLow[i] = pattern[i].toLower();
QChar tLow[256];
for (int i = 0; i < tLen; i++) tLow[i] = text[i].toLower();
// Quick subsequence reject using pre-lowered arrays
{ int pi = 0;
for (int ti = 0; ti < tLen && pi < pLen; ti++)
if (pattern[pi].toLower() == text[ti].toLower()) pi++;
if (pLow[pi] == tLow[ti]) pi++;
if (pi < pLen) return 0;
}
// Recursive best-match (bounded: max 4 branches per pattern char)
QVector<int> bestPos;
// Stack arrays instead of QVector to avoid heap allocation
int bestPos[kMaxFuzzyLen];
int curPos[kMaxFuzzyLen];
int best = 0;
int bestLen = 0;
auto solve = [&](auto& self, int pi, int ti, QVector<int>& cur, int score) -> void {
auto solve = [&](auto& self, int pi, int ti, int curLen, int score) -> void {
if (pi == pLen) {
if (score > best) { best = score; bestPos = cur; }
if (score > best) {
best = score;
bestLen = curLen;
memcpy(bestPos, curPos, curLen * sizeof(int));
}
return;
}
int maxTi = tLen - (pLen - pi);
int branches = 0;
for (int i = ti; i <= maxTi && branches < 4; i++) {
if (pattern[pi].toLower() != text[i].toLower()) continue;
if (pLow[pi] != tLow[i]) continue;
int bonus = 1;
if (i == 0) bonus = 10;
else if (text[i - 1] == '_' || text[i - 1] == ' ') bonus = 8;
else if (text[i].isUpper() && text[i - 1].isLower()) bonus = 8;
if (!cur.isEmpty() && i == cur.last() + 1) bonus += 5;
cur.append(i);
self(self, pi + 1, i + 1, cur, score + bonus);
cur.removeLast();
if (curLen > 0 && i == curPos[curLen - 1] + 1) bonus += 5;
curPos[curLen] = i;
self(self, pi + 1, i + 1, curLen + 1, score + bonus);
branches++;
}
};
QVector<int> cur;
solve(solve, 0, 0, cur, 0);
solve(solve, 0, 0, 0, 0);
if (best > 0) {
best += qMax(0, 20 - (tLen - pLen)); // tightness bonus
if (pLen == tLen) best += 20; // exact match bonus
if (outPositions) *outPositions = bestPos;
if (outPositions) {
outPositions->resize(bestLen);
memcpy(outPositions->data(), bestPos, bestLen * sizeof(int));
}
}
return best;
}
@@ -113,7 +135,7 @@ public:
explicit TypeSelectorDelegate(TypeSelectorPopup* popup, QObject* parent = nullptr)
: QStyledItemDelegate(parent), m_popup(popup) {}
void setFont(const QFont& f) { m_font = f; }
void setFont(const QFont& f) { m_font = f; updateCachedSizeHint(); }
void setLoading(bool v) { m_isLoading = v; }
void setFilteredTypes(const QVector<TypeEntry>* filtered) {
m_filtered = filtered;
@@ -287,13 +309,13 @@ public:
}
QSize sizeHint(const QStyleOptionViewItem& /*option*/,
const QModelIndex& index) const override {
const QModelIndex& /*index*/) const override {
return m_cachedSizeHint;
}
void updateCachedSizeHint() {
QFontMetrics fm(m_font);
int row = index.row();
bool isSection = (m_filtered && row >= 0 && row < m_filtered->size()
&& (*m_filtered)[row].entryKind == TypeEntry::Section);
int h = isSection ? fm.height() + 2 : fm.height() + 8;
return QSize(200, h);
m_cachedSizeHint = QSize(200, fm.height() + 8);
}
bool helpEvent(QHelpEvent* event, QAbstractItemView* view,
@@ -304,8 +326,9 @@ public:
if (row >= 0 && row < m_filtered->size()) {
const auto& e = (*m_filtered)[row];
if (e.entryKind == TypeEntry::Composite && !e.fieldSummary.isEmpty()) {
QString tip = QStringLiteral("%1 (%2 B, %3 fields)\n")
.arg(e.displayName).arg(e.sizeBytes).arg(e.fieldCount);
QString tip = QStringLiteral("%1 (0x%2 bytes, %3 fields)\n")
.arg(e.displayName, QString::number(e.sizeBytes, 16).toUpper())
.arg(e.fieldCount);
tip += e.fieldSummary.join(QChar('\n'));
if (e.fieldCount > e.fieldSummary.size())
tip += QStringLiteral("\n...");
@@ -322,6 +345,7 @@ public:
private:
TypeSelectorPopup* m_popup = nullptr;
QFont m_font;
QSize m_cachedSizeHint{200, 20};
bool m_isLoading = false;
const QVector<TypeEntry>* m_filtered = nullptr;
const QVector<QVector<int>>* m_matchPositions = nullptr;
@@ -448,6 +472,9 @@ TypeSelectorPopup::TypeSelectorPopup(QWidget* parent)
m_listView->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_listView->viewport()->setAttribute(Qt::WA_Hover, true);
m_listView->setAccessibleName(QStringLiteral("Type list"));
m_listView->setUniformItemSizes(true);
m_listView->setLayoutMode(QListView::Batched);
m_listView->setBatchSize(50);
m_listView->installEventFilter(this);
auto* delegate = new TypeSelectorDelegate(this, m_listView);
@@ -714,6 +741,7 @@ void TypeSelectorPopup::applyTheme(const Theme& theme) {
m_titleLabel->setPalette(pal);
m_filterEdit->setPalette(pal);
m_listView->setPalette(pal);
m_listView->viewport()->setPalette(pal);
m_arrayCountEdit->setPalette(pal);
// Esc button (snapped to corner)
@@ -826,6 +854,12 @@ void TypeSelectorPopup::setTypes(const QVector<TypeEntry>& types, const TypeEntr
if (delegate) delegate->setLoading(false);
m_allTypes = types;
// Cache max display name length for popup width calculation
m_cachedMaxNameLen = 0;
for (const auto& t : m_allTypes) {
if (t.entryKind != TypeEntry::Section)
m_cachedMaxNameLen = qMax(m_cachedMaxNameLen, (int)t.displayName.size());
}
if (current) {
m_currentEntry = *current;
m_hasCurrent = true;
@@ -858,13 +892,12 @@ void TypeSelectorPopup::setTypes(const QVector<TypeEntry>& types, const TypeEntr
void TypeSelectorPopup::popup(const QPoint& globalPos) {
QFontMetrics fm(m_font);
int maxTextW = fm.horizontalAdvance(QStringLiteral("Choose element type "));
for (const auto& t : m_allTypes) {
int iconColW = fm.height() + 4;
int w = iconColW + fm.horizontalAdvance(t.displayName) + 16;
if (w > maxTextW) maxTextW = w;
}
int popupW = qBound(480, maxTextW + 24, 560);
constexpr int kMaxPopupW = 560;
// Estimate max width from cached max name length (avoids iterating all types)
int iconColW = fm.height() + 4;
int estMaxW = iconColW + fm.horizontalAdvance(QChar('W')) * m_cachedMaxNameLen + 16;
int maxTextW = qMax(fm.horizontalAdvance(QStringLiteral("Choose element type ")), estMaxW);
int popupW = qBound(480, maxTextW + 24, kMaxPopupW);
int rowH = fm.height() + 8;
int headerH = rowH * 2 + 10; // filter + chips + separator
int footerH = rowH + 6; // separator + action row
@@ -968,18 +1001,27 @@ void TypeSelectorPopup::applyFilter(const QString& text) {
auto makeLabel = [](const TypeEntry& e) {
QString label = e.displayName;
if (e.sizeBytes > 0) label += QStringLiteral(" - %1").arg(e.sizeBytes);
if (e.sizeBytes > 0) label += QStringLiteral(" - 0x%1 bytes").arg(QString::number(e.sizeBytes, 16).toUpper());
return label;
};
int primCount = 0, typeCount = 0, enumCount = 0;
const int totalTypes = m_allTypes.size();
// Pre-reserve to avoid realloc churn
m_filteredTypes.reserve(totalTypes);
m_matchPositions.reserve(totalTypes);
displayStrings.reserve(totalTypes);
if (!filterBase.isEmpty()) {
// ── Fuzzy search: flat ranked list, no section headers ──
struct Scored { TypeEntry entry; int score; QVector<int> pos; };
// Use index + score to avoid deep-copying TypeEntry structs
struct Scored { int idx; int score; QVector<int> pos; };
QVector<Scored> scored;
scored.reserve(totalTypes);
for (const auto& t : m_allTypes) {
for (int i = 0; i < totalTypes; i++) {
const auto& t = m_allTypes[i];
if (t.entryKind == TypeEntry::Section) continue;
QVector<int> pos;
int sc = fuzzyScore(filterBase, t.displayName, &pos);
@@ -988,15 +1030,15 @@ void TypeSelectorPopup::applyFilter(const QString& text) {
else if (t.category == TypeEntry::CatEnum) enumCount++;
else typeCount++;
if (catAllowed(t))
scored.append({t, sc, pos});
scored.append({i, sc, std::move(pos)});
}
std::sort(scored.begin(), scored.end(),
[](const Scored& a, const Scored& b) { return a.score > b.score; });
for (const auto& s : scored) {
m_filteredTypes.append(s.entry);
m_filteredTypes.append(m_allTypes[s.idx]);
m_matchPositions.append(s.pos);
displayStrings << makeLabel(s.entry);
displayStrings << makeLabel(m_allTypes[s.idx]);
}
} else {
// ── No filter: grouped sections, alphabetical ──

View File

@@ -120,6 +120,7 @@ private:
int m_pointerSize = 8;
bool m_loading = false;
QFont m_font;
int m_cachedMaxNameLen = 0; // longest displayName length (chars)
void applyFilter(const QString& text);
void updateModifierPreview();

View File

@@ -5,6 +5,7 @@
#include <QStandardItemModel>
#include <QStandardItem>
#include <QStyledItemDelegate>
#include <QSortFilterProxyModel>
#include <QPainter>
#include <QApplication>
#include <algorithm>
@@ -43,6 +44,7 @@ inline void buildStructChildren(QStandardItem* item,
};
for (int mi : members) {
if (mi < 0 || mi >= tree->nodes.size()) continue;
const Node& m = tree->nodes[mi];
if (isHexPad(m.kind)) continue;
QString childDisplay = QStringLiteral("%1 %2")
@@ -75,10 +77,11 @@ inline QString typeDisplayString(const Node* node, const NodeTree* tree) {
// Build a new item for a type entry.
inline QStandardItem* makeTypeItem(const Node* node, const NodeTree* tree,
void* subPtr) {
static const QIcon enumIcon(":/vsicons/symbol-enum.svg");
static const QIcon structIcon(":/vsicons/symbol-structure.svg");
bool isEnum = node->resolvedClassKeyword() == QStringLiteral("enum");
auto* item = new QStandardItem(
QIcon(isEnum ? ":/vsicons/symbol-enum.svg"
: ":/vsicons/symbol-structure.svg"),
isEnum ? enumIcon : structIcon,
typeDisplayString(node, tree));
item->setData(QVariant::fromValue(subPtr), Qt::UserRole);
item->setData(QVariant::fromValue(node->id), Qt::UserRole + 1);
@@ -92,7 +95,8 @@ inline QStandardItem* makeTypeItem(const Node* node, const NodeTree* tree,
// Full rebuild — used by benchmarks and first build.
inline void buildProjectExplorer(QStandardItemModel* model,
const QVector<TabInfo>& tabs) {
const QVector<TabInfo>& tabs,
const QSet<uint64_t>& pinnedIds = {}) {
model->clear();
model->setHorizontalHeaderLabels({QStringLiteral("Name")});
@@ -110,18 +114,32 @@ inline void buildProjectExplorer(QStandardItemModel* model,
}
}
for (const auto& e : types)
// Pinned items at the very top, then structs, then enums
QVector<Entry> pinned;
QVector<Entry> unpinnedTypes, unpinnedEnums;
for (const auto& e : types) {
if (pinnedIds.contains(e.node->id)) pinned.append(e);
else unpinnedTypes.append(e);
}
for (const auto& e : enums) {
if (pinnedIds.contains(e.node->id)) pinned.append(e);
else unpinnedEnums.append(e);
}
for (const auto& e : pinned)
model->appendRow(makeTypeItem(e.node, e.tree, e.subPtr));
for (const auto& e : enums)
for (const auto& e : unpinnedTypes)
model->appendRow(makeTypeItem(e.node, e.tree, e.subPtr));
for (const auto& e : unpinnedEnums)
model->appendRow(makeTypeItem(e.node, e.tree, e.subPtr));
}
// Incremental sync — preserves tree expansion/scroll state.
inline void syncProjectExplorer(QStandardItemModel* model,
const QVector<TabInfo>& tabs) {
const QVector<TabInfo>& tabs,
const QSet<uint64_t>& pinnedIds = {}) {
// First call — full build
if (model->rowCount() == 0 && !tabs.isEmpty()) {
buildProjectExplorer(model, tabs);
buildProjectExplorer(model, tabs, pinnedIds);
return;
}
@@ -145,7 +163,9 @@ inline void syncProjectExplorer(QStandardItemModel* model,
// Remove stale items (backwards)
for (int i = model->rowCount() - 1; i >= 0; --i) {
uint64_t id = model->item(i)->data(Qt::UserRole + 1).toULongLong();
auto* item = model->item(i);
if (!item) continue;
uint64_t id = item->data(Qt::UserRole + 1).toULongLong();
if (!desiredMap.contains(id))
model->removeRow(i);
}
@@ -201,6 +221,8 @@ public:
m_selected = t.selected;
m_accent = t.borderFocused; // left accent bar
m_bg = t.background;
m_badgeBg = t.backgroundAlt;
m_badgeText = t.textDim;
}
QSize sizeHint(const QStyleOptionViewItem& option,
@@ -234,38 +256,74 @@ public:
QString fullText = index.data(Qt::DisplayRole).toString();
QRect textRect = opt.rect.adjusted(4, 0, -4, 0);
// Draw icon for top-level items
if (!isChild) {
QVariant iconVar = index.data(Qt::DecorationRole);
if (iconVar.isValid()) {
QIcon icon = iconVar.value<QIcon>();
int iconSz = opt.fontMetrics.height();
int iconY = textRect.y() + (textRect.height() - iconSz) / 2;
icon.paint(painter, textRect.x(), iconY, iconSz, iconSz);
textRect.setLeft(textRect.left() + iconSz + 4);
// Letter badge (S/E for top-level, F for children)
{
QChar letter = 'F';
if (!isChild) {
bool isEnum = index.data(Qt::UserRole + 2).toBool();
letter = isEnum ? 'E' : 'S';
}
int sz = opt.fontMetrics.height();
int y = textRect.y() + (textRect.height() - sz) / 2;
QRect badge(textRect.x(), y, sz, sz);
painter->setRenderHint(QPainter::Antialiasing, true);
painter->setRenderHint(QPainter::TextAntialiasing, true);
painter->setPen(Qt::NoPen);
painter->setBrush(m_badgeBg);
painter->drawRoundedRect(badge, 3, 3);
QColor letterCol = m_badgeText;
if (!isChild && !index.data(Qt::UserRole + 3).toBool())
letterCol.setAlpha(100);
painter->setPen(letterCol);
QFont bf = opt.font;
bf.setBold(true);
painter->setFont(bf);
painter->drawText(badge, Qt::AlignCenter, letter);
painter->setRenderHint(QPainter::Antialiasing, false);
textRect.setLeft(textRect.left() + sz + 4);
}
painter->setFont(opt.font);
if (!isChild) {
// Top-level: "StructName — 3"
// Top-level: "StructName — 3" → name left, count pill right
int dashPos = fullText.indexOf(QChar(0x2014));
if (dashPos > 1) {
QString name = fullText.left(dashPos - 1);
QString meta = fullText.mid(dashPos - 1);
QString name = (dashPos > 1) ? fullText.left(dashPos - 1) : fullText;
QString count = (dashPos > 1) ? fullText.mid(dashPos + 2).trimmed() : QString();
painter->setPen(m_text);
painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, name);
int nameW = opt.fontMetrics.horizontalAdvance(name);
bool pinned = index.data(Qt::UserRole + 4).toBool();
QRect metaRect = textRect;
metaRect.setLeft(textRect.left() + nameW);
// Reserve right side for pin icon + count pill
int rightEdge = textRect.right();
if (!count.isEmpty()) {
int cw = opt.fontMetrics.horizontalAdvance(count) + 10;
int ch = opt.fontMetrics.height();
int cy = textRect.y() + (textRect.height() - ch) / 2;
QRect pill(rightEdge - cw, cy, cw, ch);
rightEdge = pill.left() - 2;
painter->setPen(Qt::NoPen);
painter->setBrush(m_badgeBg);
painter->drawRect(pill);
painter->setPen(m_textMuted);
painter->drawText(metaRect, Qt::AlignLeft | Qt::AlignVCenter, meta);
} else {
painter->drawText(pill, Qt::AlignCenter, count);
}
if (pinned) {
static const QIcon pinIcon(":/vsicons/pin.svg");
int isz = opt.fontMetrics.height() - 2;
int iy = textRect.y() + (textRect.height() - isz) / 2;
QRect pinRect(rightEdge - isz, iy, isz, isz);
pinIcon.paint(painter, pinRect);
rightEdge = pinRect.left() - 2;
}
// Draw name clipped before right-side elements
if (rightEdge > textRect.left() + 4) {
QRect nameRect = textRect;
nameRect.setRight(rightEdge);
QString elided = opt.fontMetrics.elidedText(name, Qt::ElideRight, nameRect.width());
painter->setPen(m_text);
painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, fullText);
painter->drawText(nameRect, Qt::AlignLeft | Qt::AlignVCenter, elided);
}
} else {
// Child: "TypeName fieldName"
@@ -294,6 +352,7 @@ public:
private:
QColor m_text, m_textDim, m_textMuted, m_syntaxType;
QColor m_hover, m_selected, m_accent, m_bg;
QColor m_badgeBg, m_badgeText;
};
} // namespace rcx

View File

@@ -38,6 +38,7 @@ static void buildSmallTree(NodeTree& tree) {
root.name = "root";
root.parentId = 0;
root.offset = 0;
root.collapsed = false;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;

View File

@@ -1964,6 +1964,7 @@ private slots:
root.structTypeName = "Chain";
root.name = "chain";
root.parentId = 0;
root.collapsed = false;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
@@ -1974,6 +1975,7 @@ private slots:
inner.name = "Inner";
inner.parentId = 0;
inner.offset = 300;
inner.collapsed = false;
int ii = tree.addNode(inner);
uint64_t innerId = tree.nodes[ii].id;
{
@@ -1990,6 +1992,7 @@ private slots:
outer.name = "Outer";
outer.parentId = 0;
outer.offset = 200;
outer.collapsed = false;
int oi = tree.addNode(outer);
uint64_t outerId = tree.nodes[oi].id;
{
@@ -2002,6 +2005,7 @@ private slots:
p.kind = NodeKind::Pointer64; p.name = "pInner";
p.parentId = outerId; p.offset = 8;
p.refId = innerId;
p.collapsed = false;
tree.addNode(p);
}
@@ -2011,6 +2015,7 @@ private slots:
p.kind = NodeKind::Pointer64; p.name = "pOuter";
p.parentId = rootId; p.offset = 0;
p.refId = outerId;
p.collapsed = false;
tree.addNode(p);
}
@@ -2706,6 +2711,7 @@ private slots:
sf.offset = 0;
sf.isStatic = true;
sf.offsetExpr = QStringLiteral("base + 0x10");
sf.collapsed = false;
tree.addNode(sf);
NullProvider prov;

View File

@@ -1,807 +0,0 @@
#include <QtTest/QTest>
#include <QJsonDocument>
#include <QJsonObject>
#include <QTemporaryFile>
#include <QStandardItemModel>
#include "core.h"
#include "generator.h"
#include "controller.h"
#include "workspace_model.h"
using namespace rcx;
class TestNewFeatures : public QObject {
Q_OBJECT
private:
NodeTree makeSimpleTree() {
NodeTree tree;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.name = "Player";
root.structTypeName = "Player";
root.parentId = 0;
root.offset = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node f1;
f1.kind = NodeKind::Int32;
f1.name = "health";
f1.parentId = rootId;
f1.offset = 0;
tree.addNode(f1);
Node f2;
f2.kind = NodeKind::Float;
f2.name = "speed";
f2.parentId = rootId;
f2.offset = 4;
tree.addNode(f2);
return tree;
}
NodeTree makeTwoRootTree() {
NodeTree tree;
tree.baseAddress = 0;
// Root struct A
Node a;
a.kind = NodeKind::Struct;
a.name = "Alpha";
a.structTypeName = "Alpha";
a.parentId = 0;
a.offset = 0;
int ai = tree.addNode(a);
uint64_t aId = tree.nodes[ai].id;
Node af;
af.kind = NodeKind::UInt32;
af.name = "flagsA";
af.parentId = aId;
af.offset = 0;
tree.addNode(af);
// Root struct B
Node b;
b.kind = NodeKind::Struct;
b.name = "Bravo";
b.structTypeName = "Bravo";
b.parentId = 0;
b.offset = 0x100;
int bi = tree.addNode(b);
uint64_t bId = tree.nodes[bi].id;
Node bf;
bf.kind = NodeKind::UInt64;
bf.name = "flagsB";
bf.parentId = bId;
bf.offset = 0;
tree.addNode(bf);
return tree;
}
NodeTree makeRichTree() {
NodeTree tree;
tree.baseAddress = 0x00400000;
// ── Pet (root struct) ──
Node pet;
pet.kind = NodeKind::Struct;
pet.name = "Pet";
pet.structTypeName = "Pet";
pet.parentId = 0;
pet.offset = 0;
int pi = tree.addNode(pet);
uint64_t petId = tree.nodes[pi].id;
{ Node n; n.kind = NodeKind::Hex64; n.name = "hex_00"; n.parentId = petId; n.offset = 0; tree.addNode(n); }
{ Node n; n.kind = NodeKind::UTF8; n.name = "name"; n.parentId = petId; n.offset = 8; n.strLen = 16; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex64; n.name = "hex_18"; n.parentId = petId; n.offset = 24; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex32; n.name = "hex_20"; n.parentId = petId; n.offset = 32; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex32; n.name = "hex_24"; n.parentId = petId; n.offset = 36; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Pointer64; n.name = "owner"; n.parentId = petId; n.offset = 40; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex64; n.name = "hex_30"; n.parentId = petId; n.offset = 48; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex64; n.name = "hex_38"; n.parentId = petId; n.offset = 56; tree.addNode(n); }
// ── Cat (root struct, "inherits" Pet via nested struct) ──
Node cat;
cat.kind = NodeKind::Struct;
cat.name = "Cat";
cat.structTypeName = "Cat";
cat.parentId = 0;
cat.offset = 0;
int ci = tree.addNode(cat);
uint64_t catId = tree.nodes[ci].id;
// base = embedded Pet (nested struct child at offset 0)
Node base;
base.kind = NodeKind::Struct;
base.name = "base";
base.structTypeName = "Pet";
base.parentId = catId;
base.offset = 0;
int bi = tree.addNode(base);
uint64_t baseId = tree.nodes[bi].id;
// Children inside the nested Pet base
{ Node n; n.kind = NodeKind::Hex64; n.name = "hex_00"; n.parentId = baseId; n.offset = 0; tree.addNode(n); }
{ Node n; n.kind = NodeKind::UTF8; n.name = "name"; n.parentId = baseId; n.offset = 8; n.strLen = 16; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex64; n.name = "hex_18"; n.parentId = baseId; n.offset = 24; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Pointer64; n.name = "owner"; n.parentId = baseId; n.offset = 32; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex64; n.name = "hex_28"; n.parentId = baseId; n.offset = 40; tree.addNode(n); }
// Cat's own fields after base
{ Node n; n.kind = NodeKind::Hex64; n.name = "hex_30"; n.parentId = catId; n.offset = 48; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex64; n.name = "hex_38"; n.parentId = catId; n.offset = 56; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Float; n.name = "whiskerLen"; n.parentId = catId; n.offset = 64; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex32; n.name = "hex_44"; n.parentId = catId; n.offset = 68; tree.addNode(n); }
{ Node n; n.kind = NodeKind::UInt8; n.name = "lives"; n.parentId = catId; n.offset = 72; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex8; n.name = "hex_49"; n.parentId = catId; n.offset = 73; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex16; n.name = "hex_4A"; n.parentId = catId; n.offset = 74; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex32; n.name = "hex_4C"; n.parentId = catId; n.offset = 76; tree.addNode(n); }
// ── Ball (independent root struct) ──
Node ball;
ball.kind = NodeKind::Struct;
ball.name = "Ball";
ball.structTypeName = "Ball";
ball.parentId = 0;
ball.offset = 0;
int bli = tree.addNode(ball);
uint64_t ballId = tree.nodes[bli].id;
{ Node n; n.kind = NodeKind::Hex64; n.name = "hex_00"; n.parentId = ballId; n.offset = 0; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex64; n.name = "hex_08"; n.parentId = ballId; n.offset = 8; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Float; n.name = "speed"; n.parentId = ballId; n.offset = 16; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex32; n.name = "hex_14"; n.parentId = ballId; n.offset = 20; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex64; n.name = "hex_18"; n.parentId = ballId; n.offset = 24; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Vec4; n.name = "position"; n.parentId = ballId; n.offset = 32; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex64; n.name = "hex_30"; n.parentId = ballId; n.offset = 48; tree.addNode(n); }
{ Node n; n.kind = NodeKind::UInt32; n.name = "color"; n.parentId = ballId; n.offset = 56; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex32; n.name = "hex_3C"; n.parentId = ballId; n.offset = 60; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex64; n.name = "hex_40"; n.parentId = ballId; n.offset = 64; tree.addNode(n); }
return tree;
}
private slots:
// ═══════════════════════════════════════════════════
// Feature 1: Type Aliases
// ═══════════════════════════════════════════════════
void testResolveTypeName_noAlias() {
RcxDocument doc;
// No aliases set — should return default type name
QString name = doc.resolveTypeName(NodeKind::Int32);
QCOMPARE(name, QString("int32_t"));
name = doc.resolveTypeName(NodeKind::Float);
QCOMPARE(name, QString("float"));
name = doc.resolveTypeName(NodeKind::Hex64);
QCOMPARE(name, QString("hex64"));
}
void testResolveTypeName_withAlias() {
RcxDocument doc;
doc.typeAliases[NodeKind::Int32] = "DWORD";
doc.typeAliases[NodeKind::Float] = "FLOAT";
QCOMPARE(doc.resolveTypeName(NodeKind::Int32), QString("DWORD"));
QCOMPARE(doc.resolveTypeName(NodeKind::Float), QString("FLOAT"));
// Non-aliased types still return default
QCOMPARE(doc.resolveTypeName(NodeKind::UInt64), QString("uint64_t"));
}
void testResolveTypeName_emptyAlias() {
RcxDocument doc;
doc.typeAliases[NodeKind::Int32] = ""; // empty alias should be ignored
QCOMPARE(doc.resolveTypeName(NodeKind::Int32), QString("int32_t"));
}
void testTypeAliases_saveLoad() {
// Save a document with type aliases, reload, verify aliases persist
QTemporaryFile tmpFile;
tmpFile.setAutoRemove(true);
QVERIFY(tmpFile.open());
QString path = tmpFile.fileName();
tmpFile.close();
// Create document with aliases and save
{
RcxDocument doc;
doc.tree = makeSimpleTree();
doc.typeAliases[NodeKind::Int32] = "DWORD";
doc.typeAliases[NodeKind::Float] = "FLOAT";
QVERIFY(doc.save(path));
}
// Reload and check aliases
{
RcxDocument doc;
QVERIFY(doc.load(path));
QCOMPARE(doc.typeAliases.size(), 2);
QCOMPARE(doc.typeAliases.value(NodeKind::Int32), QString("DWORD"));
QCOMPARE(doc.typeAliases.value(NodeKind::Float), QString("FLOAT"));
}
}
void testTypeAliases_saveLoadEmpty() {
// Save without aliases, reload, verify no aliases
QTemporaryFile tmpFile;
tmpFile.setAutoRemove(true);
QVERIFY(tmpFile.open());
QString path = tmpFile.fileName();
tmpFile.close();
{
RcxDocument doc;
doc.tree = makeSimpleTree();
QVERIFY(doc.save(path));
}
{
RcxDocument doc;
QVERIFY(doc.load(path));
QVERIFY(doc.typeAliases.isEmpty());
}
}
void testTypeAliases_jsonFormat() {
// Verify the JSON format of saved aliases
QTemporaryFile tmpFile;
tmpFile.setAutoRemove(true);
QVERIFY(tmpFile.open());
QString path = tmpFile.fileName();
tmpFile.close();
RcxDocument doc;
doc.tree = makeSimpleTree();
doc.typeAliases[NodeKind::UInt32] = "UINT";
QVERIFY(doc.save(path));
// Read raw JSON
QFile file(path);
QVERIFY(file.open(QIODevice::ReadOnly));
QJsonDocument jdoc = QJsonDocument::fromJson(file.readAll());
QJsonObject root = jdoc.object();
QVERIFY(root.contains("typeAliases"));
QJsonObject aliases = root["typeAliases"].toObject();
QCOMPARE(aliases["UInt32"].toString(), QString("UINT"));
}
void testGenerator_typeAliases() {
// Generator should use aliases for field types
auto tree = makeSimpleTree();
uint64_t rootId = tree.nodes[0].id;
QHash<NodeKind, QString> aliases;
aliases[NodeKind::Int32] = "LONG";
aliases[NodeKind::Float] = "FLOAT";
QString result = renderCpp(tree, rootId, &aliases);
QVERIFY(result.contains("LONG health;"));
QVERIFY(result.contains("FLOAT speed;"));
// struct keyword itself should not be aliased
QVERIFY(result.contains("struct Player {"));
}
void testGenerator_typeAliases_null() {
// With nullptr aliases, should behave like before
auto tree = makeSimpleTree();
uint64_t rootId = tree.nodes[0].id;
QString result = renderCpp(tree, rootId, nullptr);
QVERIFY(result.contains("int32_t health;"));
QVERIFY(result.contains("float speed;"));
}
void testGenerator_typeAliases_array() {
// Array element type should use alias
NodeTree tree;
Node root;
root.kind = NodeKind::Struct;
root.name = "ArrTest";
root.structTypeName = "ArrTest";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node arr;
arr.kind = NodeKind::Array;
arr.name = "data";
arr.parentId = rootId;
arr.offset = 0;
arr.arrayLen = 16;
arr.elementKind = NodeKind::UInt32;
tree.addNode(arr);
QHash<NodeKind, QString> aliases;
aliases[NodeKind::UInt32] = "DWORD";
QString result = renderCpp(tree, rootId, &aliases);
QVERIFY(result.contains("DWORD data[16];"));
}
void testGenerator_renderCppAll_typeAliases() {
auto tree = makeTwoRootTree();
QHash<NodeKind, QString> aliases;
aliases[NodeKind::UInt32] = "DWORD";
aliases[NodeKind::UInt64] = "QWORD";
QString result = renderCppAll(tree, &aliases);
QVERIFY(result.contains("DWORD flagsA;"));
QVERIFY(result.contains("QWORD flagsB;"));
}
// ═══════════════════════════════════════════════════
// Feature 3: Per-Window View Root Class
// ═══════════════════════════════════════════════════
void testCompose_viewRootId_zero() {
// viewRootId=0 should show all roots (same as default)
auto tree = makeTwoRootTree();
NullProvider prov;
ComposeResult result = compose(tree, prov, 0);
// Should have content from both structs
QStringList lines = result.text.split('\n');
bool foundFlagsA = false, foundFlagsB = false;
for (const QString& l : lines) {
if (l.contains("flagsA")) foundFlagsA = true;
if (l.contains("flagsB")) foundFlagsB = true;
}
QVERIFY2(foundFlagsA, "viewRootId=0 should include Alpha struct");
QVERIFY2(foundFlagsB, "viewRootId=0 should include Bravo struct");
}
void testCompose_viewRootId_filter() {
// viewRootId set to Alpha's id should only show Alpha's fields
auto tree = makeTwoRootTree();
uint64_t alphaId = tree.nodes[0].id;
NullProvider prov;
ComposeResult result = compose(tree, prov, alphaId);
QStringList lines = result.text.split('\n');
bool foundFlagsA = false, foundFlagsB = false;
for (const QString& l : lines) {
if (l.contains("flagsA")) foundFlagsA = true;
if (l.contains("flagsB")) foundFlagsB = true;
}
QVERIFY2(foundFlagsA, "viewRootId=Alpha should include Alpha's fields");
QVERIFY2(!foundFlagsB, "viewRootId=Alpha should NOT include Bravo's fields");
}
void testCompose_viewRootId_otherRoot() {
// viewRootId set to Bravo's id should only show Bravo's fields
auto tree = makeTwoRootTree();
uint64_t bravoId = tree.nodes[2].id; // Bravo is the 3rd node (index 2)
NullProvider prov;
ComposeResult result = compose(tree, prov, bravoId);
QStringList lines = result.text.split('\n');
bool foundFlagsA = false, foundFlagsB = false;
for (const QString& l : lines) {
if (l.contains("flagsA")) foundFlagsA = true;
if (l.contains("flagsB")) foundFlagsB = true;
}
QVERIFY2(!foundFlagsA, "viewRootId=Bravo should NOT include Alpha's fields");
QVERIFY2(foundFlagsB, "viewRootId=Bravo should include Bravo's fields");
}
void testCompose_viewRootId_invalid() {
// viewRootId pointing to non-existent node: should show nothing (only command rows)
auto tree = makeTwoRootTree();
NullProvider prov;
ComposeResult result = compose(tree, prov, 99999);
// Only command row
QCOMPARE(result.meta.size(), 1);
QCOMPARE(result.meta[0].lineKind, LineKind::CommandRow);
}
void testCompose_viewRootId_singleRoot() {
// Single root tree with viewRootId set to that root — should work normally
auto tree = makeSimpleTree();
uint64_t rootId = tree.nodes[0].id;
NullProvider prov;
ComposeResult full = compose(tree, prov, 0);
ComposeResult filtered = compose(tree, prov, rootId);
// Both should have same number of lines (only one root anyway)
QCOMPARE(full.meta.size(), filtered.meta.size());
}
void testDocument_compose_viewRootId() {
// Test RcxDocument::compose passes viewRootId through
RcxDocument doc;
doc.tree = makeTwoRootTree();
uint64_t alphaId = doc.tree.nodes[0].id;
ComposeResult fullResult = doc.compose(0);
ComposeResult filtered = doc.compose(alphaId);
// Filtered should have fewer lines than full
QVERIFY(filtered.meta.size() < fullResult.meta.size());
// Filtered should have Alpha's fields
bool foundFlagsA = false;
for (const QString& l : filtered.text.split('\n')) {
if (l.contains("flagsA")) foundFlagsA = true;
}
QVERIFY(foundFlagsA);
}
// ═══════════════════════════════════════════════════
// Feature 2: Project Lifecycle API (document-level)
// ═══════════════════════════════════════════════════
void testDocument_saveLoadPreservesData() {
// Verify save/load round-trip preserves tree + aliases + baseAddress
QTemporaryFile tmpFile;
tmpFile.setAutoRemove(true);
QVERIFY(tmpFile.open());
QString path = tmpFile.fileName();
tmpFile.close();
{
RcxDocument doc;
doc.tree = makeTwoRootTree();
doc.tree.baseAddress = 0xDEADBEEF;
doc.typeAliases[NodeKind::Int32] = "INT";
QVERIFY(doc.save(path));
}
{
RcxDocument doc;
QVERIFY(doc.load(path));
QCOMPARE(doc.tree.baseAddress, (uint64_t)0xDEADBEEF);
QCOMPARE(doc.tree.nodes.size(), 4); // 2 roots + 2 fields
QCOMPARE(doc.typeAliases.value(NodeKind::Int32), QString("INT"));
QCOMPARE(doc.filePath, path);
QVERIFY(!doc.modified);
}
}
void testDocument_saveCreatesFile() {
QTemporaryFile tmpFile;
tmpFile.setAutoRemove(true);
QVERIFY(tmpFile.open());
QString path = tmpFile.fileName();
tmpFile.close();
RcxDocument doc;
doc.tree = makeSimpleTree();
QVERIFY(doc.save(path));
QCOMPARE(doc.filePath, path);
QVERIFY(!doc.modified);
// Verify file exists and is valid JSON
QFile file(path);
QVERIFY(file.open(QIODevice::ReadOnly));
QJsonDocument jdoc = QJsonDocument::fromJson(file.readAll());
QVERIFY(!jdoc.isNull());
QVERIFY(jdoc.object().contains("nodes"));
}
void testDocument_loadInvalidPath() {
RcxDocument doc;
QVERIFY(!doc.load("/nonexistent/path/file.rcx"));
}
// ═══════════════════════════════════════════════════
// Integration: Type aliases + compose + generator
// ═══════════════════════════════════════════════════
// ═══════════════════════════════════════════════════
// Feature 4: Workspace Model
// ═══════════════════════════════════════════════════
void testWorkspace_simpleTree() {
auto tree = makeSimpleTree();
QStandardItemModel model;
QVector<TabInfo> tabs = {{ &tree, "TestProject.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
// Flat model: Player at root (has 2 non-hex members → lazy placeholder)
QCOMPARE(model.rowCount(), 1);
QVERIFY(model.item(0)->text().contains("Player"));
QVERIFY(model.item(0)->text().contains("struct"));
QVERIFY(model.item(0)->rowCount() > 0); // children populated directly
}
void testWorkspace_twoRootTree() {
auto tree = makeTwoRootTree();
QStandardItemModel model;
QVector<TabInfo> tabs = {{ &tree, "TwoRoot.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
// Flat model: 2 types at root
QCOMPARE(model.rowCount(), 2);
QVERIFY(model.item(0)->text().contains("Alpha"));
QVERIFY(model.item(1)->text().contains("Bravo"));
}
void testWorkspace_richTree_rootCount() {
auto tree = makeRichTree();
QStandardItemModel model;
QVector<TabInfo> tabs = {{ &tree, "Rich.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
QCOMPARE(model.rowCount(), 3); // Ball, Cat, Pet
}
void testWorkspace_richTree_insertionOrder() {
auto tree = makeRichTree();
QStandardItemModel model;
QVector<TabInfo> tabs = {{ &tree, "Rich.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
// Types at root in insertion order: Pet, Cat, Ball
QVERIFY(model.item(0)->text().contains("Pet"));
QVERIFY(model.item(1)->text().contains("Cat"));
QVERIFY(model.item(2)->text().contains("Ball"));
}
void testWorkspace_emptyTree() {
NodeTree tree;
QStandardItemModel model;
QVector<TabInfo> tabs = {{ &tree, "Empty.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
// Flat model: no types means no rows
QCOMPARE(model.rowCount(), 0);
}
void testWorkspace_structIdRole() {
auto tree = makeSimpleTree();
QStandardItemModel model;
QVector<TabInfo> tabs = {{ &tree, "Test.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
// Flat model: first item is the Player type with its structId
QVERIFY(model.rowCount() > 0);
QStandardItem* player = model.item(0);
QVERIFY(player->data(Qt::UserRole + 1).isValid());
QVERIFY(player->data(Qt::UserRole + 1).toULongLong() > 0);
}
// ═══════════════════════════════════════════════════
// Feature: Double-click navigation (viewRootId + scroll)
// ═══════════════════════════════════════════════════
void testDoubleClick_switchToCollapsedClass() {
// Simulates: Ball is collapsed (hidden). Double-click Ball in workspace
// → uncollapse, set viewRootId, compose shows only Ball with children.
RcxDocument doc;
doc.tree = makeRichTree();
// Collapse Ball (3rd root struct)
uint64_t ballId = 0;
for (auto& node : doc.tree.nodes) {
if (node.parentId == 0 && node.kind == NodeKind::Struct
&& node.structTypeName == "Ball") {
node.collapsed = true;
ballId = node.id;
break;
}
}
QVERIFY(ballId != 0);
// Compose with viewRootId=0 should skip collapsed Ball
{
NullProvider prov;
ComposeResult result = compose(doc.tree, prov, 0);
bool foundSpeed = false;
for (const auto& lm : result.meta) {
int ni = lm.nodeIdx;
if (ni >= 0 && ni < doc.tree.nodes.size()
&& doc.tree.nodes[ni].name == "speed")
foundSpeed = true;
}
QVERIFY2(!foundSpeed, "Collapsed Ball's children should not appear with viewRootId=0");
}
// Simulate double-click: uncollapse Ball + set viewRootId
int bi = doc.tree.indexOfId(ballId);
QVERIFY(bi >= 0);
doc.tree.nodes[bi].collapsed = false;
// Compose with viewRootId=Ball should show Ball and its children
{
NullProvider prov;
ComposeResult result = compose(doc.tree, prov, ballId);
bool foundSpeed = false, foundPosition = false, foundColor = false;
for (const auto& lm : result.meta) {
int ni = lm.nodeIdx;
if (ni < 0 || ni >= doc.tree.nodes.size()) continue;
const QString& name = doc.tree.nodes[ni].name;
if (name == "speed") foundSpeed = true;
if (name == "position") foundPosition = true;
if (name == "color") foundColor = true;
}
QVERIFY2(foundSpeed, "Ball's speed field should appear");
QVERIFY2(foundPosition, "Ball's position field should appear");
QVERIFY2(foundColor, "Ball's color field should appear");
}
// Pet/Cat fields should NOT be in the Ball-filtered result
{
NullProvider prov;
ComposeResult result = compose(doc.tree, prov, ballId);
bool foundPetField = false;
for (const auto& lm : result.meta) {
int ni = lm.nodeIdx;
if (ni < 0 || ni >= doc.tree.nodes.size()) continue;
if (doc.tree.nodes[ni].name == "owner") foundPetField = true;
}
QVERIFY2(!foundPetField, "Pet's owner should not appear when viewing Ball");
}
}
void testDoubleClick_fieldNavigatesToParentRoot() {
// Simulates: double-click a field inside Ball → walk up to Ball root,
// set viewRootId to Ball, and the field should be in the compose output.
RcxDocument doc;
doc.tree = makeRichTree();
// Find Ball's "speed" child
uint64_t ballId = 0, speedId = 0;
for (auto& node : doc.tree.nodes) {
if (node.parentId == 0 && node.structTypeName == "Ball")
ballId = node.id;
}
QVERIFY(ballId != 0);
for (auto& node : doc.tree.nodes) {
if (node.parentId == ballId && node.name == "speed")
speedId = node.id;
}
QVERIFY(speedId != 0);
// Walk up from speed to find root struct (simulating handler logic)
uint64_t rootId = 0;
uint64_t cur = speedId;
while (cur != 0) {
int idx = doc.tree.indexOfId(cur);
if (idx < 0) break;
if (doc.tree.nodes[idx].parentId == 0) { rootId = cur; break; }
cur = doc.tree.nodes[idx].parentId;
}
QCOMPARE(rootId, ballId);
// Compose with viewRootId=Ball should contain speed
NullProvider prov;
ComposeResult result = compose(doc.tree, prov, ballId);
bool foundSpeed = false;
for (const auto& lm : result.meta) {
if (lm.nodeId == speedId) { foundSpeed = true; break; }
}
QVERIFY2(foundSpeed, "speed field should be in compose output when viewing its root");
}
void testDoubleClick_projectRootShowsAll() {
// Double-click project root clears viewRootId → all non-collapsed roots shown
RcxDocument doc;
doc.tree = makeRichTree();
// Collapse Ball
for (auto& node : doc.tree.nodes) {
if (node.parentId == 0 && node.structTypeName == "Ball")
node.collapsed = true;
}
// viewRootId=0 → Pet and Cat visible, Ball hidden
NullProvider prov;
ComposeResult result = compose(doc.tree, prov, 0);
bool foundOwner = false, foundWhiskerLen = false, foundSpeed = false;
for (const auto& lm : result.meta) {
int ni = lm.nodeIdx;
if (ni < 0 || ni >= doc.tree.nodes.size()) continue;
const QString& name = doc.tree.nodes[ni].name;
if (name == "owner") foundOwner = true;
if (name == "whiskerLen") foundWhiskerLen = true;
if (name == "speed") foundSpeed = true;
}
QVERIFY2(foundOwner, "Pet's owner should appear with viewRootId=0");
QVERIFY2(foundWhiskerLen, "Cat's whiskerLen should appear with viewRootId=0");
QVERIFY2(!foundSpeed, "Collapsed Ball's speed should not appear with viewRootId=0");
}
// ═══════════════════════════════════════════════════
// Integration: Type aliases + compose + generator
// ═══════════════════════════════════════════════════
void testAliasesPreservedThroughSaveReloadCompose() {
// Full workflow: set aliases, save, reload, compose + generate
QTemporaryFile tmpFile;
tmpFile.setAutoRemove(true);
QVERIFY(tmpFile.open());
QString path = tmpFile.fileName();
tmpFile.close();
auto tree = makeSimpleTree();
// Save with aliases
{
RcxDocument doc;
doc.tree = tree;
doc.typeAliases[NodeKind::Int32] = "my_int32";
doc.typeAliases[NodeKind::Float] = "my_float";
QVERIFY(doc.save(path));
}
// Reload and verify compose + generate work
{
RcxDocument doc;
QVERIFY(doc.load(path));
// Compose should succeed
ComposeResult result = doc.compose();
QVERIFY(result.meta.size() > 0);
// Generator should use aliases
uint64_t rootId = doc.tree.nodes[0].id;
const QHash<NodeKind, QString>* aliases =
doc.typeAliases.isEmpty() ? nullptr : &doc.typeAliases;
QString cpp = renderCpp(doc.tree, rootId, aliases);
QVERIFY(cpp.contains("my_int32 health;"));
QVERIFY(cpp.contains("my_float speed;"));
}
}
void testVec4SingleLineValue() {
NodeTree tree;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.name = "Obj";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node v;
v.kind = NodeKind::Vec4;
v.name = "position";
v.parentId = rootId;
v.offset = 0;
tree.addNode(v);
NullProvider prov;
ComposeResult result = compose(tree, prov);
// CommandRow + 1 Vec4 line + footer = 3
QCOMPARE(result.meta.size(), 3);
// The Vec4 line (index 1) is a single field line, not continuation
QCOMPARE(result.meta[1].lineKind, LineKind::Field);
QCOMPARE(result.meta[1].nodeKind, NodeKind::Vec4);
QVERIFY(!result.meta[1].isContinuation);
// Copy text (equivalent to editor's "Copy All as Text")
QString text = result.text;
// NullProvider reads 0 for all floats, values are "0.f, 0.f, 0.f, 0.f"
QVERIFY(text.contains("0.f, 0.f, 0.f, 0.f"));
// Confirm type, name, and values all on the same line
QStringList lines = text.split('\n');
QVERIFY(lines[1].contains("vec4"));
QVERIFY(lines[1].contains("position"));
QVERIFY(lines[1].contains("0.f, 0.f, 0.f, 0.f"));
}
};
QTEST_MAIN(TestNewFeatures)
#include "test_new_features.moc"

92
tests/test_pixels.py Normal file
View File

@@ -0,0 +1,92 @@
"""
Pixel boundary test: validates no Fusion outline leak at the workspace→editor seam.
Usage:
python tests/test_pixels.py [screenshot.png]
If no screenshot given, launches Reclass.exe --screenshot to grab one.
Scans for the specific Fusion outline artifact: color (23,23,23) which is
window.darker(140) for the VS2022 Dark theme background #1e1e1e.
"""
import sys, os, subprocess
from PIL import Image
from collections import defaultdict
GRAB_PATH = os.path.join("build", "test_grab.png")
def get_screenshot(path):
if not os.path.exists(path):
print(f"Launching Reclass.exe --screenshot {path}")
subprocess.run(["./build/Reclass.exe", "--screenshot", path],
timeout=15, check=True)
return Image.open(path)
def scan_for_artifact(img):
"""Scan entire image for the Fusion outline color (23,23,23).
Also find all near-black pixels (< 28,28,28) that aren't the
theme background (30,30,30)."""
w, h = img.size
px = img.load()
target = (23, 23, 23)
bg = (30, 30, 30)
target_hits = []
dark_hits = defaultdict(list) # color → [(x,y), ...]
for y in range(h):
for x in range(w):
r, g, b = px[x, y][:3]
if r == target[0] and g == target[1] and b == target[2]:
target_hits.append((x, y))
elif r < 28 and g < 28 and b < 28 and (r, g, b) != (0, 0, 0):
# Near-black but not pure black (text anti-aliasing) and not bg
dark_hits[(r, g, b)].append((x, y))
return target_hits, dark_hits
def summarize_region(hits):
"""Summarize a list of (x,y) hits."""
if not hits:
return "none"
xs = [p[0] for p in hits]
ys = [p[1] for p in hits]
return (f"{len(hits)}px x=[{min(xs)}..{max(xs)}] y=[{min(ys)}..{max(ys)}] "
f"size={max(xs)-min(xs)+1}x{max(ys)-min(ys)+1}")
def main():
path = sys.argv[1] if len(sys.argv) > 1 else GRAB_PATH
img = get_screenshot(path)
w, h = img.size
print(f"Image: {w}x{h}")
target_hits, dark_hits = scan_for_artifact(img)
print(f"\n(23,23,23) Fusion outline pixels: {summarize_region(target_hits)}")
if dark_hits:
print(f"\nOther near-black pixels (< 28,28,28):")
for c, positions in sorted(dark_hits.items(), key=lambda t: -len(t[1])):
print(f" ({c[0]:3},{c[1]:3},{c[2]:3}): {summarize_region(positions)}")
if target_hits:
# Show row distribution (condensed)
rows = defaultdict(list)
for x, y in target_hits:
rows[y].append(x)
print(f"\n(23,23,23) row detail:")
for y in sorted(rows.keys()):
xs = sorted(rows[y])
if len(xs) > 5:
print(f" y={y}: {len(xs)}px x=[{xs[0]}..{xs[-1]}]")
else:
print(f" y={y}: {len(xs)}px x={xs}")
print(f"\nFAIL: Found {len(target_hits)} Fusion outline pixels (23,23,23)")
sys.exit(1)
else:
print("\nPASS: No Fusion outline artifact found")
sys.exit(0)
if __name__ == "__main__":
main()

View File

@@ -322,6 +322,104 @@ private slots:
}
}
// ── Benchmark: large SDK (5000 structs) ──
void benchmarkLargeSDK() {
auto ms = [](qint64 ns) { return QString::number(ns / 1000000.0, 'f', 2); };
// Build 5000 composite types with field summaries (simulates WinSDK)
QVector<TypeEntry> types;
types.reserve(5000);
for (int i = 0; i < 5000; i++) {
TypeEntry e;
e.entryKind = TypeEntry::Composite;
e.structId = (uint64_t)(i + 1);
e.displayName = QStringLiteral("_STRUCT_%1").arg(i, 4, 10, QChar('0'));
e.classKeyword = QStringLiteral("struct");
e.sizeBytes = 64 + (i % 256) * 8;
e.alignment = 8;
e.fieldCount = 5 + (i % 20);
for (int f = 0; f < qMin(6, e.fieldCount); f++)
e.fieldSummary << QStringLiteral("0x%1: int32_t field_%2")
.arg(f * 4, 2, 16, QChar('0')).arg(f);
types.append(e);
}
QFont font("Consolas", 12);
font.setFixedPitch(true);
auto* popup = new TypeSelectorPopup();
popup->warmUp();
popup->setFont(font);
// Measure setTypes (data loading)
QElapsedTimer t;
t.start();
popup->setTypes(types, nullptr);
qint64 tSetTypes = t.nsecsElapsed();
// Measure popup show (broken down)
t.restart();
popup->popup(QPoint(100, 100));
qint64 tPopupCall = t.nsecsElapsed();
t.restart();
QApplication::processEvents();
qint64 tProcessEvents = t.nsecsElapsed();
qint64 tShow = tPopupCall + tProcessEvents;
// Second popup show (warm)
popup->hide();
QApplication::processEvents();
t.restart();
popup->popup(QPoint(100, 100));
qint64 tPopup2 = t.nsecsElapsed();
t.restart();
QApplication::processEvents();
qint64 tProcess2 = t.nsecsElapsed();
// Measure filter with 1-char (worst case: most matches)
t.restart();
auto* filterEdit = popup->findChild<QLineEdit*>();
QVERIFY(filterEdit);
filterEdit->setText(QStringLiteral("S"));
qint64 tFilter1 = t.nsecsElapsed();
// Measure filter with 3-char (moderate filtering)
t.restart();
filterEdit->setText(QStringLiteral("STR"));
qint64 tFilter3 = t.nsecsElapsed();
// Measure filter with 6-char (narrow results)
t.restart();
filterEdit->setText(QStringLiteral("STRUCT"));
qint64 tFilter6 = t.nsecsElapsed();
// Measure clear filter (back to grouped view)
t.restart();
filterEdit->setText(QString());
qint64 tClear = t.nsecsElapsed();
popup->hide();
QApplication::processEvents();
qDebug() << "";
qDebug().noquote() << "=== Large SDK Benchmark (5000 structs) ===";
qDebug().noquote() << QString(" setTypes: %1 ms").arg(ms(tSetTypes));
qDebug().noquote() << QString(" popup() call: %1 ms").arg(ms(tPopupCall));
qDebug().noquote() << QString(" processEvents: %1 ms").arg(ms(tProcessEvents));
qDebug().noquote() << QString(" popup total: %1 ms").arg(ms(tShow));
qDebug().noquote() << QString(" popup2() call: %1 ms (warm)").arg(ms(tPopup2));
qDebug().noquote() << QString(" processEvents2: %1 ms (warm)").arg(ms(tProcess2));
qDebug().noquote() << QString(" popup2 total: %1 ms (warm)").arg(ms(tPopup2 + tProcess2));
qDebug().noquote() << QString(" filter 'S': %1 ms").arg(ms(tFilter1));
qDebug().noquote() << QString(" filter 'STR': %1 ms").arg(ms(tFilter3));
qDebug().noquote() << QString(" filter 'STRUCT': %1 ms").arg(ms(tFilter6));
qDebug().noquote() << QString(" clear filter: %1 ms").arg(ms(tClear));
QVERIFY(tSetTypes > 0);
delete popup;
}
// ── Popup data model ──
void testPopupListsRootStructs() {

186
tests/test_typeinfer.cpp Normal file
View File

@@ -0,0 +1,186 @@
#include <QtTest/QTest>
#include <cstring>
#include "typeinfer.h"
using namespace rcx;
class TestTypeInfer : public QObject {
Q_OBJECT
private slots:
// ── NULL / zero → empty ──
void nullPtr() {
QVERIFY(inferTypes(nullptr, 8).isEmpty());
}
void zeroLen() {
uint8_t d[4] = {};
QVERIFY(inferTypes(d, 0).isEmpty());
}
void allZeros8() {
uint8_t d[8] = {};
QVERIFY(inferTypes(d, 8).isEmpty());
}
void allZeros4() {
uint8_t d[4] = {};
QVERIFY(inferTypes(d, 4).isEmpty());
}
void allZeros2() {
uint8_t d[2] = {};
QVERIFY(inferTypes(d, 2).isEmpty());
}
// ── Hex64: float pair ──
// {21.0488f, 547.3f} — two clear floats with fractional parts;
// whole-width Double/Ptr64 score poorly → Float×2 dominates
void hex64_floatPair() {
float a = 21.0488f, b = 547.3f;
uint8_t d[8];
std::memcpy(d, &a, 4);
std::memcpy(d + 4, &b, 4);
auto r = inferTypes(d, 8);
QVERIFY(!r.isEmpty());
auto& top = r[0];
QCOMPARE(top.kinds.size(), 2);
QCOMPARE(top.kinds[0], NodeKind::Float);
QVERIFY(top.strength >= 3); // strong
}
// ── Hex64: int32 pair ──
// {42, 99} — two small integers
void hex64_intPair() {
int32_t a = 42, b = 99;
uint8_t d[8];
std::memcpy(d, &a, 4);
std::memcpy(d + 4, &b, 4);
auto r = inferTypes(d, 8);
QVERIFY(!r.isEmpty());
auto& top = r[0];
QVERIFY(top.kinds.size() == 2);
QVERIFY(top.kinds[0] == NodeKind::Int32 || top.kinds[0] == NodeKind::UInt32);
}
// ── Hex64: UTF-8 string ──
void hex64_utf8() {
uint8_t d[8] = {'I', 'C', 'h', 'o', 'o', 's', 'e', 'Y'};
auto r = inferTypes(d, 8);
QVERIFY(!r.isEmpty());
// Top should be UTF8 (strong)
bool foundUtf8 = false;
for (const auto& s : r) {
if (s.kinds.size() == 1 && s.kinds[0] == NodeKind::UTF8) {
foundUtf8 = true;
QVERIFY(s.strength >= 3); // strong
}
}
QVERIFY(foundUtf8);
}
// ── Hex64: pointer-like value ──
void hex64_pointer() {
// 0x00007FF6A0B01000 — typical Windows user-mode address
uint8_t d[8] = {0x00, 0x10, 0xB0, 0xA0, 0xF6, 0x7F, 0x00, 0x00};
auto r = inferTypes(d, 8);
QVERIFY(!r.isEmpty());
bool foundPtr = false;
for (const auto& s : r)
if (s.kinds.size() == 1 && s.kinds[0] == NodeKind::Pointer64)
foundPtr = true;
QVERIFY(foundPtr);
}
// ── Hex32: clear float ──
void hex32_float() {
// 21.0488f = 0x41A86600
uint8_t d[4] = {0x00, 0x66, 0xA8, 0x41};
auto r = inferTypes(d, 4);
QVERIFY(!r.isEmpty());
QCOMPARE(r[0].kinds.size(), 1);
QCOMPARE(r[0].kinds[0], NodeKind::Float);
QVERIFY(r[0].strength >= 2);
}
// ── Hex32: small integer with monotonic history ──
void hex32_int_monotonic() {
// Value: 0x0000BFFC = 49148 (signed: 49148)
uint8_t d[4] = {0xFC, 0xBF, 0x00, 0x00};
InferHints h;
h.monotonic = true;
h.sampleCount = 10;
uint8_t minB[4] = {0x10, 0x00, 0x00, 0x00}; // 16
uint8_t maxB[4] = {0xFC, 0xBF, 0x00, 0x00}; // 49148
h.minObserved = minB;
h.maxObserved = maxB;
auto r = inferTypes(d, 4, h);
QVERIFY(!r.isEmpty());
QVERIFY(r[0].kinds[0] == NodeKind::Int32 || r[0].kinds[0] == NodeKind::UInt32);
QVERIFY(r[0].strength >= 2);
}
// ── Hex16: small unsigned ──
void hex16_uint() {
uint8_t d[2] = {0x5F, 0x00}; // 95
auto r = inferTypes(d, 2);
QVERIFY(!r.isEmpty());
QVERIFY(r[0].kinds[0] == NodeKind::Int16 || r[0].kinds[0] == NodeKind::UInt16);
}
// ── Hex8: uint8 ──
void hex8_uint() {
uint8_t d[1] = {1};
auto r = inferTypes(d, 1);
QVERIFY(!r.isEmpty());
QCOMPARE(r[0].kinds[0], NodeKind::UInt8);
}
// ── formatHint ──
void formatHint_single() {
TypeSuggestion s;
s.kinds = {NodeKind::Float};
s.strength = 3;
QCOMPARE(formatHint(s), QStringLiteral("float"));
}
void formatHint_split() {
TypeSuggestion s;
s.kinds = {NodeKind::Float, NodeKind::Float};
s.strength = 3;
QString h = formatHint(s);
QCOMPARE(h, QStringLiteral("float\u00D72"));
}
// ── Denormal rejection ──
void denormalRejected() {
// Denormal float: exp=0, mantissa non-zero → 0x00000001
uint8_t d[4] = {0x01, 0x00, 0x00, 0x00};
auto r = inferTypes(d, 4);
// Should NOT suggest Float as top pick
if (!r.isEmpty() && r[0].kinds.size() == 1)
QVERIFY(r[0].kinds[0] != NodeKind::Float);
}
// ── Benchmark: single call ──
void bench_singleCall() {
uint8_t d[8] = {0x00, 0x00, 0x80, 0x3F, 0xCD, 0xCC, 0x4C, 0x3E};
QBENCHMARK {
inferTypes(d, 8);
}
}
// ── Benchmark: 200-node batch (simulates one refresh) ──
void bench_batchRefresh() {
// Prepare 200 varied byte patterns
QVector<std::array<uint8_t, 8>> data(200);
for (int i = 0; i < 200; ++i) {
uint32_t seed = (uint32_t)(i * 7919 + 1);
for (int j = 0; j < 8; ++j)
data[i][j] = (uint8_t)((seed >> (j * 3)) ^ (i + j));
}
QBENCHMARK {
for (int i = 0; i < 200; ++i)
inferTypes(data[i].data(), 8);
}
}
};
QTEST_MAIN(TestTypeInfer)
#include "test_typeinfer.moc"

File diff suppressed because it is too large Load Diff