feat: tree lines, scanner improvements, themes, tooltips, README overhaul

- Tree line connectors (Unicode box-drawing ├─ └─ │) at arbitrary depth
- Fix editor overwriting tree chars at depth 2+ (applyMarginText Pass 2)
- Scanner: unknown value scan, comparison rescan modes (Changed/Unchanged/Increased/Decreased)
- New Tailwind theme (tw.json), WCAG contrast fixes for warm/mid themes
- Tooltip system (rcxtooltip.h)
- Comprehensive README rewrite with full feature inventory
- New tests for compose tree lines, scanner, tooltips

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
IChooseYou
2026-03-04 09:20:56 -07:00
parent 0dc4af6b1d
commit e999c664b8
25 changed files with 4024 additions and 2492 deletions

173
README.md
View File

@@ -12,64 +12,143 @@
[![Build](https://github.com/IChooseYou/Reclass/actions/workflows/build.yml/badge.svg)](https://github.com/IChooseYou/Reclass/actions/workflows/build.yml)
[![License](https://img.shields.io/github/license/IChooseYou/Reclass)](LICENSE)
[![Release](https://img.shields.io/github/v/release/IChooseYou/Reclass?label=snapshot)](https://github.com/IChooseYou/Reclass/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux-blue)]()
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-blue)]()
</div>
Reclass helps you inspect raw bytes and interpret them as types (structs, arrays, primitives, pointers, padding) instead of just hex. It is essentially a debugging tool for figuring out unknown data structures — either at runtime from a live process, or from a static source like a binary file or crash dump.
Reclass helps you inspect raw bytes and interpret them as types (structs, arrays, primitives, pointers, padding) instead of just hex. It is a debugging tool for figuring out unknown data structures — either at runtime from a live process, or from a static source like a binary file or crash dump.
Built with C++17, Qt 6 (Qt 5 also supported), and QScintilla. The entire editor surface is rendered as formatted plain text with inline editing, fold markers, and hex/ASCII previews.
## Features
- **Structured binary view** — render raw bytes as typed fields (integers, floats, pointers, vectors, matrices, strings, booleans, padding)
- **Struct & array nesting** — define nested structs and arrays with collapsible fold regions
- **Enums & bitfields** — define enums and bitfield types with named members, inline editing, and auto-sort
- **Inline editing** — click to edit type names, field names, values, and base addresses directly in the editor
- **Undo/redo** — full undo history for all mutations via command stack
- **Multi-document tabs** — open multiple projects simultaneously in MDI sub-windows
### Type System — 25 Data Types
| Category | Types |
|----------|-------|
| **Hex preview** | Hex8, Hex16, Hex32, Hex64 |
| **Integers** | Int8/16/32/64, UInt8/16/32/64 |
| **Floating point** | Float, Double |
| **Boolean** | Bool |
| **Pointers** | Pointer32, Pointer64, FuncPtr32, FuncPtr64 |
| **Vectors & matrices** | Vec2, Vec3, Vec4, Mat4x4 |
| **Strings** | UTF8, UTF16 (length-aware) |
| **Containers** | Struct (class/struct/union/enum keywords), Array (typed elements with viewport scrolling) |
### Editor
- **Structured binary view** — render raw bytes as typed fields with columnar alignment
- **Inline editing** — click to edit type names, field names, values, base addresses, array metadata, pointer targets, enum members, bitfield members, static expressions, and comments — all with real-time validation
- **Tab-cycling** — tab through editable fields within a line
- **Type autocomplete** — cached popup type picker with search/filter for struct targets
- **Multi-select** — Ctrl+click individual nodes or Shift+click for range selection
- **Split views** — multiple synchronized editor panes over the same document
- **Type autocomplete** — popup type picker when changing field kinds
- **Hex + ASCII margins** — raw byte previews alongside the structured view
- **Value history & heatmap** — track value changes over time with color-coded heat indicators
- **Disassembly preview** — hover over code pointers to see decoded instructions
- **C/C++ code generation** — export structs as compilable C/C++ headers
- **Import / export** — PDB import (Windows), ReClass XML import/export, C/C++ source import
- **Themes** — built-in theme editor with multiple presets
- **MCP bridge** — expose all tool functionality to AI clients via Model Context Protocol
- **Plugin system** — extend with custom data source providers via DLL plugins; the following ship by default:
- **Process plugin** — access memory of live processes on Windows and Linux
- **WinDbg plugin** — access data sources live in WinDbg debugging sessions
- **ReClass.NET compatibility layer** — load existing .NET and native ReClass.NET plugins
- **Find bar** — Ctrl+F in-editor search with indicator highlighting
- **Fold/collapse** — expand and collapse structs, arrays, and pointer expansions with embedded fold indicators
- **Hex + ASCII columns** — raw byte previews alongside the structured view with per-byte change highlighting
## Roadmap
### Struct & Container Support
- [ ] Process memory section enumeration
- [ ] Address parser auto-complete
- [ ] Safe mode
- [ ] File import for other Reclass instances
- [ ] Expose UI functionality to plugins
- [ ] iOS support
- [ ] Display RTTI information
- **Struct nesting** — define nested structs and arrays with collapsible fold regions
- **Enums** — define enums with named value members, inline editing, and auto-sort
- **Bitfields** — named bit-range members within structs, per-bit toggle, masked extraction
- **Unions** — group nodes into unions, dissolve unions back to flat fields
- **Pointer virtual expansion** — pointer nodes auto-dereference and inline-expand the target struct's fields, with cycle detection to prevent infinite recursion
- **Cross-document type resolution** — pointer targets resolve across all open tabs
- **Static fields** — C/C++ expression-evaluated offsets for globals, vtable entries, and computed addresses
### Live Memory Analysis
- **Auto-refresh** — configurable interval (default 660ms) with async page-based reads for non-blocking UI
- **Value history & heatmap** — per-node ring buffer (10 samples with timestamps), color-coded heat indicators (static/cold/warm/hot) based on change frequency
- **Changed-byte highlighting** — per-byte change indicators within hex preview lines
- **Memory write-back** — edit values inline, writes propagate through the provider to live process memory
- **Pointer chasing** — automatic reads of dereferenced memory regions across pointer chains
- **Address parser** — formula expressions like `<module.exe>+0x1A0`, pointer dereference chains, symbol resolution
### Visualization
- **Tree line connectors** — Unicode box-drawing characters (│ ├─ └─) at arbitrary nesting depth
- **Compact column mode** — caps type column width, overflows long type names
- **Relative / absolute offsets** — toggle inline offset display in the indent area
- **Disassembly preview** — hover over code pointers to see decoded x86 instructions (32/64-bit via Fadec)
- **Themes** — 5 built-in themes (ReClass Dark, VS Light, Warm, Midtone, Tailwind), live theme editor with 25-color customization, JSON import/export
### Undo / Redo
Full command stack with 15 undoable operations: ChangeKind, Rename, Collapse, Insert, Remove, ChangeBase, WriteBytes, ChangeArrayMeta, ChangePointerRef, ChangeStructTypeName, ChangeClassKeyword, ChangeOffset, ChangeEnumMembers, ChangeOffsetExpr, ToggleStatic. Batch macro support for multi-node operations.
### Scanner / Memory Search
- **Signature scanning** — IDA-style pattern matching (`48 8B ?? 05`)
- **Typed value search** — Int8-64, UInt8-64, Float, Double, Vec2/3/4, UTF8, UTF16, HexBytes
- **Scan conditions** — ExactValue, UnknownValue, Changed, Unchanged, Increased, Decreased
- **Region filtering** — filter by executable, writable, or struct-only regions
- **Alignment control** — 1, 4, or 8-byte alignment
- **Async multi-threaded** — progress bar, abort capability, up to 50K results
- **Rescan** — refine results with condition-based filtering
### Import / Export
| Format | Import | Export |
|--------|:------:|:------:|
| **Native JSON (.rcx)** | Full tree + metadata | Full tree + metadata |
| **C/C++ source** | Struct/class/union/enum parsing with offset comments | Header generation with optional static asserts |
| **ReClass XML** | Full compatibility with ReClass Classic | Full compatibility |
| **PDB symbols (Windows)** | UDT enumeration with selective recursive import via raw_pdb — no DIA SDK dependency | — |
| **Binary files** | Raw file loading as memory buffer | — |
### Workspace & Navigation
- **Multi-document tabs** — MDI interface, one document per tab
- **Workspace dock** — project explorer tree with struct/enum/union icons, sorted by field count, quick navigation to members
- **Scanner dock** — integrated memory search panel
- **Dual view mode** — switch between ReClass tree view and rendered C/C++ output per tab
- **View root** — focus on a specific struct, hiding all others
- **Scroll to node** — programmatic navigation to any node by ID
## Data Sources
- **File** — open any binary file and inspect its contents as structured data
- **Process** — attach to a live process and read its memory in real time
- **Remote Process** — read another process's memory via shared memory
- **WinDbg** — load `.dmp` crash dump files or connect to live debugging sessions
- **Process** — attach to a live process and read its memory in real time (Windows/Linux)
- **Remote Process** — read another process's memory over TCP with cross-architecture 32/64-bit support
- **WinDbg** — connect to live WinDbg debugging sessions or load crash dumps
- **Saved sources** — quick-switch between recently used data sources per tab
## Screenshots
## Plugin System
![Type chooser and struct inspection](docs/README_PIC1.png)
Extensible provider architecture via DLL plugins with `IPlugin` interface, factory function discovery, and auto/manual loading from a Plugins folder.
![VTable pointer expansion with disassembly preview](docs/README_PIC2.png)
**Bundled plugins:**
![Split view with rendered C/C++ output](docs/README_PIC3.png)
| Plugin | Description |
|--------|-------------|
| **Process memory** | Attach to local processes on Windows and Linux — PID-based, with symbol resolution and module/region enumeration |
| **WinDbg** | Access data from live WinDbg debugging sessions |
| **Remote process memory** | TCP RPC-based remote process access with cross-architecture support |
| **ReClass.NET compatibility** | Load existing ReClass.NET native DLL plugins directly; optional .NET CLR hosting for managed plugins |
## MCP Integration
Built-in [Model Context Protocol](https://modelcontextprotocol.io/) bridge via `ReclassMcpBridge`. The server starts automatically on launch and can be toggled from the File menu. It exposes all tool functionality to any MCP-compatible client (e.g. Claude Code). A standalone stdio-to-pipe bridge binary is built alongside the main application. To connect, add this to your MCP client config (e.g. `.mcp.json`):
Built-in [Model Context Protocol](https://modelcontextprotocol.io/) bridge via `ReclassMcpBridge` — the first reverse engineering tool with native AI/LLM integration. The server uses JSON-RPC 2.0 over named pipes and can be toggled from the Tools menu or auto-started on launch.
**Available tools:**
| Tool | Description |
|------|-------------|
| `projectState` | Read current tree structure, base address, tab state |
| `treeApply` | Apply structural command deltas to the node tree |
| `sourceSwitch` | Switch the active data source |
| `hexRead` | Read bytes at an address |
| `hexWrite` | Write bytes at an address |
| `statusSet` | Update the status bar text |
| `uiAction` | Trigger menu actions programmatically |
| `treeSearch` | Search nodes by name or type |
| `nodeHistory` | Query value change history for a node |
**Notifications:** `notifyTreeChanged`, `notifyDataChanged`
A standalone stdio-to-pipe bridge binary is built alongside the main application. To connect, add this to your MCP client config (e.g. `.mcp.json`):
```json
{
@@ -82,6 +161,20 @@ Built-in [Model Context Protocol](https://modelcontextprotocol.io/) bridge via `
}
```
## Screenshots
![macOS — project tree with kernel struct inspection](docs/README_PIC1.png)
![Windows — VTable with value history popup](docs/README_PIC2.png)
![Memory scanner](docs/README_PIC3.png)
## Roadmap
- [ ] iOS support
- [ ] Display RTTI information
- [ ] Expose UI functionality to plugins
## Build
### Prerequisites
@@ -92,7 +185,7 @@ Built-in [Model Context Protocol](https://modelcontextprotocol.io/) bridge via `
### Quick Build
```/dev/null/commands.sh#L1-4
```bash
git clone --recurse-submodules https://github.com/IChooseYou/Reclass.git
cd Reclass
.\scripts\build_qscintilla.ps1
@@ -103,13 +196,13 @@ The build script auto-detects your Qt install location.
### macOS Build
```/dev/null/commands.sh#L1-2
```bash
./scripts/build_macos.sh --qt-dir /opt/homebrew/opt/qt --build-type Release --package
```
If you installed Qt via Homebrew, `--qt-dir /opt/homebrew/opt/qt` is typical on Apple Silicon. You can also set `QTDIR` or `Qt6_DIR` instead of passing `--qt-dir`.
Note: macOS Gatekeeper may block unsigned apps. If the app wont open, go to **System Settings Privacy & Security** and click **Open Anyway**.
Note: macOS Gatekeeper may block unsigned apps. If the app won't open, go to **System Settings > Privacy & Security** and click **Open Anyway**.
### Manual Build (MinGW)
@@ -132,6 +225,8 @@ The `msvc/` folder contains a ready-made solution (`Reclass.slnx`) with projects
ctest --test-dir build --output-on-failure
```
30 tests covering composition, serialization, undo/redo, import/export, provider switching, type visibility, validation, scanning, and rendering.
## Alternatives
- [ReClass.NET](https://github.com/ReClassNET/ReClass.NET)

View File

@@ -24,6 +24,8 @@ struct ComposeState {
int offsetHexDigits = 8; // hex digit tier for offset margin
bool baseEmitted = false; // only first root struct shows base address
bool compactColumns = false; // compact column mode: cap type width, overflow long types
bool treeLines = false; // draw Unicode tree connectors in indentation
QVector<bool> siblingStack; // per-depth: true = more siblings follow at this level
uint64_t currentPtrBase = 0; // absolute addr of current pointer expansion target
// Precomputed for O(1) lookups
@@ -41,6 +43,15 @@ struct ComposeState {
return scopeNameW.value(scopeId, nameW);
}
// Set sibling-continuation flag for children at the given depth.
// childDepth is the depth of the children being iterated.
void setTreeSibling(int childDepth, bool hasMoreSiblings) {
if (!treeLines) return;
int d = childDepth - 1;
while (siblingStack.size() <= d) siblingStack.append(false);
siblingStack[d] = hasMoreSiblings;
}
void emitLine(const QString& lineText, LineMeta lm) {
if (currentLine > 0) text += '\n';
// 3-char fold indicator column: " - " expanded, " + " collapsed, " " other
@@ -52,7 +63,29 @@ struct ComposeState {
text += lm.foldCollapsed ? QStringLiteral(" \u25B8 ") : QStringLiteral(" \u25BE ");
else
text += QStringLiteral(" ");
text += lineText;
// Replace leading indent spaces with Unicode tree connectors
if (treeLines && lm.depth > 0) {
QString treeIndent;
int D = lm.depth;
bool isFooter = (lm.lineKind == LineKind::Footer);
for (int d = 0; d < D; d++) {
bool active = (d < siblingStack.size() && siblingStack[d]);
if (isFooter || d < D - 1) {
// Ancestor continuation or footer's own level
treeIndent += active ? QStringLiteral("\u2502 ")
: QStringLiteral(" ");
} else {
// This node's own connector (non-footer only)
treeIndent += active ? QStringLiteral("\u251C\u2500 ")
: QStringLiteral("\u2514\u2500 ");
}
}
text += treeIndent + lineText.mid(D * 3);
} else {
text += lineText;
}
meta.append(lm);
currentLine++;
}
@@ -305,6 +338,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
});
for (int oi = 0; oi < order.size(); oi++) {
state.setTreeSibling(childDepth, oi < order.size() - 1);
int mi = order[oi];
const auto& m = node.enumMembers[mi];
LineMeta lm;
@@ -353,6 +387,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
maxNameLen = qMax(maxNameLen, (int)m.name.size());
for (int mi = 0; mi < node.bitfieldMembers.size(); mi++) {
state.setTreeSibling(childDepth, mi < node.bitfieldMembers.size() - 1);
const auto& m = node.bitfieldMembers[mi];
uint64_t bitVal = fmt::extractBits(prov, absAddr, node.elementKind,
m.bitOffset, m.bitWidth);
@@ -415,6 +450,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
int eTW = state.effectiveTypeW(node.id);
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;
// Type override: "float[0]", "uint32_t[1]", etc.
@@ -460,6 +496,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
int elemSize = tree.structSpan(node.refId, &state.childMap);
if (elemSize <= 0) elemSize = 1;
for (int i = 0; i < node.arrayLen; i++) {
state.setTreeSibling(childDepth, i < node.arrayLen - 1);
uint64_t elemBase = absAddr + (uint64_t)i * elemSize;
// Use base offset that maps refStruct's children to the right provider address
composeParent(state, tree, prov, refIdx, childDepth, elemBase, node.refId,
@@ -476,7 +513,9 @@ void composeParent(ComposeState& state, const NodeTree& tree,
const QVector<int>& refChildren = childIndices(state, node.refId);
// Use the referenced struct's scope widths (children come from there)
uint64_t refScopeId = node.refId;
for (int childIdx : refChildren) {
for (int rci = 0; rci < refChildren.size(); rci++) {
int childIdx = refChildren[rci];
state.setTreeSibling(childDepth, rci < refChildren.size() - 1);
const Node& child = tree.nodes[childIdx];
// Self-referential child → show as collapsed struct (non-expandable)
if (state.visiting.contains(child.id)) {
@@ -514,7 +553,13 @@ void composeParent(ComposeState& state, const NodeTree& tree,
// For arrays, render children as condensed (no header/footer for struct elements)
bool childrenAreArrayElements = (node.kind == NodeKind::Array);
int elementIdx = 0;
for (int childIdx : regular) {
for (int ri = 0; ri < regular.size(); ri++) {
int childIdx = regular[ri];
// A regular child has more siblings if there are more regular children
// or if static fields follow after all regular children
bool hasMore = (ri < regular.size() - 1)
|| (!staticIdxs.isEmpty() && !node.collapsed);
state.setTreeSibling(childDepth, hasMore);
// Pass this container's id as the scope for children (for per-scope widths)
// For array elements, also pass the element index for [N] separator
composeNode(state, tree, prov, childIdx, childDepth, base, rootId,
@@ -569,7 +614,9 @@ void composeParent(ComposeState& state, const NodeTree& tree,
auto cbs = makeResolver(absAddr);
for (int si : staticIdxs) {
for (int sii = 0; sii < staticIdxs.size(); sii++) {
int si = staticIdxs[sii];
state.setTreeSibling(childDepth, sii < staticIdxs.size() - 1);
const Node& sf = tree.nodes[si];
// Evaluate expression → absolute address
@@ -639,8 +686,18 @@ void composeParent(ComposeState& state, const NodeTree& tree,
// ── Body + children (only when expanded) ──
if (!isCollapsed) {
// Determine if struct children follow the body line
bool hasStructKids = exprOk
&& (sf.kind == NodeKind::Struct || sf.kind == NodeKind::Array);
const QVector<int> staticKids = hasStructKids
? childIndices(state, sf.id) : QVector<int>();
hasStructKids = hasStructKids && !staticKids.isEmpty();
// Body line: " return <expr> → 0xADDR"
{
// Body has more siblings if struct children follow
state.setTreeSibling(childDepth + 1, hasStructKids);
QString bodyLine;
if (!sf.offsetExpr.isEmpty()) {
if (exprOk)
@@ -676,10 +733,10 @@ void composeParent(ComposeState& state, const NodeTree& tree,
}
// If struct/array, compose children at evaluated address
if (exprOk && (sf.kind == NodeKind::Struct || sf.kind == NodeKind::Array)) {
const QVector<int>& staticKids = childIndices(state, sf.id);
for (int sci : staticKids) {
composeNode(state, tree, prov, sci, childDepth + 1,
if (hasStructKids) {
for (int ski = 0; ski < staticKids.size(); ski++) {
state.setTreeSibling(childDepth + 1, ski < staticKids.size() - 1);
composeNode(state, tree, prov, staticKids[ski], childDepth + 1,
staticAddr, sf.id, false, sf.id);
}
}
@@ -818,8 +875,9 @@ void composeNode(ComposeState& state, const NodeTree& tree,
// Render materialized children at the pointer target address.
// These are real tree nodes with independent state — use rootId
// so resolveAddr computes offsets relative to the pointer target.
for (int childIdx : ptrChildren) {
composeNode(state, tree, childProv, childIdx, depth + 1,
for (int pci = 0; pci < ptrChildren.size(); pci++) {
state.setTreeSibling(depth + 1, pci < ptrChildren.size() - 1);
composeNode(state, tree, childProv, ptrChildren[pci], depth + 1,
pBase, node.id, false, node.id);
}
} else {
@@ -878,9 +936,10 @@ void composeNode(ComposeState& state, const NodeTree& tree,
} // anonymous namespace
ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId,
bool compactColumns) {
bool compactColumns, bool treeLines) {
ComposeState state;
state.compactColumns = compactColumns;
state.treeLines = treeLines;
// Precompute parent→children map
for (int i = 0; i < tree.nodes.size(); i++)
@@ -1026,7 +1085,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
composeNode(state, tree, prov, idx, 0);
}
return { state.text, state.meta, LayoutInfo{state.typeW, state.nameW, state.offsetHexDigits, tree.baseAddress} };
return { state.text, state.meta, LayoutInfo{state.typeW, state.nameW, state.offsetHexDigits, tree.baseAddress, treeLines} };
}
QSet<uint64_t> NodeTree::normalizePreferAncestors(const QSet<uint64_t>& ids) const {

View File

@@ -72,8 +72,9 @@ RcxDocument::RcxDocument(QObject* parent)
});
}
ComposeResult RcxDocument::compose(uint64_t viewRootId, bool compactColumns) const {
return rcx::compose(tree, *provider, viewRootId, compactColumns);
ComposeResult RcxDocument::compose(uint64_t viewRootId, bool compactColumns,
bool treeLines) const {
return rcx::compose(tree, *provider, viewRootId, compactColumns, treeLines);
}
bool RcxDocument::save(const QString& path) {
@@ -319,7 +320,7 @@ void RcxController::connectEditor(RcxEditor* editor) {
// Regular type change
bool ok;
NodeKind k = kindFromTypeName(text, &ok);
if (ok) {
if (ok && k != NodeKind::Struct && k != NodeKind::Array) {
changeNodeKind(nodeIdx, k);
} else if (nodeIdx < m_doc->tree.nodes.size()) {
// Check if it's a defined struct type name
@@ -546,6 +547,7 @@ void RcxController::resetChangeTracking() {
m_changedOffsets.clear();
m_valueHistory.clear();
m_prevPages.clear();
m_valueTrackCooldown = 5; // suppress tracking for ~1s
for (auto& lm : m_lastResult.meta)
lm.heatLevel = 0;
}
@@ -556,9 +558,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_lastResult = rcx::compose(m_doc->tree, *m_snapshotProv, m_viewRootId, m_compactColumns, m_treeLines);
else
m_lastResult = m_doc->compose(m_viewRootId, m_compactColumns);
m_lastResult = m_doc->compose(m_viewRootId, m_compactColumns, m_treeLines);
s_composeDoc = nullptr;
@@ -602,7 +604,8 @@ void RcxController::refresh() {
else if (m_doc->provider && m_doc->provider->isValid() && m_doc->provider->isLive())
prov = m_doc->provider.get();
if (m_trackValues && prov) {
if (m_valueTrackCooldown > 0) --m_valueTrackCooldown;
if (m_trackValues && prov && m_valueTrackCooldown <= 0) {
for (auto& lm : m_lastResult.meta) {
if (lm.nodeIdx < 0 || lm.nodeIdx >= m_doc->tree.nodes.size()) continue;
if (isSyntheticLine(lm) || lm.isContinuation) continue;
@@ -1710,6 +1713,7 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
m_refreshGen++; // discard in-flight async reads
m_prevPages.clear(); // clean baseline for next read cycle
m_changedOffsets.clear(); // no phantom change indicators
m_valueTrackCooldown = 5; // suppress tracking for ~1s
refresh();
for (auto* editor : m_editors)
editor->dismissHistoryPopup();
@@ -1935,6 +1939,7 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
m_refreshGen++; // discard in-flight async reads
m_prevPages.clear(); // clean baseline for next read cycle
m_changedOffsets.clear(); // no phantom change indicators
m_valueTrackCooldown = 5; // suppress tracking for ~1s
refresh();
for (auto* editor : m_editors)
editor->dismissHistoryPopup();
@@ -2608,7 +2613,7 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
break;
case TypePopupMode::FieldType: {
addPrimitives(/*enabled=*/true, /*excludeStructArrayPad=*/false);
addPrimitives(/*enabled=*/true, /*excludeStructArrayPad=*/true);
bool isPtr = node
&& (node->kind == NodeKind::Pointer32 || node->kind == NodeKind::Pointer64);
bool isTypedPtr = isPtr && node->refId != 0;
@@ -3181,6 +3186,11 @@ void RcxController::setCompactColumns(bool v) {
refresh();
}
void RcxController::setTreeLines(bool v) {
m_treeLines = v;
refresh();
}
void RcxController::setupAutoRefresh() {
int ms = QSettings("Reclass", "Reclass").value("refreshMs", 660).toInt();
m_refreshTimer = new QTimer(this);

View File

@@ -40,7 +40,8 @@ public:
return m ? QString::fromLatin1(m->typeName) : QStringLiteral("???");
}
ComposeResult compose(uint64_t viewRootId = 0, bool compactColumns = false) const;
ComposeResult compose(uint64_t viewRootId = 0, bool compactColumns = false,
bool treeLines = false) const;
bool save(const QString& path);
bool load(const QString& path);
void loadData(const QString& binaryPath);
@@ -128,6 +129,7 @@ public:
void setEditorFont(const QString& fontName);
void setRefreshInterval(int ms);
void setCompactColumns(bool v);
void setTreeLines(bool v);
void resetProvider();
// MCP bridge accessors
@@ -151,6 +153,7 @@ public:
// Test accessors
const QHash<uint64_t, ValueHistory>& valueHistory() const { return m_valueHistory; }
const ComposeResult& lastResult() const { return m_lastResult; }
int dataExtent() const { return computeDataExtent(); }
signals:
void nodeSelected(int nodeIdx);
@@ -164,6 +167,7 @@ private:
int m_anchorLine = -1;
bool m_suppressRefresh = false;
bool m_compactColumns = false;
bool m_treeLines = false;
uint64_t m_viewRootId = 0;
// ── Saved sources for quick-switch ──
@@ -183,6 +187,7 @@ private:
QSet<int64_t> m_changedOffsets;
QHash<uint64_t, ValueHistory> m_valueHistory;
bool m_trackValues = true;
int m_valueTrackCooldown = 0; // suppress value recording for N refresh cycles after clear
uint64_t m_refreshGen = 0;
uint64_t m_readGen = 0;
bool m_readInFlight = false;

View File

@@ -86,8 +86,8 @@ inline constexpr KindMeta kKindMeta[] = {
{NodeKind::Vec3, "Vec3", "vec3", 12, 1, 4, KF_Vector},
{NodeKind::Vec4, "Vec4", "vec4", 16, 1, 4, KF_Vector},
{NodeKind::Mat4x4, "Mat4x4", "mat4x4", 64, 4, 4, KF_None},
{NodeKind::UTF8, "UTF8", "char[]", 1, 1, 1, KF_String},
{NodeKind::UTF16, "UTF16", "wchar_t[]", 2, 1, 2, KF_String},
{NodeKind::UTF8, "UTF8", "str", 1, 1, 1, KF_String},
{NodeKind::UTF16, "UTF16", "wstr", 2, 1, 2, KF_String},
{NodeKind::Struct, "Struct", "struct", 0, 1, 1, KF_Container},
{NodeKind::Array, "Array", "array", 0, 1, 1, KF_Container},
};
@@ -153,14 +153,11 @@ inline constexpr bool isValidPrimitivePtrTarget(NodeKind k) {
return true;
}
inline QStringList allTypeNamesForUI(bool stripBrackets = false) {
inline QStringList allTypeNamesForUI(bool /*stripBrackets*/ = false) {
QStringList out;
out.reserve(std::size(kKindMeta));
for (const auto& m : kKindMeta) {
QString t = QString::fromLatin1(m.typeName);
if (stripBrackets) t.remove(QStringLiteral("[]"));
out << t;
}
for (const auto& m : kKindMeta)
out << QString::fromLatin1(m.typeName);
out.sort(Qt::CaseInsensitive);
out.removeDuplicates();
return out;
@@ -636,6 +633,7 @@ struct LayoutInfo {
int nameW = 22; // Effective name column width (default = kColName)
int offsetHexDigits = 8; // Hex digits for offset margin (4/8/12/16)
uint64_t baseAddress = 0; // Base address for relative offset computation
bool treeLines = false; // Whether tree line connectors are embedded in the text
};
// ── ComposeResult ──
@@ -1033,6 +1031,6 @@ namespace fmt {
// ── Compose function forward declaration ──
ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId = 0,
bool compactColumns = false);
bool compactColumns = false, bool treeLines = false);
} // namespace rcx

View File

@@ -156,7 +156,7 @@ public:
adjustSize();
}
void showAt(const QPoint& globalPos) {
void showAt(const QPoint& globalPos, int lineHeight = 0) {
QSize sz = sizeHint();
QRect screen = QApplication::screenAt(globalPos)
? QApplication::screenAt(globalPos)->availableGeometry()
@@ -164,7 +164,7 @@ public:
int x = qMin(globalPos.x(), screen.right() - sz.width());
int y = globalPos.y();
if (y + sz.height() > screen.bottom())
y = globalPos.y() - sz.height() - 4;
y = globalPos.y() - sz.height() - lineHeight - 4;
move(x, y);
if (!isVisible()) show();
}
@@ -257,7 +257,7 @@ public:
adjustSize();
}
void showAt(const QPoint& globalPos) {
void showAt(const QPoint& globalPos, int lineHeight = 0) {
QSize sz = sizeHint();
QRect screen = QApplication::screenAt(globalPos)
? QApplication::screenAt(globalPos)->availableGeometry()
@@ -265,7 +265,7 @@ public:
int x = qMin(globalPos.x(), screen.right() - sz.width());
int y = globalPos.y();
if (y + sz.height() > screen.bottom())
y = globalPos.y() - sz.height() - 4;
y = globalPos.y() - sz.height() - lineHeight - 4;
move(x, y);
if (!isVisible()) show();
}
@@ -354,7 +354,7 @@ public:
adjustSize();
}
void showAt(const QPoint& globalPos) {
void showAt(const QPoint& globalPos, int lineHeight = 0) {
QSize sz = sizeHint();
QRect screen = QApplication::screenAt(globalPos)
? QApplication::screenAt(globalPos)->availableGeometry()
@@ -362,7 +362,7 @@ public:
int x = qMin(globalPos.x(), screen.right() - sz.width());
int y = globalPos.y();
if (y + sz.height() > screen.bottom())
y = globalPos.y() - sz.height() - 4;
y = globalPos.y() - sz.height() - lineHeight - 4;
move(x, y);
if (!isVisible()) show();
}
@@ -866,7 +866,7 @@ void RcxEditor::applyTheme(const Theme& theme) {
m_sci->setMarkerBackgroundColor(theme.background, M_CYCLE);
m_sci->setMarkerForegroundColor(theme.background, M_CYCLE);
m_sci->setMarkerBackgroundColor(theme.markerError, M_ERR);
m_sci->setMarkerForegroundColor(QColor("#ffffff"), M_ERR);
m_sci->setMarkerForegroundColor(theme.text, M_ERR);
m_sci->setMarkerBackgroundColor(theme.background, M_STRUCT_BG);
m_sci->setMarkerForegroundColor(theme.text, M_STRUCT_BG);
m_sci->setMarkerBackgroundColor(theme.hover, M_HOVER);
@@ -1061,6 +1061,11 @@ void RcxEditor::reformatMargins() {
}
// ── Pass 2: inline local offsets in the text indent area ──
// Skip when tree lines are active — the compose step already placed
// Unicode tree connectors in the indent area; overwriting with spaces
// or offsets would destroy them.
if (m_layout.treeLines)
return;
m_sci->setReadOnly(false);
for (int i = 0; i < m_meta.size(); i++) {
const auto& lm = m_meta[i];
@@ -2204,11 +2209,25 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
m_lastHoverPos = static_cast<QMouseEvent*>(event)->pos();
m_hoverInside = true;
} else if (event->type() == QEvent::Leave) {
m_hoverInside = false;
if (!m_editState.active) {
m_hoveredNodeId = 0;
m_hoveredLine = -1;
applyHoverHighlight();
// Don't dismiss if cursor moved onto one of our own popups
QPoint globalCursor = QCursor::pos();
bool onPopup = false;
if (m_historyPopup && m_historyPopup->isVisible()
&& m_historyPopup->geometry().contains(globalCursor))
onPopup = true;
if (m_disasmPopup && m_disasmPopup->isVisible()
&& m_disasmPopup->geometry().contains(globalCursor))
onPopup = true;
if (m_structPreviewPopup && m_structPreviewPopup->isVisible()
&& m_structPreviewPopup->geometry().contains(globalCursor))
onPopup = true;
if (!onPopup) {
m_hoverInside = false;
if (!m_editState.active) {
m_hoveredNodeId = 0;
m_hoveredLine = -1;
applyHoverHighlight();
}
}
} else if (event->type() == QEvent::Wheel) {
m_lastHoverPos = m_sci->viewport()->mapFromGlobal(QCursor::pos());
@@ -2992,7 +3011,7 @@ void RcxEditor::applyHoverCursor() {
int lh = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT,
(unsigned long)m_editState.line);
QPoint anchor = m_sci->viewport()->mapToGlobal(QPoint(px, py + lh));
popup->showAt(anchor);
popup->showAt(anchor, lh);
showPopup = true;
}
}
@@ -3147,7 +3166,7 @@ void RcxEditor::applyHoverCursor() {
int lh = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT,
(unsigned long)h.line);
QPoint anchor = m_sci->viewport()->mapToGlobal(QPoint(px, py + lh));
popup->showAt(anchor);
popup->showAt(anchor, lh);
showPopup = true;
}
}
@@ -3240,7 +3259,7 @@ void RcxEditor::applyHoverCursor() {
(unsigned long)h.line);
QPoint anchor = m_sci->viewport()->mapToGlobal(
QPoint(px, py + lh));
popup->showAt(anchor);
popup->showAt(anchor, lh);
showDisasm = true;
// Dismiss value history popup to avoid fighting
if (m_historyPopup && m_historyPopup->isVisible())
@@ -3307,7 +3326,7 @@ void RcxEditor::applyHoverCursor() {
(unsigned long)h.line);
QPoint anchor = m_sci->viewport()->mapToGlobal(
QPoint(px, py + lh));
popup->showAt(anchor);
popup->showAt(anchor, lh);
showPreview = true;
if (m_historyPopup && m_historyPopup->isVisible())
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();

143
src/examples/Demo.rcx Normal file
View File

@@ -0,0 +1,143 @@
{
"baseAddress": "0",
"nextId": "20",
"nodes": [
{
"id": "1",
"kind": "Struct",
"name": "player",
"structTypeName": "PlayerEntity",
"classKeyword": "class",
"parentId": "0",
"offset": 0,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
},
{
"id": "2",
"kind": "Pointer64",
"name": "__vptr",
"parentId": "1",
"offset": 0,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
},
{
"id": "3",
"kind": "Int32",
"name": "health",
"parentId": "1",
"offset": 8,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
},
{
"id": "4",
"kind": "Int32",
"name": "armor",
"parentId": "1",
"offset": 12,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
},
{
"id": "5",
"kind": "Float",
"name": "pos_x",
"parentId": "1",
"offset": 16,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
},
{
"id": "6",
"kind": "Float",
"name": "pos_y",
"parentId": "1",
"offset": 20,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
},
{
"id": "7",
"kind": "Float",
"name": "pos_z",
"parentId": "1",
"offset": 24,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
},
{
"id": "8",
"kind": "Hex32",
"name": "pad_1C",
"parentId": "1",
"offset": 28,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
},
{
"id": "9",
"kind": "Pointer64",
"name": "name",
"parentId": "1",
"offset": 32,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64,
"ptrDepth": 1
},
{
"id": "10",
"kind": "UInt64",
"name": "flags",
"parentId": "1",
"offset": 40,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
},
{
"id": "11",
"kind": "Hex64",
"name": "static_field",
"parentId": "1",
"offset": 0,
"isStatic": true,
"offsetExpr": "base + pos_x",
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -236,6 +236,14 @@ public:
class MenuBarStyle : public QProxyStyle {
public:
using QProxyStyle::QProxyStyle;
void polish(QWidget* w) override {
// Strip OS window border/shadow from QMenu popups — we draw our own
// 1px border in PE_FrameMenu. Same pattern as TypeSelectorPopup.
if (qobject_cast<QMenu*>(w))
w->setWindowFlag(Qt::FramelessWindowHint, true);
QProxyStyle::polish(w);
}
using QProxyStyle::polish;
QSize sizeFromContents(ContentsType type, const QStyleOption* opt,
const QSize& sz, const QWidget* w) const override {
QSize s = QProxyStyle::sizeFromContents(type, opt, sz, w);
@@ -247,9 +255,12 @@ public:
}
int pixelMetric(PixelMetric metric, const QStyleOption* opt,
const QWidget* w) const override {
// Kill the 1px frame margin Fusion reserves around QMenu contents
// Reserve 1px for our own menu border (drawn in PE_FrameMenu)
if (metric == PM_MenuPanelWidth)
return 0;
return 1;
// Inset menu items from border so hover rect doesn't touch edges
if (metric == PM_MenuHMargin)
return 3;
// Thin draggable separator between dock widgets / central widget
if (metric == PM_DockWidgetSeparatorExtent)
return 1;
@@ -257,9 +268,13 @@ public:
}
void drawPrimitive(PrimitiveElement elem, const QStyleOption* opt,
QPainter* p, const QWidget* w) const override {
// Kill Fusion's 3D bevel on QMenu — the OS drop shadow is enough
if (elem == PE_FrameMenu)
// Clean 1px border on QMenu (replaces Fusion's 3D bevel + OS shadow)
if (elem == PE_FrameMenu) {
p->setPen(opt->palette.color(QPalette::Dark));
p->setBrush(Qt::NoBrush);
p->drawRect(opt->rect.adjusted(0, 0, -1, -1));
return;
}
// Kill the status bar item frame and panel border
if (elem == PE_FrameStatusBarItem || elem == PE_PanelStatusBar)
return;
@@ -355,7 +370,7 @@ static void applyGlobalTheme(const rcx::Theme& theme) {
pal.setColor(QPalette::ToolTipBase, theme.backgroundAlt);
pal.setColor(QPalette::ToolTipText, theme.text);
pal.setColor(QPalette::Mid, theme.hover);
pal.setColor(QPalette::Dark, theme.background);
pal.setColor(QPalette::Dark, theme.border);
pal.setColor(QPalette::Light, theme.textFaint);
pal.setColor(QPalette::Link, theme.indHoverSpan);
@@ -657,6 +672,15 @@ void MainWindow::createMenus() {
tab.ctrl->setCompactColumns(checked);
});
auto* actTreeLines = view->addAction("&Tree Lines");
actTreeLines->setCheckable(true);
actTreeLines->setChecked(settings.value("treeLines", false).toBool());
connect(actTreeLines, &QAction::triggered, this, [this](bool checked) {
QSettings("Reclass", "Reclass").setValue("treeLines", checked);
for (auto& tab : m_tabs)
tab.ctrl->setTreeLines(checked);
});
auto* actRelOfs = view->addAction("R&elative Offsets");
actRelOfs->setCheckable(true);
actRelOfs->setChecked(settings.value("relativeOffsets", true).toBool());
@@ -1307,6 +1331,7 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) {
// Apply global compact columns setting to new tab
ctrl->setCompactColumns(QSettings("Reclass", "Reclass").value("compactColumns", true).toBool());
ctrl->setTreeLines(QSettings("Reclass", "Reclass").value("treeLines", false).toBool());
// Give every controller the shared document list for cross-tab type visibility
ctrl->setProjectDocuments(&m_allDocs);
@@ -2977,6 +3002,30 @@ void MainWindow::createScannerDock() {
return ctrl ? ctrl->document()->provider : nullptr;
});
// Wire bounds getter: struct base + size for "Current Struct" filter
m_scannerPanel->setBoundsGetter([this]() -> rcx::ScannerPanel::StructBounds {
auto* ctrl = activeController();
if (!ctrl) return {};
auto& tree = ctrl->document()->tree;
uint64_t base = tree.baseAddress;
uint64_t viewRoot = ctrl->viewRootId();
int span = 0;
if (viewRoot != 0) {
span = tree.structSpan(viewRoot);
} else {
// Compute extent from all top-level nodes
for (int i = 0; i < tree.nodes.size(); i++) {
const auto& n = tree.nodes[i];
int64_t off = tree.computeOffset(i);
int sz = (n.kind == rcx::NodeKind::Struct || n.kind == rcx::NodeKind::Array)
? tree.structSpan(n.id) : n.byteSize();
int64_t end = off + sz;
if (end > span) span = static_cast<int>(end);
}
}
return { base, static_cast<uint64_t>(span) };
});
// Wire "Go to Address" to rebase the active tab
connect(m_scannerPanel, &ScannerPanel::goToAddress, this, [this](uint64_t addr) {
auto* ctrl = activeController();

View File

@@ -1214,9 +1214,13 @@ QJsonObject McpBridge::toolUiAction(const QJsonObject& args) {
}
if (action == "reset_tracking") {
if (!ctrl) return makeTextResult("No active tab", true);
ctrl->resetChangeTracking();
return makeTextResult("Value tracking reset. All histories cleared.");
int count = m_mainWindow->tabCount();
for (int i = 0; i < count; ++i) {
auto* t = m_mainWindow->tabByIndex(i);
if (t && t->ctrl)
t->ctrl->resetChangeTracking();
}
return makeTextResult(QStringLiteral("Value tracking reset on all %1 tabs.").arg(count));
}
return makeTextResult("Unknown action: " + action, true);

241
src/rcxtooltip.h Normal file
View File

@@ -0,0 +1,241 @@
#pragma once
#include "themes/thememanager.h"
#include <QWidget>
#include <QLabel>
#include <QPainter>
#include <QPainterPath>
#include <QApplication>
#include <QScreen>
#include <QTimer>
#include <QPropertyAnimation>
#include <QCursor>
#include <cstdio>
#define TIP_LOG(...) do { \
FILE* _f = fopen("E:/game_dev/util/reclass2027-main/build/tip_trace.log", "a"); \
if (_f) { fprintf(_f, __VA_ARGS__); fclose(_f); } \
} while(0)
namespace rcx {
class RcxTooltip : public QWidget {
public:
static RcxTooltip* instance() {
static RcxTooltip* s = nullptr;
if (!s) {
s = new RcxTooltip;
QObject::connect(&ThemeManager::instance(), &ThemeManager::themeChanged,
s, [](const rcx::Theme&) { /* colors read live in paintEvent */ });
}
return s;
}
void showFor(QWidget* trigger, const QString& text) {
if (!trigger || text.isEmpty()) {
TIP_LOG("[TIP] showFor: null trigger or empty text -- dismiss\n");
dismiss(); return;
}
// Same widget+text already showing — do nothing (prevents teleport)
if (m_trigger == trigger && m_text == text && isVisible()) {
TIP_LOG("[TIP] showFor: same widget+text, already visible -- skip\n");
return;
}
TIP_LOG("[TIP] showFor: text='%s' trigger=%p class=%s\n",
qPrintable(text), (void*)trigger, trigger->metaObject()->className());
// Cancel pending dismiss
if (m_dismissTimer) m_dismissTimer->stop();
m_trigger = trigger;
m_text = text;
m_label->setText(text);
m_label->adjustSize();
// ── Size: label + padding + arrow ──
const int pad = 8;
const int vpad = 4;
int bodyW = m_label->sizeHint().width() + pad * 2;
int bodyH = m_label->sizeHint().height() + vpad * 2;
int totalW = bodyW;
int totalH = bodyH + kArrowH;
// ── Position relative to trigger widget ──
QRect trigGlobal = QRect(trigger->mapToGlobal(QPoint(0, 0)), trigger->size());
int trigCenterX = trigGlobal.center().x();
QScreen* screen = QApplication::screenAt(trigGlobal.center());
QRect scr = screen ? screen->availableGeometry() : QRect(0, 0, 1920, 1080);
// Default: above the trigger
m_arrowDown = true;
int x = trigCenterX - totalW / 2;
int y = trigGlobal.top() - totalH - kGap;
// Flip below if not enough room above
if (y < scr.top()) {
m_arrowDown = false;
y = trigGlobal.bottom() + kGap;
}
// Clamp horizontally
if (x < scr.left()) x = scr.left() + 2;
if (x + totalW > scr.right()) x = scr.right() - totalW - 2;
// Arrow X in local coords
m_arrowLocalX = trigCenterX - x;
m_arrowLocalX = qBound(kArrowHalfW + 4, m_arrowLocalX, totalW - kArrowHalfW - 4);
// Position label inside the body
if (m_arrowDown)
m_label->move(pad, vpad);
else
m_label->move(pad, kArrowH + vpad);
m_bodyRect = m_arrowDown
? QRect(0, 0, bodyW, bodyH)
: QRect(0, kArrowH, bodyW, bodyH);
setFixedSize(totalW, totalH);
move(x, y);
if (!isVisible()) {
TIP_LOG("[TIP] showFor: showing at (%d,%d) size=%dx%d arrowDown=%d arrowX=%d\n",
x, y, totalW, totalH, m_arrowDown, m_arrowLocalX);
setWindowOpacity(0.0);
show();
raise();
// Fade in
auto* anim = new QPropertyAnimation(this, "windowOpacity", this);
anim->setDuration(80);
anim->setStartValue(0.0);
anim->setEndValue(1.0);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->start(QAbstractAnimation::DeleteWhenStopped);
} else {
TIP_LOG("[TIP] showFor: already visible, updating\n");
update();
}
}
void dismiss() {
TIP_LOG("[TIP] dismiss: wasVisible=%d\n", isVisible());
if (m_dismissTimer) m_dismissTimer->stop();
if (isVisible()) hide();
m_trigger = nullptr;
}
// Schedule dismiss with a delay — but only if the cursor has truly
// left the trigger+tooltip zone. Qt fires synthetic Leave events
// when a tooltip window appears above the trigger; we must ignore those.
void scheduleDismiss() {
if (m_trigger) {
QPoint cursor = QCursor::pos();
QRect trigRect(m_trigger->mapToGlobal(QPoint(0, 0)), m_trigger->size());
QRect tipRect(pos(), size());
QRect zone = trigRect.united(tipRect).adjusted(-4, -4, 4, 4);
bool inside = zone.contains(cursor);
TIP_LOG("[TIP] scheduleDismiss: cursor=(%d,%d) zone=(%d,%d %dx%d) inside=%d\n",
cursor.x(), cursor.y(),
zone.x(), zone.y(), zone.width(), zone.height(), inside);
if (inside)
return; // cursor still inside — ignore spurious Leave
}
if (!m_dismissTimer) {
m_dismissTimer = new QTimer(this);
m_dismissTimer->setSingleShot(true);
connect(m_dismissTimer, &QTimer::timeout, this, &RcxTooltip::dismiss);
}
m_dismissTimer->start(100);
}
QWidget* currentTrigger() const { return m_trigger; }
// ── Geometry accessors (for testing) ──
bool arrowPointsDown() const { return m_arrowDown; }
int arrowLocalX() const { return m_arrowLocalX; }
QRect bodyRect() const { return m_bodyRect; }
QString currentText() const { return m_text; }
// Constants exposed for testing
static constexpr int kArrowH = 6;
static constexpr int kArrowHalfW = 6;
static constexpr int kGap = 2;
protected:
void paintEvent(QPaintEvent*) override {
TIP_LOG("[TIP] paintEvent: size=%dx%d bodyRect=(%d,%d %dx%d)\n",
width(), height(),
m_bodyRect.x(), m_bodyRect.y(), m_bodyRect.width(), m_bodyRect.height());
const auto& theme = ThemeManager::instance().current();
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
// Fill entire widget with the tooltip background first
// (no WA_TranslucentBackground, so unpainted areas would be opaque garbage)
p.fillRect(rect(), theme.backgroundAlt);
// Build path: rounded body + triangle arrow
QPainterPath path;
path.addRoundedRect(QRectF(m_bodyRect), 4.0, 4.0);
// Triangle arrow
QPolygonF arrow;
if (m_arrowDown) {
int ay = m_bodyRect.bottom();
arrow << QPointF(m_arrowLocalX - kArrowHalfW, ay)
<< QPointF(m_arrowLocalX, ay + kArrowH)
<< QPointF(m_arrowLocalX + kArrowHalfW, ay);
} else {
int ay = kArrowH;
arrow << QPointF(m_arrowLocalX - kArrowHalfW, ay)
<< QPointF(m_arrowLocalX, 0)
<< QPointF(m_arrowLocalX + kArrowHalfW, ay);
}
QPainterPath arrowPath;
arrowPath.addPolygon(arrow);
arrowPath.closeSubpath();
path = path.united(arrowPath);
// Stroke the shape border
p.setPen(QPen(theme.border, 1.0));
p.setBrush(theme.backgroundAlt);
p.drawPath(path);
}
private:
explicit RcxTooltip()
: QWidget(nullptr, Qt::ToolTip | Qt::FramelessWindowHint)
{
// NOTE: WA_TranslucentBackground removed — it breaks under DWM dark mode
// (DwmSetWindowAttribute DWMWA_USE_IMMERSIVE_DARK_MODE kills layered compositing)
setAttribute(Qt::WA_ShowWithoutActivating);
setAutoFillBackground(false); // we paint everything ourselves in paintEvent
m_label = new QLabel(this);
m_label->setAlignment(Qt::AlignCenter);
updateLabelStyle();
connect(&ThemeManager::instance(), &ThemeManager::themeChanged,
this, [this](const rcx::Theme&) { updateLabelStyle(); });
}
void updateLabelStyle() {
const auto& theme = ThemeManager::instance().current();
m_label->setStyleSheet(
QStringLiteral("QLabel { color: %1; background: transparent; padding: 0; }")
.arg(theme.text.name()));
}
QLabel* m_label = nullptr;
QWidget* m_trigger = nullptr;
QString m_text;
QTimer* m_dismissTimer = nullptr;
bool m_arrowDown = true;
int m_arrowLocalX = 0;
QRect m_bodyRect;
};
} // namespace rcx

View File

@@ -489,13 +489,21 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
const char* msk = isUnknown ? nullptr : req.mask.constData();
const int alignment = qMax(1, req.alignment);
const int valSize = isUnknown ? req.valueSize : patternLen;
const bool hasRange = (req.startAddress != 0 || req.endAddress != 0) &&
req.endAddress > req.startAddress;
// Pre-compute total bytes for progress
uint64_t totalBytes = 0;
for (const auto& r : regions) {
if (req.filterExecutable && !r.executable) continue;
if (req.filterWritable && !r.writable) continue;
totalBytes += r.size;
uint64_t rStart = r.base, rEnd = r.base + r.size;
if (hasRange) {
if (rEnd <= req.startAddress || rStart >= req.endAddress) continue;
rStart = qMax(rStart, req.startAddress);
rEnd = qMin(rEnd, req.endAddress);
}
totalBytes += rEnd - rStart;
}
qDebug() << "[scan] total scannable:" << (totalBytes / 1024) << "KB across filtered regions";
@@ -513,21 +521,35 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
if (req.filterExecutable && !region.executable) continue;
if (req.filterWritable && !region.writable) continue;
if ((uint64_t)patternLen > region.size) {
scannedBytes += region.size;
// Clip region to requested address range
uint64_t regStart = region.base;
uint64_t regEnd = region.base + region.size;
if (hasRange) {
if (regEnd <= req.startAddress || regStart >= req.endAddress) {
// Entirely outside range — skip
continue;
}
regStart = qMax(regStart, req.startAddress);
regEnd = qMin(regEnd, req.endAddress);
}
uint64_t regSize = regEnd - regStart;
if ((uint64_t)patternLen > regSize) {
scannedBytes += regSize;
continue;
}
const int overlap = patternLen - 1;
QByteArray chunk(qMin((uint64_t)kChunk, region.size), Qt::Uninitialized);
QByteArray chunk(qMin((uint64_t)kChunk, regSize), Qt::Uninitialized);
uint64_t regOffset = regStart - region.base; // offset within provider region
for (uint64_t off = 0; off < region.size; ) {
for (uint64_t off = 0; off < regSize; ) {
if (m_abort.load()) break;
uint64_t remaining = region.size - off;
uint64_t remaining = regSize - off;
int readLen = (int)qMin((uint64_t)chunk.size(), remaining);
if (!prov->read(region.base + off, chunk.data(), readLen)) {
if (!prov->read(regStart + off, chunk.data(), readLen)) {
// Skip unreadable chunk
off += readLen;
scannedBytes += readLen;
@@ -541,7 +563,7 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
// Unknown value: capture every aligned address
for (int i = 0; i <= scanEnd; i += alignment) {
ScanResult r;
r.address = region.base + off + (uint64_t)i;
r.address = regStart + off + (uint64_t)i;
r.scanValue = QByteArray(data + i, valSize);
results.append(r);
@@ -560,7 +582,7 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
}
if (match) {
ScanResult r;
r.address = region.base + off + (uint64_t)i;
r.address = regStart + off + (uint64_t)i;
r.regionModule = region.moduleName;
r.scanValue = QByteArray(data + i, qMin(16, readLen - i));
results.append(r);

View File

@@ -46,6 +46,9 @@ struct ScanRequest {
ScanCondition condition = ScanCondition::ExactValue;
int valueSize = 4; // bytes per value (for unknown scans)
uint64_t startAddress = 0; // 0 = no limit (scan all regions)
uint64_t endAddress = 0; // 0 = no limit (scan all regions)
};
struct ScanResult {

View File

@@ -124,6 +124,9 @@ ScannerPanel::ScannerPanel(QWidget* parent)
m_writeCheck = new QCheckBox(QStringLiteral("Writable"), this);
filterRow->addWidget(m_writeCheck);
m_structOnlyCheck = new QCheckBox(QStringLiteral("Current Struct"), this);
filterRow->addWidget(m_structOnlyCheck);
filterRow->addStretch();
m_scanBtn = new QPushButton(QIcon(QStringLiteral(":/vsicons/search.svg")),
@@ -257,6 +260,10 @@ void ScannerPanel::setProviderGetter(ProviderGetter getter) {
m_providerGetter = std::move(getter);
}
void ScannerPanel::setBoundsGetter(BoundsGetter getter) {
m_boundsGetter = std::move(getter);
}
void ScannerPanel::setEditorFont(const QFont& font) {
m_resultTable->setFont(font);
QFontMetrics fm(font);
@@ -278,6 +285,7 @@ void ScannerPanel::setEditorFont(const QFont& font) {
m_valueLabel->setFont(font);
m_execCheck->setFont(font);
m_writeCheck->setFont(font);
m_structOnlyCheck->setFont(font);
m_updateBtn->setFont(font);
updateComboWidth();
}
@@ -398,6 +406,14 @@ ScanRequest ScannerPanel::buildRequest() {
req.filterExecutable = m_execCheck->isChecked();
req.filterWritable = m_writeCheck->isChecked();
if (m_structOnlyCheck->isChecked() && m_boundsGetter) {
auto bounds = m_boundsGetter();
if (bounds.size > 0) {
req.startAddress = bounds.start;
req.endAddress = bounds.start + bounds.size;
}
}
return req;
}
@@ -750,6 +766,7 @@ void ScannerPanel::applyTheme(const Theme& theme) {
cp.setColor(QPalette::WindowText, theme.textDim);
m_execCheck->setPalette(cp);
m_writeCheck->setPalette(cp);
m_structOnlyCheck->setPalette(cp);
// Buttons
QString btnStyle = QStringLiteral(

View File

@@ -34,6 +34,10 @@ public:
using ProviderGetter = std::function<std::shared_ptr<Provider>()>;
void setProviderGetter(ProviderGetter getter);
struct StructBounds { uint64_t start = 0; uint64_t size = 0; };
using BoundsGetter = std::function<StructBounds()>;
void setBoundsGetter(BoundsGetter getter);
void setEditorFont(const QFont& font);
void applyTheme(const Theme& theme);
@@ -54,6 +58,7 @@ public:
ScanEngine* engine() const { return m_engine; }
QComboBox* condCombo() const { return m_condCombo; }
QLabel* condLabel() const { return m_condLabel; }
QCheckBox* structOnlyCheck() const { return m_structOnlyCheck; }
signals:
void goToAddress(uint64_t address);
@@ -90,6 +95,7 @@ private:
// Filters
QCheckBox* m_execCheck;
QCheckBox* m_writeCheck;
QCheckBox* m_structOnlyCheck;
// Actions
QPushButton* m_scanBtn;
@@ -106,6 +112,7 @@ private:
// Engine
ScanEngine* m_engine;
ProviderGetter m_providerGetter;
BoundsGetter m_boundsGetter;
QVector<ScanResult> m_results;
int m_lastScanMode = 0; // 0=signature, 1=value
ValueType m_lastValueType = ValueType::Int32;

View File

@@ -10,8 +10,8 @@
"textDim": "#505C74",
"textMuted": "#384258",
"textFaint": "#2C3448",
"hover": "#121720",
"selected": "#121720",
"hover": "#181E2A",
"selected": "#1A2D4A",
"selection": "#1A2038",
"syntaxKeyword": "#5688C0",
"syntaxNumber": "#90B480",

View File

@@ -0,0 +1,32 @@
{
"name": "Light",
"background": "#e8e8ec",
"backgroundAlt": "#dcdce0",
"surface": "#d4d4d8",
"border": "#b8b8be",
"borderFocused": "#6870a0",
"button": "#ccccd0",
"text": "#1b1b22",
"textDim": "#5c5c68",
"textMuted": "#84848e",
"textFaint": "#a8a8b0",
"hover": "#d8d8de",
"selected": "#d0d0d8",
"selection": "#b4c8e8",
"syntaxKeyword": "#4455aa",
"syntaxNumber": "#2a7a4c",
"syntaxString": "#9a4040",
"syntaxComment": "#6a7a6a",
"syntaxPreproc": "#787880",
"syntaxType": "#2e7a8a",
"indHoverSpan": "#5a68a0",
"indCmdPill": "#dcdce0",
"indDataChanged": "#2a7a4c",
"indHeatCold": "#6a6a30",
"indHeatWarm": "#a06828",
"indHeatHot": "#b83030",
"indHintGreen": "#387a44",
"markerPtr": "#b83030",
"markerCycle": "#9a7010",
"markerError": "#e8c8c8"
}

View File

@@ -15,8 +15,8 @@
"selection": "#21213A",
"syntaxKeyword": "#AA9565",
"syntaxNumber": "#AAA98C",
"syntaxString": "#6B3B21",
"syntaxComment": "#464646",
"syntaxString": "#C0825A",
"syntaxComment": "#8A8878",
"syntaxPreproc": "#AA9565",
"syntaxType": "#6B959F",
"indHoverSpan": "#AA9565",
@@ -25,8 +25,8 @@
"indHeatCold": "#C4A44A",
"indHeatWarm": "#AA9565",
"indHeatHot": "#A05040",
"indHintGreen": "#464646",
"markerPtr": "#6B3B21",
"indHintGreen": "#688A58",
"markerPtr": "#B85A42",
"markerCycle": "#AA9565",
"markerError": "#3C2121"
}

View File

@@ -2627,6 +2627,122 @@ private slots:
QVERIFY2(result.text.contains(QStringLiteral("\u2192")),
qPrintable("Expected arrow (\u2192) in text:\n" + result.text));
}
void testTreeLinesDepth2() {
// Diagnostic test: verify tree chars at depth 2+ with hex64 nodes
// (matches user's actual scenario — Hex64 inside pointer expansion)
NodeTree tree;
tree.baseAddress = 0;
// Root struct "Unnamed"
Node root;
root.kind = NodeKind::Struct;
root.name = "Unnamed";
root.parentId = 0;
root.offset = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
// First child: hex64 at depth 1
Node f1;
f1.kind = NodeKind::Hex64;
f1.name = "";
f1.parentId = rootId;
f1.offset = 0;
tree.addNode(f1);
// Ref struct "NewClass" (separate root-level definition)
Node inner;
inner.kind = NodeKind::Struct;
inner.name = "NewClass";
inner.parentId = 0;
inner.offset = 200;
int ii = tree.addNode(inner);
uint64_t innerId = tree.nodes[ii].id;
// hex64 children of NewClass
Node if1;
if1.kind = NodeKind::Hex64;
if1.name = "";
if1.parentId = innerId;
if1.offset = 0;
tree.addNode(if1);
Node if2;
if2.kind = NodeKind::Hex64;
if2.name = "";
if2.parentId = innerId;
if2.offset = 8;
tree.addNode(if2);
Node if3;
if3.kind = NodeKind::Hex64;
if3.name = "";
if3.parentId = innerId;
if3.offset = 16;
tree.addNode(if3);
// Pointer in root referencing NewClass
Node ptr;
ptr.kind = NodeKind::Pointer64;
ptr.name = "field_0008";
ptr.parentId = rootId;
ptr.offset = 8;
ptr.refId = innerId;
tree.addNode(ptr);
// Last child: hex64 at depth 1
Node f2;
f2.kind = NodeKind::Hex64;
f2.name = "";
f2.parentId = rootId;
f2.offset = 16;
tree.addNode(f2);
// Provider with pointer value
QByteArray data(256, '\0');
uint64_t ptrVal = 100;
memcpy(data.data() + 8, &ptrVal, 8);
BufferProvider prov(data);
// Compose WITH tree lines
ComposeResult result = compose(tree, prov, 0, false, true);
QStringList lines = result.text.split('\n');
// Print output with char codes for debugging
qDebug() << "=== Tree lines compose output (hex64 scenario) ===";
for (int i = 0; i < lines.size(); i++) {
// Also show hex of first 15 chars to see tree chars
QString hexChars;
for (int c = 0; c < qMin(15, lines[i].size()); c++)
hexChars += QString("U+%1 ").arg(lines[i][c].unicode(), 4, 16, QChar('0'));
qDebug().noquote() << QString("[%1] d=%2 k=%3: %4")
.arg(i, 2).arg(result.meta[i].depth).arg((int)result.meta[i].lineKind).arg(lines[i]);
qDebug().noquote() << QString(" hex: %1").arg(hexChars);
}
qDebug() << "=== end ===";
// Verify depth-2 lines contain tree chars
QChar vertLine(0x2502); // │
QChar tee(0x251C); // ├
QChar corner(0x2514); // └
bool foundDepth2TreeChar = false;
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].depth == 2
&& result.meta[i].lineKind != LineKind::Footer) {
bool has = lines[i].contains(vertLine)
|| lines[i].contains(tee)
|| lines[i].contains(corner);
if (has) foundDepth2TreeChar = true;
QVERIFY2(has,
qPrintable(QString("Depth-2 line %1 missing tree chars: %2")
.arg(i).arg(lines[i])));
}
}
QVERIFY2(foundDepth2TreeChar,
qPrintable("No depth-2 lines with tree chars found:\n" + result.text));
}
};
QTEST_MAIN(TestCompose)

112
tests/test_dbgdump.cpp Normal file
View File

@@ -0,0 +1,112 @@
#include <cstdio>
#include <cstdint>
#include <windows.h>
#include <initguid.h>
#include <dbgeng.h>
int main(int argc, char* argv[])
{
const char* dumpPath = "F:\\MEMORY_EaService2024.DMP";
if (argc > 1) dumpPath = argv[1];
HRESULT hrCom = CoInitializeEx(NULL, COINIT_MULTITHREADED);
printf("CoInitializeEx: 0x%08lX\n", hrCom);
fflush(stdout);
IDebugClient* client = nullptr;
HRESULT hr = DebugCreate(IID_IDebugClient, (void**)&client);
printf("DebugCreate: 0x%08lX, client=%p\n", hr, (void*)client);
fflush(stdout);
if (FAILED(hr) || !client) {
printf("FAILED to create debug client\n");
if (SUCCEEDED(hrCom)) CoUninitialize();
return 1;
}
printf("Opening dump: %s\n", dumpPath);
fflush(stdout);
hr = client->OpenDumpFile(dumpPath);
printf("OpenDumpFile: 0x%08lX\n", hr);
fflush(stdout);
if (FAILED(hr)) {
printf("FAILED to open dump\n");
client->Release();
if (SUCCEEDED(hrCom)) CoUninitialize();
return 1;
}
IDebugControl* ctrl = nullptr;
client->QueryInterface(IID_IDebugControl, (void**)&ctrl);
if (ctrl) {
printf("WaitForEvent(10s)...\n");
fflush(stdout);
hr = ctrl->WaitForEvent(0, 10000);
printf("WaitForEvent: 0x%08lX\n", hr);
fflush(stdout);
ULONG debugClass = 0, debugQual = 0;
hr = ctrl->GetDebuggeeType(&debugClass, &debugQual);
printf("GetDebuggeeType: 0x%08lX, class=%lu, qualifier=%lu\n",
hr, debugClass, debugQual);
printf(" -> %s\n", debugQual >= 1024 ? "DUMP" : "LIVE");
fflush(stdout);
}
IDebugDataSpaces* ds = nullptr;
client->QueryInterface(IID_IDebugDataSpaces, (void**)&ds);
IDebugSymbols* sym = nullptr;
client->QueryInterface(IID_IDebugSymbols, (void**)&sym);
if (sym) {
ULONG numMods = 0, numUnloaded = 0;
hr = sym->GetNumberModules(&numMods, &numUnloaded);
printf("GetNumberModules: 0x%08lX, loaded=%lu, unloaded=%lu\n",
hr, numMods, numUnloaded);
fflush(stdout);
if (numMods > 0) {
ULONG64 base = 0;
hr = sym->GetModuleByIndex(0, &base);
printf("Module[0] base: 0x%llX (hr=0x%08lX)\n", base, hr);
fflush(stdout);
if (SUCCEEDED(hr) && base && ds) {
uint8_t buf[16] = {};
ULONG got = 0;
hr = ds->ReadVirtual(base, buf, 16, &got);
printf("ReadVirtual(0x%llX, 16): hr=0x%08lX, got=%lu\n", base, hr, got);
printf(" data: ");
for (int i = 0; i < 16; i++) printf("%02X ", buf[i]);
printf("\n");
fflush(stdout);
}
}
}
// Try reading kernel base directly
uint64_t ntBase = 0xfffff80123c00000ULL;
if (ds) {
uint8_t buf[16] = {};
ULONG got = 0;
hr = ds->ReadVirtual(ntBase, buf, 16, &got);
printf("ReadVirtual(nt base 0x%llX, 16): hr=0x%08lX, got=%lu\n", ntBase, hr, got);
printf(" data: ");
for (int i = 0; i < 16; i++) printf("%02X ", buf[i]);
printf("\n");
fflush(stdout);
}
if (sym) sym->Release();
if (ds) ds->Release();
if (ctrl) ctrl->Release();
client->DetachProcesses();
client->Release();
printf("Done.\n");
if (SUCCEEDED(hrCom)) CoUninitialize();
return 0;
}

View File

@@ -1072,6 +1072,120 @@ private slots:
auto results = finSpy.first().first().value<QVector<ScanResult>>();
QCOMPARE(results.size(), 7); // 8 - 2 + 1 = 7 positions
}
// ═══════════════════════════════════════════════════════════════════
// Address range filtering — "Current Struct" support
// ═══════════════════════════════════════════════════════════════════
void scan_addressRangeNoLimit() {
// startAddress=0, endAddress=0 → scan all (default behavior unchanged)
QByteArray data(32, '\x00');
data[8] = '\xAA'; data[16] = '\xAA'; data[24] = '\xAA';
auto prov = std::make_shared<BufferProvider>(data);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
ScanRequest req;
req.pattern = QByteArray("\xAA", 1);
req.mask = QByteArray("\xFF", 1);
engine.start(prov, req);
QVERIFY(finSpy.wait(5000));
auto results = finSpy.first().first().value<QVector<ScanResult>>();
QCOMPARE(results.size(), 3); // all 3 found
}
void scan_addressRangeClipsResults() {
// Only scan addresses [8, 20) — should find match at offset 8 and 16 but not 24
QByteArray data(32, '\x00');
data[8] = '\xAA'; data[16] = '\xAA'; data[24] = '\xAA';
auto prov = std::make_shared<BufferProvider>(data);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
ScanRequest req;
req.pattern = QByteArray("\xAA", 1);
req.mask = QByteArray("\xFF", 1);
req.startAddress = 8;
req.endAddress = 20;
engine.start(prov, req);
QVERIFY(finSpy.wait(5000));
auto results = finSpy.first().first().value<QVector<ScanResult>>();
QCOMPARE(results.size(), 2);
QCOMPARE(results[0].address, (uint64_t)8);
QCOMPARE(results[1].address, (uint64_t)16);
}
void scan_addressRangeOutsideData() {
// Range entirely outside data → no results
QByteArray data(16, '\xAA');
auto prov = std::make_shared<BufferProvider>(data);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
ScanRequest req;
req.pattern = QByteArray("\xAA", 1);
req.mask = QByteArray("\xFF", 1);
req.startAddress = 100;
req.endAddress = 200;
engine.start(prov, req);
QVERIFY(finSpy.wait(5000));
auto results = finSpy.first().first().value<QVector<ScanResult>>();
QCOMPARE(results.size(), 0);
}
void scan_addressRangeWithRegions() {
// Two regions: [1000, 1016) and [2000, 2016). Range [1000, 1020) clips to first region only.
QByteArray data(4096, '\x00');
// Place \xBB at offset 1000 and 2000
data[1000] = '\xBB';
data[2000] = '\xBB';
QVector<MemoryRegion> regions;
{ MemoryRegion r; r.base = 1000; r.size = 16; r.readable = true; r.writable = true; regions.append(r); }
{ MemoryRegion r; r.base = 2000; r.size = 16; r.readable = true; r.writable = true; regions.append(r); }
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
ScanRequest req;
req.pattern = QByteArray("\xBB", 1);
req.mask = QByteArray("\xFF", 1);
req.startAddress = 1000;
req.endAddress = 1020;
engine.start(prov, req);
QVERIFY(finSpy.wait(5000));
auto results = finSpy.first().first().value<QVector<ScanResult>>();
QCOMPARE(results.size(), 1);
QCOMPARE(results[0].address, (uint64_t)1000);
}
void scan_unknownWithAddressRange() {
// Unknown scan with address range should only capture within range
QByteArray data(32, '\x42');
auto prov = std::make_shared<BufferProvider>(data);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
ScanRequest req;
req.condition = ScanCondition::UnknownValue;
req.valueSize = 4;
req.alignment = 4;
req.startAddress = 8;
req.endAddress = 24;
engine.start(prov, req);
QVERIFY(finSpy.wait(5000));
auto results = finSpy.first().first().value<QVector<ScanResult>>();
// Range [8, 24) = 16 bytes, alignment 4, valueSize 4 → offsets 8, 12, 16, 20 = 4 results
QCOMPARE(results.size(), 4);
QCOMPARE(results[0].address, (uint64_t)8);
QCOMPARE(results[3].address, (uint64_t)20);
}
};
QTEST_MAIN(TestScanner)

View File

@@ -1103,6 +1103,89 @@ private slots:
// Provider getter is lazy (captures at scan time)
// ═══════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════
// "Current Struct" checkbox
// ═══════════════════════════════════════════════════════════════════
void structOnly_checkboxExists() {
QVERIFY(m_panel->structOnlyCheck() != nullptr);
QCOMPARE(m_panel->structOnlyCheck()->isChecked(), false);
QCOMPARE(m_panel->structOnlyCheck()->text(), QStringLiteral("Current Struct"));
}
void structOnly_setsAddressRange() {
// Set up a bounds getter that returns a known range
m_panel->setBoundsGetter([]() -> ScannerPanel::StructBounds {
return { 0x1000, 0x200 };
});
// Set up a simple buffer provider
QByteArray data(0x2000, '\x00');
data[0x1000] = '\xCC';
data[0x1100] = '\xCC';
data[0x1500] = '\xCC'; // outside bounds (0x1000 + 0x200 = 0x1200)
auto prov = std::make_shared<BufferProvider>(data);
m_panel->setProviderGetter([prov]() { return prov; });
// Enable struct-only mode
m_panel->structOnlyCheck()->setChecked(true);
// Scan for \xCC
m_panel->patternEdit()->setText("CC");
QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished);
QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton);
QVERIFY(finSpy.wait(5000));
QApplication::processEvents();
// Should only find results within [0x1000, 0x1200)
auto results = finSpy.first().first().value<QVector<ScanResult>>();
QCOMPARE(results.size(), 2);
}
void structOnly_uncheckedScansAll() {
// Same setup but with checkbox unchecked — should find all 3
m_panel->setBoundsGetter([]() -> ScannerPanel::StructBounds {
return { 0x1000, 0x200 };
});
QByteArray data(0x2000, '\x00');
data[0x1000] = '\xCC';
data[0x1100] = '\xCC';
data[0x1500] = '\xCC';
auto prov = std::make_shared<BufferProvider>(data);
m_panel->setProviderGetter([prov]() { return prov; });
m_panel->structOnlyCheck()->setChecked(false); // unchecked
m_panel->patternEdit()->setText("CC");
QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished);
QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton);
QVERIFY(finSpy.wait(5000));
QApplication::processEvents();
auto results = finSpy.first().first().value<QVector<ScanResult>>();
QCOMPARE(results.size(), 3);
}
void structOnly_noBoundsGetterIgnored() {
// No bounds getter set — checkbox checked but no effect
QByteArray data(16, '\xDD');
auto prov = std::make_shared<BufferProvider>(data);
m_panel->setProviderGetter([prov]() { return prov; });
m_panel->structOnlyCheck()->setChecked(true);
// Don't set bounds getter
m_panel->patternEdit()->setText("DD");
QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished);
QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton);
QVERIFY(finSpy.wait(5000));
QApplication::processEvents();
auto results = finSpy.first().first().value<QVector<ScanResult>>();
QCOMPARE(results.size(), 16); // all 16 bytes match
}
void providerGetter_lazy() {
auto prov1 = std::make_shared<BufferProvider>(QByteArray(16, '\xAA'));
auto prov2 = std::make_shared<BufferProvider>(QByteArray(16, '\xBB'));

432
tests/test_tooltip.cpp Normal file
View File

@@ -0,0 +1,432 @@
#include <QtTest>
#include <QApplication>
#include <QPushButton>
#include <QScreen>
#include <QImage>
#include "rcxtooltip.h"
#include "themes/thememanager.h"
using namespace rcx;
// ─────────────────────────────────────────────────────────────────
// Test suite for the RcxTooltip callout widget
//
// These tests verify both geometry math AND real-world behavior:
// - Actual pixel rendering (catches WA_TranslucentBackground failures)
// - Leave-event resilience (catches spurious dismiss on tooltip popup)
// - Dismiss correctness (cursor truly leaves trigger zone)
// ─────────────────────────────────────────────────────────────────
class TestTooltip : public QObject {
Q_OBJECT
private:
QWidget* m_window = nullptr;
QPushButton* m_btnTop = nullptr;
QPushButton* m_btnMid = nullptr;
QPushButton* m_btnLeft = nullptr;
QPushButton* m_btnRight= nullptr;
void showAndProcess(QWidget* trigger, const QString& text) {
RcxTooltip::instance()->showFor(trigger, text);
// Process events + allow paint to complete
QCoreApplication::processEvents();
QTest::qWait(20);
QCoreApplication::processEvents();
}
// Count non-transparent pixels in a QImage region
int countOpaquePixels(const QImage& img, const QRect& region) {
int count = 0;
QRect r = region.intersected(img.rect());
for (int y = r.top(); y <= r.bottom(); ++y)
for (int x = r.left(); x <= r.right(); ++x)
if (qAlpha(img.pixel(x, y)) > 0)
++count;
return count;
}
private slots:
void initTestCase() {
m_window = new QWidget;
m_window->setFixedSize(800, 600);
QScreen* scr = QApplication::primaryScreen();
QRect avail = scr->availableGeometry();
m_window->move(avail.center() - QPoint(400, 300));
m_btnMid = new QPushButton("Middle", m_window);
m_btnMid->setFixedSize(80, 24);
m_btnMid->move(360, 288);
m_btnTop = new QPushButton("Top", m_window);
m_btnTop->setFixedSize(80, 24);
m_btnTop->move(360, 0);
m_btnLeft = new QPushButton("Left", m_window);
m_btnLeft->setFixedSize(80, 24);
m_btnLeft->move(0, 288);
m_btnRight = new QPushButton("Right", m_window);
m_btnRight->setFixedSize(80, 24);
m_btnRight->move(720, 288);
m_window->show();
QVERIFY(QTest::qWaitForWindowExposed(m_window));
}
void cleanupTestCase() {
RcxTooltip::instance()->dismiss();
delete m_window;
m_window = nullptr;
}
void cleanup() {
RcxTooltip::instance()->dismiss();
QCoreApplication::processEvents();
}
// ── Singleton ──
void testSingleton() {
QCOMPARE(RcxTooltip::instance(), RcxTooltip::instance());
}
// ── Basic show/dismiss ──
void testShowAndDismiss() {
auto* tip = RcxTooltip::instance();
QVERIFY(!tip->isVisible());
showAndProcess(m_btnMid, "Hello");
QVERIFY(tip->isVisible());
QCOMPARE(tip->currentText(), QString("Hello"));
QCOMPARE(tip->currentTrigger(), m_btnMid);
tip->dismiss();
QVERIFY(!tip->isVisible());
QVERIFY(tip->currentTrigger() == nullptr);
}
// ── Empty text / null trigger = dismiss ──
void testEmptyTextDismisses() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Test");
QVERIFY(tip->isVisible());
showAndProcess(m_btnMid, "");
QVERIFY(!tip->isVisible());
}
void testNullTriggerDismisses() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Test");
QVERIFY(tip->isVisible());
showAndProcess(nullptr, "Test");
QVERIFY(!tip->isVisible());
}
// ── Arrow direction ──
void testArrowDownByDefault() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Default placement");
QVERIFY(tip->isVisible());
QVERIFY(tip->arrowPointsDown());
QRect trigGlobal(m_btnMid->mapToGlobal(QPoint(0,0)), m_btnMid->size());
int tipBottom = tip->y() + tip->height();
QVERIFY2(tipBottom <= trigGlobal.top() + RcxTooltip::kGap + 2,
qPrintable(QStringLiteral("tipBottom=%1 trigTop=%2")
.arg(tipBottom).arg(trigGlobal.top())));
}
void testArrowFlipsAtScreenTop() {
QScreen* scr = QApplication::primaryScreen();
QRect avail = scr->availableGeometry();
QPoint oldPos = m_window->pos();
m_window->move(avail.center().x() - 400, avail.top());
QCoreApplication::processEvents();
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnTop, "Flipped");
QVERIFY(tip->isVisible());
QVERIFY2(!tip->arrowPointsDown(),
"Expected arrow to flip upward when trigger is near screen top");
QRect trigGlobal(m_btnTop->mapToGlobal(QPoint(0,0)), m_btnTop->size());
QVERIFY2(tip->y() >= trigGlobal.bottom(),
qPrintable(QStringLiteral("tipY=%1 trigBottom=%2")
.arg(tip->y()).arg(trigGlobal.bottom())));
m_window->move(oldPos);
QCoreApplication::processEvents();
}
// ── Arrow centering ──
void testArrowCenteredOnTrigger() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Center");
QVERIFY(tip->isVisible());
QRect trigGlobal(m_btnMid->mapToGlobal(QPoint(0,0)), m_btnMid->size());
int trigCenterX = trigGlobal.center().x();
int arrowGlobalX = tip->x() + tip->arrowLocalX();
int delta = qAbs(arrowGlobalX - trigCenterX);
QVERIFY2(delta <= 2,
qPrintable(QStringLiteral("arrowGlobalX=%1 trigCenterX=%2 delta=%3")
.arg(arrowGlobalX).arg(trigCenterX).arg(delta)));
}
// ── Anti-teleport ──
void testNoTeleportSameWidget() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Stable");
QPoint pos1 = tip->pos();
showAndProcess(m_btnMid, "Stable");
QCOMPARE(tip->pos(), pos1);
}
// ── Repositions for different widget ──
void testRepositionsForDifferentWidget() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnLeft, "Left");
QPoint pos1 = tip->pos();
showAndProcess(m_btnRight, "Right");
QVERIFY2(tip->pos() != pos1, "Tooltip should move when trigger widget changes");
}
// ── Horizontal clamping ──
void testHorizontalClampLeft() {
QScreen* scr = QApplication::primaryScreen();
QRect avail = scr->availableGeometry();
QPoint oldPos = m_window->pos();
m_window->move(avail.left(), avail.center().y() - 300);
QCoreApplication::processEvents();
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnLeft, "Clamped left");
QVERIFY(tip->isVisible());
QVERIFY2(tip->x() >= avail.left(),
qPrintable(QStringLiteral("tipX=%1 screenLeft=%2")
.arg(tip->x()).arg(avail.left())));
m_window->move(oldPos);
QCoreApplication::processEvents();
}
void testHorizontalClampRight() {
QScreen* scr = QApplication::primaryScreen();
QRect avail = scr->availableGeometry();
QPoint oldPos = m_window->pos();
m_window->move(avail.right() - m_window->width(), avail.center().y() - 300);
QCoreApplication::processEvents();
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnRight, "Clamped right");
QVERIFY(tip->isVisible());
QVERIFY2(tip->x() + tip->width() <= avail.right() + 2,
qPrintable(QStringLiteral("tipRight=%1 screenRight=%2")
.arg(tip->x() + tip->width()).arg(avail.right())));
m_window->move(oldPos);
QCoreApplication::processEvents();
}
// ── Body rect dimensions ──
void testBodyRectSanity() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Body");
QVERIFY(tip->isVisible());
QRect body = tip->bodyRect();
QVERIFY(body.width() > 0);
QVERIFY(body.height() > 0);
QCOMPARE(tip->height(), body.height() + RcxTooltip::kArrowH);
}
// ── Constants ──
void testConstants() {
QCOMPARE(RcxTooltip::kArrowH, 6);
QCOMPARE(RcxTooltip::kArrowHalfW, 6);
QCOMPARE(RcxTooltip::kGap, 2);
}
// ──────────────────────────────────────────────────────────────
// RENDERING VERIFICATION — catches invisible tooltip bugs
// ──────────────────────────────────────────────────────────────
void testShowForRendersBodyPixels() {
// Show tooltip and grab its rendered pixels.
// Verify that the body area has non-transparent content.
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Render test");
QVERIFY(tip->isVisible());
// Force full opacity so grab gets real pixels
tip->setWindowOpacity(1.0);
QCoreApplication::processEvents();
QImage img = tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
QVERIFY2(!img.isNull(), "grab() returned null image");
QVERIFY2(img.width() > 0 && img.height() > 0, "grab() returned empty image");
// Check body rect area for opaque pixels
QRect body = tip->bodyRect();
// Inset by 2px to avoid anti-aliased border edges
QRect checkRect = body.adjusted(2, 2, -2, -2);
int opaquePixels = countOpaquePixels(img, checkRect);
int totalPixels = checkRect.width() * checkRect.height();
QVERIFY2(opaquePixels > totalPixels / 2,
qPrintable(QStringLiteral(
"Body area has too few opaque pixels: %1 / %2 (< 50%%). "
"The tooltip is not rendering its background.")
.arg(opaquePixels).arg(totalPixels)));
}
void testArrowRendersPixels() {
// Verify the triangle arrow region has some opaque pixels.
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Arrow test");
QVERIFY(tip->isVisible());
QVERIFY(tip->arrowPointsDown());
tip->setWindowOpacity(1.0);
QCoreApplication::processEvents();
QImage img = tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
// Arrow region: below the body rect, centered on arrowLocalX
QRect body = tip->bodyRect();
int arrowTop = body.bottom();
int arrowLeft = tip->arrowLocalX() - RcxTooltip::kArrowHalfW;
int arrowRight = tip->arrowLocalX() + RcxTooltip::kArrowHalfW;
QRect arrowRect(arrowLeft, arrowTop, arrowRight - arrowLeft, RcxTooltip::kArrowH);
int opaquePixels = countOpaquePixels(img, arrowRect);
QVERIFY2(opaquePixels > 0,
qPrintable(QStringLiteral(
"Arrow region has 0 opaque pixels — triangle not painted. "
"arrowRect=(%1,%2 %3x%4) imgSize=(%5x%6)")
.arg(arrowRect.x()).arg(arrowRect.y())
.arg(arrowRect.width()).arg(arrowRect.height())
.arg(img.width()).arg(img.height())));
}
// ──────────────────────────────────────────────────────────────
// LEAVE EVENT RESILIENCE — catches spurious dismiss bugs
// ──────────────────────────────────────────────────────────────
void testSurvivesLeaveEvent() {
// The tooltip should NOT be dismissed when a Leave event fires
// on the trigger widget while the cursor is still in the
// trigger+tooltip zone (simulates the synthetic Leave that Qt
// sends when a tooltip window pops up above the trigger).
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Survive Leave");
QVERIFY(tip->isVisible());
tip->setWindowOpacity(1.0);
// Move real cursor to center of trigger (so geometry check passes)
QPoint trigCenter = m_btnMid->mapToGlobal(
QPoint(m_btnMid->width() / 2, m_btnMid->height() / 2));
QCursor::setPos(trigCenter);
QCoreApplication::processEvents();
// Send a Leave event to the trigger (like DarkApp::notify would)
QEvent leaveEvent(QEvent::Leave);
QApplication::sendEvent(m_btnMid, &leaveEvent);
// Now call scheduleDismiss as DarkApp would
tip->scheduleDismiss();
QCoreApplication::processEvents();
// Tooltip should STILL be visible — cursor is inside trigger zone
QVERIFY2(tip->isVisible(),
"Tooltip was dismissed by spurious Leave event while cursor "
"was still over the trigger widget");
// Wait beyond the dismiss timer to be sure
QTest::qWait(200);
QCoreApplication::processEvents();
QVERIFY2(tip->isVisible(),
"Tooltip was dismissed after 200ms despite cursor being over trigger");
}
void testDismissesOnRealLeave() {
// When the cursor truly leaves the trigger+tooltip zone,
// scheduleDismiss() should queue dismissal and it should fire.
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Real leave");
QVERIFY(tip->isVisible());
tip->setWindowOpacity(1.0);
// Move cursor far away from both trigger and tooltip
QScreen* scr = QApplication::primaryScreen();
QRect avail = scr->availableGeometry();
QCursor::setPos(avail.bottomRight() - QPoint(10, 10));
QCoreApplication::processEvents();
// scheduleDismiss should detect cursor is outside zone
tip->scheduleDismiss();
QCoreApplication::processEvents();
// Wait for the 100ms dismiss timer
QTest::qWait(200);
QCoreApplication::processEvents();
QVERIFY2(!tip->isVisible(),
"Tooltip should have been dismissed when cursor left the zone");
}
void testLeaveAndReshow() {
// Dismiss via real leave, then re-show on a different widget.
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "First");
QVERIFY(tip->isVisible());
// Force dismiss
tip->dismiss();
QCoreApplication::processEvents();
QVERIFY(!tip->isVisible());
// Re-show on different widget
showAndProcess(m_btnLeft, "Second");
QVERIFY2(tip->isVisible(), "Tooltip failed to re-appear after dismiss");
QCOMPARE(tip->currentText(), QString("Second"));
QCOMPARE(tip->currentTrigger(), m_btnLeft);
}
// ── Scheduled dismiss cancelled by new showFor ──
void testScheduledDismissCancelledByShow() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "First");
// Move cursor far away and schedule dismiss
QScreen* scr = QApplication::primaryScreen();
QCursor::setPos(scr->availableGeometry().bottomRight() - QPoint(10, 10));
QCoreApplication::processEvents();
tip->scheduleDismiss();
// Before timer fires, show on a different widget
showAndProcess(m_btnLeft, "Second");
QTest::qWait(200);
QCoreApplication::processEvents();
// Should still be visible — new showFor cancelled the timer
QVERIFY(tip->isVisible());
QCOMPARE(tip->currentText(), QString("Second"));
}
// ── Text change on same widget ──
void testTextChangeOnSameWidget() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Text A");
QCOMPARE(tip->currentText(), QString("Text A"));
tip->dismiss();
showAndProcess(m_btnMid, "Text B");
QCOMPARE(tip->currentText(), QString("Text B"));
}
};
QTEST_MAIN(TestTooltip)
#include "test_tooltip.moc"

View File

@@ -0,0 +1,292 @@
// Tests the full tooltip flow including DarkApp-style ToolTip interception.
// Verifies that QEvent::ToolTip fires and our custom tooltip appears.
#include <QtTest>
#include <QApplication>
#include <QPushButton>
#include <QScreen>
#include <QHelpEvent>
#include <QImage>
#include "rcxtooltip.h"
#include "themes/thememanager.h"
#include <cstdio>
using namespace rcx;
static void LOG(const char* fmt, ...) {
va_list ap;
va_start(ap, fmt);
vfprintf(stdout, fmt, ap);
va_end(ap);
fflush(stdout);
}
// Simulates DarkApp::notify behavior — installed as a global event filter
class DarkAppSimulator : public QObject {
public:
int tooltipEventCount = 0;
int leaveEventCount = 0;
int showForCallCount = 0;
bool eventFilter(QObject* obj, QEvent* ev) override {
if (ev->type() == QEvent::ToolTip) {
tooltipEventCount++;
if (obj->isWidgetType()) {
auto* w = static_cast<QWidget*>(obj);
QString tip = w->toolTip();
LOG(" [darkapp-sim] ToolTip #%d on '%s' tip='%s'\n",
tooltipEventCount, qPrintable(w->objectName()),
qPrintable(tip.left(60)));
if (!tip.isEmpty()) {
showForCallCount++;
LOG(" [darkapp-sim] calling showFor #%d\n", showForCallCount);
RcxTooltip::instance()->showFor(w, tip);
LOG(" [darkapp-sim] after showFor: visible=%d pos=(%d,%d) size=%dx%d\n",
RcxTooltip::instance()->isVisible(),
RcxTooltip::instance()->x(), RcxTooltip::instance()->y(),
RcxTooltip::instance()->width(), RcxTooltip::instance()->height());
return true; // consume — same as DarkApp
}
}
return true; // suppress default QToolTip
}
if (ev->type() == QEvent::Leave && obj->isWidgetType()) {
auto* tip = RcxTooltip::instance();
if (tip->isVisible() && tip->currentTrigger() == obj) {
leaveEventCount++;
LOG(" [darkapp-sim] Leave #%d on trigger\n", leaveEventCount);
tip->scheduleDismiss();
}
}
return false;
}
};
class TestTooltipEvent : public QObject {
Q_OBJECT
private:
QWidget* m_window = nullptr;
QPushButton* m_btn = nullptr;
QPushButton* m_btn2 = nullptr;
DarkAppSimulator* m_sim = nullptr;
private slots:
void initTestCase() {
LOG("=== TestTooltipEvent starting ===\n");
m_window = new QWidget;
m_window->setFixedSize(400, 300);
QScreen* scr = QApplication::primaryScreen();
QRect avail = scr->availableGeometry();
m_window->move(avail.center() - QPoint(200, 150));
m_btn = new QPushButton("Scan", m_window);
m_btn->setToolTip("Start scanning memory");
m_btn->setFixedSize(120, 40);
m_btn->move(30, 130);
m_btn->setObjectName("btnScan");
m_btn2 = new QPushButton("Copy", m_window);
m_btn2->setToolTip("Copy to clipboard");
m_btn2->setFixedSize(120, 40);
m_btn2->move(250, 130);
m_btn2->setObjectName("btnCopy");
// Install DarkApp simulator as global event filter
m_sim = new DarkAppSimulator;
qApp->installEventFilter(m_sim);
m_window->show();
m_window->activateWindow();
m_window->raise();
QVERIFY(QTest::qWaitForWindowExposed(m_window));
// Let window become active
QTest::qWait(200);
QCoreApplication::processEvents();
LOG(" window at (%d,%d)\n", m_window->x(), m_window->y());
LOG(" btn global: (%d,%d)\n",
m_btn->mapToGlobal(QPoint(60, 20)).x(),
m_btn->mapToGlobal(QPoint(60, 20)).y());
}
void cleanupTestCase() {
qApp->removeEventFilter(m_sim);
RcxTooltip::instance()->dismiss();
delete m_sim;
delete m_window;
LOG("=== TestTooltipEvent finished ===\n");
}
void cleanup() {
RcxTooltip::instance()->dismiss();
QCoreApplication::processEvents();
m_sim->tooltipEventCount = 0;
m_sim->leaveEventCount = 0;
m_sim->showForCallCount = 0;
}
// Test 1: Post QHelpEvent → DarkApp simulator intercepts → RcxTooltip shows
void testManualEventShowsTooltip() {
LOG("\n--- testManualEventShowsTooltip ---\n");
auto* tip = RcxTooltip::instance();
QPoint btnGlobal = m_btn->mapToGlobal(QPoint(60, 20));
QCursor::setPos(btnGlobal);
QCoreApplication::processEvents();
LOG(" posting QHelpEvent\n");
QHelpEvent helpEvent(QEvent::ToolTip, QPoint(60, 20), btnGlobal);
QApplication::sendEvent(m_btn, &helpEvent);
QCoreApplication::processEvents();
QTest::qWait(100);
QCoreApplication::processEvents();
LOG(" sim: tooltipEvents=%d showForCalls=%d\n",
m_sim->tooltipEventCount, m_sim->showForCallCount);
LOG(" tip: visible=%d text='%s'\n",
tip->isVisible(), qPrintable(tip->currentText()));
QVERIFY2(m_sim->tooltipEventCount > 0, "Event filter didn't see ToolTip event");
QVERIFY2(m_sim->showForCallCount > 0, "showFor was never called");
QVERIFY2(tip->isVisible(), "RcxTooltip not visible after manual event");
QCOMPARE(tip->currentText(), QString("Start scanning memory"));
// Verify pixels
tip->setWindowOpacity(1.0);
QCoreApplication::processEvents();
QImage img = tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
QRect body = tip->bodyRect().adjusted(2, 2, -2, -2);
int opaque = 0;
for (int y = body.top(); y <= body.bottom(); ++y)
for (int x = body.left(); x <= body.right(); ++x)
if (qAlpha(img.pixel(x, y)) > 0) opaque++;
LOG(" pixels: %d/%d opaque\n", opaque, body.width() * body.height());
QVERIFY2(opaque > body.width() * body.height() / 2, "Body not rendered");
LOG("--- testManualEventShowsTooltip PASSED ---\n");
}
// Test 2: Qt's native tooltip timer fires → our filter intercepts → tooltip shows
void testNativeTimerShowsTooltip() {
LOG("\n--- testNativeTimerShowsTooltip ---\n");
auto* tip = RcxTooltip::instance();
// Move cursor away first
QPoint away = m_window->mapToGlobal(QPoint(380, 10));
QCursor::setPos(away);
QTest::qWait(200);
QCoreApplication::processEvents();
// Move to button
QPoint btnCenter = m_btn->mapToGlobal(QPoint(60, 20));
LOG(" moving cursor to (%d,%d)\n", btnCenter.x(), btnCenter.y());
QCursor::setPos(btnCenter);
// Send Enter + MouseMove to kick the tooltip timer
QEvent enterEv(QEvent::Enter);
QApplication::sendEvent(m_btn, &enterEv);
QMouseEvent moveEv(QEvent::MouseMove, QPointF(60, 20),
m_btn->mapToGlobal(QPointF(60, 20)),
Qt::NoButton, Qt::NoButton, Qt::NoModifier);
QApplication::sendEvent(m_btn, &moveEv);
// Wait up to 2000ms for tooltip to appear
LOG(" waiting for Qt tooltip timer...\n");
bool appeared = false;
for (int i = 0; i < 20; i++) {
QTest::qWait(100);
QCoreApplication::processEvents();
if (m_sim->tooltipEventCount > 0) {
LOG(" tooltip event at ~%dms! events=%d showFor=%d\n",
(i+1)*100, m_sim->tooltipEventCount, m_sim->showForCallCount);
appeared = true;
break;
}
}
// Process remaining events
QTest::qWait(100);
QCoreApplication::processEvents();
LOG(" final: events=%d showFor=%d visible=%d text='%s'\n",
m_sim->tooltipEventCount, m_sim->showForCallCount,
tip->isVisible(), qPrintable(tip->currentText()));
QVERIFY2(appeared, "Qt tooltip timer never fired (no ToolTip event in 2 seconds)");
QVERIFY2(tip->isVisible(), "Tooltip not visible after native timer fired");
LOG("--- testNativeTimerShowsTooltip PASSED ---\n");
}
// Test 3: Leave after tooltip shown → tooltip survives (cursor still in zone)
void testLeaveSurvival() {
LOG("\n--- testLeaveSurvival ---\n");
auto* tip = RcxTooltip::instance();
QPoint btnCenter = m_btn->mapToGlobal(QPoint(60, 20));
QCursor::setPos(btnCenter);
QCoreApplication::processEvents();
// Show via manual event
QHelpEvent helpEvent(QEvent::ToolTip, QPoint(60, 20), btnCenter);
QApplication::sendEvent(m_btn, &helpEvent);
QCoreApplication::processEvents();
QTest::qWait(100);
QCoreApplication::processEvents();
QVERIFY(tip->isVisible());
// Send Leave (cursor still on button)
LOG(" sending Leave while cursor on button\n");
QEvent leaveEv(QEvent::Leave);
QApplication::sendEvent(m_btn, &leaveEv);
QTest::qWait(200);
QCoreApplication::processEvents();
LOG(" after Leave+200ms: visible=%d leaves=%d\n",
tip->isVisible(), m_sim->leaveEventCount);
QVERIFY2(tip->isVisible(), "Tooltip dismissed by spurious Leave");
LOG("--- testLeaveSurvival PASSED ---\n");
}
// Test 4: Switch between widgets
void testWidgetSwitch() {
LOG("\n--- testWidgetSwitch ---\n");
auto* tip = RcxTooltip::instance();
// Show on btn1
QPoint btn1Center = m_btn->mapToGlobal(QPoint(60, 20));
QCursor::setPos(btn1Center);
QCoreApplication::processEvents();
QHelpEvent ev1(QEvent::ToolTip, QPoint(60, 20), btn1Center);
QApplication::sendEvent(m_btn, &ev1);
QCoreApplication::processEvents();
QTest::qWait(100);
QVERIFY(tip->isVisible());
QCOMPARE(tip->currentText(), QString("Start scanning memory"));
QPoint pos1 = tip->pos();
// Switch to btn2
QPoint btn2Center = m_btn2->mapToGlobal(QPoint(60, 20));
QCursor::setPos(btn2Center);
QCoreApplication::processEvents();
QHelpEvent ev2(QEvent::ToolTip, QPoint(60, 20), btn2Center);
QApplication::sendEvent(m_btn2, &ev2);
QCoreApplication::processEvents();
QTest::qWait(100);
LOG(" after switch: visible=%d text='%s' pos=(%d,%d)\n",
tip->isVisible(), qPrintable(tip->currentText()),
tip->x(), tip->y());
QVERIFY(tip->isVisible());
QCOMPARE(tip->currentText(), QString("Copy to clipboard"));
QVERIFY(tip->pos() != pos1);
LOG("--- testWidgetSwitch PASSED ---\n");
}
};
QTEST_MAIN(TestTooltipEvent)
#include "test_tooltip_event.moc"

253
tests/test_tooltip_ui.cpp Normal file
View File

@@ -0,0 +1,253 @@
// Integration test: simulates the full tooltip flow as DarkApp would see it.
// Posts QHelpEvent (ToolTip), sends Leave events, verifies RcxTooltip behavior
// with fprintf at every stage so we can see exactly what happens.
#include <QtTest>
#include <QApplication>
#include <QPushButton>
#include <QHelpEvent>
#include <QScreen>
#include <QImage>
#include "rcxtooltip.h"
#include "themes/thememanager.h"
#include <cstdio>
using namespace rcx;
static void LOG(const char* fmt, ...) {
va_list ap;
va_start(ap, fmt);
vfprintf(stdout, fmt, ap);
va_end(ap);
fflush(stdout);
}
// Simulates what DarkApp::notify does when a ToolTip event arrives
static bool simulateDarkAppToolTip(QWidget* w) {
QString tip = w->toolTip();
LOG(" [darkapp] widget='%s' class=%s tip='%s'\n",
qPrintable(w->objectName()), w->metaObject()->className(),
qPrintable(tip));
if (!tip.isEmpty()) {
LOG(" [darkapp] calling RcxTooltip::showFor\n");
RcxTooltip::instance()->showFor(w, tip);
LOG(" [darkapp] showFor returned, visible=%d opacity=%.2f pos=(%d,%d) size=%dx%d\n",
RcxTooltip::instance()->isVisible(),
RcxTooltip::instance()->windowOpacity(),
RcxTooltip::instance()->x(), RcxTooltip::instance()->y(),
RcxTooltip::instance()->width(), RcxTooltip::instance()->height());
return true;
}
return false;
}
// Simulates what DarkApp::notify does when a Leave event arrives
static void simulateDarkAppLeave(QWidget* w) {
auto* tip = RcxTooltip::instance();
if (tip->isVisible() && tip->currentTrigger() == w) {
LOG(" [darkapp] Leave on trigger — calling scheduleDismiss\n");
tip->scheduleDismiss();
LOG(" [darkapp] after scheduleDismiss: visible=%d\n", tip->isVisible());
} else {
LOG(" [darkapp] Leave ignored (visible=%d trigger_match=%d)\n",
tip->isVisible(), tip->currentTrigger() == w);
}
}
class TestTooltipUI : public QObject {
Q_OBJECT
private:
QWidget* m_window = nullptr;
QPushButton* m_btn = nullptr;
QPushButton* m_btn2 = nullptr;
private slots:
void initTestCase() {
LOG("=== TestTooltipUI starting ===\n");
m_window = new QWidget;
m_window->setFixedSize(400, 300);
QScreen* scr = QApplication::primaryScreen();
QRect avail = scr->availableGeometry();
m_window->move(avail.center() - QPoint(200, 150));
m_btn = new QPushButton("Scan", m_window);
m_btn->setToolTip("Start scanning memory");
m_btn->setFixedSize(80, 28);
m_btn->move(160, 140);
m_btn->setObjectName("btnScan");
m_btn2 = new QPushButton("Copy", m_window);
m_btn2->setToolTip("Copy address to clipboard");
m_btn2->setFixedSize(80, 28);
m_btn2->move(260, 140);
m_btn2->setObjectName("btnCopy");
m_window->show();
QVERIFY(QTest::qWaitForWindowExposed(m_window));
LOG(" window shown at (%d,%d)\n", m_window->x(), m_window->y());
}
void cleanupTestCase() {
RcxTooltip::instance()->dismiss();
delete m_window;
LOG("=== TestTooltipUI finished ===\n");
}
void cleanup() {
RcxTooltip::instance()->dismiss();
QCoreApplication::processEvents();
}
// ─── Test 1: Full tooltip lifecycle with event simulation ───
void testFullLifecycle() {
LOG("\n--- testFullLifecycle ---\n");
auto* tip = RcxTooltip::instance();
// Step 1: Post a ToolTip event (what Qt does after hover delay)
LOG("Step 1: Posting ToolTip event to btn\n");
QPoint btnCenter = m_btn->mapToGlobal(QPoint(40, 14));
LOG(" btn global center: (%d,%d)\n", btnCenter.x(), btnCenter.y());
// Move real cursor to button center
QCursor::setPos(btnCenter);
QCoreApplication::processEvents();
LOG(" cursor moved to button\n");
// Simulate what DarkApp does on ToolTip event
bool handled = simulateDarkAppToolTip(m_btn);
QVERIFY2(handled, "DarkApp should have handled the tooltip");
// Process events (paint, animation start)
QCoreApplication::processEvents();
QTest::qWait(100); // let fade-in animation run
QCoreApplication::processEvents();
LOG("Step 2: Check tooltip state after 100ms\n");
LOG(" visible=%d opacity=%.2f text='%s'\n",
tip->isVisible(), tip->windowOpacity(),
qPrintable(tip->currentText()));
LOG(" pos=(%d,%d) size=%dx%d\n",
tip->x(), tip->y(), tip->width(), tip->height());
LOG(" arrowDown=%d arrowX=%d bodyRect=(%d,%d %dx%d)\n",
tip->arrowPointsDown(), tip->arrowLocalX(),
tip->bodyRect().x(), tip->bodyRect().y(),
tip->bodyRect().width(), tip->bodyRect().height());
QVERIFY2(tip->isVisible(), "Tooltip should be visible after showFor + 100ms");
QCOMPARE(tip->currentText(), QString("Start scanning memory"));
// Step 3: Grab pixels and verify rendering
LOG("Step 3: Verify rendering\n");
tip->setWindowOpacity(1.0);
QCoreApplication::processEvents();
QImage img = tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
LOG(" grabbed image: %dx%d format=%d\n", img.width(), img.height(), img.format());
int opaquePixels = 0;
QRect body = tip->bodyRect().adjusted(2, 2, -2, -2);
for (int y = body.top(); y <= body.bottom(); ++y)
for (int x = body.left(); x <= body.right(); ++x)
if (qAlpha(img.pixel(x, y)) > 0)
++opaquePixels;
int totalPixels = body.width() * body.height();
LOG(" body opaque pixels: %d / %d (%.1f%%)\n",
opaquePixels, totalPixels,
totalPixels > 0 ? 100.0 * opaquePixels / totalPixels : 0.0);
QVERIFY2(opaquePixels > totalPixels / 2,
qPrintable(QStringLiteral("Only %1/%2 opaque pixels in body — tooltip not rendering")
.arg(opaquePixels).arg(totalPixels)));
// Step 4: Simulate Leave event (spurious — cursor still on button)
LOG("Step 4: Simulate spurious Leave (cursor still on button)\n");
simulateDarkAppLeave(m_btn);
QTest::qWait(200);
QCoreApplication::processEvents();
LOG(" after 200ms: visible=%d\n", tip->isVisible());
QVERIFY2(tip->isVisible(),
"Tooltip dismissed by spurious Leave — geometry check failed");
// Step 5: Move cursor away and simulate real Leave
LOG("Step 5: Move cursor away, simulate real Leave\n");
QScreen* scr = QApplication::primaryScreen();
QPoint farAway = scr->availableGeometry().bottomRight() - QPoint(50, 50);
QCursor::setPos(farAway);
QCoreApplication::processEvents();
LOG(" cursor at (%d,%d)\n", farAway.x(), farAway.y());
simulateDarkAppLeave(m_btn);
QTest::qWait(200);
QCoreApplication::processEvents();
LOG(" after 200ms: visible=%d\n", tip->isVisible());
QVERIFY2(!tip->isVisible(),
"Tooltip should be dismissed when cursor truly left the zone");
// Step 6: Re-show on different widget
LOG("Step 6: Re-show on different widget\n");
QPoint btn2Center = m_btn2->mapToGlobal(QPoint(40, 14));
QCursor::setPos(btn2Center);
QCoreApplication::processEvents();
handled = simulateDarkAppToolTip(m_btn2);
QVERIFY(handled);
QCoreApplication::processEvents();
QTest::qWait(100);
QCoreApplication::processEvents();
LOG(" visible=%d text='%s'\n", tip->isVisible(), qPrintable(tip->currentText()));
QVERIFY(tip->isVisible());
QCOMPARE(tip->currentText(), QString("Copy address to clipboard"));
LOG("--- testFullLifecycle PASSED ---\n");
}
// ─── Test 2: Rapid widget switching (no dismiss between) ───
void testRapidSwitch() {
LOG("\n--- testRapidSwitch ---\n");
auto* tip = RcxTooltip::instance();
QCursor::setPos(m_btn->mapToGlobal(QPoint(40, 14)));
QCoreApplication::processEvents();
simulateDarkAppToolTip(m_btn);
QCoreApplication::processEvents();
QTest::qWait(50);
LOG(" switch to btn2 immediately\n");
QCursor::setPos(m_btn2->mapToGlobal(QPoint(40, 14)));
QCoreApplication::processEvents();
simulateDarkAppToolTip(m_btn2);
QCoreApplication::processEvents();
QTest::qWait(100);
QCoreApplication::processEvents();
LOG(" visible=%d text='%s'\n", tip->isVisible(), qPrintable(tip->currentText()));
QVERIFY(tip->isVisible());
QCOMPARE(tip->currentText(), QString("Copy address to clipboard"));
LOG("--- testRapidSwitch PASSED ---\n");
}
// ─── Test 3: Widget with no tooltip ───
void testNoTooltipWidget() {
LOG("\n--- testNoTooltipWidget ---\n");
QPushButton noTip("NoTip", m_window);
noTip.setFixedSize(80, 28);
noTip.move(50, 50);
noTip.show();
// No setToolTip called
auto* tip = RcxTooltip::instance();
bool handled = simulateDarkAppToolTip(&noTip);
LOG(" handled=%d visible=%d\n", handled, tip->isVisible());
QVERIFY(!handled);
QVERIFY(!tip->isVisible());
LOG("--- testNoTooltipWidget PASSED ---\n");
}
};
QTEST_MAIN(TestTooltipUI)
#include "test_tooltip_ui.moc"