mirror of
https://github.com/NohamR/Reclass.git
synced 2026-05-10 19:59:21 +00:00
Compare commits
8 Commits
snapshot-0
...
snapshot-0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5944dbdc81 | ||
|
|
b3425aec9e | ||
|
|
2a8cfee719 | ||
|
|
e999c664b8 | ||
|
|
0dc4af6b1d | ||
|
|
376aad2169 | ||
|
|
4937c58062 | ||
|
|
9c72265901 |
@@ -153,6 +153,14 @@ endif()
|
|||||||
|
|
||||||
add_executable(ReclassMcpBridge tools/rcx-mcp-stdio.cpp)
|
add_executable(ReclassMcpBridge tools/rcx-mcp-stdio.cpp)
|
||||||
target_link_libraries(ReclassMcpBridge PRIVATE ${QT}::Core ${QT}::Network)
|
target_link_libraries(ReclassMcpBridge PRIVATE ${QT}::Core ${QT}::Network)
|
||||||
|
if(APPLE)
|
||||||
|
add_custom_command(TARGET ReclassMcpBridge POST_BUILD
|
||||||
|
COMMAND ${CMAKE_COMMAND} -E copy
|
||||||
|
$<TARGET_FILE:ReclassMcpBridge>
|
||||||
|
$<TARGET_FILE_DIR:Reclass>/ReclassMcpBridge
|
||||||
|
COMMENT "Bundling ReclassMcpBridge into Reclass.app"
|
||||||
|
)
|
||||||
|
endif()
|
||||||
|
|
||||||
# Copy built-in theme JSON files to build directory
|
# Copy built-in theme JSON files to build directory
|
||||||
file(GLOB _theme_files "${CMAKE_SOURCE_DIR}/src/themes/defaults/*.json")
|
file(GLOB _theme_files "${CMAKE_SOURCE_DIR}/src/themes/defaults/*.json")
|
||||||
|
|||||||
125
README.md
125
README.md
@@ -12,64 +12,109 @@
|
|||||||
[](https://github.com/IChooseYou/Reclass/actions/workflows/build.yml)
|
[](https://github.com/IChooseYou/Reclass/actions/workflows/build.yml)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://github.com/IChooseYou/Reclass/releases)
|
[](https://github.com/IChooseYou/Reclass/releases)
|
||||||
[]()
|
[]()
|
||||||
|
|
||||||
</div>
|
</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.
|
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.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Structured binary view** — render raw bytes as typed fields (integers, floats, pointers, vectors, matrices, strings, booleans, padding)
|
### Editor
|
||||||
- **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
|
- **Structured binary view** — render raw bytes as typed fields with columnar alignment
|
||||||
- **Inline editing** — click to edit type names, field names, values, and base addresses directly in the editor
|
- **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
|
||||||
- **Undo/redo** — full undo history for all mutations via command stack
|
- **Tab-cycling** — tab through editable fields within a line
|
||||||
- **Multi-document tabs** — open multiple projects simultaneously in MDI sub-windows
|
- **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
|
- **Split views** — multiple synchronized editor panes over the same document
|
||||||
- **Type autocomplete** — popup type picker when changing field kinds
|
- **Find bar** — Ctrl+F in-editor search with indicator highlighting
|
||||||
- **Hex + ASCII margins** — raw byte previews alongside the structured view
|
- **Fold/collapse** — expand and collapse structs, arrays, and pointer expansions with embedded fold indicators
|
||||||
- **Value history & heatmap** — track value changes over time with color-coded heat indicators
|
- **Hex + ASCII columns** — raw byte previews alongside the structured view with per-byte change highlighting
|
||||||
- **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
|
|
||||||
|
|
||||||
## Roadmap
|
### Live Memory Analysis
|
||||||
|
|
||||||
- [ ] Process memory section enumeration
|
- **Auto-refresh** — configurable interval (default 660ms) with async page-based reads for non-blocking UI
|
||||||
- [ ] Address parser auto-complete
|
- **Value history & heatmap** — per-node ring buffer (10 samples with timestamps), color-coded heat indicators (static/cold/warm/hot) based on change frequency
|
||||||
- [ ] Safe mode
|
- **Changed-byte highlighting** — per-byte change indicators within hex preview lines
|
||||||
- [ ] File import for other Reclass instances
|
- **Memory write-back** — edit values inline, writes propagate through the provider to live process memory
|
||||||
- [ ] Expose UI functionality to plugins
|
- **Pointer chasing** — automatic reads of dereferenced memory regions across pointer chains
|
||||||
- [ ] iOS support
|
- **Address parser** — formula expressions like `<module.exe>+0x1A0`, pointer dereference chains, symbol resolution
|
||||||
- [ ] Display RTTI information
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 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 | |
|
||||||
|
|
||||||
|
### 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
|
## Data Sources
|
||||||
|
|
||||||
- **File** — open any binary file and inspect its contents as structured data
|
- **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
|
- **Process** — attach to a live process and read its memory in real time (Windows/Linux)
|
||||||
- **Remote Process** — read another process's memory via shared memory
|
- **Remote Process** — read another process's memory over TCP with cross-architecture 32/64-bit support
|
||||||
- **WinDbg** — load `.dmp` crash dump files or connect to live debugging sessions
|
- **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
|
||||||
|
|
||||||

|
Extensible provider architecture via DLL plugins with `IPlugin` interface, factory function discovery, and auto/manual loading from a Plugins folder.
|
||||||
|
|
||||||

|
**Bundled plugins:**
|
||||||
|
|
||||||

|
| 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
|
## 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
|
```json
|
||||||
{
|
{
|
||||||
@@ -92,7 +137,7 @@ Built-in [Model Context Protocol](https://modelcontextprotocol.io/) bridge via `
|
|||||||
|
|
||||||
### Quick Build
|
### Quick Build
|
||||||
|
|
||||||
```/dev/null/commands.sh#L1-4
|
```bash
|
||||||
git clone --recurse-submodules https://github.com/IChooseYou/Reclass.git
|
git clone --recurse-submodules https://github.com/IChooseYou/Reclass.git
|
||||||
cd Reclass
|
cd Reclass
|
||||||
.\scripts\build_qscintilla.ps1
|
.\scripts\build_qscintilla.ps1
|
||||||
@@ -103,13 +148,13 @@ The build script auto-detects your Qt install location.
|
|||||||
|
|
||||||
### macOS Build
|
### macOS Build
|
||||||
|
|
||||||
```/dev/null/commands.sh#L1-2
|
```bash
|
||||||
./scripts/build_macos.sh --qt-dir /opt/homebrew/opt/qt --build-type Release --package
|
./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`.
|
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 won’t 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)
|
### Manual Build (MinGW)
|
||||||
|
|
||||||
@@ -132,6 +177,8 @@ The `msvc/` folder contains a ready-made solution (`Reclass.slnx`) with projects
|
|||||||
ctest --test-dir build --output-on-failure
|
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
|
## Alternatives
|
||||||
|
|
||||||
- [ReClass.NET](https://github.com/ReClassNET/ReClass.NET)
|
- [ReClass.NET](https://github.com/ReClassNET/ReClass.NET)
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 92 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 403 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 150 KiB After Width: | Height: | Size: 113 KiB |
@@ -24,6 +24,8 @@ struct ComposeState {
|
|||||||
int offsetHexDigits = 8; // hex digit tier for offset margin
|
int offsetHexDigits = 8; // hex digit tier for offset margin
|
||||||
bool baseEmitted = false; // only first root struct shows base address
|
bool baseEmitted = false; // only first root struct shows base address
|
||||||
bool compactColumns = false; // compact column mode: cap type width, overflow long types
|
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
|
uint64_t currentPtrBase = 0; // absolute addr of current pointer expansion target
|
||||||
|
|
||||||
// Precomputed for O(1) lookups
|
// Precomputed for O(1) lookups
|
||||||
@@ -41,6 +43,15 @@ struct ComposeState {
|
|||||||
return scopeNameW.value(scopeId, nameW);
|
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) {
|
void emitLine(const QString& lineText, LineMeta lm) {
|
||||||
if (currentLine > 0) text += '\n';
|
if (currentLine > 0) text += '\n';
|
||||||
// 3-char fold indicator column: " - " expanded, " + " collapsed, " " other
|
// 3-char fold indicator column: " - " expanded, " + " collapsed, " " other
|
||||||
@@ -52,7 +63,29 @@ struct ComposeState {
|
|||||||
text += lm.foldCollapsed ? QStringLiteral(" \u25B8 ") : QStringLiteral(" \u25BE ");
|
text += lm.foldCollapsed ? QStringLiteral(" \u25B8 ") : QStringLiteral(" \u25BE ");
|
||||||
else
|
else
|
||||||
text += QStringLiteral(" ");
|
text += QStringLiteral(" ");
|
||||||
|
|
||||||
|
// 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;
|
text += lineText;
|
||||||
|
}
|
||||||
|
|
||||||
meta.append(lm);
|
meta.append(lm);
|
||||||
currentLine++;
|
currentLine++;
|
||||||
}
|
}
|
||||||
@@ -305,6 +338,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (int oi = 0; oi < order.size(); oi++) {
|
for (int oi = 0; oi < order.size(); oi++) {
|
||||||
|
state.setTreeSibling(childDepth, oi < order.size() - 1);
|
||||||
int mi = order[oi];
|
int mi = order[oi];
|
||||||
const auto& m = node.enumMembers[mi];
|
const auto& m = node.enumMembers[mi];
|
||||||
LineMeta lm;
|
LineMeta lm;
|
||||||
@@ -353,6 +387,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
|||||||
maxNameLen = qMax(maxNameLen, (int)m.name.size());
|
maxNameLen = qMax(maxNameLen, (int)m.name.size());
|
||||||
|
|
||||||
for (int mi = 0; mi < node.bitfieldMembers.size(); mi++) {
|
for (int mi = 0; mi < node.bitfieldMembers.size(); mi++) {
|
||||||
|
state.setTreeSibling(childDepth, mi < node.bitfieldMembers.size() - 1);
|
||||||
const auto& m = node.bitfieldMembers[mi];
|
const auto& m = node.bitfieldMembers[mi];
|
||||||
uint64_t bitVal = fmt::extractBits(prov, absAddr, node.elementKind,
|
uint64_t bitVal = fmt::extractBits(prov, absAddr, node.elementKind,
|
||||||
m.bitOffset, m.bitWidth);
|
m.bitOffset, m.bitWidth);
|
||||||
@@ -415,6 +450,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
|||||||
int eTW = state.effectiveTypeW(node.id);
|
int eTW = state.effectiveTypeW(node.id);
|
||||||
int eNW = state.effectiveNameW(node.id);
|
int eNW = state.effectiveNameW(node.id);
|
||||||
for (int i = 0; i < node.arrayLen; i++) {
|
for (int i = 0; i < node.arrayLen; i++) {
|
||||||
|
state.setTreeSibling(childDepth, i < node.arrayLen - 1);
|
||||||
uint64_t elemAddr = absAddr + i * elemSize;
|
uint64_t elemAddr = absAddr + i * elemSize;
|
||||||
|
|
||||||
// Type override: "float[0]", "uint32_t[1]", etc.
|
// 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);
|
int elemSize = tree.structSpan(node.refId, &state.childMap);
|
||||||
if (elemSize <= 0) elemSize = 1;
|
if (elemSize <= 0) elemSize = 1;
|
||||||
for (int i = 0; i < node.arrayLen; i++) {
|
for (int i = 0; i < node.arrayLen; i++) {
|
||||||
|
state.setTreeSibling(childDepth, i < node.arrayLen - 1);
|
||||||
uint64_t elemBase = absAddr + (uint64_t)i * elemSize;
|
uint64_t elemBase = absAddr + (uint64_t)i * elemSize;
|
||||||
// Use base offset that maps refStruct's children to the right provider address
|
// Use base offset that maps refStruct's children to the right provider address
|
||||||
composeParent(state, tree, prov, refIdx, childDepth, elemBase, node.refId,
|
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);
|
const QVector<int>& refChildren = childIndices(state, node.refId);
|
||||||
// Use the referenced struct's scope widths (children come from there)
|
// Use the referenced struct's scope widths (children come from there)
|
||||||
uint64_t refScopeId = node.refId;
|
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];
|
const Node& child = tree.nodes[childIdx];
|
||||||
// Self-referential child → show as collapsed struct (non-expandable)
|
// Self-referential child → show as collapsed struct (non-expandable)
|
||||||
if (state.visiting.contains(child.id)) {
|
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)
|
// For arrays, render children as condensed (no header/footer for struct elements)
|
||||||
bool childrenAreArrayElements = (node.kind == NodeKind::Array);
|
bool childrenAreArrayElements = (node.kind == NodeKind::Array);
|
||||||
int elementIdx = 0;
|
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)
|
// 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
|
// For array elements, also pass the element index for [N] separator
|
||||||
composeNode(state, tree, prov, childIdx, childDepth, base, rootId,
|
composeNode(state, tree, prov, childIdx, childDepth, base, rootId,
|
||||||
@@ -569,7 +614,9 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
|||||||
|
|
||||||
auto cbs = makeResolver(absAddr);
|
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];
|
const Node& sf = tree.nodes[si];
|
||||||
|
|
||||||
// Evaluate expression → absolute address
|
// Evaluate expression → absolute address
|
||||||
@@ -639,8 +686,18 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
|||||||
|
|
||||||
// ── Body + children (only when expanded) ──
|
// ── Body + children (only when expanded) ──
|
||||||
if (!isCollapsed) {
|
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 line: " return <expr> → 0xADDR"
|
||||||
{
|
{
|
||||||
|
// Body has more siblings if struct children follow
|
||||||
|
state.setTreeSibling(childDepth + 1, hasStructKids);
|
||||||
|
|
||||||
QString bodyLine;
|
QString bodyLine;
|
||||||
if (!sf.offsetExpr.isEmpty()) {
|
if (!sf.offsetExpr.isEmpty()) {
|
||||||
if (exprOk)
|
if (exprOk)
|
||||||
@@ -676,10 +733,10 @@ void composeParent(ComposeState& state, const NodeTree& tree,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// If struct/array, compose children at evaluated address
|
// If struct/array, compose children at evaluated address
|
||||||
if (exprOk && (sf.kind == NodeKind::Struct || sf.kind == NodeKind::Array)) {
|
if (hasStructKids) {
|
||||||
const QVector<int>& staticKids = childIndices(state, sf.id);
|
for (int ski = 0; ski < staticKids.size(); ski++) {
|
||||||
for (int sci : staticKids) {
|
state.setTreeSibling(childDepth + 1, ski < staticKids.size() - 1);
|
||||||
composeNode(state, tree, prov, sci, childDepth + 1,
|
composeNode(state, tree, prov, staticKids[ski], childDepth + 1,
|
||||||
staticAddr, sf.id, false, sf.id);
|
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.
|
// Render materialized children at the pointer target address.
|
||||||
// These are real tree nodes with independent state — use rootId
|
// These are real tree nodes with independent state — use rootId
|
||||||
// so resolveAddr computes offsets relative to the pointer target.
|
// so resolveAddr computes offsets relative to the pointer target.
|
||||||
for (int childIdx : ptrChildren) {
|
for (int pci = 0; pci < ptrChildren.size(); pci++) {
|
||||||
composeNode(state, tree, childProv, childIdx, depth + 1,
|
state.setTreeSibling(depth + 1, pci < ptrChildren.size() - 1);
|
||||||
|
composeNode(state, tree, childProv, ptrChildren[pci], depth + 1,
|
||||||
pBase, node.id, false, node.id);
|
pBase, node.id, false, node.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -878,9 +936,10 @@ void composeNode(ComposeState& state, const NodeTree& tree,
|
|||||||
} // anonymous namespace
|
} // anonymous namespace
|
||||||
|
|
||||||
ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId,
|
ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId,
|
||||||
bool compactColumns) {
|
bool compactColumns, bool treeLines) {
|
||||||
ComposeState state;
|
ComposeState state;
|
||||||
state.compactColumns = compactColumns;
|
state.compactColumns = compactColumns;
|
||||||
|
state.treeLines = treeLines;
|
||||||
|
|
||||||
// Precompute parent→children map
|
// Precompute parent→children map
|
||||||
for (int i = 0; i < tree.nodes.size(); i++)
|
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);
|
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 {
|
QSet<uint64_t> NodeTree::normalizePreferAncestors(const QSet<uint64_t>& ids) const {
|
||||||
|
|||||||
@@ -72,8 +72,9 @@ RcxDocument::RcxDocument(QObject* parent)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
ComposeResult RcxDocument::compose(uint64_t viewRootId, bool compactColumns) const {
|
ComposeResult RcxDocument::compose(uint64_t viewRootId, bool compactColumns,
|
||||||
return rcx::compose(tree, *provider, viewRootId, compactColumns);
|
bool treeLines) const {
|
||||||
|
return rcx::compose(tree, *provider, viewRootId, compactColumns, treeLines);
|
||||||
}
|
}
|
||||||
|
|
||||||
bool RcxDocument::save(const QString& path) {
|
bool RcxDocument::save(const QString& path) {
|
||||||
@@ -319,7 +320,7 @@ void RcxController::connectEditor(RcxEditor* editor) {
|
|||||||
// Regular type change
|
// Regular type change
|
||||||
bool ok;
|
bool ok;
|
||||||
NodeKind k = kindFromTypeName(text, &ok);
|
NodeKind k = kindFromTypeName(text, &ok);
|
||||||
if (ok) {
|
if (ok && k != NodeKind::Struct && k != NodeKind::Array) {
|
||||||
changeNodeKind(nodeIdx, k);
|
changeNodeKind(nodeIdx, k);
|
||||||
} else if (nodeIdx < m_doc->tree.nodes.size()) {
|
} else if (nodeIdx < m_doc->tree.nodes.size()) {
|
||||||
// Check if it's a defined struct type name
|
// Check if it's a defined struct type name
|
||||||
@@ -546,6 +547,7 @@ void RcxController::resetChangeTracking() {
|
|||||||
m_changedOffsets.clear();
|
m_changedOffsets.clear();
|
||||||
m_valueHistory.clear();
|
m_valueHistory.clear();
|
||||||
m_prevPages.clear();
|
m_prevPages.clear();
|
||||||
|
m_valueTrackCooldown = 5; // suppress tracking for ~1s
|
||||||
for (auto& lm : m_lastResult.meta)
|
for (auto& lm : m_lastResult.meta)
|
||||||
lm.heatLevel = 0;
|
lm.heatLevel = 0;
|
||||||
}
|
}
|
||||||
@@ -556,9 +558,9 @@ void RcxController::refresh() {
|
|||||||
|
|
||||||
// Compose against snapshot provider if active, otherwise real provider
|
// Compose against snapshot provider if active, otherwise real provider
|
||||||
if (m_snapshotProv)
|
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
|
else
|
||||||
m_lastResult = m_doc->compose(m_viewRootId, m_compactColumns);
|
m_lastResult = m_doc->compose(m_viewRootId, m_compactColumns, m_treeLines);
|
||||||
|
|
||||||
s_composeDoc = nullptr;
|
s_composeDoc = nullptr;
|
||||||
|
|
||||||
@@ -602,7 +604,8 @@ void RcxController::refresh() {
|
|||||||
else if (m_doc->provider && m_doc->provider->isValid() && m_doc->provider->isLive())
|
else if (m_doc->provider && m_doc->provider->isValid() && m_doc->provider->isLive())
|
||||||
prov = m_doc->provider.get();
|
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) {
|
for (auto& lm : m_lastResult.meta) {
|
||||||
if (lm.nodeIdx < 0 || lm.nodeIdx >= m_doc->tree.nodes.size()) continue;
|
if (lm.nodeIdx < 0 || lm.nodeIdx >= m_doc->tree.nodes.size()) continue;
|
||||||
if (isSyntheticLine(lm) || lm.isContinuation) 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_refreshGen++; // discard in-flight async reads
|
||||||
m_prevPages.clear(); // clean baseline for next read cycle
|
m_prevPages.clear(); // clean baseline for next read cycle
|
||||||
m_changedOffsets.clear(); // no phantom change indicators
|
m_changedOffsets.clear(); // no phantom change indicators
|
||||||
|
m_valueTrackCooldown = 5; // suppress tracking for ~1s
|
||||||
refresh();
|
refresh();
|
||||||
for (auto* editor : m_editors)
|
for (auto* editor : m_editors)
|
||||||
editor->dismissHistoryPopup();
|
editor->dismissHistoryPopup();
|
||||||
@@ -1935,6 +1939,7 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
|
|||||||
m_refreshGen++; // discard in-flight async reads
|
m_refreshGen++; // discard in-flight async reads
|
||||||
m_prevPages.clear(); // clean baseline for next read cycle
|
m_prevPages.clear(); // clean baseline for next read cycle
|
||||||
m_changedOffsets.clear(); // no phantom change indicators
|
m_changedOffsets.clear(); // no phantom change indicators
|
||||||
|
m_valueTrackCooldown = 5; // suppress tracking for ~1s
|
||||||
refresh();
|
refresh();
|
||||||
for (auto* editor : m_editors)
|
for (auto* editor : m_editors)
|
||||||
editor->dismissHistoryPopup();
|
editor->dismissHistoryPopup();
|
||||||
@@ -2608,7 +2613,7 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
|
|||||||
break;
|
break;
|
||||||
|
|
||||||
case TypePopupMode::FieldType: {
|
case TypePopupMode::FieldType: {
|
||||||
addPrimitives(/*enabled=*/true, /*excludeStructArrayPad=*/false);
|
addPrimitives(/*enabled=*/true, /*excludeStructArrayPad=*/true);
|
||||||
bool isPtr = node
|
bool isPtr = node
|
||||||
&& (node->kind == NodeKind::Pointer32 || node->kind == NodeKind::Pointer64);
|
&& (node->kind == NodeKind::Pointer32 || node->kind == NodeKind::Pointer64);
|
||||||
bool isTypedPtr = isPtr && node->refId != 0;
|
bool isTypedPtr = isPtr && node->refId != 0;
|
||||||
@@ -3181,6 +3186,11 @@ void RcxController::setCompactColumns(bool v) {
|
|||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void RcxController::setTreeLines(bool v) {
|
||||||
|
m_treeLines = v;
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
void RcxController::setupAutoRefresh() {
|
void RcxController::setupAutoRefresh() {
|
||||||
int ms = QSettings("Reclass", "Reclass").value("refreshMs", 660).toInt();
|
int ms = QSettings("Reclass", "Reclass").value("refreshMs", 660).toInt();
|
||||||
m_refreshTimer = new QTimer(this);
|
m_refreshTimer = new QTimer(this);
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ public:
|
|||||||
return m ? QString::fromLatin1(m->typeName) : QStringLiteral("???");
|
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 save(const QString& path);
|
||||||
bool load(const QString& path);
|
bool load(const QString& path);
|
||||||
void loadData(const QString& binaryPath);
|
void loadData(const QString& binaryPath);
|
||||||
@@ -128,6 +129,7 @@ public:
|
|||||||
void setEditorFont(const QString& fontName);
|
void setEditorFont(const QString& fontName);
|
||||||
void setRefreshInterval(int ms);
|
void setRefreshInterval(int ms);
|
||||||
void setCompactColumns(bool v);
|
void setCompactColumns(bool v);
|
||||||
|
void setTreeLines(bool v);
|
||||||
void resetProvider();
|
void resetProvider();
|
||||||
|
|
||||||
// MCP bridge accessors
|
// MCP bridge accessors
|
||||||
@@ -151,6 +153,7 @@ public:
|
|||||||
// Test accessors
|
// Test accessors
|
||||||
const QHash<uint64_t, ValueHistory>& valueHistory() const { return m_valueHistory; }
|
const QHash<uint64_t, ValueHistory>& valueHistory() const { return m_valueHistory; }
|
||||||
const ComposeResult& lastResult() const { return m_lastResult; }
|
const ComposeResult& lastResult() const { return m_lastResult; }
|
||||||
|
int dataExtent() const { return computeDataExtent(); }
|
||||||
|
|
||||||
signals:
|
signals:
|
||||||
void nodeSelected(int nodeIdx);
|
void nodeSelected(int nodeIdx);
|
||||||
@@ -164,6 +167,7 @@ private:
|
|||||||
int m_anchorLine = -1;
|
int m_anchorLine = -1;
|
||||||
bool m_suppressRefresh = false;
|
bool m_suppressRefresh = false;
|
||||||
bool m_compactColumns = false;
|
bool m_compactColumns = false;
|
||||||
|
bool m_treeLines = false;
|
||||||
uint64_t m_viewRootId = 0;
|
uint64_t m_viewRootId = 0;
|
||||||
|
|
||||||
// ── Saved sources for quick-switch ──
|
// ── Saved sources for quick-switch ──
|
||||||
@@ -183,6 +187,7 @@ private:
|
|||||||
QSet<int64_t> m_changedOffsets;
|
QSet<int64_t> m_changedOffsets;
|
||||||
QHash<uint64_t, ValueHistory> m_valueHistory;
|
QHash<uint64_t, ValueHistory> m_valueHistory;
|
||||||
bool m_trackValues = true;
|
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_refreshGen = 0;
|
||||||
uint64_t m_readGen = 0;
|
uint64_t m_readGen = 0;
|
||||||
bool m_readInFlight = false;
|
bool m_readInFlight = false;
|
||||||
|
|||||||
16
src/core.h
16
src/core.h
@@ -86,8 +86,8 @@ inline constexpr KindMeta kKindMeta[] = {
|
|||||||
{NodeKind::Vec3, "Vec3", "vec3", 12, 1, 4, KF_Vector},
|
{NodeKind::Vec3, "Vec3", "vec3", 12, 1, 4, KF_Vector},
|
||||||
{NodeKind::Vec4, "Vec4", "vec4", 16, 1, 4, KF_Vector},
|
{NodeKind::Vec4, "Vec4", "vec4", 16, 1, 4, KF_Vector},
|
||||||
{NodeKind::Mat4x4, "Mat4x4", "mat4x4", 64, 4, 4, KF_None},
|
{NodeKind::Mat4x4, "Mat4x4", "mat4x4", 64, 4, 4, KF_None},
|
||||||
{NodeKind::UTF8, "UTF8", "char[]", 1, 1, 1, KF_String},
|
{NodeKind::UTF8, "UTF8", "str", 1, 1, 1, KF_String},
|
||||||
{NodeKind::UTF16, "UTF16", "wchar_t[]", 2, 1, 2, KF_String},
|
{NodeKind::UTF16, "UTF16", "wstr", 2, 1, 2, KF_String},
|
||||||
{NodeKind::Struct, "Struct", "struct", 0, 1, 1, KF_Container},
|
{NodeKind::Struct, "Struct", "struct", 0, 1, 1, KF_Container},
|
||||||
{NodeKind::Array, "Array", "array", 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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
inline QStringList allTypeNamesForUI(bool stripBrackets = false) {
|
inline QStringList allTypeNamesForUI(bool /*stripBrackets*/ = false) {
|
||||||
QStringList out;
|
QStringList out;
|
||||||
out.reserve(std::size(kKindMeta));
|
out.reserve(std::size(kKindMeta));
|
||||||
for (const auto& m : kKindMeta) {
|
for (const auto& m : kKindMeta)
|
||||||
QString t = QString::fromLatin1(m.typeName);
|
out << QString::fromLatin1(m.typeName);
|
||||||
if (stripBrackets) t.remove(QStringLiteral("[]"));
|
|
||||||
out << t;
|
|
||||||
}
|
|
||||||
out.sort(Qt::CaseInsensitive);
|
out.sort(Qt::CaseInsensitive);
|
||||||
out.removeDuplicates();
|
out.removeDuplicates();
|
||||||
return out;
|
return out;
|
||||||
@@ -636,6 +633,7 @@ struct LayoutInfo {
|
|||||||
int nameW = 22; // Effective name column width (default = kColName)
|
int nameW = 22; // Effective name column width (default = kColName)
|
||||||
int offsetHexDigits = 8; // Hex digits for offset margin (4/8/12/16)
|
int offsetHexDigits = 8; // Hex digits for offset margin (4/8/12/16)
|
||||||
uint64_t baseAddress = 0; // Base address for relative offset computation
|
uint64_t baseAddress = 0; // Base address for relative offset computation
|
||||||
|
bool treeLines = false; // Whether tree line connectors are embedded in the text
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── ComposeResult ──
|
// ── ComposeResult ──
|
||||||
@@ -1033,6 +1031,6 @@ namespace fmt {
|
|||||||
// ── Compose function forward declaration ──
|
// ── Compose function forward declaration ──
|
||||||
|
|
||||||
ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId = 0,
|
ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId = 0,
|
||||||
bool compactColumns = false);
|
bool compactColumns = false, bool treeLines = false);
|
||||||
|
|
||||||
} // namespace rcx
|
} // namespace rcx
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ public:
|
|||||||
adjustSize();
|
adjustSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
void showAt(const QPoint& globalPos) {
|
void showAt(const QPoint& globalPos, int lineHeight = 0) {
|
||||||
QSize sz = sizeHint();
|
QSize sz = sizeHint();
|
||||||
QRect screen = QApplication::screenAt(globalPos)
|
QRect screen = QApplication::screenAt(globalPos)
|
||||||
? QApplication::screenAt(globalPos)->availableGeometry()
|
? QApplication::screenAt(globalPos)->availableGeometry()
|
||||||
@@ -164,7 +164,7 @@ public:
|
|||||||
int x = qMin(globalPos.x(), screen.right() - sz.width());
|
int x = qMin(globalPos.x(), screen.right() - sz.width());
|
||||||
int y = globalPos.y();
|
int y = globalPos.y();
|
||||||
if (y + sz.height() > screen.bottom())
|
if (y + sz.height() > screen.bottom())
|
||||||
y = globalPos.y() - sz.height() - 4;
|
y = globalPos.y() - sz.height() - lineHeight - 4;
|
||||||
move(x, y);
|
move(x, y);
|
||||||
if (!isVisible()) show();
|
if (!isVisible()) show();
|
||||||
}
|
}
|
||||||
@@ -257,7 +257,7 @@ public:
|
|||||||
adjustSize();
|
adjustSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
void showAt(const QPoint& globalPos) {
|
void showAt(const QPoint& globalPos, int lineHeight = 0) {
|
||||||
QSize sz = sizeHint();
|
QSize sz = sizeHint();
|
||||||
QRect screen = QApplication::screenAt(globalPos)
|
QRect screen = QApplication::screenAt(globalPos)
|
||||||
? QApplication::screenAt(globalPos)->availableGeometry()
|
? QApplication::screenAt(globalPos)->availableGeometry()
|
||||||
@@ -265,7 +265,7 @@ public:
|
|||||||
int x = qMin(globalPos.x(), screen.right() - sz.width());
|
int x = qMin(globalPos.x(), screen.right() - sz.width());
|
||||||
int y = globalPos.y();
|
int y = globalPos.y();
|
||||||
if (y + sz.height() > screen.bottom())
|
if (y + sz.height() > screen.bottom())
|
||||||
y = globalPos.y() - sz.height() - 4;
|
y = globalPos.y() - sz.height() - lineHeight - 4;
|
||||||
move(x, y);
|
move(x, y);
|
||||||
if (!isVisible()) show();
|
if (!isVisible()) show();
|
||||||
}
|
}
|
||||||
@@ -354,7 +354,7 @@ public:
|
|||||||
adjustSize();
|
adjustSize();
|
||||||
}
|
}
|
||||||
|
|
||||||
void showAt(const QPoint& globalPos) {
|
void showAt(const QPoint& globalPos, int lineHeight = 0) {
|
||||||
QSize sz = sizeHint();
|
QSize sz = sizeHint();
|
||||||
QRect screen = QApplication::screenAt(globalPos)
|
QRect screen = QApplication::screenAt(globalPos)
|
||||||
? QApplication::screenAt(globalPos)->availableGeometry()
|
? QApplication::screenAt(globalPos)->availableGeometry()
|
||||||
@@ -362,7 +362,7 @@ public:
|
|||||||
int x = qMin(globalPos.x(), screen.right() - sz.width());
|
int x = qMin(globalPos.x(), screen.right() - sz.width());
|
||||||
int y = globalPos.y();
|
int y = globalPos.y();
|
||||||
if (y + sz.height() > screen.bottom())
|
if (y + sz.height() > screen.bottom())
|
||||||
y = globalPos.y() - sz.height() - 4;
|
y = globalPos.y() - sz.height() - lineHeight - 4;
|
||||||
move(x, y);
|
move(x, y);
|
||||||
if (!isVisible()) show();
|
if (!isVisible()) show();
|
||||||
}
|
}
|
||||||
@@ -405,7 +405,7 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
|
|||||||
// Find bar (hidden by default, shown with Ctrl+F)
|
// Find bar (hidden by default, shown with Ctrl+F)
|
||||||
m_findBarContainer = new QWidget(this);
|
m_findBarContainer = new QWidget(this);
|
||||||
auto* fbLayout = new QHBoxLayout(m_findBarContainer);
|
auto* fbLayout = new QHBoxLayout(m_findBarContainer);
|
||||||
fbLayout->setContentsMargins(4, 0, 0, 0);
|
fbLayout->setContentsMargins(4, 1, 4, 1);
|
||||||
fbLayout->setSpacing(2);
|
fbLayout->setSpacing(2);
|
||||||
auto* findPrevBtn = new QToolButton(m_findBarContainer);
|
auto* findPrevBtn = new QToolButton(m_findBarContainer);
|
||||||
findPrevBtn->setText(QStringLiteral("\u25C0"));
|
findPrevBtn->setText(QStringLiteral("\u25C0"));
|
||||||
@@ -418,6 +418,7 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
|
|||||||
findCloseBtn->setFixedSize(24, 24);
|
findCloseBtn->setFixedSize(24, 24);
|
||||||
m_findBar = new QLineEdit(m_findBarContainer);
|
m_findBar = new QLineEdit(m_findBarContainer);
|
||||||
m_findBar->setPlaceholderText(QStringLiteral("Find..."));
|
m_findBar->setPlaceholderText(QStringLiteral("Find..."));
|
||||||
|
m_findBar->setFixedHeight(24);
|
||||||
fbLayout->addWidget(findPrevBtn);
|
fbLayout->addWidget(findPrevBtn);
|
||||||
fbLayout->addWidget(findNextBtn);
|
fbLayout->addWidget(findNextBtn);
|
||||||
fbLayout->addWidget(findCloseBtn);
|
fbLayout->addWidget(findCloseBtn);
|
||||||
@@ -865,7 +866,7 @@ void RcxEditor::applyTheme(const Theme& theme) {
|
|||||||
m_sci->setMarkerBackgroundColor(theme.background, M_CYCLE);
|
m_sci->setMarkerBackgroundColor(theme.background, M_CYCLE);
|
||||||
m_sci->setMarkerForegroundColor(theme.background, M_CYCLE);
|
m_sci->setMarkerForegroundColor(theme.background, M_CYCLE);
|
||||||
m_sci->setMarkerBackgroundColor(theme.markerError, M_ERR);
|
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->setMarkerBackgroundColor(theme.background, M_STRUCT_BG);
|
||||||
m_sci->setMarkerForegroundColor(theme.text, M_STRUCT_BG);
|
m_sci->setMarkerForegroundColor(theme.text, M_STRUCT_BG);
|
||||||
m_sci->setMarkerBackgroundColor(theme.hover, M_HOVER);
|
m_sci->setMarkerBackgroundColor(theme.hover, M_HOVER);
|
||||||
@@ -889,7 +890,7 @@ void RcxEditor::applyTheme(const Theme& theme) {
|
|||||||
if (m_findBarContainer) {
|
if (m_findBarContainer) {
|
||||||
m_findBar->setStyleSheet(
|
m_findBar->setStyleSheet(
|
||||||
QStringLiteral("QLineEdit { background: %1; color: %2; border: 1px solid %3;"
|
QStringLiteral("QLineEdit { background: %1; color: %2; border: 1px solid %3;"
|
||||||
" padding: 4px 8px; font-size: 13px; }")
|
" padding: 2px 6px; font-size: 13px; }")
|
||||||
.arg(theme.backgroundAlt.name(), theme.text.name(), theme.border.name()));
|
.arg(theme.backgroundAlt.name(), theme.text.name(), theme.border.name()));
|
||||||
m_findBarContainer->setStyleSheet(
|
m_findBarContainer->setStyleSheet(
|
||||||
QStringLiteral("QToolButton { background: %1; color: %2; border: 1px solid %3; border-radius: 2px; }"
|
QStringLiteral("QToolButton { background: %1; color: %2; border: 1px solid %3; border-radius: 2px; }"
|
||||||
@@ -1060,6 +1061,11 @@ void RcxEditor::reformatMargins() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Pass 2: inline local offsets in the text indent area ──
|
// ── 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);
|
m_sci->setReadOnly(false);
|
||||||
for (int i = 0; i < m_meta.size(); i++) {
|
for (int i = 0; i < m_meta.size(); i++) {
|
||||||
const auto& lm = m_meta[i];
|
const auto& lm = m_meta[i];
|
||||||
@@ -2203,12 +2209,26 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
|
|||||||
m_lastHoverPos = static_cast<QMouseEvent*>(event)->pos();
|
m_lastHoverPos = static_cast<QMouseEvent*>(event)->pos();
|
||||||
m_hoverInside = true;
|
m_hoverInside = true;
|
||||||
} else if (event->type() == QEvent::Leave) {
|
} else if (event->type() == QEvent::Leave) {
|
||||||
|
// 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;
|
m_hoverInside = false;
|
||||||
if (!m_editState.active) {
|
if (!m_editState.active) {
|
||||||
m_hoveredNodeId = 0;
|
m_hoveredNodeId = 0;
|
||||||
m_hoveredLine = -1;
|
m_hoveredLine = -1;
|
||||||
applyHoverHighlight();
|
applyHoverHighlight();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
} else if (event->type() == QEvent::Wheel) {
|
} else if (event->type() == QEvent::Wheel) {
|
||||||
m_lastHoverPos = m_sci->viewport()->mapFromGlobal(QCursor::pos());
|
m_lastHoverPos = m_sci->viewport()->mapFromGlobal(QCursor::pos());
|
||||||
m_hoverInside = m_sci->viewport()->rect().contains(m_lastHoverPos);
|
m_hoverInside = m_sci->viewport()->rect().contains(m_lastHoverPos);
|
||||||
@@ -2991,7 +3011,7 @@ void RcxEditor::applyHoverCursor() {
|
|||||||
int lh = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT,
|
int lh = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT,
|
||||||
(unsigned long)m_editState.line);
|
(unsigned long)m_editState.line);
|
||||||
QPoint anchor = m_sci->viewport()->mapToGlobal(QPoint(px, py + lh));
|
QPoint anchor = m_sci->viewport()->mapToGlobal(QPoint(px, py + lh));
|
||||||
popup->showAt(anchor);
|
popup->showAt(anchor, lh);
|
||||||
showPopup = true;
|
showPopup = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3146,7 +3166,7 @@ void RcxEditor::applyHoverCursor() {
|
|||||||
int lh = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT,
|
int lh = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT,
|
||||||
(unsigned long)h.line);
|
(unsigned long)h.line);
|
||||||
QPoint anchor = m_sci->viewport()->mapToGlobal(QPoint(px, py + lh));
|
QPoint anchor = m_sci->viewport()->mapToGlobal(QPoint(px, py + lh));
|
||||||
popup->showAt(anchor);
|
popup->showAt(anchor, lh);
|
||||||
showPopup = true;
|
showPopup = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3239,7 +3259,7 @@ void RcxEditor::applyHoverCursor() {
|
|||||||
(unsigned long)h.line);
|
(unsigned long)h.line);
|
||||||
QPoint anchor = m_sci->viewport()->mapToGlobal(
|
QPoint anchor = m_sci->viewport()->mapToGlobal(
|
||||||
QPoint(px, py + lh));
|
QPoint(px, py + lh));
|
||||||
popup->showAt(anchor);
|
popup->showAt(anchor, lh);
|
||||||
showDisasm = true;
|
showDisasm = true;
|
||||||
// Dismiss value history popup to avoid fighting
|
// Dismiss value history popup to avoid fighting
|
||||||
if (m_historyPopup && m_historyPopup->isVisible())
|
if (m_historyPopup && m_historyPopup->isVisible())
|
||||||
@@ -3306,7 +3326,7 @@ void RcxEditor::applyHoverCursor() {
|
|||||||
(unsigned long)h.line);
|
(unsigned long)h.line);
|
||||||
QPoint anchor = m_sci->viewport()->mapToGlobal(
|
QPoint anchor = m_sci->viewport()->mapToGlobal(
|
||||||
QPoint(px, py + lh));
|
QPoint(px, py + lh));
|
||||||
popup->showAt(anchor);
|
popup->showAt(anchor, lh);
|
||||||
showPreview = true;
|
showPreview = true;
|
||||||
if (m_historyPopup && m_historyPopup->isVisible())
|
if (m_historyPopup && m_historyPopup->isVisible())
|
||||||
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
|
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
|
||||||
|
|||||||
143
src/examples/Demo.rcx
Normal file
143
src/examples/Demo.rcx
Normal 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
64
src/main.cpp
64
src/main.cpp
@@ -236,6 +236,14 @@ public:
|
|||||||
class MenuBarStyle : public QProxyStyle {
|
class MenuBarStyle : public QProxyStyle {
|
||||||
public:
|
public:
|
||||||
using QProxyStyle::QProxyStyle;
|
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,
|
QSize sizeFromContents(ContentsType type, const QStyleOption* opt,
|
||||||
const QSize& sz, const QWidget* w) const override {
|
const QSize& sz, const QWidget* w) const override {
|
||||||
QSize s = QProxyStyle::sizeFromContents(type, opt, sz, w);
|
QSize s = QProxyStyle::sizeFromContents(type, opt, sz, w);
|
||||||
@@ -247,9 +255,12 @@ public:
|
|||||||
}
|
}
|
||||||
int pixelMetric(PixelMetric metric, const QStyleOption* opt,
|
int pixelMetric(PixelMetric metric, const QStyleOption* opt,
|
||||||
const QWidget* w) const override {
|
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)
|
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
|
// Thin draggable separator between dock widgets / central widget
|
||||||
if (metric == PM_DockWidgetSeparatorExtent)
|
if (metric == PM_DockWidgetSeparatorExtent)
|
||||||
return 1;
|
return 1;
|
||||||
@@ -257,9 +268,13 @@ public:
|
|||||||
}
|
}
|
||||||
void drawPrimitive(PrimitiveElement elem, const QStyleOption* opt,
|
void drawPrimitive(PrimitiveElement elem, const QStyleOption* opt,
|
||||||
QPainter* p, const QWidget* w) const override {
|
QPainter* p, const QWidget* w) const override {
|
||||||
// Kill Fusion's 3D bevel on QMenu — the OS drop shadow is enough
|
// Clean 1px border on QMenu (replaces Fusion's 3D bevel + OS shadow)
|
||||||
if (elem == PE_FrameMenu)
|
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;
|
return;
|
||||||
|
}
|
||||||
// Kill the status bar item frame and panel border
|
// Kill the status bar item frame and panel border
|
||||||
if (elem == PE_FrameStatusBarItem || elem == PE_PanelStatusBar)
|
if (elem == PE_FrameStatusBarItem || elem == PE_PanelStatusBar)
|
||||||
return;
|
return;
|
||||||
@@ -355,7 +370,7 @@ static void applyGlobalTheme(const rcx::Theme& theme) {
|
|||||||
pal.setColor(QPalette::ToolTipBase, theme.backgroundAlt);
|
pal.setColor(QPalette::ToolTipBase, theme.backgroundAlt);
|
||||||
pal.setColor(QPalette::ToolTipText, theme.text);
|
pal.setColor(QPalette::ToolTipText, theme.text);
|
||||||
pal.setColor(QPalette::Mid, theme.hover);
|
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::Light, theme.textFaint);
|
||||||
pal.setColor(QPalette::Link, theme.indHoverSpan);
|
pal.setColor(QPalette::Link, theme.indHoverSpan);
|
||||||
|
|
||||||
@@ -657,6 +672,15 @@ void MainWindow::createMenus() {
|
|||||||
tab.ctrl->setCompactColumns(checked);
|
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");
|
auto* actRelOfs = view->addAction("R&elative Offsets");
|
||||||
actRelOfs->setCheckable(true);
|
actRelOfs->setCheckable(true);
|
||||||
actRelOfs->setChecked(settings.value("relativeOffsets", true).toBool());
|
actRelOfs->setChecked(settings.value("relativeOffsets", true).toBool());
|
||||||
@@ -1125,7 +1149,7 @@ MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) {
|
|||||||
// Find bar with prev/next buttons (hidden by default)
|
// Find bar with prev/next buttons (hidden by default)
|
||||||
pane.findContainer = new QWidget;
|
pane.findContainer = new QWidget;
|
||||||
auto* fcLayout = new QHBoxLayout(pane.findContainer);
|
auto* fcLayout = new QHBoxLayout(pane.findContainer);
|
||||||
fcLayout->setContentsMargins(4, 0, 0, 0);
|
fcLayout->setContentsMargins(4, 1, 4, 1);
|
||||||
fcLayout->setSpacing(2);
|
fcLayout->setSpacing(2);
|
||||||
const auto& fbTheme = ThemeManager::instance().current();
|
const auto& fbTheme = ThemeManager::instance().current();
|
||||||
auto* ccPrevBtn = new QToolButton;
|
auto* ccPrevBtn = new QToolButton;
|
||||||
@@ -1148,9 +1172,10 @@ MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) {
|
|||||||
ccCloseBtn->setStyleSheet(btnCss);
|
ccCloseBtn->setStyleSheet(btnCss);
|
||||||
pane.findBar = new QLineEdit;
|
pane.findBar = new QLineEdit;
|
||||||
pane.findBar->setPlaceholderText("Find...");
|
pane.findBar->setPlaceholderText("Find...");
|
||||||
|
pane.findBar->setFixedHeight(24);
|
||||||
pane.findBar->setStyleSheet(
|
pane.findBar->setStyleSheet(
|
||||||
QStringLiteral("QLineEdit { background: %1; color: %2; border: 1px solid %3;"
|
QStringLiteral("QLineEdit { background: %1; color: %2; border: 1px solid %3;"
|
||||||
" padding: 4px 8px; font-size: 13px; }")
|
" padding: 2px 6px; font-size: 13px; }")
|
||||||
.arg(fbTheme.backgroundAlt.name(), fbTheme.text.name(), fbTheme.border.name()));
|
.arg(fbTheme.backgroundAlt.name(), fbTheme.text.name(), fbTheme.border.name()));
|
||||||
fcLayout->addWidget(ccPrevBtn);
|
fcLayout->addWidget(ccPrevBtn);
|
||||||
fcLayout->addWidget(ccNextBtn);
|
fcLayout->addWidget(ccNextBtn);
|
||||||
@@ -1306,6 +1331,7 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) {
|
|||||||
|
|
||||||
// Apply global compact columns setting to new tab
|
// Apply global compact columns setting to new tab
|
||||||
ctrl->setCompactColumns(QSettings("Reclass", "Reclass").value("compactColumns", true).toBool());
|
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
|
// Give every controller the shared document list for cross-tab type visibility
|
||||||
ctrl->setProjectDocuments(&m_allDocs);
|
ctrl->setProjectDocuments(&m_allDocs);
|
||||||
@@ -2976,6 +3002,30 @@ void MainWindow::createScannerDock() {
|
|||||||
return ctrl ? ctrl->document()->provider : nullptr;
|
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
|
// Wire "Go to Address" to rebase the active tab
|
||||||
connect(m_scannerPanel, &ScannerPanel::goToAddress, this, [this](uint64_t addr) {
|
connect(m_scannerPanel, &ScannerPanel::goToAddress, this, [this](uint64_t addr) {
|
||||||
auto* ctrl = activeController();
|
auto* ctrl = activeController();
|
||||||
|
|||||||
@@ -203,7 +203,28 @@ QJsonObject McpBridge::handleInitialize(const QJsonValue& id, const QJsonObject&
|
|||||||
{"serverInfo", QJsonObject{
|
{"serverInfo", QJsonObject{
|
||||||
{"name", "reclass-mcp"},
|
{"name", "reclass-mcp"},
|
||||||
{"version", "1.0.0"}
|
{"version", "1.0.0"}
|
||||||
}}
|
}},
|
||||||
|
{"instructions",
|
||||||
|
"You are connected to ReClass, a live memory structure editor for reverse engineering. "
|
||||||
|
"You have two types of data available:\n"
|
||||||
|
"1. STRUCTURE: The node tree defines typed fields (project.state, tree.search, tree.apply). "
|
||||||
|
"Each node has a kind (the data type: UInt32, Float, Hex64, etc.) and a name.\n"
|
||||||
|
"2. LIVE DATA: The provider reads real memory from an attached process (hex.read, hex.write). "
|
||||||
|
"node.history returns timestamped value changes with heat levels (0=static, 1=cold, 2=warm, 3=hot).\n\n"
|
||||||
|
"CRITICAL RULES:\n"
|
||||||
|
"- When labeling/identifying a field, ALWAYS change BOTH name AND kind in one tree.apply call. "
|
||||||
|
"Example: [{op:'rename',nodeId:'X',name:'health'},{op:'change_kind',nodeId:'X',kind:'Int32'}]. "
|
||||||
|
"A node named 'health' with kind Hex64 is WRONG — the kind must match the actual data type.\n"
|
||||||
|
"- To detect what changed after an in-game event: call ui.action with action:'reset_tracking', "
|
||||||
|
"then have the user perform the action, then call node.history on the relevant nodes "
|
||||||
|
"to see which ones have new timestamped entries.\n"
|
||||||
|
"- hex.read offset is relative to the struct base address by default. "
|
||||||
|
"Use baseRelative=true for absolute virtual addresses in the process.\n"
|
||||||
|
"- tree.apply operations are atomic (undo macro). Batch related changes into one call.\n"
|
||||||
|
"- Use tree.search to quickly find nodes by name instead of paging through project.state.\n"
|
||||||
|
"- project.state returns structure metadata only (kinds, names, offsets), NOT live values. "
|
||||||
|
"Use hex.read for actual memory values and node.history for tracking changes over time."
|
||||||
|
}
|
||||||
};
|
};
|
||||||
return okReply(id, result);
|
return okReply(id, result);
|
||||||
}
|
}
|
||||||
@@ -219,6 +240,8 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
|
|||||||
tools.append(QJsonObject{
|
tools.append(QJsonObject{
|
||||||
{"name", "project.state"},
|
{"name", "project.state"},
|
||||||
{"description", "Returns project state with paginated node tree. "
|
{"description", "Returns project state with paginated node tree. "
|
||||||
|
"NOTE: This returns structure metadata only (kinds, names, offsets), NOT live memory values. "
|
||||||
|
"Use hex.read to read actual values and node.history to track value changes over time. "
|
||||||
"Responses return max 'limit' nodes (default 50). "
|
"Responses return max 'limit' nodes (default 50). "
|
||||||
"Use depth:1 first, then parentId to drill into a struct. "
|
"Use depth:1 first, then parentId to drill into a struct. "
|
||||||
"Enum/bitfield member arrays are omitted by default (counts shown instead); "
|
"Enum/bitfield member arrays are omitted by default (counts shown instead); "
|
||||||
@@ -249,6 +272,10 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
|
|||||||
tools.append(QJsonObject{
|
tools.append(QJsonObject{
|
||||||
{"name", "tree.apply"},
|
{"name", "tree.apply"},
|
||||||
{"description", "Apply batch of tree operations atomically (undo macro). "
|
{"description", "Apply batch of tree operations atomically (undo macro). "
|
||||||
|
"IMPORTANT: When identifying/labeling a field, you MUST use BOTH rename AND change_kind "
|
||||||
|
"in the same batch. A renamed node still has its original kind (e.g. Hex64) unless you "
|
||||||
|
"explicitly change it. Example: "
|
||||||
|
"[{op:'rename',nodeId:'ID',name:'health'},{op:'change_kind',nodeId:'ID',kind:'Int32'}]. "
|
||||||
"Each op is a JSON object with an 'op' field for the operation type and 'nodeId' (string) for the target node. "
|
"Each op is a JSON object with an 'op' field for the operation type and 'nodeId' (string) for the target node. "
|
||||||
"Operations: "
|
"Operations: "
|
||||||
"remove: {op:'remove', nodeId:'ID'}. "
|
"remove: {op:'remove', nodeId:'ID'}. "
|
||||||
@@ -301,10 +328,11 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
|
|||||||
// 4. hex.read
|
// 4. hex.read
|
||||||
tools.append(QJsonObject{
|
tools.append(QJsonObject{
|
||||||
{"name", "hex.read"},
|
{"name", "hex.read"},
|
||||||
{"description", "Read raw bytes from provider. Returns hex dump, ASCII, and multi-type "
|
{"description", "Read raw bytes from provider (live process memory). Returns hex dump, ASCII, and multi-type "
|
||||||
"interpretations (u8/u16/u32/u64/i32/f32/f64/ptr/string heuristics). "
|
"interpretations (u8/u16/u32/u64/i32/f32/f64/ptr/string heuristics). "
|
||||||
|
"Use this to see what actual values are in memory at any offset. "
|
||||||
"Offset is tree-relative (0-based, baseAddress added automatically) "
|
"Offset is tree-relative (0-based, baseAddress added automatically) "
|
||||||
"unless baseRelative=true (offset is absolute)."},
|
"unless baseRelative=true (offset is absolute virtual address in the process)."},
|
||||||
{"inputSchema", QJsonObject{
|
{"inputSchema", QJsonObject{
|
||||||
{"type", "object"},
|
{"type", "object"},
|
||||||
{"properties", QJsonObject{
|
{"properties", QJsonObject{
|
||||||
@@ -359,8 +387,10 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
|
|||||||
{"description", "Trigger a UI action. Fallback for operations without dedicated tools. "
|
{"description", "Trigger a UI action. Fallback for operations without dedicated tools. "
|
||||||
"Actions: undo, redo, new_file, open_file, save_file, save_file_as, "
|
"Actions: undo, redo, new_file, open_file, save_file, save_file_as, "
|
||||||
"export_cpp, set_view_root, scroll_to_node, collapse_node, expand_node, "
|
"export_cpp, set_view_root, scroll_to_node, collapse_node, expand_node, "
|
||||||
"select_node, refresh. "
|
"select_node, refresh, reset_tracking. "
|
||||||
"export_cpp accepts optional nodeId to export a single struct (recommended for large projects)."},
|
"export_cpp accepts optional nodeId to export a single struct (recommended for large projects). "
|
||||||
|
"reset_tracking clears all value change histories — use before an in-game event, "
|
||||||
|
"then check node.history afterward to see what changed."},
|
||||||
{"inputSchema", QJsonObject{
|
{"inputSchema", QJsonObject{
|
||||||
{"type", "object"},
|
{"type", "object"},
|
||||||
{"properties", QJsonObject{
|
{"properties", QJsonObject{
|
||||||
@@ -399,8 +429,11 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
|
|||||||
// 9. node.history
|
// 9. node.history
|
||||||
tools.append(QJsonObject{
|
tools.append(QJsonObject{
|
||||||
{"name", "node.history"},
|
{"name", "node.history"},
|
||||||
{"description", "Returns timestamped value change history (up to 10 entries) "
|
{"description", "Returns timestamped value change history (up to 10 entries) for specified nodes. "
|
||||||
"for specified nodes. Requires live provider with value tracking enabled."},
|
"Use this to detect what changed after an in-game event — no need to manually snapshot memory. "
|
||||||
|
"Each node returns: entries[] with {value, timestamp}, heatLevel (0=static to 3=hot), "
|
||||||
|
"and uniqueCount. Heat level 3 means the field is actively changing. "
|
||||||
|
"Requires live provider with value tracking enabled."},
|
||||||
{"inputSchema", QJsonObject{
|
{"inputSchema", QJsonObject{
|
||||||
{"type", "object"},
|
{"type", "object"},
|
||||||
{"properties", QJsonObject{
|
{"properties", QJsonObject{
|
||||||
@@ -1180,6 +1213,16 @@ QJsonObject McpBridge::toolUiAction(const QJsonObject& args) {
|
|||||||
return makeTextResult("Selected node " + nodeIdStr);
|
return makeTextResult("Selected node " + nodeIdStr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action == "reset_tracking") {
|
||||||
|
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);
|
return makeTextResult("Unknown action: " + action, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
241
src/rcxtooltip.h
Normal file
241
src/rcxtooltip.h
Normal 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
|
||||||
163
src/scanner.cpp
163
src/scanner.cpp
@@ -347,6 +347,64 @@ int naturalAlignment(ValueType type) {
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int valueSizeForType(ValueType type) {
|
||||||
|
switch (type) {
|
||||||
|
case ValueType::Int8: case ValueType::UInt8: return 1;
|
||||||
|
case ValueType::Int16: case ValueType::UInt16: return 2;
|
||||||
|
case ValueType::Int32: case ValueType::UInt32: case ValueType::Float: return 4;
|
||||||
|
case ValueType::Int64: case ValueType::UInt64: case ValueType::Double: return 8;
|
||||||
|
case ValueType::Vec2: return 8;
|
||||||
|
case ValueType::Vec3: return 12;
|
||||||
|
case ValueType::Vec4: return 16;
|
||||||
|
default: return 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Typed comparison for rescan conditions ──
|
||||||
|
|
||||||
|
static int compareTyped(const QByteArray& a, const QByteArray& b, ValueType vt) {
|
||||||
|
const char* da = a.constData();
|
||||||
|
const char* db = b.constData();
|
||||||
|
int sz = qMin(a.size(), b.size());
|
||||||
|
|
||||||
|
switch (vt) {
|
||||||
|
case ValueType::Int8:
|
||||||
|
if (sz >= 1) { int8_t va, vb; memcpy(&va, da, 1); memcpy(&vb, db, 1); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
case ValueType::UInt8:
|
||||||
|
if (sz >= 1) { uint8_t va, vb; memcpy(&va, da, 1); memcpy(&vb, db, 1); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
case ValueType::Int16:
|
||||||
|
if (sz >= 2) { int16_t va, vb; memcpy(&va, da, 2); memcpy(&vb, db, 2); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
case ValueType::UInt16:
|
||||||
|
if (sz >= 2) { uint16_t va, vb; memcpy(&va, da, 2); memcpy(&vb, db, 2); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
case ValueType::Int32:
|
||||||
|
if (sz >= 4) { int32_t va, vb; memcpy(&va, da, 4); memcpy(&vb, db, 4); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
case ValueType::UInt32:
|
||||||
|
if (sz >= 4) { uint32_t va, vb; memcpy(&va, da, 4); memcpy(&vb, db, 4); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
case ValueType::Int64:
|
||||||
|
if (sz >= 8) { int64_t va, vb; memcpy(&va, da, 8); memcpy(&vb, db, 8); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
case ValueType::UInt64:
|
||||||
|
if (sz >= 8) { uint64_t va, vb; memcpy(&va, da, 8); memcpy(&vb, db, 8); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
case ValueType::Float:
|
||||||
|
if (sz >= 4) { float va, vb; memcpy(&va, da, 4); memcpy(&vb, db, 4); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
case ValueType::Double:
|
||||||
|
if (sz >= 8) { double va, vb; memcpy(&va, da, 8); memcpy(&vb, db, 8); return (va > vb) - (va < vb); }
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// Fallback: byte comparison
|
||||||
|
return memcmp(da, db, sz);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Scan engine ──
|
// ── Scan engine ──
|
||||||
|
|
||||||
ScanEngine::ScanEngine(QObject* parent)
|
ScanEngine::ScanEngine(QObject* parent)
|
||||||
@@ -366,6 +424,7 @@ void ScanEngine::abort() {
|
|||||||
void ScanEngine::start(std::shared_ptr<Provider> provider, const ScanRequest& req) {
|
void ScanEngine::start(std::shared_ptr<Provider> provider, const ScanRequest& req) {
|
||||||
if (isRunning()) return;
|
if (isRunning()) return;
|
||||||
|
|
||||||
|
if (req.condition != ScanCondition::UnknownValue) {
|
||||||
if (req.pattern.isEmpty()) {
|
if (req.pattern.isEmpty()) {
|
||||||
emit error(QStringLiteral("Empty pattern"));
|
emit error(QStringLiteral("Empty pattern"));
|
||||||
return;
|
return;
|
||||||
@@ -374,6 +433,7 @@ void ScanEngine::start(std::shared_ptr<Provider> provider, const ScanRequest& re
|
|||||||
emit error(QStringLiteral("Pattern and mask size mismatch"));
|
emit error(QStringLiteral("Pattern and mask size mismatch"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
m_abort.store(false);
|
m_abort.store(false);
|
||||||
|
|
||||||
@@ -400,14 +460,16 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
|
|||||||
timer.start();
|
timer.start();
|
||||||
|
|
||||||
QVector<ScanResult> results;
|
QVector<ScanResult> results;
|
||||||
|
const bool isUnknown = (req.condition == ScanCondition::UnknownValue);
|
||||||
|
|
||||||
if (!prov || req.pattern.isEmpty())
|
if (!prov || (!isUnknown && req.pattern.isEmpty()))
|
||||||
return results;
|
return results;
|
||||||
|
|
||||||
auto regions = prov->enumerateRegions();
|
auto regions = prov->enumerateRegions();
|
||||||
qDebug() << "[scan] regions:" << regions.size()
|
qDebug() << "[scan] regions:" << regions.size()
|
||||||
<< " pattern:" << req.pattern.size() << "bytes"
|
<< " pattern:" << req.pattern.size() << "bytes"
|
||||||
<< " align:" << req.alignment
|
<< " align:" << req.alignment
|
||||||
|
<< " condition:" << (int)req.condition
|
||||||
<< " filterExec:" << req.filterExecutable
|
<< " filterExec:" << req.filterExecutable
|
||||||
<< " filterWrite:" << req.filterWritable;
|
<< " filterWrite:" << req.filterWritable;
|
||||||
|
|
||||||
@@ -422,17 +484,26 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
|
|||||||
regions.append(fallback);
|
regions.append(fallback);
|
||||||
}
|
}
|
||||||
|
|
||||||
const int patternLen = req.pattern.size();
|
const int patternLen = isUnknown ? req.valueSize : req.pattern.size();
|
||||||
const char* pat = req.pattern.constData();
|
const char* pat = isUnknown ? nullptr : req.pattern.constData();
|
||||||
const char* msk = req.mask.constData();
|
const char* msk = isUnknown ? nullptr : req.mask.constData();
|
||||||
const int alignment = qMax(1, req.alignment);
|
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
|
// Pre-compute total bytes for progress
|
||||||
uint64_t totalBytes = 0;
|
uint64_t totalBytes = 0;
|
||||||
for (const auto& r : regions) {
|
for (const auto& r : regions) {
|
||||||
if (req.filterExecutable && !r.executable) continue;
|
if (req.filterExecutable && !r.executable) continue;
|
||||||
if (req.filterWritable && !r.writable) 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";
|
qDebug() << "[scan] total scannable:" << (totalBytes / 1024) << "KB across filtered regions";
|
||||||
@@ -450,21 +521,35 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
|
|||||||
if (req.filterExecutable && !region.executable) continue;
|
if (req.filterExecutable && !region.executable) continue;
|
||||||
if (req.filterWritable && !region.writable) continue;
|
if (req.filterWritable && !region.writable) continue;
|
||||||
|
|
||||||
if ((uint64_t)patternLen > region.size) {
|
// Clip region to requested address range
|
||||||
scannedBytes += region.size;
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const int overlap = patternLen - 1;
|
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;
|
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);
|
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
|
// Skip unreadable chunk
|
||||||
off += readLen;
|
off += readLen;
|
||||||
scannedBytes += readLen;
|
scannedBytes += readLen;
|
||||||
@@ -474,6 +559,19 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
|
|||||||
int scanEnd = readLen - patternLen;
|
int scanEnd = readLen - patternLen;
|
||||||
const char* data = chunk.constData();
|
const char* data = chunk.constData();
|
||||||
|
|
||||||
|
if (isUnknown) {
|
||||||
|
// Unknown value: capture every aligned address
|
||||||
|
for (int i = 0; i <= scanEnd; i += alignment) {
|
||||||
|
ScanResult r;
|
||||||
|
r.address = regStart + off + (uint64_t)i;
|
||||||
|
r.scanValue = QByteArray(data + i, valSize);
|
||||||
|
results.append(r);
|
||||||
|
|
||||||
|
if (results.size() >= req.maxResults)
|
||||||
|
goto done;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Exact pattern match
|
||||||
for (int i = 0; i <= scanEnd; i += alignment) {
|
for (int i = 0; i <= scanEnd; i += alignment) {
|
||||||
bool match = true;
|
bool match = true;
|
||||||
for (int j = 0; j < patternLen; j++) {
|
for (int j = 0; j < patternLen; j++) {
|
||||||
@@ -484,7 +582,7 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
|
|||||||
}
|
}
|
||||||
if (match) {
|
if (match) {
|
||||||
ScanResult r;
|
ScanResult r;
|
||||||
r.address = region.base + off + (uint64_t)i;
|
r.address = regStart + off + (uint64_t)i;
|
||||||
r.regionModule = region.moduleName;
|
r.regionModule = region.moduleName;
|
||||||
r.scanValue = QByteArray(data + i, qMin(16, readLen - i));
|
r.scanValue = QByteArray(data + i, qMin(16, readLen - i));
|
||||||
results.append(r);
|
results.append(r);
|
||||||
@@ -493,6 +591,7 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
|
|||||||
goto done;
|
goto done;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Advance with overlap to catch patterns that straddle chunks
|
// Advance with overlap to catch patterns that straddle chunks
|
||||||
uint64_t advance;
|
uint64_t advance;
|
||||||
@@ -522,6 +621,7 @@ done:
|
|||||||
|
|
||||||
void ScanEngine::startRescan(std::shared_ptr<Provider> provider,
|
void ScanEngine::startRescan(std::shared_ptr<Provider> provider,
|
||||||
QVector<ScanResult> results, int readSize,
|
QVector<ScanResult> results, int readSize,
|
||||||
|
ScanCondition condition, ValueType valueType,
|
||||||
const QByteArray& filterPattern,
|
const QByteArray& filterPattern,
|
||||||
const QByteArray& filterMask) {
|
const QByteArray& filterMask) {
|
||||||
if (isRunning()) return;
|
if (isRunning()) return;
|
||||||
@@ -541,14 +641,15 @@ void ScanEngine::startRescan(std::shared_ptr<Provider> provider,
|
|||||||
|
|
||||||
watcher->setFuture(QtConcurrent::run(
|
watcher->setFuture(QtConcurrent::run(
|
||||||
[this, provider, results = std::move(results), readSize,
|
[this, provider, results = std::move(results), readSize,
|
||||||
filterPattern, filterMask]() mutable {
|
condition, valueType, filterPattern, filterMask]() mutable {
|
||||||
return runRescan(provider, std::move(results), readSize,
|
return runRescan(provider, std::move(results), readSize,
|
||||||
filterPattern, filterMask);
|
condition, valueType, filterPattern, filterMask);
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
|
QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
|
||||||
QVector<ScanResult> results, int readSize,
|
QVector<ScanResult> results, int readSize,
|
||||||
|
ScanCondition condition, ValueType valueType,
|
||||||
const QByteArray& filterPattern,
|
const QByteArray& filterPattern,
|
||||||
const QByteArray& filterMask) {
|
const QByteArray& filterMask) {
|
||||||
QElapsedTimer timer;
|
QElapsedTimer timer;
|
||||||
@@ -557,9 +658,17 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
|
|||||||
int total = results.size();
|
int total = results.size();
|
||||||
if (total == 0 || !prov) return results;
|
if (total == 0 || !prov) return results;
|
||||||
|
|
||||||
bool hasFilter = !filterPattern.isEmpty();
|
bool hasExactFilter = !filterPattern.isEmpty() && condition == ScanCondition::ExactValue;
|
||||||
|
bool hasComparison = (condition == ScanCondition::Changed ||
|
||||||
|
condition == ScanCondition::Unchanged ||
|
||||||
|
condition == ScanCondition::Increased ||
|
||||||
|
condition == ScanCondition::Decreased);
|
||||||
|
bool needsFilter = hasExactFilter || hasComparison;
|
||||||
|
|
||||||
qDebug() << "[rescan] start:" << total << "results, readSize:" << readSize
|
qDebug() << "[rescan] start:" << total << "results, readSize:" << readSize
|
||||||
<< "filter:" << (hasFilter ? "yes" : "no");
|
<< "condition:" << (int)condition
|
||||||
|
<< "exactFilter:" << (hasExactFilter ? "yes" : "no")
|
||||||
|
<< "comparison:" << (hasComparison ? "yes" : "no");
|
||||||
|
|
||||||
// Save previous values
|
// Save previous values
|
||||||
for (auto& r : results)
|
for (auto& r : results)
|
||||||
@@ -579,8 +688,8 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
|
|||||||
uint64_t totalBytesRead = 0;
|
uint64_t totalBytesRead = 0;
|
||||||
int i = 0;
|
int i = 0;
|
||||||
|
|
||||||
// Track which results matched the filter (by original index)
|
// Track which results matched (by original index)
|
||||||
QVector<bool> matched(total, !hasFilter); // if no filter, all match
|
QVector<bool> matched(total, !needsFilter); // if no filter, all match
|
||||||
|
|
||||||
while (i < total && !m_abort.load()) {
|
while (i < total && !m_abort.load()) {
|
||||||
uint64_t spanBase = results[order[i]].address;
|
uint64_t spanBase = results[order[i]].address;
|
||||||
@@ -604,8 +713,8 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
|
|||||||
int off = (int)(r.address - spanBase);
|
int off = (int)(r.address - spanBase);
|
||||||
r.scanValue = chunk.mid(off, readSize);
|
r.scanValue = chunk.mid(off, readSize);
|
||||||
|
|
||||||
// Apply filter: compare re-read bytes against the new pattern
|
// Apply exact-value filter
|
||||||
if (hasFilter) {
|
if (hasExactFilter) {
|
||||||
int patLen = filterPattern.size();
|
int patLen = filterPattern.size();
|
||||||
if (r.scanValue.size() >= patLen) {
|
if (r.scanValue.size() >= patLen) {
|
||||||
bool ok = true;
|
bool ok = true;
|
||||||
@@ -621,6 +730,18 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
|
|||||||
matched[idx] = ok;
|
matched[idx] = ok;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply comparison-based filter
|
||||||
|
if (hasComparison && !r.previousValue.isEmpty()) {
|
||||||
|
int cmp = compareTyped(r.scanValue, r.previousValue, valueType);
|
||||||
|
switch (condition) {
|
||||||
|
case ScanCondition::Changed: matched[idx] = (cmp != 0); break;
|
||||||
|
case ScanCondition::Unchanged: matched[idx] = (cmp == 0); break;
|
||||||
|
case ScanCondition::Increased: matched[idx] = (cmp > 0); break;
|
||||||
|
case ScanCondition::Decreased: matched[idx] = (cmp < 0); break;
|
||||||
|
default: break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
chunks++;
|
chunks++;
|
||||||
@@ -637,7 +758,7 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Filter out non-matching results
|
// Filter out non-matching results
|
||||||
if (hasFilter) {
|
if (needsFilter) {
|
||||||
QVector<ScanResult> filtered;
|
QVector<ScanResult> filtered;
|
||||||
filtered.reserve(total);
|
filtered.reserve(total);
|
||||||
for (int k = 0; k < total; k++) {
|
for (int k = 0; k < total; k++) {
|
||||||
|
|||||||
@@ -10,26 +10,6 @@
|
|||||||
|
|
||||||
namespace rcx {
|
namespace rcx {
|
||||||
|
|
||||||
// ── Scan request / result ──
|
|
||||||
|
|
||||||
struct ScanRequest {
|
|
||||||
QByteArray pattern; // literal bytes to match
|
|
||||||
QByteArray mask; // 0xFF = must match, 0x00 = wildcard
|
|
||||||
|
|
||||||
bool filterExecutable = false; // only scan +x regions
|
|
||||||
bool filterWritable = false; // only scan +w regions
|
|
||||||
|
|
||||||
int alignment = 1; // 1 = every byte, 4 = dword, 8 = qword
|
|
||||||
int maxResults = 50000;
|
|
||||||
};
|
|
||||||
|
|
||||||
struct ScanResult {
|
|
||||||
uint64_t address;
|
|
||||||
QString regionModule;
|
|
||||||
QByteArray scanValue; // cached bytes at scan/update time
|
|
||||||
QByteArray previousValue; // value before last update
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Value scan types ──
|
// ── Value scan types ──
|
||||||
|
|
||||||
enum class ValueType {
|
enum class ValueType {
|
||||||
@@ -41,6 +21,43 @@ enum class ValueType {
|
|||||||
HexBytes
|
HexBytes
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── Scan condition (Cheat Engine-style) ──
|
||||||
|
|
||||||
|
enum class ScanCondition {
|
||||||
|
ExactValue, // first scan + rescan: match specific bytes
|
||||||
|
UnknownValue, // first scan only: capture all aligned addresses
|
||||||
|
Changed, // rescan: current != previous
|
||||||
|
Unchanged, // rescan: current == previous
|
||||||
|
Increased, // rescan: current > previous (numeric)
|
||||||
|
Decreased // rescan: current < previous (numeric)
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Scan request / result ──
|
||||||
|
|
||||||
|
struct ScanRequest {
|
||||||
|
QByteArray pattern; // literal bytes to match (empty for UnknownValue)
|
||||||
|
QByteArray mask; // 0xFF = must match, 0x00 = wildcard
|
||||||
|
|
||||||
|
bool filterExecutable = false; // only scan +x regions
|
||||||
|
bool filterWritable = false; // only scan +w regions
|
||||||
|
|
||||||
|
int alignment = 1; // 1 = every byte, 4 = dword, 8 = qword
|
||||||
|
int maxResults = 50000;
|
||||||
|
|
||||||
|
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 {
|
||||||
|
uint64_t address;
|
||||||
|
QString regionModule;
|
||||||
|
QByteArray scanValue; // cached bytes at scan/update time
|
||||||
|
QByteArray previousValue; // value before last update
|
||||||
|
};
|
||||||
|
|
||||||
// ── Pattern parsing ──
|
// ── Pattern parsing ──
|
||||||
|
|
||||||
// Parse IDA-style signature string ("48 8B ?? 05") into pattern + mask.
|
// Parse IDA-style signature string ("48 8B ?? 05") into pattern + mask.
|
||||||
@@ -57,6 +74,9 @@ bool serializeValue(ValueType type, const QString& input,
|
|||||||
// Natural alignment for a value type (used as default alignment for value scans).
|
// Natural alignment for a value type (used as default alignment for value scans).
|
||||||
int naturalAlignment(ValueType type);
|
int naturalAlignment(ValueType type);
|
||||||
|
|
||||||
|
// Byte-size for a value type (used for unknown scans and rescan read size).
|
||||||
|
int valueSizeForType(ValueType type);
|
||||||
|
|
||||||
// ── Scan engine ──
|
// ── Scan engine ──
|
||||||
|
|
||||||
class ScanEngine : public QObject {
|
class ScanEngine : public QObject {
|
||||||
@@ -67,6 +87,8 @@ public:
|
|||||||
void start(std::shared_ptr<Provider> provider, const ScanRequest& req);
|
void start(std::shared_ptr<Provider> provider, const ScanRequest& req);
|
||||||
void startRescan(std::shared_ptr<Provider> provider,
|
void startRescan(std::shared_ptr<Provider> provider,
|
||||||
QVector<ScanResult> results, int readSize,
|
QVector<ScanResult> results, int readSize,
|
||||||
|
ScanCondition condition = ScanCondition::ExactValue,
|
||||||
|
ValueType valueType = ValueType::Int32,
|
||||||
const QByteArray& filterPattern = {},
|
const QByteArray& filterPattern = {},
|
||||||
const QByteArray& filterMask = {});
|
const QByteArray& filterMask = {});
|
||||||
void abort();
|
void abort();
|
||||||
@@ -82,6 +104,7 @@ private:
|
|||||||
QVector<ScanResult> runScan(std::shared_ptr<Provider> prov, const ScanRequest& req);
|
QVector<ScanResult> runScan(std::shared_ptr<Provider> prov, const ScanRequest& req);
|
||||||
QVector<ScanResult> runRescan(std::shared_ptr<Provider> prov,
|
QVector<ScanResult> runRescan(std::shared_ptr<Provider> prov,
|
||||||
QVector<ScanResult> results, int readSize,
|
QVector<ScanResult> results, int readSize,
|
||||||
|
ScanCondition condition, ValueType valueType,
|
||||||
const QByteArray& filterPattern,
|
const QByteArray& filterPattern,
|
||||||
const QByteArray& filterMask);
|
const QByteArray& filterMask);
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,18 @@ ScannerPanel::ScannerPanel(QWidget* parent)
|
|||||||
m_typeCombo->setCurrentIndex(2); // default: int32
|
m_typeCombo->setCurrentIndex(2); // default: int32
|
||||||
inputRow->addWidget(m_typeCombo);
|
inputRow->addWidget(m_typeCombo);
|
||||||
|
|
||||||
|
m_condLabel = new QLabel(QStringLiteral("Scan:"), this);
|
||||||
|
inputRow->addWidget(m_condLabel);
|
||||||
|
|
||||||
|
m_condCombo = new QComboBox(this);
|
||||||
|
m_condCombo->addItem(QStringLiteral("Exact Value"), (int)ScanCondition::ExactValue);
|
||||||
|
m_condCombo->addItem(QStringLiteral("Unknown Value"), (int)ScanCondition::UnknownValue);
|
||||||
|
m_condCombo->addItem(QStringLiteral("Changed"), (int)ScanCondition::Changed);
|
||||||
|
m_condCombo->addItem(QStringLiteral("Unchanged"), (int)ScanCondition::Unchanged);
|
||||||
|
m_condCombo->addItem(QStringLiteral("Increased"), (int)ScanCondition::Increased);
|
||||||
|
m_condCombo->addItem(QStringLiteral("Decreased"), (int)ScanCondition::Decreased);
|
||||||
|
inputRow->addWidget(m_condCombo);
|
||||||
|
|
||||||
m_valueLabel = new QLabel(QStringLiteral("Value:"), this);
|
m_valueLabel = new QLabel(QStringLiteral("Value:"), this);
|
||||||
inputRow->addWidget(m_valueLabel);
|
inputRow->addWidget(m_valueLabel);
|
||||||
|
|
||||||
@@ -112,6 +124,9 @@ ScannerPanel::ScannerPanel(QWidget* parent)
|
|||||||
m_writeCheck = new QCheckBox(QStringLiteral("Writable"), this);
|
m_writeCheck = new QCheckBox(QStringLiteral("Writable"), this);
|
||||||
filterRow->addWidget(m_writeCheck);
|
filterRow->addWidget(m_writeCheck);
|
||||||
|
|
||||||
|
m_structOnlyCheck = new QCheckBox(QStringLiteral("Current Struct"), this);
|
||||||
|
filterRow->addWidget(m_structOnlyCheck);
|
||||||
|
|
||||||
filterRow->addStretch();
|
filterRow->addStretch();
|
||||||
|
|
||||||
m_scanBtn = new QPushButton(QIcon(QStringLiteral(":/vsicons/search.svg")),
|
m_scanBtn = new QPushButton(QIcon(QStringLiteral(":/vsicons/search.svg")),
|
||||||
@@ -174,6 +189,8 @@ ScannerPanel::ScannerPanel(QWidget* parent)
|
|||||||
// ── Initial state: signature mode ──
|
// ── Initial state: signature mode ──
|
||||||
m_typeLabel->hide();
|
m_typeLabel->hide();
|
||||||
m_typeCombo->hide();
|
m_typeCombo->hide();
|
||||||
|
m_condLabel->hide();
|
||||||
|
m_condCombo->hide();
|
||||||
m_valueLabel->hide();
|
m_valueLabel->hide();
|
||||||
m_valueEdit->hide();
|
m_valueEdit->hide();
|
||||||
m_execCheck->setChecked(true);
|
m_execCheck->setChecked(true);
|
||||||
@@ -181,6 +198,8 @@ ScannerPanel::ScannerPanel(QWidget* parent)
|
|||||||
// ── Connections ──
|
// ── Connections ──
|
||||||
connect(m_modeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
|
connect(m_modeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
|
||||||
this, &ScannerPanel::onModeChanged);
|
this, &ScannerPanel::onModeChanged);
|
||||||
|
connect(m_condCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
|
||||||
|
this, &ScannerPanel::onConditionChanged);
|
||||||
connect(m_scanBtn, &QPushButton::clicked,
|
connect(m_scanBtn, &QPushButton::clicked,
|
||||||
this, &ScannerPanel::onScanClicked);
|
this, &ScannerPanel::onScanClicked);
|
||||||
connect(m_updateBtn, &QPushButton::clicked,
|
connect(m_updateBtn, &QPushButton::clicked,
|
||||||
@@ -241,6 +260,10 @@ void ScannerPanel::setProviderGetter(ProviderGetter getter) {
|
|||||||
m_providerGetter = std::move(getter);
|
m_providerGetter = std::move(getter);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ScannerPanel::setBoundsGetter(BoundsGetter getter) {
|
||||||
|
m_boundsGetter = std::move(getter);
|
||||||
|
}
|
||||||
|
|
||||||
void ScannerPanel::setEditorFont(const QFont& font) {
|
void ScannerPanel::setEditorFont(const QFont& font) {
|
||||||
m_resultTable->setFont(font);
|
m_resultTable->setFont(font);
|
||||||
QFontMetrics fm(font);
|
QFontMetrics fm(font);
|
||||||
@@ -251,15 +274,18 @@ void ScannerPanel::setEditorFont(const QFont& font) {
|
|||||||
m_valueEdit->setFont(font);
|
m_valueEdit->setFont(font);
|
||||||
m_modeCombo->setFont(font);
|
m_modeCombo->setFont(font);
|
||||||
m_typeCombo->setFont(font);
|
m_typeCombo->setFont(font);
|
||||||
|
m_condCombo->setFont(font);
|
||||||
m_statusLabel->setFont(font);
|
m_statusLabel->setFont(font);
|
||||||
m_scanBtn->setFont(font);
|
m_scanBtn->setFont(font);
|
||||||
m_gotoBtn->setFont(font);
|
m_gotoBtn->setFont(font);
|
||||||
m_copyBtn->setFont(font);
|
m_copyBtn->setFont(font);
|
||||||
m_patternLabel->setFont(font);
|
m_patternLabel->setFont(font);
|
||||||
m_typeLabel->setFont(font);
|
m_typeLabel->setFont(font);
|
||||||
|
m_condLabel->setFont(font);
|
||||||
m_valueLabel->setFont(font);
|
m_valueLabel->setFont(font);
|
||||||
m_execCheck->setFont(font);
|
m_execCheck->setFont(font);
|
||||||
m_writeCheck->setFont(font);
|
m_writeCheck->setFont(font);
|
||||||
|
m_structOnlyCheck->setFont(font);
|
||||||
m_updateBtn->setFont(font);
|
m_updateBtn->setFont(font);
|
||||||
updateComboWidth();
|
updateComboWidth();
|
||||||
}
|
}
|
||||||
@@ -280,14 +306,29 @@ void ScannerPanel::onModeChanged(int index) {
|
|||||||
|
|
||||||
m_typeLabel->setVisible(!isSig);
|
m_typeLabel->setVisible(!isSig);
|
||||||
m_typeCombo->setVisible(!isSig);
|
m_typeCombo->setVisible(!isSig);
|
||||||
|
m_condLabel->setVisible(!isSig);
|
||||||
|
m_condCombo->setVisible(!isSig);
|
||||||
|
|
||||||
|
// Enable/disable value input based on condition
|
||||||
|
auto cond = (ScanCondition)m_condCombo->currentData().toInt();
|
||||||
|
bool needsValue = !isSig && (cond == ScanCondition::ExactValue);
|
||||||
m_valueLabel->setVisible(!isSig);
|
m_valueLabel->setVisible(!isSig);
|
||||||
m_valueEdit->setVisible(!isSig);
|
m_valueEdit->setVisible(!isSig);
|
||||||
|
m_valueEdit->setEnabled(needsValue);
|
||||||
|
m_valueLabel->setEnabled(needsValue);
|
||||||
|
|
||||||
// Auto-toggle filters: signatures → executable code, values → writable data
|
// Auto-toggle filters: signatures → executable code, values → writable data
|
||||||
m_execCheck->setChecked(isSig);
|
m_execCheck->setChecked(isSig);
|
||||||
m_writeCheck->setChecked(!isSig);
|
m_writeCheck->setChecked(!isSig);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void ScannerPanel::onConditionChanged(int /*index*/) {
|
||||||
|
auto cond = (ScanCondition)m_condCombo->currentData().toInt();
|
||||||
|
bool needsValue = (cond == ScanCondition::ExactValue);
|
||||||
|
m_valueEdit->setEnabled(needsValue);
|
||||||
|
m_valueLabel->setEnabled(needsValue);
|
||||||
|
}
|
||||||
|
|
||||||
void ScannerPanel::onScanClicked() {
|
void ScannerPanel::onScanClicked() {
|
||||||
if (m_engine->isRunning()) {
|
if (m_engine->isRunning()) {
|
||||||
m_engine->abort();
|
m_engine->abort();
|
||||||
@@ -306,12 +347,14 @@ void ScannerPanel::onScanClicked() {
|
|||||||
|
|
||||||
// Build request
|
// Build request
|
||||||
ScanRequest req = buildRequest();
|
ScanRequest req = buildRequest();
|
||||||
if (req.pattern.isEmpty())
|
if (req.condition != ScanCondition::UnknownValue && req.pattern.isEmpty())
|
||||||
return; // error already shown by buildRequest
|
return; // error already shown by buildRequest
|
||||||
|
|
||||||
m_lastScanMode = m_modeCombo->currentIndex();
|
m_lastScanMode = m_modeCombo->currentIndex();
|
||||||
if (m_lastScanMode == 1)
|
if (m_lastScanMode == 1) {
|
||||||
m_lastValueType = (ValueType)m_typeCombo->currentData().toInt();
|
m_lastValueType = (ValueType)m_typeCombo->currentData().toInt();
|
||||||
|
m_lastCondition = req.condition;
|
||||||
|
}
|
||||||
m_lastPattern = req.pattern;
|
m_lastPattern = req.pattern;
|
||||||
|
|
||||||
m_scanBtn->setText(QStringLiteral("Cancel"));
|
m_scanBtn->setText(QStringLiteral("Cancel"));
|
||||||
@@ -336,16 +379,41 @@ ScanRequest ScannerPanel::buildRequest() {
|
|||||||
} else {
|
} else {
|
||||||
// Value mode
|
// Value mode
|
||||||
auto vt = (ValueType)m_typeCombo->currentData().toInt();
|
auto vt = (ValueType)m_typeCombo->currentData().toInt();
|
||||||
|
auto cond = (ScanCondition)m_condCombo->currentData().toInt();
|
||||||
|
|
||||||
|
// Comparison conditions on fresh scan → treat as unknown
|
||||||
|
if (cond == ScanCondition::Changed || cond == ScanCondition::Unchanged ||
|
||||||
|
cond == ScanCondition::Increased || cond == ScanCondition::Decreased) {
|
||||||
|
cond = ScanCondition::UnknownValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.condition = cond;
|
||||||
|
req.alignment = naturalAlignment(vt);
|
||||||
|
req.valueSize = valueSizeForType(vt);
|
||||||
|
|
||||||
|
if (cond == ScanCondition::UnknownValue) {
|
||||||
|
// No pattern needed — capture all aligned addresses
|
||||||
|
req.maxResults = 10000000;
|
||||||
|
} else {
|
||||||
|
// Exact value mode
|
||||||
if (!serializeValue(vt, m_valueEdit->text(), req.pattern, req.mask, &err)) {
|
if (!serializeValue(vt, m_valueEdit->text(), req.pattern, req.mask, &err)) {
|
||||||
m_statusLabel->setText(QStringLiteral("Value error: %1").arg(err));
|
m_statusLabel->setText(QStringLiteral("Value error: %1").arg(err));
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
req.alignment = naturalAlignment(vt);
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
req.filterExecutable = m_execCheck->isChecked();
|
req.filterExecutable = m_execCheck->isChecked();
|
||||||
req.filterWritable = m_writeCheck->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;
|
return req;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -355,10 +423,11 @@ void ScannerPanel::onScanFinished(QVector<ScanResult> results) {
|
|||||||
m_results = std::move(results);
|
m_results = std::move(results);
|
||||||
|
|
||||||
// Bytes are cached by the engine during scan.
|
// Bytes are cached by the engine during scan.
|
||||||
// Value mode: override with exact search pattern (engine caches raw chunk bytes).
|
// Value mode (exact): override with exact search pattern (engine caches raw chunk bytes).
|
||||||
|
// Unknown mode: keep engine-captured bytes as-is (they're the baseline).
|
||||||
for (auto& r : m_results) {
|
for (auto& r : m_results) {
|
||||||
r.previousValue.clear();
|
r.previousValue.clear();
|
||||||
if (m_lastScanMode == 1)
|
if (m_lastScanMode == 1 && m_lastCondition == ScanCondition::ExactValue)
|
||||||
r.scanValue = m_lastPattern;
|
r.scanValue = m_lastPattern;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -372,8 +441,10 @@ void ScannerPanel::onScanFinished(QVector<ScanResult> results) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
int n = m_results.size();
|
int n = m_results.size();
|
||||||
m_statusLabel->setText(QStringLiteral("%1 result%2")
|
if (m_lastCondition == ScanCondition::UnknownValue && n >= 10000000)
|
||||||
.arg(n).arg(n == 1 ? "" : "s"));
|
m_statusLabel->setText(QStringLiteral("%1 results (capped — narrow with Re-scan)").arg(n));
|
||||||
|
else
|
||||||
|
m_statusLabel->setText(QStringLiteral("%1 result%2").arg(n).arg(n == 1 ? "" : "s"));
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScannerPanel::populateTable(bool showPrevious) {
|
void ScannerPanel::populateTable(bool showPrevious) {
|
||||||
@@ -425,8 +496,18 @@ void ScannerPanel::onUpdateClicked() {
|
|||||||
|
|
||||||
int readSize = (m_lastScanMode == 1) ? valueSize() : 16;
|
int readSize = (m_lastScanMode == 1) ? valueSize() : 16;
|
||||||
|
|
||||||
// Build filter from current input field
|
// Determine rescan condition
|
||||||
|
ScanCondition cond = ScanCondition::ExactValue;
|
||||||
|
if (m_lastScanMode == 1)
|
||||||
|
cond = (ScanCondition)m_condCombo->currentData().toInt();
|
||||||
|
|
||||||
|
// For UnknownValue on rescan, just re-read all (update only, no filter)
|
||||||
|
if (cond == ScanCondition::UnknownValue)
|
||||||
|
cond = ScanCondition::ExactValue; // with empty filter = update only
|
||||||
|
|
||||||
|
// Build filter from current input field (only for ExactValue condition)
|
||||||
QByteArray filterPattern, filterMask;
|
QByteArray filterPattern, filterMask;
|
||||||
|
if (cond == ScanCondition::ExactValue) {
|
||||||
if (m_lastScanMode == 0) {
|
if (m_lastScanMode == 0) {
|
||||||
// Signature mode
|
// Signature mode
|
||||||
QString err;
|
QString err;
|
||||||
@@ -437,7 +518,7 @@ void ScannerPanel::onUpdateClicked() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Value mode
|
// Value mode — exact value filter
|
||||||
QString err;
|
QString err;
|
||||||
if (!m_valueEdit->text().trimmed().isEmpty()) {
|
if (!m_valueEdit->text().trimmed().isEmpty()) {
|
||||||
auto vt = (ValueType)m_typeCombo->currentData().toInt();
|
auto vt = (ValueType)m_typeCombo->currentData().toInt();
|
||||||
@@ -448,6 +529,8 @@ void ScannerPanel::onUpdateClicked() {
|
|||||||
m_lastValueType = vt;
|
m_lastValueType = vt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
// Comparison conditions (Changed/Unchanged/Increased/Decreased) don't need a filter pattern
|
||||||
|
|
||||||
// Update last pattern so display uses the new value
|
// Update last pattern so display uses the new value
|
||||||
if (!filterPattern.isEmpty())
|
if (!filterPattern.isEmpty())
|
||||||
@@ -460,7 +543,8 @@ void ScannerPanel::onUpdateClicked() {
|
|||||||
m_progressBar->setValue(0);
|
m_progressBar->setValue(0);
|
||||||
m_progressBar->show();
|
m_progressBar->show();
|
||||||
|
|
||||||
m_engine->startRescan(prov, m_results, readSize, filterPattern, filterMask);
|
m_engine->startRescan(prov, m_results, readSize, cond, m_lastValueType,
|
||||||
|
filterPattern, filterMask);
|
||||||
}
|
}
|
||||||
|
|
||||||
void ScannerPanel::onRescanFinished(QVector<ScanResult> results) {
|
void ScannerPanel::onRescanFinished(QVector<ScanResult> results) {
|
||||||
@@ -666,12 +750,14 @@ void ScannerPanel::applyTheme(const Theme& theme) {
|
|||||||
theme.border.name(), theme.hover.name());
|
theme.border.name(), theme.hover.name());
|
||||||
m_modeCombo->setStyleSheet(comboStyle);
|
m_modeCombo->setStyleSheet(comboStyle);
|
||||||
m_typeCombo->setStyleSheet(comboStyle);
|
m_typeCombo->setStyleSheet(comboStyle);
|
||||||
|
m_condCombo->setStyleSheet(comboStyle);
|
||||||
|
|
||||||
// Labels
|
// Labels
|
||||||
QPalette lp;
|
QPalette lp;
|
||||||
lp.setColor(QPalette::WindowText, theme.textDim);
|
lp.setColor(QPalette::WindowText, theme.textDim);
|
||||||
m_patternLabel->setPalette(lp);
|
m_patternLabel->setPalette(lp);
|
||||||
m_typeLabel->setPalette(lp);
|
m_typeLabel->setPalette(lp);
|
||||||
|
m_condLabel->setPalette(lp);
|
||||||
m_valueLabel->setPalette(lp);
|
m_valueLabel->setPalette(lp);
|
||||||
m_statusLabel->setPalette(lp);
|
m_statusLabel->setPalette(lp);
|
||||||
|
|
||||||
@@ -680,6 +766,7 @@ void ScannerPanel::applyTheme(const Theme& theme) {
|
|||||||
cp.setColor(QPalette::WindowText, theme.textDim);
|
cp.setColor(QPalette::WindowText, theme.textDim);
|
||||||
m_execCheck->setPalette(cp);
|
m_execCheck->setPalette(cp);
|
||||||
m_writeCheck->setPalette(cp);
|
m_writeCheck->setPalette(cp);
|
||||||
|
m_structOnlyCheck->setPalette(cp);
|
||||||
|
|
||||||
// Buttons
|
// Buttons
|
||||||
QString btnStyle = QStringLiteral(
|
QString btnStyle = QStringLiteral(
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ public:
|
|||||||
using ProviderGetter = std::function<std::shared_ptr<Provider>()>;
|
using ProviderGetter = std::function<std::shared_ptr<Provider>()>;
|
||||||
void setProviderGetter(ProviderGetter getter);
|
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 setEditorFont(const QFont& font);
|
||||||
void applyTheme(const Theme& theme);
|
void applyTheme(const Theme& theme);
|
||||||
|
|
||||||
@@ -52,6 +56,9 @@ public:
|
|||||||
QPushButton* gotoButton() const { return m_gotoBtn; }
|
QPushButton* gotoButton() const { return m_gotoBtn; }
|
||||||
QPushButton* copyButton() const { return m_copyBtn; }
|
QPushButton* copyButton() const { return m_copyBtn; }
|
||||||
ScanEngine* engine() const { return m_engine; }
|
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:
|
signals:
|
||||||
void goToAddress(uint64_t address);
|
void goToAddress(uint64_t address);
|
||||||
@@ -72,18 +79,23 @@ private:
|
|||||||
void populateTable(bool showPrevious);
|
void populateTable(bool showPrevious);
|
||||||
void updateComboWidth();
|
void updateComboWidth();
|
||||||
|
|
||||||
|
void onConditionChanged(int index);
|
||||||
|
|
||||||
// Input widgets
|
// Input widgets
|
||||||
QComboBox* m_modeCombo; // Signature / Value
|
QComboBox* m_modeCombo; // Signature / Value
|
||||||
QLineEdit* m_patternEdit; // Signature pattern input
|
QLineEdit* m_patternEdit; // Signature pattern input
|
||||||
QComboBox* m_typeCombo; // Value type dropdown
|
QComboBox* m_typeCombo; // Value type dropdown
|
||||||
|
QComboBox* m_condCombo; // Scan condition (Exact/Unknown/Changed/...)
|
||||||
QLineEdit* m_valueEdit; // Value input
|
QLineEdit* m_valueEdit; // Value input
|
||||||
QLabel* m_patternLabel;
|
QLabel* m_patternLabel;
|
||||||
QLabel* m_typeLabel;
|
QLabel* m_typeLabel;
|
||||||
|
QLabel* m_condLabel;
|
||||||
QLabel* m_valueLabel;
|
QLabel* m_valueLabel;
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
QCheckBox* m_execCheck;
|
QCheckBox* m_execCheck;
|
||||||
QCheckBox* m_writeCheck;
|
QCheckBox* m_writeCheck;
|
||||||
|
QCheckBox* m_structOnlyCheck;
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
QPushButton* m_scanBtn;
|
QPushButton* m_scanBtn;
|
||||||
@@ -100,9 +112,11 @@ private:
|
|||||||
// Engine
|
// Engine
|
||||||
ScanEngine* m_engine;
|
ScanEngine* m_engine;
|
||||||
ProviderGetter m_providerGetter;
|
ProviderGetter m_providerGetter;
|
||||||
|
BoundsGetter m_boundsGetter;
|
||||||
QVector<ScanResult> m_results;
|
QVector<ScanResult> m_results;
|
||||||
int m_lastScanMode = 0; // 0=signature, 1=value
|
int m_lastScanMode = 0; // 0=signature, 1=value
|
||||||
ValueType m_lastValueType = ValueType::Int32;
|
ValueType m_lastValueType = ValueType::Int32;
|
||||||
|
ScanCondition m_lastCondition = ScanCondition::ExactValue;
|
||||||
QByteArray m_lastPattern; // serialized search value
|
QByteArray m_lastPattern; // serialized search value
|
||||||
int m_preRescanCount = 0; // result count before last rescan
|
int m_preRescanCount = 0; // result count before last rescan
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@
|
|||||||
"textDim": "#505C74",
|
"textDim": "#505C74",
|
||||||
"textMuted": "#384258",
|
"textMuted": "#384258",
|
||||||
"textFaint": "#2C3448",
|
"textFaint": "#2C3448",
|
||||||
"hover": "#121720",
|
"hover": "#181E2A",
|
||||||
"selected": "#121720",
|
"selected": "#1A2D4A",
|
||||||
"selection": "#1A2038",
|
"selection": "#1A2038",
|
||||||
"syntaxKeyword": "#5688C0",
|
"syntaxKeyword": "#5688C0",
|
||||||
"syntaxNumber": "#90B480",
|
"syntaxNumber": "#90B480",
|
||||||
|
|||||||
32
src/themes/defaults/tw.json
Normal file
32
src/themes/defaults/tw.json
Normal 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"
|
||||||
|
}
|
||||||
@@ -15,8 +15,8 @@
|
|||||||
"selection": "#21213A",
|
"selection": "#21213A",
|
||||||
"syntaxKeyword": "#AA9565",
|
"syntaxKeyword": "#AA9565",
|
||||||
"syntaxNumber": "#AAA98C",
|
"syntaxNumber": "#AAA98C",
|
||||||
"syntaxString": "#6B3B21",
|
"syntaxString": "#C0825A",
|
||||||
"syntaxComment": "#464646",
|
"syntaxComment": "#8A8878",
|
||||||
"syntaxPreproc": "#AA9565",
|
"syntaxPreproc": "#AA9565",
|
||||||
"syntaxType": "#6B959F",
|
"syntaxType": "#6B959F",
|
||||||
"indHoverSpan": "#AA9565",
|
"indHoverSpan": "#AA9565",
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
"indHeatCold": "#C4A44A",
|
"indHeatCold": "#C4A44A",
|
||||||
"indHeatWarm": "#AA9565",
|
"indHeatWarm": "#AA9565",
|
||||||
"indHeatHot": "#A05040",
|
"indHeatHot": "#A05040",
|
||||||
"indHintGreen": "#464646",
|
"indHintGreen": "#688A58",
|
||||||
"markerPtr": "#6B3B21",
|
"markerPtr": "#B85A42",
|
||||||
"markerCycle": "#AA9565",
|
"markerCycle": "#AA9565",
|
||||||
"markerError": "#3C2121"
|
"markerError": "#3C2121"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2627,6 +2627,122 @@ private slots:
|
|||||||
QVERIFY2(result.text.contains(QStringLiteral("\u2192")),
|
QVERIFY2(result.text.contains(QStringLiteral("\u2192")),
|
||||||
qPrintable("Expected arrow (\u2192) in text:\n" + result.text));
|
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(static_cast<uint>(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)
|
QTEST_MAIN(TestCompose)
|
||||||
|
|||||||
112
tests/test_dbgdump.cpp
Normal file
112
tests/test_dbgdump.cpp
Normal 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;
|
||||||
|
}
|
||||||
@@ -1072,6 +1072,120 @@ private slots:
|
|||||||
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
auto results = finSpy.first().first().value<QVector<ScanResult>>();
|
||||||
QCOMPARE(results.size(), 7); // 8 - 2 + 1 = 7 positions
|
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)
|
QTEST_MAIN(TestScanner)
|
||||||
|
|||||||
@@ -1103,6 +1103,89 @@ private slots:
|
|||||||
// Provider getter is lazy (captures at scan time)
|
// 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() {
|
void providerGetter_lazy() {
|
||||||
auto prov1 = std::make_shared<BufferProvider>(QByteArray(16, '\xAA'));
|
auto prov1 = std::make_shared<BufferProvider>(QByteArray(16, '\xAA'));
|
||||||
auto prov2 = std::make_shared<BufferProvider>(QByteArray(16, '\xBB'));
|
auto prov2 = std::make_shared<BufferProvider>(QByteArray(16, '\xBB'));
|
||||||
|
|||||||
432
tests/test_tooltip.cpp
Normal file
432
tests/test_tooltip.cpp
Normal 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"
|
||||||
292
tests/test_tooltip_event.cpp
Normal file
292
tests/test_tooltip_event.cpp
Normal 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
253
tests/test_tooltip_ui.cpp
Normal 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"
|
||||||
Reference in New Issue
Block a user