Compare commits

...

19 Commits

Author SHA1 Message Date
√(noham)²
b4727df3e9 Add macOS support for ProcessMemory plugin
Implement macOS-specific support for the ProcessMemory plugin and update plugin discovery/build.

- Add macOS build/install support: include plugins/ProcessMemory in top-level CMake and copy the built plugin into Reclass.app/Contents/PlugIns on macOS.
- Implement Apple-specific ProcessMemoryProvider: task_for_pid usage, mach_vm_read_overwrite/mach_vm_write, proc_pidpath/proc_regionfilename based module caching, region enumeration, symbol formatting, module enumeration, and proper cleanup (mach_port_deallocate).
- Extend plugin header to track m_task and adjust readability checks for macOS.
- Add macOS handling in getInitialBaseAddress and process enumeration to find base addresses and processes using proc APIs.
- Improve PluginManager to probe multiple plugin locations (including Contents/PlugIns inside macOS bundles), aggregate/log candidate counts, and continue scanning multiple dirs.
- Add macOS screenshot to README (docs/README_PIC6.png) and reference it in README.

These changes enable the ProcessMemory plugin to operate on macOS and make plugin discovery more robust on macOS app bundles.
2026-03-15 14:47:48 +01:00
IChooseYou
dc6963e0d5 feat: extract typeIndex from PDB symbols and add symbols.importType MCP tool
extractPdbSymbols() was reading S_GDATA32/S_GTHREAD32 records which
contain a typeIndex field linking the symbol to its type definition in
the TPI stream, but this field was discarded — only name + RVA were
kept. This meant loading symbols gave you address resolution but no
way to automatically import the type associated with a global variable.

Changes:
- PdbSymbol now carries typeIndex (0 = no type info / public symbol)
- extractPdbSymbols() captures typeIndex from all global data symbols
- PdbSymbolSet stores nameToTypeIndex mapping alongside nameToRva
- New importTypeForSymbol() follows LF_POINTER/LF_MODIFIER chains to
  find the underlying UDT/enum and imports it with full recursive children
- New symbols.importType MCP tool: given "ntdll!g_pShimEngineModule",
  resolves its typeIndex, imports the type definition from the PDB, and
  merges it into the active project
- loadPdbIntoStore() helper consolidates the extract+store pattern with
  type index support
2026-03-14 18:11:57 -06:00
IChooseYou
cb10bc8a82 docs: remove dedicated kernel driver section from README 2026-03-14 17:47:07 -06:00
IChooseYou
b5521bd638 docs: add kernel driver plugin to README
Document the KernelMemory plugin — capabilities, driver build
instructions, and architecture diagram.
2026-03-14 17:45:21 -06:00
IChooseYou
89d6e1944b fix: guard computeOffset against negative results before address arithmetic
computeOffset() returns int64_t but most callers added the result directly
to baseAddress (uint64_t) without checking for negative values. A malformed
tree with negative cumulative offsets would produce wrapped addresses,
potentially reading/writing arbitrary memory in the bitfield toggle and
edit paths. Added sign checks at all 9 unguarded call sites.
2026-03-14 17:31:13 -06:00
IChooseYou
7528d1bbbb Merge pull request #12 from 70RMUND/fix/linux-menubar-toolbuttons
fix: Linux menu bar horizontal layout via QToolButton fallback
2026-03-14 16:07:39 -06:00
Your Name
4f2288048e fix: Linux menu bar renders as horizontal tool buttons instead of collapsed extension popup
On Linux, QMenuBar inside a custom title bar widget (setMenuWidget) collapses
all items into the extension overflow popup. Replace with QToolButton widgets
on Linux that share the same QMenu objects. Includes hover-to-switch behavior
via event filter on open menus.

Windows and macOS paths are unchanged — guarded by #ifdef __linux__ and
runtime m_useToolButtons flag.
2026-03-14 15:51:31 -04:00
IChooseYou
97b6f55e1f fix: Linux menu popups and SVG icon rendering
- Guard FramelessWindowHint + WA_TranslucentBackground on QMenu to
  Windows only — breaks popup submenus on Linux/Wayland compositors
- Render SVG icons via QSvgRenderer to QPixmap explicitly, avoiding
  dependency on the qsvgicon image format plugin which may not be
  deployed on Linux
2026-03-14 12:41:11 -06:00
IChooseYou
6a30e0a402 fix: replace remaining QList::append({}) in plugins and tests
Missed plugin and test directories in the previous Qt 6.8 compat fix.
2026-03-14 12:11:08 -06:00
IChooseYou
1501a1542c feat: symbol double-click navigation, tree icons, and module.dll address parsing
- Double-click symbol in Symbols tab navigates to moduleBase + RVA
- Add symbol-method.svg icon for function symbols, symbol-structure.svg for modules
- Force-populate all modules on search so filter works without expanding first
- Parse module.dll/exe/sys as identifiers in address bar (e.g. client.dll + 0xFF)
2026-03-14 11:57:32 -06:00
IChooseYou
4f82b39785 fix: uint64_t to QVariant ambiguity on Qt 6.8 Linux 2026-03-14 11:52:45 -06:00
IChooseYou
009ddc951c fix: commit remaining uncommitted source changes for CI
Add extractPdbSymbols declaration to import_pdb.h,
enumerateModules to provider.h, and other pending changes
that were only local.
2026-03-14 09:36:49 -06:00
IChooseYou
5921af2b4f fix: replace QList::append({}) with push_back/emplaceBack for Qt 6.8
Qt 6.8's stricter QList rejects brace-enclosed initializer lists in
append(). Fixed 43 call sites across 13 files.
2026-03-14 09:21:14 -06:00
IChooseYou
5ded192990 fix: sync tab title on keyword convert, add new screenshots
- Update dock tab title when converting enum/class via workspace menu
- Add tooltip and source picker screenshots to README
2026-03-14 09:14:28 -06:00
IChooseYou
54bee5022b fix: add missing symbols dock declarations to mainwindow.h 2026-03-14 09:03:41 -06:00
IChooseYou
5d2d324946 fix: add missing symbol store and PDB debug info sources
These files were referenced in CMakeLists.txt and main.cpp but
never committed, breaking the CI build.
2026-03-14 08:13:58 -06:00
IChooseYou
5b2cf1ae1f feat: arrow tooltip improvements and base address cheat sheet
- Scale tooltip font to 90% of editor font
- Replace inline edit hint for base address with hover tooltip
- Two-column cheat sheet: syntax examples + explanations
- Dismiss all popups on alt-tab (ActivationChange)
2026-03-14 08:03:23 -06:00
IChooseYou
f1a36f2ad3 feat: custom arrow tooltip with transparent background
Rewrite RcxTooltip to use WA_TranslucentBackground with a single
contiguous QPainterPath (rounded rect + arrow notch). Pre-set the
DarkTitleBar property to prevent DarkApp from calling
DwmSetWindowAttribute which breaks layered window compositing.

Dismiss all popups (including arrow tooltip) on alt-tab via
MainWindow::changeEvent(ActivationChange).
2026-03-14 06:45:45 -06:00
IChooseYou
665138e688 fix: force all nodes collapsed on file load
Prevents 512-element arrays from expanding on load and triggering
thousands of memory reads. Root nodes still show children via
isRootHeader override.
2026-03-14 06:05:53 -06:00
54 changed files with 4937 additions and 1211 deletions

View File

@@ -147,6 +147,12 @@ add_executable(Reclass
src/mcp/mcp_bridge.cpp
src/addressparser.h
src/addressparser.cpp
src/symbolstore.h
src/symbolstore.cpp
src/symbol_downloader.h
src/symbol_downloader.cpp
src/imports/pe_debug_info.h
src/imports/pe_debug_info.cpp
src/disasm.h
src/disasm.cpp
third_party/fadec/decode.c
@@ -415,7 +421,7 @@ if(BUILD_TESTING)
if(BUILD_UI_TESTS)
add_executable(test_controller tests/test_controller.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp src/symbolstore.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
@@ -429,7 +435,7 @@ if(BUILD_TESTING)
add_test(NAME test_controller COMMAND test_controller)
add_executable(test_context_menu tests/test_context_menu.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp src/symbolstore.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
@@ -443,7 +449,7 @@ if(BUILD_TESTING)
add_test(NAME test_context_menu COMMAND test_context_menu)
add_executable(test_source_management tests/test_source_management.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp src/symbolstore.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
@@ -475,7 +481,7 @@ if(BUILD_TESTING)
add_test(NAME test_rendered_view COMMAND test_rendered_view)
add_executable(test_type_selector tests/test_type_selector.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp src/symbolstore.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
@@ -489,7 +495,7 @@ if(BUILD_TESTING)
add_test(NAME test_type_selector COMMAND test_type_selector)
add_executable(test_type_visibility tests/test_type_visibility.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp src/symbolstore.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
@@ -509,7 +515,7 @@ if(BUILD_TESTING)
add_test(NAME test_options_dialog COMMAND test_options_dialog)
add_executable(test_source_provider tests/test_source_provider.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp src/symbolstore.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS}
@@ -593,8 +599,8 @@ if(BUILD_TESTING)
endif() # BUILD_UI_TESTS
endif()
add_subdirectory(plugins/ProcessMemory)
if(NOT APPLE)
add_subdirectory(plugins/ProcessMemory)
add_subdirectory(plugins/RemoteProcessMemory)
endif()
if(WIN32)

View File

@@ -22,12 +22,18 @@ Built with C++17, Qt 6 (Qt 5 also supported), and QScintilla. The entire editor
## Screenshots
![Base address tooltip with expression cheat sheet](docs/README_PIC5.png)
![Data source picker with saved sources](docs/README_PIC4.png)
![Windows — VTable with value history popup](docs/README_PIC1.png)
![macOS — project tree with kernel struct inspection](docs/README_PIC2.png)
![Memory scanner](docs/README_PIC3.png)
![MacOS - Process Memory scanner](docs/README_PIC6.png)
## Features
### Editor
@@ -77,6 +83,7 @@ Full command stack with 15 undoable operations: ChangeKind, Rename, Collapse, In
- **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 (Windows/Linux)
- **Kernel driver** — Windows kernel driver (IOCTL) for process memory, physical memory, page table walking, and CR3/VTOP translation
- **Remote Process** — read another process's memory over TCP with cross-architecture 32/64-bit support
- **WinDbg** — connect to live WinDbg debugging sessions or load crash dumps
- **Saved sources** — quick-switch between recently used data sources per tab
@@ -90,6 +97,7 @@ DLL plugins loaded from a `Plugins` folder, auto or manual.
| Plugin | Description |
|--------|-------------|
| **Process memory** | Attach to local processes on Windows and Linux — PID-based, with symbol resolution and module/region enumeration |
| **Kernel memory** | Windows kernel driver (IOCTL) for reading/writing process and physical memory, CR3 queries, virtual-to-physical translation, and full 4-level page table walking — supports 4KB, 2MB, and 1GB pages |
| **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 |

BIN
docs/README_PIC4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

BIN
docs/README_PIC5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

BIN
docs/README_PIC6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

View File

@@ -222,7 +222,7 @@ QVector<rcx::Provider::ThreadInfo> KernelProcessProvider::tebs() const
auto* entries = reinterpret_cast<const RcxDrvTebEntry*>(outBuf.constData());
for (int i = 0; i < count; ++i)
result.append({entries[i].tebAddress, entries[i].threadId});
result.push_back(ThreadInfo{entries[i].tebAddress, entries[i].threadId});
#endif
return result;
}
@@ -253,7 +253,7 @@ void KernelProcessProvider::cacheModules()
if (i == 0)
m_base = entries[i].base;
m_modules.append({modName, entries[i].base, entries[i].size});
m_modules.push_back(ModuleInfo{modName, entries[i].base, entries[i].size});
}
#endif
}

View File

@@ -45,3 +45,12 @@ set_target_properties(ProcessMemoryPlugin PROPERTIES
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Plugins"
)
if(APPLE AND TARGET Reclass)
add_custom_command(TARGET ProcessMemoryPlugin POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:Reclass>/../PlugIns"
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"$<TARGET_FILE:ProcessMemoryPlugin>"
"$<TARGET_FILE_DIR:Reclass>/../PlugIns/"
COMMENT "Copying ProcessMemoryPlugin into Reclass.app/Contents/PlugIns")
endif()

View File

@@ -10,6 +10,7 @@
#include <QImage>
#include <QDir>
#include <QFileInfo>
#include <QMap>
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) && defined(_WIN32)
#include <QtWin>
#endif
@@ -83,6 +84,13 @@ typedef struct alignas(8) _THREAD_BASIC_INFORMATION {
#include <fstream>
#include <sstream>
#include <cstring>
#elif defined(__APPLE__)
#include <mach/mach.h>
#include <mach/mach_vm.h>
#include <libproc.h>
#include <sys/proc_info.h>
#include <unistd.h>
#include <cstring>
#endif
// ──────────────────────────────────────────────────────────────────────────
@@ -185,8 +193,14 @@ void ProcessMemoryProvider::cacheModules()
if ( i == 0 )
m_base = (uint64_t)mi.lpBaseOfDll;
m_modules.append({
WCHAR modPath[MAX_PATH];
QString fullPath;
if (GetModuleFileNameExW(m_handle, mods[i], modPath, MAX_PATH))
fullPath = QString::fromWCharArray(modPath);
m_modules.push_back(ModuleInfo{
QString::fromWCharArray(modName),
fullPath,
(uint64_t)mi.lpBaseOfDll,
(uint64_t)mi.SizeOfImage
});
@@ -194,6 +208,15 @@ void ProcessMemoryProvider::cacheModules()
}
}
QVector<rcx::Provider::ModuleEntry> ProcessMemoryProvider::enumerateModules() const
{
QVector<ModuleEntry> result;
result.reserve(m_modules.size());
for (const auto& m : m_modules)
result.push_back(ModuleEntry{m.name, m.fullPath, m.base, m.size});
return result;
}
QVector<rcx::MemoryRegion> ProcessMemoryProvider::enumerateRegions() const
{
QVector<rcx::MemoryRegion> regions;
@@ -400,8 +423,9 @@ void ProcessMemoryProvider::cacheModules()
for (auto it = moduleRanges.begin(); it != moduleRanges.end(); ++it)
{
QFileInfo fi(it.key());
m_modules.append({
m_modules.push_back(ModuleInfo{
fi.fileName(),
it.key(),
it->base,
it->end - it->base
});
@@ -460,8 +484,239 @@ QVector<rcx::MemoryRegion> ProcessMemoryProvider::enumerateRegions() const
return regions;
}
#elif defined(__APPLE__)
ProcessMemoryProvider::ProcessMemoryProvider(uint32_t pid, const QString& processName)
: m_task(0)
, m_pid(pid)
, m_processName(processName)
, m_writable(false)
, m_base(0)
{
mach_port_t task = MACH_PORT_NULL;
kern_return_t kr = task_for_pid(mach_task_self(), static_cast<int>(pid), &task);
if (kr != KERN_SUCCESS || task == MACH_PORT_NULL)
return;
m_task = static_cast<uint32_t>(task);
m_writable = true;
proc_bsdinfo bsdInfo{};
int infoLen = proc_pidinfo(static_cast<int>(pid), PROC_PIDTBSDINFO, 0, &bsdInfo, sizeof(bsdInfo));
if (infoLen == (int)sizeof(bsdInfo)) {
#ifdef PROC_FLAG_LP64
m_pointerSize = (bsdInfo.pbi_flags & PROC_FLAG_LP64) ? 8 : 4;
#else
m_pointerSize = 8;
#endif
}
cacheModules();
}
bool ProcessMemoryProvider::read(uint64_t addr, void* buf, int len) const
{
if (m_task == 0 || len <= 0)
return false;
mach_vm_size_t outSize = 0;
kern_return_t kr = mach_vm_read_overwrite(
static_cast<mach_port_name_t>(m_task),
static_cast<mach_vm_address_t>(addr),
static_cast<mach_vm_size_t>(len),
reinterpret_cast<mach_vm_address_t>(buf),
&outSize);
if ((int)outSize < len)
memset((char*)buf + outSize, 0, len - outSize);
return kr == KERN_SUCCESS && outSize > 0;
}
bool ProcessMemoryProvider::write(uint64_t addr, const void* buf, int len)
{
if (m_task == 0 || !m_writable || len <= 0)
return false;
kern_return_t kr = mach_vm_write(
static_cast<mach_port_name_t>(m_task),
static_cast<mach_vm_address_t>(addr),
reinterpret_cast<vm_offset_t>(const_cast<void*>(buf)),
static_cast<mach_msg_type_number_t>(len));
return kr == KERN_SUCCESS;
}
QString ProcessMemoryProvider::getSymbol(uint64_t addr) const
{
for (const auto& mod : m_modules)
{
if (addr >= mod.base && addr < mod.base + mod.size)
{
uint64_t offset = addr - mod.base;
return QStringLiteral("%1+0x%2")
.arg(mod.name)
.arg(offset, 0, 16, QChar('0'));
}
}
return {};
}
void ProcessMemoryProvider::cacheModules()
{
if (m_task == 0)
return;
m_modules.clear();
char mainPathBuf[PROC_PIDPATHINFO_MAXSIZE] = {};
QString mainPath;
if (proc_pidpath((int)m_pid, mainPathBuf, sizeof(mainPathBuf)) > 0)
mainPath = QString::fromUtf8(mainPathBuf);
struct Range { uint64_t base; uint64_t end; };
QMap<QString, Range> moduleRanges;
mach_vm_address_t addr = 0;
uint32_t depth = 0;
for (;;) {
mach_vm_size_t size = 0;
vm_region_submap_info_data_64_t info{};
mach_msg_type_number_t count = VM_REGION_SUBMAP_INFO_COUNT_64;
kern_return_t kr = mach_vm_region_recurse(
static_cast<mach_port_name_t>(m_task),
&addr,
&size,
&depth,
reinterpret_cast<vm_region_recurse_info_t>(&info),
&count);
if (kr != KERN_SUCCESS)
break;
if (info.is_submap) {
++depth;
continue;
}
if (size == 0) {
++addr;
continue;
}
char pathBuf[PROC_PIDPATHINFO_MAXSIZE] = {};
int pathLen = proc_regionfilename((int)m_pid, (uint64_t)addr, pathBuf, sizeof(pathBuf));
if (pathLen > 0) {
QString fullPath = QString::fromUtf8(pathBuf);
uint64_t regionBase = (uint64_t)addr;
uint64_t regionEnd = regionBase + (uint64_t)size;
auto it = moduleRanges.find(fullPath);
if (it == moduleRanges.end()) {
moduleRanges.insert(fullPath, {regionBase, regionEnd});
} else {
if (regionBase < it->base) it->base = regionBase;
if (regionEnd > it->end) it->end = regionEnd;
}
if (m_base == 0 && !mainPath.isEmpty() && fullPath == mainPath && (info.protection & VM_PROT_EXECUTE))
m_base = regionBase;
}
uint64_t next = (uint64_t)addr + (uint64_t)size;
if (next <= (uint64_t)addr)
break;
addr = (mach_vm_address_t)next;
}
m_modules.reserve(moduleRanges.size());
for (auto it = moduleRanges.begin(); it != moduleRanges.end(); ++it)
{
QFileInfo fi(it.key());
m_modules.push_back(ModuleInfo{
fi.fileName(),
it.key(),
it->base,
it->end - it->base
});
}
if (m_base == 0 && !m_modules.isEmpty())
m_base = m_modules.front().base;
}
QVector<rcx::MemoryRegion> ProcessMemoryProvider::enumerateRegions() const
{
QVector<rcx::MemoryRegion> regions;
if (m_task == 0)
return regions;
mach_vm_address_t addr = 0;
uint32_t depth = 0;
for (;;) {
mach_vm_size_t size = 0;
vm_region_submap_info_data_64_t info{};
mach_msg_type_number_t count = VM_REGION_SUBMAP_INFO_COUNT_64;
kern_return_t kr = mach_vm_region_recurse(
static_cast<mach_port_name_t>(m_task),
&addr,
&size,
&depth,
reinterpret_cast<vm_region_recurse_info_t>(&info),
&count);
if (kr != KERN_SUCCESS)
break;
if (info.is_submap) {
++depth;
continue;
}
if (size == 0) {
++addr;
continue;
}
bool readable = (info.protection & VM_PROT_READ) != 0;
if (readable)
{
rcx::MemoryRegion region;
region.base = (uint64_t)addr;
region.size = (uint64_t)size;
region.readable = readable;
region.writable = (info.protection & VM_PROT_WRITE) != 0;
region.executable = (info.protection & VM_PROT_EXECUTE) != 0;
char pathBuf[PROC_PIDPATHINFO_MAXSIZE] = {};
int pathLen = proc_regionfilename((int)m_pid, region.base, pathBuf, sizeof(pathBuf));
if (pathLen > 0) {
QFileInfo fi(QString::fromUtf8(pathBuf));
region.moduleName = fi.fileName();
}
regions.append(region);
}
uint64_t next = (uint64_t)addr + (uint64_t)size;
if (next <= (uint64_t)addr)
break;
addr = (mach_vm_address_t)next;
}
return regions;
}
#endif // platform
#ifndef _WIN32
QVector<rcx::Provider::ModuleEntry> ProcessMemoryProvider::enumerateModules() const
{
QVector<ModuleEntry> result;
result.reserve(m_modules.size());
for (const auto& m : m_modules)
result.push_back(ModuleEntry{m.name, m.fullPath, m.base, m.size});
return result;
}
#endif
uint64_t ProcessMemoryProvider::symbolToAddress(const QString& name) const
{
for (const auto& mod : m_modules) {
@@ -479,6 +734,9 @@ ProcessMemoryProvider::~ProcessMemoryProvider()
#elif defined(__linux__)
if (m_fd >= 0)
::close(m_fd);
#elif defined(__APPLE__)
if (m_task != 0)
mach_port_deallocate(mach_task_self(), static_cast<mach_port_name_t>(m_task));
#endif
}
@@ -488,6 +746,8 @@ int ProcessMemoryProvider::size() const
return m_handle ? 0x10000 : 0;
#elif defined(__linux__)
return (m_fd >= 0) ? 0x10000 : 0;
#elif defined(__APPLE__)
return (m_task != 0) ? 0x10000 : 0;
#endif
}
@@ -530,7 +790,7 @@ QVector<rcx::Provider::ThreadInfo> ProcessMemoryProvider::tebs() const
ULONG tbiLen = 0;
NTSTATUS qitSt = pNtQIT(hThread, 0, &tbi, sizeof(tbi), &tbiLen);
if (qitSt >= 0 && tbi.TebBaseAddress)
result.append({(uint64_t)(uintptr_t)tbi.TebBaseAddress, tid});
result.push_back(ThreadInfo{(uint64_t)(uintptr_t)tbi.TebBaseAddress, tid});
CloseHandle(hThread);
}
break;
@@ -638,6 +898,68 @@ uint64_t ProcessMemoryPlugin::getInitialBaseAddress(const QString& target) const
}
}
return 0;
#elif defined(__APPLE__)
QStringList parts = target.split(':');
bool ok = false;
uint32_t pid = parts[0].toUInt(&ok);
if (!ok || pid == 0)
return 0;
mach_port_t task = MACH_PORT_NULL;
kern_return_t tkr = task_for_pid(mach_task_self(), static_cast<int>(pid), &task);
if (tkr != KERN_SUCCESS || task == MACH_PORT_NULL)
return 0;
char mainPathBuf[PROC_PIDPATHINFO_MAXSIZE] = {};
QString mainPath;
if (proc_pidpath((int)pid, mainPathBuf, sizeof(mainPathBuf)) > 0)
mainPath = QString::fromUtf8(mainPathBuf);
uint64_t base = 0;
mach_vm_address_t addr = 0;
uint32_t depth = 0;
for (;;) {
mach_vm_size_t size = 0;
vm_region_submap_info_data_64_t info{};
mach_msg_type_number_t count = VM_REGION_SUBMAP_INFO_COUNT_64;
kern_return_t kr = mach_vm_region_recurse(task, &addr, &size, &depth,
reinterpret_cast<vm_region_recurse_info_t>(&info),
&count);
if (kr != KERN_SUCCESS)
break;
if (info.is_submap) {
++depth;
continue;
}
if (size == 0) {
++addr;
continue;
}
if ((info.protection & VM_PROT_EXECUTE) != 0) {
if (!mainPath.isEmpty()) {
char pathBuf[PROC_PIDPATHINFO_MAXSIZE] = {};
int pathLen = proc_regionfilename((int)pid, (uint64_t)addr, pathBuf, sizeof(pathBuf));
if (pathLen > 0 && QString::fromUtf8(pathBuf) == mainPath) {
base = (uint64_t)addr;
break;
}
} else {
base = (uint64_t)addr;
break;
}
}
uint64_t next = (uint64_t)addr + (uint64_t)size;
if (next <= (uint64_t)addr)
break;
addr = (mach_vm_address_t)next;
}
mach_port_deallocate(mach_task_self(), task);
return base;
#else
Q_UNUSED(target);
return 0;
@@ -781,6 +1103,61 @@ QVector<PluginProcessInfo> ProcessMemoryPlugin::enumerateProcesses()
::close(exeFd);
}
processes.append(info);
}
#elif defined(__APPLE__)
QIcon defaultIcon = qApp->style()->standardIcon(QStyle::SP_ComputerIcon);
int bytes = proc_listpids(PROC_ALL_PIDS, 0, nullptr, 0);
if (bytes <= 0)
return processes;
int count = bytes / (int)sizeof(pid_t);
QVector<pid_t> pids(count);
bytes = proc_listpids(PROC_ALL_PIDS, 0, pids.data(), count * (int)sizeof(pid_t));
if (bytes <= 0)
return processes;
count = bytes / (int)sizeof(pid_t);
for (int i = 0; i < count; ++i) {
pid_t pid = pids[i];
if (pid <= 0)
continue;
mach_port_t task = MACH_PORT_NULL;
if (task_for_pid(mach_task_self(), pid, &task) != KERN_SUCCESS || task == MACH_PORT_NULL)
continue;
mach_port_deallocate(mach_task_self(), task);
char nameBuf[PROC_PIDPATHINFO_MAXSIZE] = {};
int nameLen = proc_name(pid, nameBuf, sizeof(nameBuf));
QString procName;
if (nameLen > 0)
procName = QString::fromUtf8(nameBuf);
if (procName.isEmpty())
continue;
char pathBuf[PROC_PIDPATHINFO_MAXSIZE] = {};
QString procPath;
if (proc_pidpath(pid, pathBuf, sizeof(pathBuf)) > 0)
procPath = QString::fromUtf8(pathBuf);
PluginProcessInfo info;
info.pid = static_cast<uint32_t>(pid);
info.name = procName;
info.path = procPath;
info.icon = defaultIcon;
proc_bsdinfo bsdInfo{};
int infoLen = proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &bsdInfo, sizeof(bsdInfo));
if (infoLen == (int)sizeof(bsdInfo)) {
#ifdef PROC_FLAG_LP64
info.is32Bit = (bsdInfo.pbi_flags & PROC_FLAG_LP64) == 0;
#else
info.is32Bit = false;
#endif
}
processes.append(info);
}
#endif

View File

@@ -35,6 +35,8 @@ public:
return m_handle && len >= 0;
#elif defined(__linux__)
return m_fd >= 0 && len >= 0;
#elif defined(__APPLE__)
return m_task != 0 && len >= 0;
#endif
}
@@ -43,6 +45,7 @@ public:
void refreshModules() { m_modules.clear(); cacheModules(); }
uint64_t peb() const override { return m_peb; }
QVector<ThreadInfo> tebs() const override;
QVector<ModuleEntry> enumerateModules() const override;
private:
void cacheModules();
@@ -52,6 +55,8 @@ private:
void* m_handle;
#elif defined(__linux__)
int m_fd;
#elif defined(__APPLE__)
uint32_t m_task;
#endif
uint32_t m_pid;
QString m_processName;
@@ -62,6 +67,7 @@ private:
struct ModuleInfo {
QString name;
QString fullPath;
uint64_t base;
uint64_t size;
};

View File

@@ -244,7 +244,7 @@ struct IpcClient {
reinterpret_cast<const char*>(data + entry->nameOffset),
(int)entry->nameLength);
#endif
result.append({modName, entry->base, entry->size});
result.push_back(RemoteProcessProvider::ModuleInfo{modName, entry->base, entry->size});
}
return result;
}

View File

@@ -31,6 +31,7 @@ namespace rcx {
//
// All numeric literals are hexadecimal (base 16).
// Identifiers: [a-zA-Z_][a-zA-Z0-9_]* containing at least one non-hex char.
// Module names with extensions (e.g. "client.dll") are scanned as one token.
// Pure hex-digit words (e.g. "DEAD") are treated as hex literals.
class ExpressionParser {
@@ -273,17 +274,46 @@ private:
// Identifier or hex literal disambiguation.
// Scan [a-zA-Z_][a-zA-Z0-9_]*. If it contains any non-hex char → identifier.
// Otherwise → backtrack and parse as hex number.
// WinDbg-style "module!symbol" is scanned as a single identifier token.
// If the identifier is followed by '(', try to parse as a built-in function call.
bool parseIdentifierOrHex(uint64_t& result) {
int start = m_pos;
bool hasNonHex = false;
// Scan full token
// Scan full token, including "module!symbol" as one token
while (!atEnd() && isIdentChar(peek())) {
if (!isHexDigit(peek()))
hasNonHex = true;
advance();
}
// Handle module.dll / module.exe / module.sys extensions
// e.g. "client.dll + 0xFF" should parse "client.dll" as one token
if (!atEnd() && peek() == '.' && m_pos > start) {
int dotPos = m_pos;
advance(); // skip '.'
int extStart = m_pos;
while (!atEnd() && isIdentChar(peek()))
advance();
if (m_pos > extStart) {
hasNonHex = true; // '.' makes it definitively an identifier
} else {
m_pos = dotPos; // backtrack — '.' at end isn't an extension
}
}
// If we hit '!' and the next char is an identifier start, extend the token
// to include the second part (WinDbg module!symbol syntax)
if (!atEnd() && peek() == '!' && m_pos > start) {
int bangPos = m_pos;
advance(); // skip '!'
if (!atEnd() && isIdentStart(peek())) {
hasNonHex = true;
while (!atEnd() && isIdentChar(peek())) {
advance();
}
} else {
m_pos = bangPos; // backtrack — '!' at end isn't module!symbol
}
}
QString token = m_input.mid(start, m_pos - start);

View File

@@ -71,6 +71,7 @@ struct ComposeState {
bool treeLines = false; // draw Unicode tree connectors in indentation
bool braceWrap = false; // opening brace on its own line
bool typeHints = false; // show type inference hints on hex nodes
SymbolLookupFn symbolLookup; // optional PDB symbol lookup callback
QVector<bool> siblingStack; // per-depth: true = more siblings follow at this level
uint64_t currentPtrBase = 0; // absolute addr of current pointer expansion target
@@ -262,7 +263,7 @@ void composeLeaf(ComposeState& state, const NodeTree& tree,
auto suggestions = inferTypes(
reinterpret_cast<const uint8_t*>(b.constData()), sz);
if (!suggestions.isEmpty() && suggestions[0].strength >= 3) {
lm.typeHintStart = lineText.size() + 2; // after " " gap
lm.typeHintStart = kFoldCol + lineText.size() + 2; // after fold prefix + " " gap
lm.typeHintKinds = suggestions[0].kinds;
QString typeName = formatHint(suggestions[0]);
QString preview = formatPreview(
@@ -276,6 +277,13 @@ void composeLeaf(ComposeState& state, const NodeTree& tree,
}
}
// PDB symbol annotation: show symbol name if this address matches a loaded symbol
if (sub == 0 && state.symbolLookup) {
QString sym = state.symbolLookup(absAddr);
if (!sym.isEmpty())
lineText += QStringLiteral(" // ") + sym;
}
state.emitLine(lineText, std::move(lm));
}
}
@@ -1087,12 +1095,13 @@ void composeNode(ComposeState& state, const NodeTree& tree,
ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId,
bool compactColumns, bool treeLines, bool braceWrap,
bool typeHints) {
bool typeHints, SymbolLookupFn symbolLookup) {
ComposeState state;
state.compactColumns = compactColumns;
state.treeLines = treeLines;
state.braceWrap = braceWrap;
state.typeHints = typeHints;
state.symbolLookup = std::move(symbolLookup);
// Precompute parent→children map
for (int i = 0; i < tree.nodes.size(); i++)

View File

@@ -1,5 +1,6 @@
#include "controller.h"
#include "addressparser.h"
#include "symbolstore.h"
#include "typeselectorpopup.h"
#include "providerregistry.h"
#include "themes/thememanager.h"
@@ -74,8 +75,10 @@ RcxDocument::RcxDocument(QObject* parent)
}
ComposeResult RcxDocument::compose(uint64_t viewRootId, bool compactColumns,
bool treeLines, bool braceWrap, bool typeHints) const {
return rcx::compose(tree, *provider, viewRootId, compactColumns, treeLines, braceWrap, typeHints);
bool treeLines, bool braceWrap, bool typeHints,
SymbolLookupFn symbolLookup) const {
return rcx::compose(tree, *provider, viewRootId, compactColumns, treeLines, braceWrap, typeHints,
std::move(symbolLookup));
}
bool RcxDocument::save(const QString& path) {
@@ -269,6 +272,10 @@ void RcxController::connectEditor(RcxEditor* editor) {
// Footer "Trim" button — remove trailing hex nodes from end of struct
connect(editor, &RcxEditor::trimHexRequested,
this, [this](uint64_t structId) {
// Unions don't have trailing padding — all members overlap at offset 0
int si = m_doc->tree.indexOfId(structId);
if (si >= 0 && m_doc->tree.nodes[si].classKeyword == QStringLiteral("union"))
return;
QVector<int> children = m_doc->tree.childrenOf(structId);
if (children.isEmpty()) return;
@@ -304,7 +311,7 @@ void RcxController::connectEditor(RcxEditor* editor) {
int64_t nextVal = members.isEmpty() ? 0 : members.last().second + 1;
auto oldMembers = members;
for (int i = 0; i < count; i++)
members.append({QStringLiteral("Member%1").arg(nextVal + i), nextVal + i});
members.emplaceBack(QStringLiteral("Member%1").arg(nextVal + i), nextVal + i);
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeEnumMembers{enumId, oldMembers, members}));
});
@@ -442,6 +449,9 @@ void RcxController::connectEditor(RcxEditor* editor) {
*ok = prov->read(addr, &val, ptrSz);
return val;
};
cbs.resolveIdentifier = [prov](const QString& name, bool* ok) -> uint64_t {
return SymbolStore::instance().resolve(name, prov, ok);
};
// Wire kernel paging callbacks if provider supports it
if (prov->hasKernelPaging()) {
cbs.vtop = [prov](uint32_t pid, uint64_t va, bool* ok) -> uint64_t {
@@ -467,9 +477,9 @@ void RcxController::connectEditor(RcxEditor* editor) {
if (result.ok && result.value != m_doc->tree.baseAddress) {
uint64_t oldBase = m_doc->tree.baseAddress;
QString oldFormula = m_doc->tree.baseAddressFormula;
// Store formula if input uses module/deref/kernel-function syntax
// Store formula if input uses module/deref/kernel-function/symbol syntax
static const QRegularExpression formulaRx(
QStringLiteral("[<\\[]|\\b(?:vtop|cr3|phys)\\s*\\("));
QStringLiteral("[<\\[]|\\b(?:vtop|cr3|phys)\\s*\\(|\\w+!\\w+"));
QString newFormula = formulaRx.match(s).hasMatch() ? s : QString();
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeBase{oldBase, result.value, oldFormula, newFormula}));
@@ -631,11 +641,20 @@ void RcxController::refresh() {
// Bracket compose with thread-local doc pointer for type name resolution
s_composeDoc = m_doc;
// Build symbol lookup callback if PDB symbols are loaded
SymbolLookupFn symLookup;
if (SymbolStore::instance().hasSymbols() && m_doc->provider) {
auto* prov = m_doc->provider.get();
symLookup = [prov](uint64_t addr) -> QString {
return SymbolStore::instance().getSymbolForAddress(addr, prov);
};
}
// Compose against snapshot provider if active, otherwise real provider
if (m_snapshotProv)
m_lastResult = rcx::compose(m_doc->tree, *m_snapshotProv, m_viewRootId, m_compactColumns, m_treeLines, m_braceWrap, m_typeHints);
m_lastResult = rcx::compose(m_doc->tree, *m_snapshotProv, m_viewRootId, m_compactColumns, m_treeLines, m_braceWrap, m_typeHints, symLookup);
else
m_lastResult = m_doc->compose(m_viewRootId, m_compactColumns, m_treeLines, m_braceWrap, m_typeHints);
m_lastResult = m_doc->compose(m_viewRootId, m_compactColumns, m_treeLines, m_braceWrap, m_typeHints, symLookup);
s_composeDoc = nullptr;
@@ -644,6 +663,7 @@ void RcxController::refresh() {
for (auto& lm : m_lastResult.meta) {
if (lm.nodeIdx < 0 || lm.nodeIdx >= m_doc->tree.nodes.size()) continue;
int64_t offset = m_doc->tree.computeOffset(lm.nodeIdx);
if (offset < 0) continue;
const Node& node = m_doc->tree.nodes[lm.nodeIdx];
if (isHexPreview(node.kind)) {
@@ -836,7 +856,7 @@ void RcxController::changeNodeKind(int nodeIdx, NodeKind newKind) {
if (si == nodeIdx) continue;
auto& sib = m_doc->tree.nodes[si];
if (sib.offset >= oldEnd)
adjs.append({sib.id, sib.offset, sib.offset + delta});
adjs.push_back(cmd::OffsetAdj{sib.id, sib.offset, sib.offset + delta});
}
}
bool needsRename = isHexNode(node.kind) && !isHexNode(newKind);
@@ -910,7 +930,7 @@ void RcxController::insertNodeAbove(int beforeIdx, NodeKind kind, const QString&
for (int si : siblings) {
auto& sib = m_doc->tree.nodes[si];
if (sib.offset >= before.offset)
adjs.append({sib.id, sib.offset, sib.offset + insertSize});
adjs.push_back(cmd::OffsetAdj{sib.id, sib.offset, sib.offset + insertSize});
}
m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{n, adjs}));
@@ -935,7 +955,7 @@ void RcxController::removeNode(int nodeIdx) {
if (si == nodeIdx) continue;
auto& sib = m_doc->tree.nodes[si];
if (sib.offset >= deletedEnd) {
adjs.append({sib.id, sib.offset, sib.offset - deletedSize});
adjs.push_back(cmd::OffsetAdj{sib.id, sib.offset, sib.offset - deletedSize});
}
}
}
@@ -1442,7 +1462,7 @@ void RcxController::duplicateNode(int nodeIdx) {
if (si == nodeIdx) continue;
auto& sib = m_doc->tree.nodes[si];
if (sib.offset >= copyOffset)
adjs.append({sib.id, sib.offset, sib.offset + copySize});
adjs.push_back(cmd::OffsetAdj{sib.id, sib.offset, sib.offset + copySize});
}
}
@@ -1588,7 +1608,9 @@ void RcxController::toggleBitfieldBit(uint64_t nodeId, int memberIdx) {
if (!m_doc->provider || !m_doc->provider->isWritable()) return;
const auto& bm = node.bitfieldMembers[memberIdx];
uint64_t addr = m_doc->tree.baseAddress + m_doc->tree.computeOffset(ni);
int64_t signedOff = m_doc->tree.computeOffset(ni);
if (signedOff < 0) return;
uint64_t addr = m_doc->tree.baseAddress + static_cast<uint64_t>(signedOff);
int containerSize = sizeForKind(node.elementKind);
if (containerSize <= 0) containerSize = 4;
@@ -1616,7 +1638,9 @@ void RcxController::editBitfieldValue(uint64_t nodeId, int memberIdx) {
if (!m_doc->provider || !m_doc->provider->isWritable()) return;
const auto& bm = node.bitfieldMembers[memberIdx];
uint64_t addr = m_doc->tree.baseAddress + m_doc->tree.computeOffset(ni);
int64_t signedOff = m_doc->tree.computeOffset(ni);
if (signedOff < 0) return;
uint64_t addr = m_doc->tree.baseAddress + static_cast<uint64_t>(signedOff);
int containerSize = sizeForKind(node.elementKind);
if (containerSize <= 0) containerSize = 4;
@@ -1762,14 +1786,24 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
// ── Insert ► submenu ──
{
// Find earliest selected node (lowest offset) for insert-above
int firstIdx = -1;
int lowestOff = INT_MAX;
for (uint64_t id : ids) {
int idx = m_doc->tree.indexOfId(id);
if (idx >= 0 && m_doc->tree.nodes[idx].offset < lowestOff) {
lowestOff = m_doc->tree.nodes[idx].offset;
firstIdx = idx;
}
}
auto* insertMenu = menu.addMenu(icon("diff-added.svg"), "Insert");
insertMenu->addAction("Insert 4", [this]() {
uint64_t target = m_viewRootId ? m_viewRootId : 0;
insertNode(target, -1, NodeKind::Hex32, QStringLiteral("field"));
insertMenu->addAction("Insert 4 Above", [this, firstIdx]() {
if (firstIdx >= 0)
insertNodeAbove(firstIdx, NodeKind::Hex32, QStringLiteral("field"));
});
insertMenu->addAction("Insert 8", [this]() {
uint64_t target = m_viewRootId ? m_viewRootId : 0;
insertNode(target, -1, NodeKind::Hex64, QStringLiteral("field"));
insertMenu->addAction("Insert 8 Above", [this, firstIdx]() {
if (firstIdx >= 0)
insertNodeAbove(firstIdx, NodeKind::Hex64, QStringLiteral("field"));
});
}
@@ -1835,7 +1869,9 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
for (uint64_t id : ids) {
int ni = m_doc->tree.indexOfId(id);
if (ni < 0) continue;
uint64_t addr = m_doc->tree.baseAddress + m_doc->tree.computeOffset(ni);
int64_t off = m_doc->tree.computeOffset(ni);
if (off < 0) continue;
uint64_t addr = m_doc->tree.baseAddress + static_cast<uint64_t>(off);
addrs << QStringLiteral("0x") + QString::number(addr, 16).toUpper();
}
QApplication::clipboard()->setText(addrs.join('\n'));
@@ -1919,7 +1955,7 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
auto members = m_doc->tree.nodes[ni].enumMembers;
int64_t nextVal = members.isEmpty() ? 0 : members.last().second + 1;
auto oldMembers = members;
members.append({QStringLiteral("NewMember"), nextVal});
members.emplaceBack(QStringLiteral("NewMember"), nextVal);
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeEnumMembers{nodeId, oldMembers, members}));
});
@@ -2429,7 +2465,9 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
copyMenu->addAction(icon("link.svg"), "Copy &Address", [this, copyNodeId]() {
int ni = m_doc->tree.indexOfId(copyNodeId);
if (ni < 0) return;
uint64_t addr = m_doc->tree.baseAddress + m_doc->tree.computeOffset(ni);
int64_t off = m_doc->tree.computeOffset(ni);
if (off < 0) return;
uint64_t addr = m_doc->tree.baseAddress + static_cast<uint64_t>(off);
QApplication::clipboard()->setText(
QStringLiteral("0x") + QString::number(addr, 16).toUpper());
});
@@ -2470,8 +2508,9 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
// Show Physical Address — translate the node's VA to physical
if (hasNode) {
uint64_t nodeAddr = m_doc->tree.baseAddress
+ m_doc->tree.computeOffset(nodeIdx);
int64_t nodeOff = m_doc->tree.computeOffset(nodeIdx);
uint64_t nodeAddr = (nodeOff >= 0)
? m_doc->tree.baseAddress + static_cast<uint64_t>(nodeOff) : 0;
kernelMenu->addAction("Show Physical Address", [this, nodeAddr, &menu]() {
auto result = m_doc->provider->translateAddress(nodeAddr);
if (result.valid) {
@@ -2528,8 +2567,10 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
if (bf.name == QStringLiteral("PhysAddr")) {
int bitOff = bf.bitOffset;
int bitWid = bf.bitWidth;
int64_t nodeOff = m_doc->tree.computeOffset(nodeIdx);
if (nodeOff < 0) break;
uint64_t nodeAddr = m_doc->tree.baseAddress
+ m_doc->tree.computeOffset(nodeIdx);
+ static_cast<uint64_t>(nodeOff);
kernelMenu->addAction("Follow Physical Frame",
[this, nodeAddr, bitOff, bitWid]() {
uint64_t pteValue = 0;
@@ -3328,6 +3369,9 @@ void RcxController::attachViaPlugin(const QString& providerIdentifier, const QSt
*ok = prov->read(addr, &val, ptrSz);
return val;
};
cbs.resolveIdentifier = [prov](const QString& name, bool* ok) -> uint64_t {
return SymbolStore::instance().resolve(name, prov, ok);
};
// Wire kernel paging callbacks if provider supports it
if (prov->hasKernelPaging()) {
cbs.vtop = [prov](uint32_t pid, uint64_t va, bool* ok) -> uint64_t {
@@ -3470,6 +3514,9 @@ void RcxController::selectSource(const QString& text) {
*ok = prov->read(addr, &val, ptrSz);
return val;
};
cbs.resolveIdentifier = [prov](const QString& name, bool* ok) -> uint64_t {
return SymbolStore::instance().resolve(name, prov, ok);
};
// Wire kernel paging callbacks if provider supports it
if (prov->hasKernelPaging()) {
cbs.vtop = [prov](uint32_t pid, uint64_t va, bool* ok) -> uint64_t {
@@ -3616,7 +3663,7 @@ void RcxController::collectPointerRanges(
int span = m_doc->tree.structSpan(structId);
if (span <= 0) return;
ranges.append({memBase, span});
ranges.emplaceBack(memBase, span);
if (!m_snapshotProv) return;
@@ -3664,7 +3711,7 @@ void RcxController::onRefreshTick() {
// Collect all needed ranges: main struct + pointer targets (absolute addresses)
QVector<QPair<uint64_t,int>> ranges;
ranges.append({m_doc->tree.baseAddress, extent});
ranges.emplaceBack(m_doc->tree.baseAddress, extent);
if (m_snapshotProv) {
QSet<QPair<uint64_t,uint64_t>> visited;
@@ -3767,6 +3814,7 @@ int RcxController::computeDataExtent() const {
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
const Node& node = m_doc->tree.nodes[i];
int64_t off = m_doc->tree.computeOffset(i);
if (off < 0) continue;
int sz = (node.kind == NodeKind::Struct || node.kind == NodeKind::Array)
? m_doc->tree.structSpan(node.id) : node.byteSize();
int64_t end = off + sz;

View File

@@ -42,7 +42,8 @@ public:
ComposeResult compose(uint64_t viewRootId = 0, bool compactColumns = false,
bool treeLines = false, bool braceWrap = false,
bool typeHints = false) const;
bool typeHints = false,
SymbolLookupFn symbolLookup = {}) const;
bool save(const QString& path);
bool load(const QString& path);
void loadData(const QString& binaryPath);

View File

@@ -289,7 +289,7 @@ struct Node {
n.isRelative = o["isRelative"].toBool(false);
n.arrayLen = qBound(1, o["arrayLen"].toInt(1), 1000000);
n.strLen = qBound(1, o["strLen"].toInt(64), 1000000);
n.collapsed = o["collapsed"].toBool(true);
n.collapsed = true; // Always load collapsed; user expands as needed
n.refId = o["refId"].toString("0").toULongLong();
n.elementKind = kindFromString(o["elementKind"].toString("UInt8"));
n.ptrDepth = qBound(0, o["ptrDepth"].toInt(0), 2);
@@ -297,8 +297,8 @@ struct Node {
QJsonArray arr = o["enumMembers"].toArray();
for (const auto& v : arr) {
QJsonObject em = v.toObject();
n.enumMembers.append({em["name"].toString(),
em["value"].toString("0").toLongLong()});
n.enumMembers.emplaceBack(em["name"].toString(),
em["value"].toString("0").toLongLong());
}
}
if (o.contains("bitfieldMembers")) {
@@ -1043,8 +1043,13 @@ namespace fmt {
// ── Compose function forward declaration ──
// Optional callback: given an absolute address, return a symbol name (e.g. "nt!PsActiveProcessHead")
// or empty string if no symbol matches. Used for PDB symbol annotations on rows.
using SymbolLookupFn = std::function<QString(uint64_t addr)>;
ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId = 0,
bool compactColumns = false, bool treeLines = false,
bool braceWrap = false, bool typeHints = false);
bool braceWrap = false, bool typeHints = false,
SymbolLookupFn symbolLookup = {});
} // namespace rcx

View File

@@ -1,6 +1,7 @@
#include "editor.h"
#include "disasm.h"
#include "providerregistry.h"
#include "rcxtooltip.h"
#include <QDebug>
#include <Qsci/qsciscintilla.h>
#include <Qsci/qsciscintillabase.h>
@@ -1397,6 +1398,7 @@ void RcxEditor::dismissAllPopups() {
if (m_historyPopup) static_cast<HoverPopup*>(m_historyPopup)->dismiss();
if (m_disasmPopup) static_cast<HoverPopup*>(m_disasmPopup)->dismiss();
if (m_structPreviewPopup) static_cast<HoverPopup*>(m_structPreviewPopup)->dismiss();
if (m_arrowTooltip) static_cast<RcxTooltip*>(m_arrowTooltip)->dismiss();
}
void RcxEditor::hideFindBar() {
@@ -2879,8 +2881,9 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
setEditComment(QStringLiteral("Enter=Save Esc=Cancel"));
} else if (target == EditTarget::Name && m_editState.hexOverwrite) {
setEditComment(QStringLiteral("ASCII edit: Enter=Save Esc=Cancel"));
} else if (target == EditTarget::BaseAddress)
setEditComment(QStringLiteral("e.g. <mod.exe> + 0xFF | [0x1000 + 0x10] | 7ff6`1234ABCD"));
} else if (target == EditTarget::BaseAddress) {
// No inline hint — the hover tooltip already shows examples
}
// Note: Type, ArrayElementType, PointerTarget are handled by TypeSelectorPopup
// and exit early above (never reach here).
@@ -3764,6 +3767,82 @@ void RcxEditor::applyHoverCursor() {
// else: desired stays Arrow (hovering over column padding)
}
// ── Arrow tooltip on command row spans ──
{
bool showTip = false;
if (tokenHit && h.line == 0 && h.line < m_meta.size()
&& m_meta[0].lineKind == LineKind::CommandRow) {
NormalizedSpan span;
QString lineText;
if (resolvedSpanFor(0, t, span, &lineText)
&& h.col >= span.start && h.col < span.end) {
QString tipTitle, tipBody;
switch (t) {
case EditTarget::Source:
tipTitle = QStringLiteral("Data Source");
tipBody = QStringLiteral("Click to change the attached\nmemory source (process, file)");
break;
case EditTarget::BaseAddress:
tipTitle = QStringLiteral("Base Address");
tipBody = QStringLiteral(
"0x7FF61234ABCD hex address\n"
"<app.exe> module base\n"
"<app.exe> + 0x1A0 module + offset\n"
"[<app.exe> + 0x58] follow pointer\n"
"ntdll!SymbolName PDB symbol\n"
"\n"
"Operators: + - * << >> & | ^\n"
"All numbers are hexadecimal");
break;
case EditTarget::RootClassName:
tipTitle = QStringLiteral("Class Name");
tipBody = QStringLiteral("Click to rename this type");
break;
case EditTarget::TypeSelector:
tipTitle = QStringLiteral("Switch View");
tipBody = QStringLiteral("View a different struct in this tab");
break;
default: break;
}
if (!tipTitle.isEmpty()) {
if (!m_arrowTooltip) {
m_arrowTooltip = new RcxTooltip(this);
static_cast<RcxTooltip*>(m_arrowTooltip)->onMouseMove =
[this](QMouseEvent* e) {
QPoint gp = e->globalPosition().toPoint();
QPoint vp = m_sci->viewport()->mapFromGlobal(gp);
m_lastHoverPos = vp;
m_hoverInside = m_sci->viewport()->rect().contains(vp);
applyHoverCursor();
};
}
auto* tip = static_cast<RcxTooltip*>(m_arrowTooltip);
const auto& theme = ThemeManager::instance().current();
tip->setTheme(theme.backgroundAlt, theme.border,
theme.text, theme.textDim, theme.border);
tip->populate(tipTitle, tipBody, editorFont());
// Anchor at center of the hovered span, bottom edge of line
long posA = posFromCol(m_sci, 0, span.start);
long posB = posFromCol(m_sci, 0, span.end);
int xA = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_POINTXFROMPOSITION, 0UL, posA);
int xB = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_POINTXFROMPOSITION, 0UL, posB);
int py = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_POINTYFROMPOSITION, 0UL, posA);
int lh = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_TEXTHEIGHT, 0UL);
QPoint anchor = m_sci->viewport()->mapToGlobal(
QPoint((xA + xB) / 2, py + lh));
tip->showAt(anchor);
showTip = true;
}
}
}
if (!showTip && m_arrowTooltip && m_arrowTooltip->isVisible())
static_cast<RcxTooltip*>(m_arrowTooltip)->dismiss();
}
m_sci->viewport()->setCursor(desired);
}
@@ -3830,12 +3909,8 @@ void RcxEditor::validateEditLive() {
if (isValid) {
m_sci->markerDelete(m_editState.line, M_ERR);
if (isSelected) m_sci->markerAdd(m_editState.line, M_SELECTED);
if (stateChanged) {
if (m_editState.target == EditTarget::BaseAddress)
setEditComment(QStringLiteral("e.g. <mod.exe> + 0xFF | [0x1000 + 0x10] | 7ff6`1234ABCD"));
else
setEditComment("Enter=Save Esc=Cancel");
}
if (stateChanged)
setEditComment("Enter=Save Esc=Cancel");
} else {
if (isSelected) m_sci->markerDelete(m_editState.line, M_SELECTED);
m_sci->markerAdd(m_editState.line, M_ERR);

View File

@@ -159,6 +159,7 @@ private:
QWidget* m_historyPopup = nullptr; // ValueHistoryPopup (file-local class in editor.cpp)
QWidget* m_disasmPopup = nullptr; // TitleBodyPopup (file-local class in editor.cpp)
QWidget* m_structPreviewPopup = nullptr; // TitleBodyPopup (file-local class in editor.cpp)
QWidget* m_arrowTooltip = nullptr; // RcxTooltip (arrow callout)
const Provider* m_disasmProvider = nullptr; // snapshot or real — for reading tree data
const Provider* m_disasmRealProv = nullptr; // real process provider — for reading code at arbitrary addresses
const NodeTree* m_disasmTree = nullptr;

126
src/examples/PageTables.rcx Normal file
View File

@@ -0,0 +1,126 @@
{
"baseAddress": "0",
"nextId": "2000",
"nodes": [
{
"id": "100",
"kind": "Struct",
"name": "pte",
"structTypeName": "X64_PTE",
"classKeyword": "bitfield",
"elementKind": "UInt64",
"offset": 0,
"parentId": "0",
"refId": "0",
"collapsed": true,
"arrayLen": 1,
"strLen": 64,
"bitfieldMembers": [
{"name": "Present", "bitOffset": 0, "bitWidth": 1},
{"name": "ReadWrite", "bitOffset": 1, "bitWidth": 1},
{"name": "UserSuper", "bitOffset": 2, "bitWidth": 1},
{"name": "WriteThrough", "bitOffset": 3, "bitWidth": 1},
{"name": "CacheDisable", "bitOffset": 4, "bitWidth": 1},
{"name": "Accessed", "bitOffset": 5, "bitWidth": 1},
{"name": "Dirty", "bitOffset": 6, "bitWidth": 1},
{"name": "PageSize", "bitOffset": 7, "bitWidth": 1},
{"name": "Global", "bitOffset": 8, "bitWidth": 1},
{"name": "AVL", "bitOffset": 9, "bitWidth": 3},
{"name": "PhysAddr", "bitOffset": 12, "bitWidth": 40},
{"name": "Available", "bitOffset": 52, "bitWidth": 7},
{"name": "ProtKey", "bitOffset": 59, "bitWidth": 4},
{"name": "NX", "bitOffset": 63, "bitWidth": 1}
]
},
{
"id": "200",
"kind": "Struct",
"name": "page_table",
"structTypeName": "X64_PAGE_TABLE",
"offset": 0,
"parentId": "0",
"refId": "0",
"collapsed": true
},
{
"id": "201",
"kind": "Array",
"name": "entries",
"offset": 0,
"parentId": "200",
"refId": "100",
"elementKind": "Struct",
"arrayLen": 512,
"strLen": 64,
"collapsed": true
},
{
"id": "300",
"kind": "Struct",
"name": "pde_2mb",
"structTypeName": "X64_PDE_LARGE",
"classKeyword": "bitfield",
"elementKind": "UInt64",
"offset": 0,
"parentId": "0",
"refId": "0",
"collapsed": true,
"arrayLen": 1,
"strLen": 64,
"bitfieldMembers": [
{"name": "Present", "bitOffset": 0, "bitWidth": 1},
{"name": "ReadWrite", "bitOffset": 1, "bitWidth": 1},
{"name": "UserSuper", "bitOffset": 2, "bitWidth": 1},
{"name": "WriteThrough", "bitOffset": 3, "bitWidth": 1},
{"name": "CacheDisable", "bitOffset": 4, "bitWidth": 1},
{"name": "Accessed", "bitOffset": 5, "bitWidth": 1},
{"name": "Dirty", "bitOffset": 6, "bitWidth": 1},
{"name": "PageSize", "bitOffset": 7, "bitWidth": 1},
{"name": "Global", "bitOffset": 8, "bitWidth": 1},
{"name": "AVL", "bitOffset": 9, "bitWidth": 3},
{"name": "PAT", "bitOffset": 12, "bitWidth": 1},
{"name": "Reserved", "bitOffset": 13, "bitWidth": 8},
{"name": "PhysAddr", "bitOffset": 21, "bitWidth": 31},
{"name": "Available", "bitOffset": 52, "bitWidth": 7},
{"name": "ProtKey", "bitOffset": 59, "bitWidth": 4},
{"name": "NX", "bitOffset": 63, "bitWidth": 1}
]
},
{
"id": "400",
"kind": "Struct",
"name": "pdpte_1gb",
"structTypeName": "X64_PDPTE_HUGE",
"classKeyword": "bitfield",
"elementKind": "UInt64",
"offset": 0,
"parentId": "0",
"refId": "0",
"collapsed": true,
"arrayLen": 1,
"strLen": 64,
"bitfieldMembers": [
{"name": "Present", "bitOffset": 0, "bitWidth": 1},
{"name": "ReadWrite", "bitOffset": 1, "bitWidth": 1},
{"name": "UserSuper", "bitOffset": 2, "bitWidth": 1},
{"name": "WriteThrough", "bitOffset": 3, "bitWidth": 1},
{"name": "CacheDisable", "bitOffset": 4, "bitWidth": 1},
{"name": "Accessed", "bitOffset": 5, "bitWidth": 1},
{"name": "Dirty", "bitOffset": 6, "bitWidth": 1},
{"name": "PageSize", "bitOffset": 7, "bitWidth": 1},
{"name": "Global", "bitOffset": 8, "bitWidth": 1},
{"name": "AVL", "bitOffset": 9, "bitWidth": 3},
{"name": "PAT", "bitOffset": 12, "bitWidth": 1},
{"name": "Reserved", "bitOffset": 13, "bitWidth": 17},
{"name": "PhysAddr", "bitOffset": 30, "bitWidth": 22},
{"name": "Available", "bitOffset": 52, "bitWidth": 7},
{"name": "ProtKey", "bitOffset": 59, "bitWidth": 4},
{"name": "NX", "bitOffset": 63, "bitWidth": 1}
]
}
],
"rootIds": ["200"]
}

View File

@@ -396,7 +396,7 @@ uint64_t PdbCtx::importEnum(uint32_t typeIndex) {
field->data.LF_ENUMERATE.value,
field->data.LF_ENUMERATE.lfEasy.kind);
if (eName)
s.enumMembers.append({QString::fromUtf8(eName), val});
s.enumMembers.emplaceBack(QString::fromUtf8(eName), val);
i += static_cast<size_t>(eName - reinterpret_cast<const char*>(field));
i += strnlen(eName, maxSize - i - 1) + 1;
@@ -880,7 +880,7 @@ void PdbCtx::importMemberType(uint32_t typeIndex, int offset, const QString& nam
n.name = name;
n.parentId = parentId;
n.offset = offset;
n.bitfieldMembers.append({name, bitPos, bitLen});
n.bitfieldMembers.push_back(BitfieldMember{name, bitPos, bitLen});
tree.addNode(n);
break;
}
@@ -943,6 +943,123 @@ struct PdbFile {
}
};
// ── Public API: extractPdbSymbols ──
PdbSymbolResult extractPdbSymbols(const QString& pdbPath, QString* errorMsg) {
auto setErr = [&](const QString& msg) { if (errorMsg) *errorMsg = msg; };
MappedFile mapped;
if (!QFile::exists(pdbPath)) {
setErr(QStringLiteral("PDB file not found: ") + pdbPath);
return {};
}
if (!mapped.open(pdbPath)) {
setErr(QStringLiteral("Failed to memory-map PDB file: ") + pdbPath);
return {};
}
if (PDB::ValidateFile(mapped.base, mapped.size) != PDB::ErrorCode::Success) {
setErr(QStringLiteral("Invalid PDB file: ") + pdbPath);
return {};
}
PDB::RawFile rawFile = PDB::CreateRawFile(mapped.base);
if (PDB::HasValidDBIStream(rawFile) != PDB::ErrorCode::Success) {
setErr(QStringLiteral("PDB has no valid DBI stream: ") + pdbPath);
return {};
}
const PDB::DBIStream dbiStream = PDB::CreateDBIStream(rawFile);
// Validate required sub-streams
if (dbiStream.HasValidSymbolRecordStream(rawFile) != PDB::ErrorCode::Success ||
dbiStream.HasValidPublicSymbolStream(rawFile) != PDB::ErrorCode::Success ||
dbiStream.HasValidImageSectionStream(rawFile) != PDB::ErrorCode::Success) {
setErr(QStringLiteral("PDB DBI stream missing required sub-streams"));
return {};
}
const PDB::ImageSectionStream imageSectionStream = dbiStream.CreateImageSectionStream(rawFile);
const PDB::CoalescedMSFStream symbolRecordStream = dbiStream.CreateSymbolRecordStream(rawFile);
PdbSymbolResult result;
// Derive module name from PDB filename (e.g. "ntoskrnl.pdb" → "ntoskrnl")
QFileInfo fi(pdbPath);
result.moduleName = fi.completeBaseName();
// Read public symbols (S_PUB32)
const PDB::PublicSymbolStream publicSymbolStream = dbiStream.CreatePublicSymbolStream(rawFile);
{
const PDB::ArrayView<PDB::HashRecord> hashRecords = publicSymbolStream.GetRecords();
const size_t count = hashRecords.GetLength();
result.symbols.reserve(static_cast<int>(count));
for (const PDB::HashRecord& hashRecord : hashRecords) {
const PDB::CodeView::DBI::Record* record =
publicSymbolStream.GetRecord(symbolRecordStream, hashRecord);
if (record->header.kind != PDB::CodeView::DBI::SymbolRecordKind::S_PUB32)
continue;
const uint32_t rva = imageSectionStream.ConvertSectionOffsetToRVA(
record->data.S_PUB32.section, record->data.S_PUB32.offset);
if (rva == 0u)
continue;
result.symbols.push_back(PdbSymbol{QString::fromUtf8(record->data.S_PUB32.name), rva});
}
}
// Read global symbols (S_GDATA32, S_GTHREAD32, S_LDATA32, S_LTHREAD32, S_GPROC32, S_LPROC32)
if (dbiStream.HasValidGlobalSymbolStream(rawFile) == PDB::ErrorCode::Success) {
const PDB::GlobalSymbolStream globalSymbolStream = dbiStream.CreateGlobalSymbolStream(rawFile);
const PDB::ArrayView<PDB::HashRecord> hashRecords = globalSymbolStream.GetRecords();
result.symbols.reserve(result.symbols.size() + static_cast<int>(hashRecords.GetLength()));
for (const PDB::HashRecord& hashRecord : hashRecords) {
const PDB::CodeView::DBI::Record* record =
globalSymbolStream.GetRecord(symbolRecordStream, hashRecord);
const char* name = nullptr;
uint32_t rva = 0u;
uint32_t typeIdx = 0u;
if (record->header.kind == PDB::CodeView::DBI::SymbolRecordKind::S_GDATA32) {
name = record->data.S_GDATA32.name;
typeIdx = record->data.S_GDATA32.typeIndex;
rva = imageSectionStream.ConvertSectionOffsetToRVA(
record->data.S_GDATA32.section, record->data.S_GDATA32.offset);
} else if (record->header.kind == PDB::CodeView::DBI::SymbolRecordKind::S_GTHREAD32) {
name = record->data.S_GTHREAD32.name;
typeIdx = record->data.S_GTHREAD32.typeIndex;
rva = imageSectionStream.ConvertSectionOffsetToRVA(
record->data.S_GTHREAD32.section, record->data.S_GTHREAD32.offset);
} else if (record->header.kind == PDB::CodeView::DBI::SymbolRecordKind::S_LDATA32) {
name = record->data.S_LDATA32.name;
typeIdx = record->data.S_LDATA32.typeIndex;
rva = imageSectionStream.ConvertSectionOffsetToRVA(
record->data.S_LDATA32.section, record->data.S_LDATA32.offset);
} else if (record->header.kind == PDB::CodeView::DBI::SymbolRecordKind::S_LTHREAD32) {
name = record->data.S_LTHREAD32.name;
typeIdx = record->data.S_LTHREAD32.typeIndex;
rva = imageSectionStream.ConvertSectionOffsetToRVA(
record->data.S_LTHREAD32.section, record->data.S_LTHREAD32.offset);
}
if (rva == 0u)
continue;
if (!name || name[0] == '\0')
continue;
result.symbols.push_back(PdbSymbol{QString::fromUtf8(name), rva, typeIdx});
}
}
qDebug() << "[PDB] extractPdbSymbols:" << result.symbols.size() << "symbols from"
<< result.moduleName;
return result;
}
// ── Public API: enumeratePdbTypes ──
QVector<PdbTypeInfo> enumeratePdbTypes(const QString& pdbPath, QString* errorMsg) {
@@ -1120,12 +1237,130 @@ NodeTree importPdb(const QString& pdbPath, const QString& structFilter, QString*
return ctx.tree;
}
// ── Public API: importTypeForSymbol ──
NodeTree importTypeForSymbol(const QString& pdbPath,
uint32_t typeIndex,
QString* typeName,
QString* errorMsg) {
auto setErr = [&](const QString& msg) { if (errorMsg) *errorMsg = msg; };
if (typeIndex == 0) {
setErr(QStringLiteral("Symbol has no associated type (typeIndex=0)"));
return {};
}
PdbFile pdb;
if (!pdb.open(pdbPath, errorMsg)) return {};
const TypeTable& tt = *pdb.typeTable;
// Walk through LF_MODIFIER and LF_POINTER chains to find the underlying UDT/enum
uint32_t ti = typeIndex;
int depth = 0;
while (ti >= tt.firstIndex() && depth < 16) {
const auto* rec = tt.get(ti);
if (!rec) break;
if (rec->header.kind == TRK::LF_MODIFIER) {
ti = rec->data.LF_MODIFIER.type;
depth++;
continue;
}
if (rec->header.kind == TRK::LF_POINTER) {
ti = rec->data.LF_POINTER.utype;
depth++;
continue;
}
break; // reached a non-wrapper type
}
// Check if we landed on a UDT or enum
if (ti < tt.firstIndex()) {
setErr(QStringLiteral("Symbol type resolves to a primitive (typeIndex %1)")
.arg(typeIndex));
return {};
}
const auto* rec = tt.get(ti);
if (!rec) {
setErr(QStringLiteral("Invalid type index %1").arg(ti));
return {};
}
bool isUDT = (rec->header.kind == TRK::LF_STRUCTURE ||
rec->header.kind == TRK::LF_CLASS ||
rec->header.kind == TRK::LF_UNION);
bool isEnum = (rec->header.kind == TRK::LF_ENUM);
if (!isUDT && !isEnum) {
setErr(QStringLiteral("Symbol type is not a struct/class/union/enum (kind 0x%1)")
.arg((uint16_t)rec->header.kind, 0, 16));
return {};
}
// Extract the type name for the caller
if (typeName) {
const char* name = nullptr;
if (isEnum) {
name = rec->data.LF_ENUM.name;
} else if (rec->header.kind == TRK::LF_UNION) {
name = leafName(rec->data.LF_UNION.data, unionLeafKind(rec->data.LF_UNION.data));
} else {
name = leafName(rec->data.LF_CLASS.data, rec->data.LF_CLASS.lfEasy.kind);
}
if (name) *typeName = QString::fromUtf8(name);
}
// If this is a forward reference, resolve to the full definition
PdbCtx ctx;
ctx.tt = &tt;
if (isUDT) {
bool fwdref = false;
if (rec->header.kind == TRK::LF_UNION)
fwdref = rec->data.LF_UNION.property.fwdref;
else
fwdref = rec->data.LF_CLASS.property.fwdref;
if (fwdref) {
// Build the definition index to find the real definition
ctx.buildUdtDefinitionIndex();
const char* name = nullptr;
if (rec->header.kind == TRK::LF_UNION)
name = leafName(rec->data.LF_UNION.data, unionLeafKind(rec->data.LF_UNION.data));
else
name = leafName(rec->data.LF_CLASS.data, rec->data.LF_CLASS.lfEasy.kind);
if (name) {
uint32_t defTi = ctx.findUdtDefinitionIndex(rec->header.kind, name);
if (defTi != 0)
ti = defTi;
}
}
ctx.importUDT(ti);
} else {
ctx.importEnum(ti);
}
if (ctx.tree.nodes.isEmpty()) {
setErr(QStringLiteral("Failed to import type at index %1").arg(ti));
}
return ctx.tree;
}
} // namespace rcx
#else // !_WIN32
namespace rcx {
PdbSymbolResult extractPdbSymbols(const QString&, QString* errorMsg) {
if (errorMsg) *errorMsg = QStringLiteral("PDB import requires Windows");
return {};
}
QVector<PdbTypeInfo> enumeratePdbTypes(const QString&, QString* errorMsg) {
if (errorMsg) *errorMsg = QStringLiteral("PDB import requires Windows");
return {};
@@ -1142,6 +1377,11 @@ NodeTree importPdb(const QString&, const QString&, QString* errorMsg) {
return {};
}
NodeTree importTypeForSymbol(const QString&, uint32_t, QString*, QString* errorMsg) {
if (errorMsg) *errorMsg = QStringLiteral("PDB import requires Windows");
return {};
}
} // namespace rcx
#endif

View File

@@ -5,6 +5,26 @@
namespace rcx {
// ── PDB Symbol Extraction ──
struct PdbSymbol {
QString name;
uint32_t rva;
uint32_t typeIndex = 0; // TPI type index (0 = unknown / public symbol)
};
struct PdbSymbolResult {
QString moduleName; // derived from PDB filename (e.g. "ntoskrnl")
QVector<PdbSymbol> symbols;
};
// Extract public/global symbols (name → RVA) from a PDB file.
// This reads the DBI stream's public and global symbol sub-streams.
PdbSymbolResult extractPdbSymbols(const QString& pdbPath,
QString* errorMsg = nullptr);
// ── PDB Type Import ──
struct PdbTypeInfo {
uint32_t typeIndex; // TPI type index
QString name; // struct/class/union/enum name
@@ -32,4 +52,12 @@ NodeTree importPdb(const QString& pdbPath,
const QString& structFilter = {},
QString* errorMsg = nullptr);
// Import the type associated with a global symbol's typeIndex.
// Opens the PDB, resolves the typeIndex to a UDT/enum, and returns the imported tree.
// Returns empty tree if the symbol has no associated type or the type is a simple primitive.
NodeTree importTypeForSymbol(const QString& pdbPath,
uint32_t typeIndex,
QString* typeName = nullptr,
QString* errorMsg = nullptr);
} // namespace rcx

View File

@@ -294,7 +294,7 @@ NodeTree importReclassXml(const QString& filePath, QString* errorMsg, int pointe
// Defer ref resolution if array references a class
if (!arrayClassName.isEmpty()) {
pendingRefs.append({arrId, arrayClassName});
pendingRefs.push_back(PendingRef{arrId, arrayClassName});
}
childOffset += nodeSize > 0 ? nodeSize : 0;
@@ -321,7 +321,7 @@ NodeTree importReclassXml(const QString& filePath, QString* errorMsg, int pointe
n.collapsed = true; // Start collapsed to avoid recursive expansion freeze
int nodeIdx = tree.addNode(n);
uint64_t nodeId = tree.nodes[nodeIdx].id;
pendingRefs.append({nodeId, ptrClass});
pendingRefs.push_back(PendingRef{nodeId, ptrClass});
childOffset += nodeSize > 0 ? nodeSize : sizeForKind(kind);
continue;
}
@@ -335,7 +335,7 @@ NodeTree importReclassXml(const QString& filePath, QString* errorMsg, int pointe
if (!n.structTypeName.isEmpty()) {
int nodeIdx = tree.addNode(n);
uint64_t nodeId = tree.nodes[nodeIdx].id;
pendingRefs.append({nodeId, n.structTypeName});
pendingRefs.push_back(PendingRef{nodeId, n.structTypeName});
} else {
tree.addNode(n);
}

View File

@@ -200,10 +200,10 @@ struct Tokenizer {
case '=': tk = TokKind::Equals; break;
default: tk = TokKind::Other; break;
}
tokens.append({tk, QString(c), line});
tokens.push_back(Token{tk, QString(c), line});
pos++;
}
tokens.append({TokKind::Eof, {}, line});
tokens.push_back(Token{TokKind::Eof, {}, line});
}
private:
@@ -241,7 +241,7 @@ private:
bool ok;
int val = m.captured(1).toInt(&ok, 16);
if (ok) {
offsets.append({commentLine, val});
offsets.push_back(LineOffset{commentLine, val});
}
}
}
@@ -259,7 +259,7 @@ private:
void parseIdent() {
int start = pos;
while (pos < src.size() && (src[pos].isLetterOrNumber() || src[pos] == '_')) pos++;
tokens.append({TokKind::Ident, src.mid(start, pos - start), line});
tokens.push_back(Token{TokKind::Ident, src.mid(start, pos - start), line});
}
void parseNumber() {
@@ -276,7 +276,7 @@ private:
// Skip integer suffixes (U, L, LL, ULL, etc.)
while (pos < src.size() && (src[pos] == 'u' || src[pos] == 'U' ||
src[pos] == 'l' || src[pos] == 'L')) pos++;
tokens.append({TokKind::Number, src.mid(start, pos - start), line});
tokens.push_back(Token{TokKind::Number, src.mid(start, pos - start), line});
}
};
@@ -1034,7 +1034,7 @@ struct Parser {
}
}
ps.enumValues.append({memberName, memberValue});
ps.enumValues.emplaceBack(memberName, memberValue);
nextValue = memberValue + 1;
// Skip comma between members
@@ -1312,7 +1312,7 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
if (!field.pointerTarget.isEmpty() &&
field.pointerTarget != QStringLiteral("void")) {
ctx.pendingRefs.append({nodeId, field.pointerTarget});
ctx.pendingRefs.push_back(PendingRef{nodeId, field.pointerTarget});
}
computedOffset = fieldOffset + ctx.ptrSize;
@@ -1342,7 +1342,7 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
n.offset = fieldOffset;
int nodeIdx = ctx.tree.addNode(n);
uint64_t nodeId = ctx.tree.nodes[nodeIdx].id;
ctx.pendingRefs.append({nodeId, field.typeName});
ctx.pendingRefs.push_back(PendingRef{nodeId, field.typeName});
computedOffset = fieldOffset + elemSize;
}
continue;
@@ -1461,7 +1461,7 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
int nodeIdx = ctx.tree.addNode(n);
uint64_t nodeId = ctx.tree.nodes[nodeIdx].id;
ctx.pendingRefs.append({nodeId, field.typeName});
ctx.pendingRefs.push_back(PendingRef{nodeId, field.typeName});
if (elemSize > 0)
computedOffset = fieldOffset + totalElements * elemSize;
continue;
@@ -1477,7 +1477,7 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
int nodeIdx = ctx.tree.addNode(n);
uint64_t nodeId = ctx.tree.nodes[nodeIdx].id;
ctx.pendingRefs.append({nodeId, field.typeName});
ctx.pendingRefs.push_back(PendingRef{nodeId, field.typeName});
if (elemSize > 0)
computedOffset = fieldOffset + elemSize;
continue;

View File

@@ -0,0 +1,193 @@
#include "pe_debug_info.h"
#include "../providers/provider.h"
#include <cstring>
namespace rcx {
// Minimal PE structures (no Windows SDK dependency)
#pragma pack(push, 1)
struct DosHeader {
uint16_t e_magic; // 'MZ'
uint8_t pad[58];
int32_t e_lfanew; // offset to PE signature
};
struct CoffHeader {
uint16_t Machine;
uint16_t NumberOfSections;
uint32_t TimeDateStamp;
uint32_t PointerToSymbolTable;
uint32_t NumberOfSymbols;
uint16_t SizeOfOptionalHeader;
uint16_t Characteristics;
};
struct DataDirectory {
uint32_t VirtualAddress;
uint32_t Size;
};
// Only the fields we need from the optional header
struct OptionalHeader32 {
uint16_t Magic; // 0x10b = PE32, 0x20b = PE32+
uint8_t pad[90];
uint32_t NumberOfRvaAndSizes;
// DataDirectory[0] = Export, [1] = Import, ... [6] = Debug
};
struct OptionalHeader64 {
uint16_t Magic; // 0x20b = PE32+
uint8_t pad[106];
uint32_t NumberOfRvaAndSizes;
};
struct DebugDirectory {
uint32_t Characteristics;
uint32_t TimeDateStamp;
uint16_t MajorVersion;
uint16_t MinorVersion;
uint32_t Type;
uint32_t SizeOfData;
uint32_t AddressOfRawData; // RVA when loaded
uint32_t PointerToRawData; // file offset (not used for memory reads)
};
struct CvInfoPdb70 {
uint32_t Signature; // 'RSDS'
uint8_t Guid[16];
uint32_t Age;
// char PdbFileName[] follows
};
#pragma pack(pop)
static constexpr uint16_t kMZ = 0x5A4D;
static constexpr uint32_t kPE = 0x00004550;
static constexpr uint16_t kPE32 = 0x10b;
static constexpr uint16_t kPE32P = 0x20b;
static constexpr uint32_t kRSDS = 0x53445352;
static constexpr uint32_t kDebugType_CodeView = 2;
static QString guidToString(const uint8_t guid[16]) {
// Windows GUID is mixed-endian: Data1(4B LE), Data2(2B LE), Data3(2B LE), Data4(8B sequential)
// MS symbol server expects native integer values for Data1/2/3, sequential for Data4
uint32_t d1; memcpy(&d1, guid, 4);
uint16_t d2; memcpy(&d2, guid + 4, 2);
uint16_t d3; memcpy(&d3, guid + 6, 2);
QString s = QStringLiteral("%1%2%3")
.arg(d1, 8, 16, QLatin1Char('0'))
.arg(d2, 4, 16, QLatin1Char('0'))
.arg(d3, 4, 16, QLatin1Char('0'));
for (int i = 8; i < 16; i++)
s += QStringLiteral("%1").arg(guid[i], 2, 16, QLatin1Char('0'));
return s.toUpper();
}
PdbDebugInfo extractPdbDebugInfo(const Provider& prov, uint64_t moduleBase) {
PdbDebugInfo result;
// Read DOS header
DosHeader dos;
if (!prov.read(moduleBase, &dos, sizeof(dos)))
return result;
if (dos.e_magic != kMZ)
return result;
uint64_t peOffset = moduleBase + dos.e_lfanew;
// Read PE signature
uint32_t peSig = 0;
if (!prov.read(peOffset, &peSig, 4))
return result;
if (peSig != kPE)
return result;
// Read COFF header
uint64_t coffOffset = peOffset + 4;
CoffHeader coff;
if (!prov.read(coffOffset, &coff, sizeof(coff)))
return result;
// Read optional header magic to determine PE32 vs PE32+
uint64_t optOffset = coffOffset + sizeof(CoffHeader);
uint16_t optMagic = 0;
if (!prov.read(optOffset, &optMagic, 2))
return result;
// Locate debug data directory (index 6)
uint32_t numRvaAndSizes = 0;
uint64_t dataDirsOffset = 0;
if (optMagic == kPE32) {
// PE32: NumberOfRvaAndSizes at offset 92, data dirs at offset 96
if (!prov.read(optOffset + 92, &numRvaAndSizes, 4))
return result;
dataDirsOffset = optOffset + 96;
} else if (optMagic == kPE32P) {
// PE32+: NumberOfRvaAndSizes at offset 108, data dirs at offset 112
if (!prov.read(optOffset + 108, &numRvaAndSizes, 4))
return result;
dataDirsOffset = optOffset + 112;
} else {
return result;
}
if (numRvaAndSizes <= 6)
return result; // no debug directory
DataDirectory debugDir;
if (!prov.read(dataDirsOffset + 6 * sizeof(DataDirectory), &debugDir, sizeof(debugDir)))
return result;
if (debugDir.VirtualAddress == 0 || debugDir.Size == 0)
return result;
// Read debug directory entries
int numEntries = debugDir.Size / sizeof(DebugDirectory);
for (int i = 0; i < numEntries; i++) {
DebugDirectory entry;
uint64_t entryAddr = moduleBase + debugDir.VirtualAddress + i * sizeof(DebugDirectory);
if (!prov.read(entryAddr, &entry, sizeof(entry)))
continue;
if (entry.Type != kDebugType_CodeView)
continue;
// Read CodeView info (RSDS)
if (entry.AddressOfRawData == 0 || entry.SizeOfData < sizeof(CvInfoPdb70) + 1)
continue;
CvInfoPdb70 cv;
uint64_t cvAddr = moduleBase + entry.AddressOfRawData;
if (!prov.read(cvAddr, &cv, sizeof(cv)))
continue;
if (cv.Signature != kRSDS)
continue;
// Read PDB filename (null-terminated string after the struct)
int nameMaxLen = entry.SizeOfData - sizeof(CvInfoPdb70);
if (nameMaxLen > 260) nameMaxLen = 260;
char nameBuf[261] = {};
if (!prov.read(cvAddr + sizeof(CvInfoPdb70), nameBuf, nameMaxLen))
continue;
nameBuf[nameMaxLen] = '\0';
result.pdbName = QString::fromLatin1(nameBuf);
// Extract just the filename if it contains a path
int lastSlash = result.pdbName.lastIndexOf('\\');
if (lastSlash >= 0)
result.pdbName = result.pdbName.mid(lastSlash + 1);
int lastFwdSlash = result.pdbName.lastIndexOf('/');
if (lastFwdSlash >= 0)
result.pdbName = result.pdbName.mid(lastFwdSlash + 1);
result.guidString = guidToString(cv.Guid);
result.age = cv.Age;
result.valid = true;
return result;
}
return result;
}
} // namespace rcx

View File

@@ -0,0 +1,20 @@
#pragma once
#include <QString>
#include <cstdint>
namespace rcx {
class Provider;
struct PdbDebugInfo {
QString pdbName; // e.g. "ntoskrnl.pdb"
QString guidString; // 32 hex chars, no dashes, uppercase
uint32_t age = 0;
bool valid = false;
};
// Extract PDB debug info (GUID, age, filename) from a PE module in memory.
// Reads DOS header → PE header → debug directory → CodeView RSDS record.
PdbDebugInfo extractPdbDebugInfo(const Provider& prov, uint64_t moduleBase);
} // namespace rcx

File diff suppressed because it is too large Load Diff

View File

@@ -5,6 +5,7 @@
#include "scannerpanel.h"
#include "startpage.h"
#include "workspace_model.h"
namespace rcx { class SymbolDownloader; }
#include <QMainWindow>
#include <QLabel>
#include <QSplitter>
@@ -199,6 +200,30 @@ private:
DockGripWidget* m_scanDockGrip = nullptr;
void createScannerDock();
// Modules/Symbols dock
QDockWidget* m_symbolsDock = nullptr;
QTabWidget* m_symTabWidget = nullptr;
// Modules tab
QTreeView* m_modulesTree = nullptr;
QStandardItemModel* m_modulesModel = nullptr;
// Symbols tab
QTreeView* m_symbolsTree = nullptr;
QStandardItemModel* m_symbolsModel = nullptr;
QSortFilterProxyModel* m_symbolsProxy = nullptr;
QLineEdit* m_symbolsSearch = nullptr;
// Title bar
QLabel* m_symDockTitle = nullptr;
QToolButton* m_symDockCloseBtn = nullptr;
QToolButton* m_symDownloadBtn = nullptr;
DockGripWidget* m_symDockGrip = nullptr;
rcx::SymbolDownloader* m_symDownloader = nullptr;
void createSymbolsDock();
void rebuildSymbolsModel();
void rebuildModulesModel();
void downloadSymbolsForProcess();
// Load PDB symbols + typeIndices into SymbolStore. Returns symbol count.
static int loadPdbIntoStore(const QString& pdbPath);
// Start page
StartPageWidget* m_startPage = nullptr;
Q_INVOKABLE void showStartPage();

View File

@@ -5,7 +5,10 @@
#include "generator.h"
#include "mainwindow.h"
#include "scanner.h"
#include "symbolstore.h"
#include "imports/import_pdb.h"
#include <QCoreApplication>
#include <QFile>
#include <QSettings>
#include <QTimer>
#include <QDebug>
@@ -121,7 +124,7 @@ void McpBridge::onNewConnection() {
auto* pending = m_server->nextPendingConnection();
if (!pending) return;
m_clients.append({pending, {}, false});
m_clients.push_back(ClientState{pending, {}, false});
connect(pending, &QLocalSocket::readyRead,
this, &McpBridge::onReadyRead);
@@ -156,7 +159,7 @@ void McpBridge::onReadyRead() {
if (line.isEmpty()) continue;
if (m_processing) {
m_pendingRequests.append({sock, line});
m_pendingRequests.push_back(PendingRequest{sock, line});
continue;
}
m_processing = true;
@@ -323,8 +326,8 @@ QJsonObject McpBridge::handleInitialize(const QJsonValue& id, const QJsonObject&
"- 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"
"- hex.read offset is an absolute virtual address by default. "
"Use baseRelative=true to make it relative to the struct base address (0 = start of struct).\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. "
@@ -385,7 +388,11 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
"Operations: "
"remove: {op:'remove', nodeId:'ID'}. "
"rename: {op:'rename', nodeId:'ID', name:'newName'}. "
"insert: {op:'insert', kind:'Hex64', name:'field', parentId:'ID', offset:0}. "
"insert: {op:'insert', kind:'Hex64', name:'field', parentId:'ID', offset:0} "
"optional fields: structTypeName, classKeyword, strLen, elementKind, arrayLen, refId, "
"ptrDepth (0=struct ptr, 1=prim*, 2=prim**), isStatic (bool), offsetExpr (string), "
"isRelative (bool, RVA pointer), "
"enumMembers ([{name:'X',value:0},...]), bitfieldMembers ([{name:'X',bitOffset:0,bitWidth:1},...]). "
"change_kind: {op:'change_kind', nodeId:'ID', kind:'UInt32'}. "
"change_offset: {op:'change_offset', nodeId:'ID', offset:16}. "
"change_base: {op:'change_base', baseAddress:'0x400000', formula:'[0x233CA80]'} — formula is optional, enables auto-resolve on provider attach. "
@@ -394,9 +401,15 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
"change_pointer_ref: {op:'change_pointer_ref', nodeId:'ID', refId:'targetID'}. "
"change_array_meta: {op:'change_array_meta', nodeId:'ID', elementKind:'UInt32', arrayLen:10}. "
"collapse: {op:'collapse', nodeId:'ID', collapsed:true}. "
"change_enum_members: {op:'change_enum_members', nodeId:'ID', members:[{name:'X',value:0},...]}. "
"change_offset_expr: {op:'change_offset_expr', nodeId:'ID', offsetExpr:'base + 0x10'}. "
"toggle_static: {op:'toggle_static', nodeId:'ID', isStatic:true}. "
"toggle_relative: {op:'toggle_relative', nodeId:'ID', isRelative:true}. "
"group_into_union: {op:'group_into_union', nodeIds:['ID1','ID2',...]} — groups siblings into a union. "
"dissolve_union: {op:'dissolve_union', nodeId:'ID'} — flattens a union back to parent scope. "
"Insert ops get auto-assigned IDs; use $0, $1 etc. to reference them in later ops. "
"Kinds: Hex8 Hex16 Hex32 Hex64 Int8 Int16 Int32 Int64 UInt8 UInt16 UInt32 UInt64 "
"Float Double Bool Pointer32 Pointer64 Vec2 Vec3 Vec4 Mat4x4 UTF8 UTF16 Struct Array"},
"Float Double Bool Pointer32 Pointer64 FuncPtr32 FuncPtr64 Vec2 Vec3 Vec4 Mat4x4 UTF8 UTF16 Struct Array"},
{"inputSchema", QJsonObject{
{"type", "object"},
{"properties", QJsonObject{
@@ -451,16 +464,20 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
{"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). "
"Use this to see what actual values are in memory at any offset. "
"Offset is tree-relative (0-based, baseAddress added automatically) "
"unless baseRelative=true (offset is absolute virtual address in the process)."},
"By default offset is an absolute virtual address in the target process. "
"Set baseRelative=true to make offset relative to the struct base address "
"(e.g. offset=0 reads at baseAddress, offset=0x10 reads at baseAddress+0x10)."},
{"inputSchema", QJsonObject{
{"type", "object"},
{"properties", QJsonObject{
{"tabIndex", QJsonObject{{"type", "integer"},
{"description", "MDI tab index (0-based). Omit for active tab."}}},
{"offset", QJsonObject{{"type", "integer"}}},
{"length", QJsonObject{{"type", "integer"}}},
{"baseRelative", QJsonObject{{"type", "boolean"}}}
{"offset", QJsonObject{{"type", "integer"},
{"description", "Address to read from. Absolute VA by default, or relative to struct base if baseRelative=true."}}},
{"length", QJsonObject{{"type", "integer"},
{"description", "Number of bytes to read (1-4096, default 64)."}}},
{"baseRelative", QJsonObject{{"type", "boolean"},
{"description", "If true, offset is relative to the tree's base address (added automatically). Default false (offset is absolute VA)."}}}
}},
{"required", QJsonArray{"offset", "length"}}
}}
@@ -469,15 +486,20 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
// 5. hex.write
tools.append(QJsonObject{
{"name", "hex.write"},
{"description", "Write raw bytes to provider (through undo stack). Hex string format: '4D5A9000'"},
{"description", "Write raw bytes to provider (through undo stack). Hex string format: '4D5A9000'. "
"By default offset is an absolute virtual address. "
"Set baseRelative=true to make offset relative to the struct base address."},
{"inputSchema", QJsonObject{
{"type", "object"},
{"properties", QJsonObject{
{"tabIndex", QJsonObject{{"type", "integer"},
{"description", "MDI tab index (0-based). Omit for active tab."}}},
{"offset", QJsonObject{{"type", "integer"}}},
{"hexBytes", QJsonObject{{"type", "string"}}},
{"baseRelative", QJsonObject{{"type", "boolean"}}}
{"offset", QJsonObject{{"type", "integer"},
{"description", "Address to write to. Absolute VA by default, or relative to struct base if baseRelative=true."}}},
{"hexBytes", QJsonObject{{"type", "string"},
{"description", "Hex byte string to write, e.g. '4D5A9000'. Spaces allowed."}}},
{"baseRelative", QJsonObject{{"type", "boolean"},
{"description", "If true, offset is relative to the tree's base address. Default false (absolute VA)."}}}
}},
{"required", QJsonArray{"offset", "hexBytes"}}
}}
@@ -650,6 +672,86 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
}}
}}
});
// symbols.load
tools.append(QJsonObject{
{"name", "symbols.load"},
{"description", "Load PDB symbols from a file path into the global symbol store. "
"Symbols are used for address annotations (e.g. 'ntdll!RtlInitUnicodeString') "
"and can be resolved via symbols.lookup. "
"Returns the number of symbols loaded and the module name."},
{"inputSchema", QJsonObject{
{"type", "object"},
{"properties", QJsonObject{
{"pdbPath", QJsonObject{{"type", "string"},
{"description", "Absolute path to a .pdb file."}}},
{"tabIndex", QJsonObject{{"type", "integer"},
{"description", "MDI tab index (0-based). Omit for active tab. Used to refresh annotations after loading."}}}
}},
{"required", QJsonArray{"pdbPath"}}
}}
});
// symbols.lookup
tools.append(QJsonObject{
{"name", "symbols.lookup"},
{"description", "Resolve a symbol name to an absolute virtual address in the attached process. "
"Supports qualified names like 'ntdll!RtlInitUnicodeString' and bare names. "
"Requires symbols to be loaded (via symbols.load or the UI) and a live provider."},
{"inputSchema", QJsonObject{
{"type", "object"},
{"properties", QJsonObject{
{"symbol", QJsonObject{{"type", "string"},
{"description", "Symbol to resolve. Use 'module!name' for qualified lookup, or bare 'name' for unqualified."}}},
{"tabIndex", QJsonObject{{"type", "integer"},
{"description", "MDI tab index (0-based). Omit for active tab."}}}
}},
{"required", QJsonArray{"symbol"}}
}}
});
// symbols.importType
tools.append(QJsonObject{
{"name", "symbols.importType"},
{"description", "Import the type definition for a global symbol from its PDB into the active project. "
"Given a qualified symbol like 'ntdll!g_pShimEngineModule', resolves its typeIndex from "
"the PDB, follows pointer/modifier chains to find the underlying struct/class/union/enum, "
"and imports it with full recursive child types. "
"Requires symbols to be loaded first (via symbols.load). "
"Returns the imported type name and node count, or an error if the symbol has no type info."},
{"inputSchema", QJsonObject{
{"type", "object"},
{"properties", QJsonObject{
{"symbol", QJsonObject{{"type", "string"},
{"description", "Qualified symbol name (e.g. 'ntdll!g_pShimEngineModule'). Must include 'module!' prefix."}}},
{"tabIndex", QJsonObject{{"type", "integer"},
{"description", "MDI tab index (0-based). Omit for active tab."}}}
}},
{"required", QJsonArray{"symbol"}}
}}
});
// node.read_value
tools.append(QJsonObject{
{"name", "node.read_value"},
{"description", "Read the formatted typed value for one or more nodes. Unlike hex.read (which returns raw bytes), "
"this returns the value as the user sees it in the editor: e.g. '120.0f' for Float, '0x7FF61234' for Pointer64, "
"'true' for Bool, '1.0, 2.0, 3.0' for Vec3. "
"For Hex nodes returns the hex byte preview. For Struct/Array returns the computed size. "
"Requires a live provider for meaningful values."},
{"inputSchema", QJsonObject{
{"type", "object"},
{"properties", QJsonObject{
{"nodeIds", QJsonObject{{"type", "array"},
{"items", QJsonObject{{"type", "string"}}},
{"description", "Array of node IDs to read values for."}}},
{"tabIndex", QJsonObject{{"type", "integer"},
{"description", "MDI tab index (0-based). Omit for active tab."}}}
}},
{"required", QJsonArray{"nodeIds"}}
}}
});
return okReply(id, QJsonObject{{"tools", tools}});
}
@@ -680,6 +782,10 @@ QJsonObject McpBridge::handleToolsCall(const QJsonValue& id, const QJsonObject&
else if (toolName == "scanner.scan_pattern") result = toolScannerScanPattern(args);
else if (toolName == "mcp.reconnect") result = toolReconnect(args);
else if (toolName == "process.info") result = toolProcessInfo(args);
else if (toolName == "symbols.load") result = toolSymbolsLoad(args);
else if (toolName == "symbols.lookup") result = toolSymbolsLookup(args);
else if (toolName == "symbols.importType") result = toolSymbolsImportType(args);
else if (toolName == "node.read_value") result = toolNodeReadValue(args);
else return errReply(id, -32601, "Unknown tool: " + toolName);
m_mainWindow->clearMcpStatus();
@@ -819,7 +925,7 @@ QJsonObject McpBridge::toolProjectState(const QJsonObject& args) {
QJsonArray nodeArr;
struct QueueEntry { uint64_t parentId; int depth; };
QVector<QueueEntry> queue;
queue.append({filterParentId, 0});
queue.push_back(QueueEntry{filterParentId, 0});
int totalCount = 0; // total nodes that match depth filter
int emitted = 0;
@@ -839,13 +945,13 @@ QJsonObject McpBridge::toolProjectState(const QJsonObject& args) {
if (totalCount <= offset) {
// Still skipping — but enqueue children for counting
if (entry.depth + 1 <= maxDepth)
queue.append({n.id, entry.depth + 1});
queue.push_back(QueueEntry{n.id, entry.depth + 1});
continue;
}
if (emitted >= limit) {
// Past limit — just keep counting total
if (entry.depth + 1 <= maxDepth)
queue.append({n.id, entry.depth + 1});
queue.push_back(QueueEntry{n.id, entry.depth + 1});
continue;
}
@@ -875,7 +981,7 @@ QJsonObject McpBridge::toolProjectState(const QJsonObject& args) {
// Enqueue children if we haven't hit depth limit
if (entry.depth + 1 <= maxDepth)
queue.append({n.id, entry.depth + 1});
queue.push_back(QueueEntry{n.id, entry.depth + 1});
}
}
@@ -965,6 +1071,31 @@ QJsonObject McpBridge::toolTreeApply(const QJsonObject& args) {
n.strLen = qBound(1, (int)parseInteger(op.value("strLen"), 64), 1000000);
n.elementKind = kindFromString(op.value("elementKind").toString("UInt8"));
n.arrayLen = qBound(1, (int)parseInteger(op.value("arrayLen"), 1), 1000000);
n.ptrDepth = qBound(0, (int)parseInteger(op.value("ptrDepth"), 0), 2);
n.isStatic = op.value("isStatic").toBool(false);
n.offsetExpr = op.value("offsetExpr").toString();
n.isRelative = op.value("isRelative").toBool(false);
// Enum members
if (op.contains("enumMembers")) {
QJsonArray emArr = op.value("enumMembers").toArray();
for (const auto& ev : emArr) {
QJsonObject eo = ev.toObject();
n.enumMembers.emplaceBack(eo.value("name").toString(),
(int64_t)parseInteger(eo.value("value")));
}
}
// Bitfield members
if (op.contains("bitfieldMembers")) {
QJsonArray bmArr = op.value("bitfieldMembers").toArray();
for (const auto& bv : bmArr) {
QJsonObject bo = bv.toObject();
BitfieldMember bm;
bm.name = bo.value("name").toString();
bm.bitOffset = (uint8_t)qBound(0, (int)parseInteger(bo.value("bitOffset")), 255);
bm.bitWidth = (uint8_t)qBound(1, (int)parseInteger(bo.value("bitWidth"), 1), 64);
n.bitfieldMembers.append(bm);
}
}
bool refOk;
QString refStr = resolvePlaceholder(op.value("refId").toString("0"), placeholders, &refOk);
if (!refOk) {
@@ -1118,6 +1249,89 @@ QJsonObject McpBridge::toolTreeApply(const QJsonObject& args) {
skippedOps.append(QStringLiteral("op[%1]: collapse nodeId '%2' not found").arg(i).arg(nid));
}
}
else if (opType == "change_enum_members") {
QString nid = resolvePlaceholder(op.value("nodeId").toString(), placeholders);
int idx = tree.indexOfId(nid.toULongLong());
if (idx >= 0) {
QVector<QPair<QString, int64_t>> newMembers;
QJsonArray membersArr = op.value("members").toArray();
for (const auto& mv : membersArr) {
QJsonObject mo = mv.toObject();
newMembers.emplaceBack(mo.value("name").toString(),
(int64_t)parseInteger(mo.value("value")));
}
doc->undoStack.push(new RcxCommand(ctrl,
cmd::ChangeEnumMembers{tree.nodes[idx].id,
tree.nodes[idx].enumMembers, newMembers}));
applied++;
} else {
skippedOps.append(QStringLiteral("op[%1]: change_enum_members nodeId '%2' not found").arg(i).arg(nid));
}
}
else if (opType == "change_offset_expr") {
QString nid = resolvePlaceholder(op.value("nodeId").toString(), placeholders);
int idx = tree.indexOfId(nid.toULongLong());
if (idx >= 0) {
doc->undoStack.push(new RcxCommand(ctrl,
cmd::ChangeOffsetExpr{tree.nodes[idx].id,
tree.nodes[idx].offsetExpr,
op.value("offsetExpr").toString()}));
applied++;
} else {
skippedOps.append(QStringLiteral("op[%1]: change_offset_expr nodeId '%2' not found").arg(i).arg(nid));
}
}
else if (opType == "toggle_static") {
QString nid = resolvePlaceholder(op.value("nodeId").toString(), placeholders);
int idx = tree.indexOfId(nid.toULongLong());
if (idx >= 0) {
bool newVal = op.value("isStatic").toBool();
doc->undoStack.push(new RcxCommand(ctrl,
cmd::ToggleStatic{tree.nodes[idx].id,
tree.nodes[idx].isStatic, newVal}));
applied++;
} else {
skippedOps.append(QStringLiteral("op[%1]: toggle_static nodeId '%2' not found").arg(i).arg(nid));
}
}
else if (opType == "toggle_relative") {
QString nid = resolvePlaceholder(op.value("nodeId").toString(), placeholders);
int idx = tree.indexOfId(nid.toULongLong());
if (idx >= 0) {
bool newVal = op.value("isRelative").toBool();
doc->undoStack.push(new RcxCommand(ctrl,
cmd::ToggleRelative{tree.nodes[idx].id,
tree.nodes[idx].isRelative, newVal}));
applied++;
} else {
skippedOps.append(QStringLiteral("op[%1]: toggle_relative nodeId '%2' not found").arg(i).arg(nid));
}
}
else if (opType == "group_into_union") {
QJsonArray idsArr = op.value("nodeIds").toArray();
QSet<uint64_t> ids;
for (const auto& v : idsArr) {
QString resolved = resolvePlaceholder(v.toString(), placeholders);
ids.insert(resolved.toULongLong());
}
if (ids.size() >= 2) {
ctrl->groupIntoUnion(ids);
applied++;
} else {
skippedOps.append(QStringLiteral("op[%1]: group_into_union needs >= 2 nodeIds").arg(i));
}
}
else if (opType == "dissolve_union") {
QString nid = resolvePlaceholder(op.value("nodeId").toString(), placeholders);
uint64_t unionId = nid.toULongLong();
int idx = tree.indexOfId(unionId);
if (idx >= 0) {
ctrl->dissolveUnion(unionId);
applied++;
} else {
skippedOps.append(QStringLiteral("op[%1]: dissolve_union nodeId '%2' not found").arg(i).arg(nid));
}
}
else {
skippedOps.append(QStringLiteral("op[%1]: unknown op '%2'").arg(i).arg(opType));
}
@@ -1665,7 +1879,7 @@ static QVector<AddressRange> parseRegionsArg(const QJsonObject& args, QString* e
if (errOut) *errOut = QStringLiteral("regions[%1]: end must be > start").arg(i);
return {};
}
out.append({start, end});
out.push_back(AddressRange{start, end});
}
return out;
}
@@ -1820,6 +2034,224 @@ QJsonObject McpBridge::toolProcessInfo(const QJsonObject& args) {
return makeTextResult(QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Indented)));
}
// ════════════════════════════════════════════════════════════════════
// TOOL: symbols.load — load PDB symbols into the global store
// ════════════════════════════════════════════════════════════════════
QJsonObject McpBridge::toolSymbolsLoad(const QJsonObject& args) {
QString pdbPath = args.value("pdbPath").toString();
if (pdbPath.isEmpty())
return makeTextResult("pdbPath is required", true);
if (!QFile::exists(pdbPath))
return makeTextResult("File not found: " + pdbPath, true);
QString symErr;
auto result = extractPdbSymbols(pdbPath, &symErr);
if (result.symbols.isEmpty())
return makeTextResult(symErr.isEmpty()
? QStringLiteral("No symbols found in PDB") : symErr, true);
QVector<QPair<QString, uint32_t>> pairs;
QHash<QString, uint32_t> typeIndices;
pairs.reserve(result.symbols.size());
for (const auto& s : result.symbols) {
pairs.emplaceBack(s.name, s.rva);
if (s.typeIndex != 0)
typeIndices.insert(s.name, s.typeIndex);
}
int count = SymbolStore::instance().addModule(result.moduleName, pdbPath, pairs);
if (!typeIndices.isEmpty())
SymbolStore::instance().addModuleTypeIndices(result.moduleName, typeIndices);
// Refresh the active tab so annotations pick up new symbols
auto* tab = resolveTab(args);
if (tab && tab->ctrl)
tab->ctrl->refresh();
m_mainWindow->rebuildSymbolsModel();
QJsonObject out;
out["moduleName"] = result.moduleName;
out["symbolCount"] = count;
out["pdbPath"] = pdbPath;
return makeTextResult(QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Indented)));
}
// ════════════════════════════════════════════════════════════════════
// TOOL: symbols.lookup — resolve symbol name to address
// ════════════════════════════════════════════════════════════════════
QJsonObject McpBridge::toolSymbolsLookup(const QJsonObject& args) {
QString symbol = args.value("symbol").toString();
if (symbol.isEmpty())
return makeTextResult("symbol is required", true);
auto* tab = resolveTab(args);
if (!tab || !tab->doc->provider)
return makeTextResult("No active tab or provider", true);
auto* prov = tab->doc->provider.get();
bool ok = false;
uint64_t addr = SymbolStore::instance().resolve(symbol, prov, &ok);
if (!ok || addr == 0)
return makeTextResult("Symbol not found: " + symbol, true);
QJsonObject out;
out["symbol"] = symbol;
out["address"] = "0x" + QString::number(addr, 16).toUpper();
return makeTextResult(QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Indented)));
}
// ════════════════════════════════════════════════════════════════════
// TOOL: symbols.importType — import type definition for a symbol
// ════════════════════════════════════════════════════════════════════
QJsonObject McpBridge::toolSymbolsImportType(const QJsonObject& args) {
QString symbol = args.value("symbol").toString();
if (symbol.isEmpty())
return makeTextResult("symbol is required", true);
int bangIdx = symbol.indexOf('!');
if (bangIdx <= 0 || bangIdx >= symbol.size() - 1)
return makeTextResult("Symbol must be qualified: 'module!name'", true);
QString modPart = symbol.left(bangIdx);
// Look up the typeIndex from the symbol store
uint32_t typeIdx = SymbolStore::instance().typeIndexForSymbol(symbol);
if (typeIdx == 0)
return makeTextResult("No type info for symbol '" + symbol +
"'. The PDB may not have been loaded with symbols.load, or this "
"is a public symbol (S_PUB32) without type metadata.", true);
// Find the PDB path for this module
QString canonical = SymbolStore::instance().resolveAlias(modPart);
const auto* modData = SymbolStore::instance().moduleData(canonical);
if (!modData || modData->pdbPath.isEmpty())
return makeTextResult("No PDB path found for module '" + modPart + "'", true);
// Import the type from the PDB
QString importedTypeName;
QString importErr;
NodeTree importedTree = importTypeForSymbol(modData->pdbPath, typeIdx,
&importedTypeName, &importErr);
if (importedTree.nodes.isEmpty())
return makeTextResult(importErr.isEmpty()
? QStringLiteral("Failed to import type for typeIndex %1").arg(typeIdx)
: importErr, true);
// Merge imported nodes into the active document
auto* tab = resolveTab(args);
if (!tab)
return makeTextResult("No active tab", true);
auto& tree = tab->doc->tree;
tab->ctrl->setSuppressRefresh(true);
tab->doc->undoStack.beginMacro(
QStringLiteral("Import type for ") + symbol);
// Map old IDs to new IDs to preserve parent-child relationships
QHash<uint64_t, uint64_t> idMap;
for (const auto& node : importedTree.nodes) {
uint64_t newId = tree.reserveId();
idMap[node.id] = newId;
}
for (const auto& node : importedTree.nodes) {
Node copy = node;
copy.id = idMap.value(node.id, node.id);
copy.parentId = idMap.value(node.parentId, node.parentId);
if (copy.refId != 0)
copy.refId = idMap.value(node.refId, node.refId);
tab->doc->undoStack.push(new RcxCommand(tab->ctrl,
cmd::Insert{copy}));
}
tab->doc->undoStack.endMacro();
tab->ctrl->setSuppressRefresh(false);
tab->ctrl->refresh();
m_mainWindow->rebuildWorkspaceModel();
QJsonObject out;
out["symbol"] = symbol;
out["typeName"] = importedTypeName;
out["typeIndex"] = (int)typeIdx;
out["nodesImported"] = importedTree.nodes.size();
return makeTextResult(QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Indented)));
}
// ════════════════════════════════════════════════════════════════════
// TOOL: node.read_value — read formatted typed values for nodes
// ════════════════════════════════════════════════════════════════════
QJsonObject McpBridge::toolNodeReadValue(const QJsonObject& args) {
auto* tab = resolveTab(args);
if (!tab) return makeTextResult("No active tab", true);
const auto& tree = tab->doc->tree;
auto* prov = tab->doc->provider.get();
if (!prov) return makeTextResult("No provider", true);
QJsonArray requestedIds = args.value("nodeIds").toArray();
if (requestedIds.isEmpty())
return makeTextResult("nodeIds array is required", true);
QJsonObject result;
for (const auto& idVal : requestedIds) {
QString idStr = idVal.toString();
uint64_t nodeId = idStr.toULongLong();
int idx = tree.indexOfId(nodeId);
if (idx < 0) {
QJsonObject entry;
entry["error"] = "node not found";
result[idStr] = entry;
continue;
}
const Node& node = tree.nodes[idx];
// Compute absolute address
int64_t signedOff = tree.computeOffset(idx);
uint64_t addr = (signedOff >= 0)
? tree.baseAddress + static_cast<uint64_t>(signedOff) : 0;
QJsonObject entry;
entry["kind"] = kindToString(node.kind);
entry["name"] = node.name;
entry["offset"] = node.offset;
entry["address"] = "0x" + QString::number(addr, 16).toUpper();
if (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) {
// Containers don't have scalar values — return computed size
int span = tree.structSpan(node.id);
entry["computedSize"] = span;
entry["value"] = QStringLiteral("(container, size=0x%1)")
.arg(QString::number(span, 16).toUpper());
} else if (addr != 0 && prov->isReadable(addr, node.byteSize())) {
// Read formatted value using the same formatting as the editor
int numLines = linesForKind(node.kind);
if (numLines <= 1) {
entry["value"] = fmt::readValue(node, *prov, addr, 0);
} else {
// Multi-line types (Mat4x4): return all sub-lines
QJsonArray lines;
for (int sub = 0; sub < numLines; sub++)
lines.append(fmt::readValue(node, *prov, addr, sub));
entry["value"] = lines;
}
} else {
entry["value"] = QJsonValue();
entry["error"] = "not readable";
}
result[idStr] = entry;
}
return makeTextResult(QString::fromUtf8(QJsonDocument(result).toJson(QJsonDocument::Indented)));
}
// ════════════════════════════════════════════════════════════════════
// Notifications (call from MainWindow/Controller hooks)
// ════════════════════════════════════════════════════════════════════

View File

@@ -85,6 +85,10 @@ private:
QJsonObject toolScannerScanPattern(const QJsonObject& args);
QJsonObject toolReconnect(const QJsonObject& args);
QJsonObject toolProcessInfo(const QJsonObject& args);
QJsonObject toolSymbolsLoad(const QJsonObject& args);
QJsonObject toolSymbolsLookup(const QJsonObject& args);
QJsonObject toolSymbolsImportType(const QJsonObject& args);
QJsonObject toolNodeReadValue(const QJsonObject& args);
// Helpers
QJsonObject makeTextResult(const QString& text, bool isError = false);

View File

@@ -12,17 +12,16 @@ PluginManager::~PluginManager()
void PluginManager::LoadPlugins()
{
// Get the Plugins directory relative to the executable
// Probe plugin locations relative to the executable.
QString appDir = QCoreApplication::applicationDirPath();
QString pluginsDir = appDir + "/Plugins";
QDir dir(pluginsDir);
if (!dir.exists())
{
qWarning() << "PluginManager: Plugins directory not found:" << pluginsDir;
return;
}
QStringList pluginDirs;
pluginDirs << (appDir + "/Plugins");
#ifdef __APPLE__
// In macOS app bundles, plugins may live in Contents/PlugIns or in
// the top-level build/Plugins directory during local development.
pluginDirs << QDir::cleanPath(appDir + "/../PlugIns");
#endif
// Find all DLL files
QStringList filters;
#ifdef _WIN32
@@ -32,17 +31,36 @@ void PluginManager::LoadPlugins()
#else
filters << "*.so";
#endif
dir.setNameFilters(filters);
QFileInfoList files = dir.entryInfoList(QDir::Files);
qDebug() << "PluginManager: Scanning for plugins in:" << pluginsDir;
qDebug() << "PluginManager: Found" << files.count() << "potential plugin(s)";
for (const QFileInfo& fileInfo : files)
int totalCandidates = 0;
bool foundAnyDir = false;
for (const QString& pluginsDir : pluginDirs)
{
LoadPlugin(fileInfo.absoluteFilePath());
QDir dir(pluginsDir);
if (!dir.exists())
continue;
foundAnyDir = true;
dir.setNameFilters(filters);
QFileInfoList files = dir.entryInfoList(QDir::Files);
totalCandidates += files.count();
qDebug() << "PluginManager: Scanning for plugins in:" << pluginsDir;
for (const QFileInfo& fileInfo : files)
{
// Skip the remote-inject payload binary — it's not a plugin and
// loading it (especially on Linux) spawns a rogue thread.
if (fileInfo.baseName().startsWith("rcx_payload"))
continue;
LoadPlugin(fileInfo.absoluteFilePath());
}
}
if (!foundAnyDir)
qWarning() << "PluginManager: Plugins directory not found. Searched:" << pluginDirs;
else
qDebug() << "PluginManager: Found" << totalCandidates << "potential plugin(s)";
qDebug() << "PluginManager: Loaded" << m_plugins.count() << "plugin(s)";
}
@@ -83,7 +101,7 @@ bool PluginManager::LoadPlugin(const QString& path)
qDebug() << "PluginManager: Loaded plugin:" << plugin->Name().c_str() << plugin->Version().c_str() << "by" << plugin->Author().c_str();
// Store plugin entry
m_entries.append({library, plugin});
m_entries.push_back(PluginEntry{library, plugin});
m_plugins.append(plugin);
// Auto-register providers in global registry

View File

@@ -87,6 +87,9 @@ public:
struct ThreadInfo { uint64_t tebAddress; uint32_t threadId; };
virtual QVector<ThreadInfo> tebs() const { return {}; }
struct ModuleEntry { QString name; QString fullPath; uint64_t base; uint64_t size; };
virtual QVector<ModuleEntry> enumerateModules() const { return {}; }
// --- Kernel paging capabilities (override in kernel providers) ---
virtual bool hasKernelPaging() const { return false; }
virtual uint64_t getCr3() const { return 0; }

View File

@@ -1,241 +1,173 @@
#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)
#include <QApplication>
#include <QMouseEvent>
#include <functional>
namespace rcx {
// ── Modern arrow tooltip ──
// Draws a rounded-rect body with a triangular arrow whose tip touches
// the anchor point (center of the dwell area).
//
// Bypasses Fusion/CSS/DWM entirely — everything is manual QPainter on a
// WA_TranslucentBackground layered window. The DarkTitleBar property is
// pre-set to prevent DarkApp::notify from calling DwmSetWindowAttribute
// (which was the root cause of the previous transparent-window failure).
//
// Usage:
// tip->setTheme(bg, border, titleCol, bodyCol, sepCol);
// tip->populate("Title", "line1\nline2", font);
// tip->showAt(QPoint(midX, lineBottom)); // arrow tip at this point
// tip->dismiss();
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;
static constexpr int kArrowH = 8;
static constexpr int kArrowW = 14;
static constexpr int kRadius = 6;
static constexpr int kPad = 10;
static constexpr int kGap = 4;
static constexpr int kMaxW = 550;
std::function<void(QMouseEvent*)> onMouseMove;
explicit RcxTooltip(QWidget* parent = nullptr)
: QWidget(parent, Qt::ToolTip | Qt::FramelessWindowHint)
{
// ── Key fix: prevent DwmSetWindowAttribute on this window ──
// DarkApp::notify checks this property and skips DWM calls.
// Without this, DWMWA_USE_IMMERSIVE_DARK_MODE breaks WS_EX_LAYERED
// alpha compositing on Windows 10/11.
setProperty("DarkTitleBar", true);
setAttribute(Qt::WA_TranslucentBackground);
setAttribute(Qt::WA_ShowWithoutActivating);
setAttribute(Qt::WA_DeleteOnClose, false);
setMouseTracking(true);
}
void showFor(QWidget* trigger, const QString& text) {
if (!trigger || text.isEmpty()) {
TIP_LOG("[TIP] showFor: null trigger or empty text -- dismiss\n");
dismiss(); return;
}
void setTheme(const QColor& bg, const QColor& border,
const QColor& title, const QColor& body, const QColor& sep) {
m_bg = bg; m_border = border;
m_titleCol = title; m_bodyCol = body; m_sepCol = sep;
}
// 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;
}
void populate(const QString& title, const QString& body, const QFont& font) {
if (title == m_title && body == m_body && isVisible()) return;
m_title = title; m_body = body;
m_lines = body.split('\n');
m_font = font;
m_font.setPointSizeF(font.pointSizeF() * 0.9);
m_bold = m_font; m_bold.setBold(true);
recalc();
}
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);
// `anchor`: global screen point where the arrow tip touches.
// Typically the center-bottom of the hovered span.
void showAt(const QPoint& anchor) {
QRect scr = screenAt(anchor);
int w = m_bw, h = m_bh + kArrowH;
m_up = (anchor.y() + h <= scr.bottom());
int x = qBound(scr.left() + 2, anchor.x() - w / 2, scr.right() - w - 2);
int y = m_up ? anchor.y() : anchor.y() - h;
m_ax = qBound(kRadius + kArrowW/2 + 1, anchor.x() - x,
w - kRadius - kArrowW/2 - 1);
setFixedSize(w, h);
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();
}
if (!isVisible()) show();
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;
void dismiss() { if (isVisible()) hide(); }
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);
// Body rect (excludes arrow space)
QRectF b(0.5, m_up ? kArrowH + 0.5 : 0.5,
width() - 1.0, m_bh - 1.0);
qreal r = kRadius, ax = m_ax, ah = kArrowW / 2.0;
// 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);
// ── Single contiguous path: rounded rect + arrow notch ──
// No QPainterPath::united() — that causes junction artifacts.
// Clockwise from top-left, inserting the arrow inline.
QPainterPath pp;
pp.moveTo(b.left() + r, b.top());
if (m_up) {
pp.lineTo(ax - ah, b.top());
pp.lineTo(ax, 0.5);
pp.lineTo(ax + ah, b.top());
}
QPainterPath arrowPath;
arrowPath.addPolygon(arrow);
arrowPath.closeSubpath();
path = path.united(arrowPath);
pp.lineTo(b.right() - r, b.top());
pp.arcTo(b.right() - 2*r, b.top(), 2*r, 2*r, 90, -90);
pp.lineTo(b.right(), b.bottom() - r);
pp.arcTo(b.right() - 2*r, b.bottom() - 2*r, 2*r, 2*r, 0, -90);
if (!m_up) {
pp.lineTo(ax + ah, b.bottom());
pp.lineTo(ax, height() - 0.5);
pp.lineTo(ax - ah, b.bottom());
}
pp.lineTo(b.left() + r, b.bottom());
pp.arcTo(b.left(), b.bottom() - 2*r, 2*r, 2*r, 270, -90);
pp.lineTo(b.left(), b.top() + r);
pp.arcTo(b.left(), b.top(), 2*r, 2*r, 180, -90);
pp.closeSubpath();
// Stroke the shape border
p.setPen(QPen(theme.border, 1.0));
p.setBrush(theme.backgroundAlt);
p.drawPath(path);
p.setPen(QPen(m_border, 1));
p.setBrush(m_bg);
p.drawPath(pp);
// ── Content: title + separator + body ──
qreal cy = (m_up ? kArrowH : 0) + kPad;
QFontMetrics tf(m_bold), bf(m_font);
if (!m_title.isEmpty()) {
p.setFont(m_bold); p.setPen(m_titleCol);
p.drawText(QPointF(kPad, cy + tf.ascent()), m_title);
cy += tf.height() + kGap;
p.setPen(m_sepCol);
p.drawLine(QPointF(kPad, cy), QPointF(width() - kPad, cy));
cy += 1 + kGap;
}
p.setFont(m_font); p.setPen(m_bodyCol);
for (const auto& l : m_lines) {
p.drawText(QPointF(kPad, cy + bf.ascent()), l);
cy += bf.lineSpacing();
}
}
void mouseMoveEvent(QMouseEvent* e) override {
if (onMouseMove) onMouseMove(e); else QWidget::mouseMoveEvent(e);
}
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(); });
static QRect screenAt(const QPoint& pt) {
auto* s = QApplication::screenAt(pt);
return s ? s->availableGeometry() : QRect(0, 0, 1920, 1080);
}
void updateLabelStyle() {
const auto& theme = ThemeManager::instance().current();
m_label->setStyleSheet(
QStringLiteral("QLabel { color: %1; background: transparent; padding: 0; }")
.arg(theme.text.name()));
void recalc() {
QFontMetrics tf(m_bold), bf(m_font);
int maxW = m_title.isEmpty() ? 0 : tf.horizontalAdvance(m_title);
for (const auto& l : m_lines) maxW = qMax(maxW, bf.horizontalAdvance(l));
m_bw = qMin(maxW + 2 * kPad, kMaxW);
m_bh = kPad + (m_title.isEmpty() ? 0 : tf.height() + kGap + 1 + kGap)
+ m_lines.size() * bf.lineSpacing() + kPad;
}
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;
QString m_title, m_body;
QStringList m_lines;
QFont m_font, m_bold;
QColor m_bg{30, 30, 30}, m_border{60, 60, 60};
QColor m_titleCol{220, 220, 220}, m_bodyCol{180, 180, 180}, m_sepCol{60, 60, 60};
bool m_up = true;
int m_ax = 0, m_bw = 0, m_bh = 0;
};
} // namespace rcx

View File

@@ -17,6 +17,7 @@
<file alias="file-binary.svg">vsicons/file-binary.svg</file>
<file alias="debug.svg">vsicons/debug.svg</file>
<file alias="close.svg">vsicons/close.svg</file>
<file alias="cloud-download.svg">vsicons/cloud-download.svg</file>
<file alias="arrow-left.svg">vsicons/arrow-left.svg</file>
<file alias="arrow-right.svg">vsicons/arrow-right.svg</file>
<file alias="split-horizontal.svg">vsicons/split-horizontal.svg</file>
@@ -55,6 +56,7 @@
<file alias="symbol-enum.svg">vsicons/symbol-enum.svg</file>
<file alias="symbol-class.svg">vsicons/symbol-class.svg</file>
<file alias="symbol-variable.svg">vsicons/symbol-variable.svg</file>
<file alias="symbol-method.svg">vsicons/symbol-method.svg</file>
<file alias="server-process.svg">vsicons/server-process.svg</file>
<file alias="remote.svg">vsicons/remote.svg</file>
<file alias="plug.svg">vsicons/plug.svg</file>

View File

@@ -171,8 +171,8 @@ private:
for (const auto& path : s.value("recentFiles").toStringList()) {
QFileInfo fi(path);
if (!fi.exists()) continue;
m_all.append({fi.absoluteFilePath(), fi.fileName(), fi.absolutePath(),
fi.lastModified(), false});
m_all.push_back(Entry{fi.absoluteFilePath(), fi.fileName(), fi.absolutePath(),
fi.lastModified(), false});
}
#ifdef __APPLE__
QDir exDir(QDir::cleanPath(QCoreApplication::applicationDirPath() + "/../Resources/examples"));
@@ -180,8 +180,8 @@ private:
QDir exDir(QCoreApplication::applicationDirPath() + "/examples");
#endif
for (const auto& fn : exDir.entryList({"*.rcx"}, QDir::Files, QDir::Name))
m_all.append({exDir.absoluteFilePath(fn), fn, exDir.absolutePath(),
QFileInfo(exDir.filePath(fn)).lastModified(), true});
m_all.push_back(Entry{exDir.absoluteFilePath(fn), fn, exDir.absolutePath(),
QFileInfo(exDir.filePath(fn)).lastModified(), true});
}
void buildGroups() {
@@ -207,7 +207,7 @@ private:
static const char* names[] = {"Today","Yesterday","This week","This month","Older","Examples"};
m_groups.clear();
for (int i = 0; i < 6; i++)
if (!bk[i].isEmpty()) m_groups.append({names[i], true, bk[i]});
if (!bk[i].isEmpty()) m_groups.push_back(Group{names[i], true, bk[i]});
m_scrollY = 0;
}
@@ -223,13 +223,11 @@ private:
{":/vsicons/debug.svg", "Import PDB", "Import types from a .pdb symbol file"}
};
const int N = 5, CH = 84, R = 6, panelH = N * CH;
const int N = 5, CH = 84, panelH = N * CH;
// Rounded panel background
QPainterPath clip;
clip.addRoundedRect(QRectF(x, y, w, panelH), R, R);
// Sharp-cornered panel background
p.save();
p.setClipPath(clip);
p.setClipRect(QRectF(x, y, w, panelH));
p.fillRect(x, y, w, panelH, m_t.background);
for (int i = 0; i < N; i++) {
@@ -289,7 +287,7 @@ private:
if (gi > 0) fy += 15;
// Group header
m_grpRects.append({gi, QRectF(x, fy, w, 28)});
m_grpRects.emplaceBack(gi, QRectF(x, fy, w, 28));
p.setPen(Qt::NoPen); p.setBrush(m_t.text);
int triX = x + 8, triY = fy + 11;
QPolygonF tri;
@@ -307,7 +305,7 @@ private:
for (int ei : g.entries) {
auto& e = m_filtered[ei];
QRectF er(x, fy, w, 52);
m_entRects.append({ei, er});
m_entRects.emplaceBack(ei, er);
if (m_hz == HZ_Entry && m_hi == ei) p.fillRect(er, m_t.hover);
drawIcon(p, e.isExample ? ":/vsicons/book.svg" : ":/vsicons/symbol-structure.svg",

123
src/symbol_downloader.cpp Normal file
View File

@@ -0,0 +1,123 @@
#include "symbol_downloader.h"
#include <QNetworkAccessManager>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QStandardPaths>
#include <QUrl>
namespace rcx {
SymbolDownloader::SymbolDownloader(QObject* parent)
: QObject(parent)
, m_nam(new QNetworkAccessManager(this))
{
}
QString SymbolDownloader::cacheDir() {
QString base = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
return base + QStringLiteral("/SymbolCache");
}
QString SymbolDownloader::findCached(const DownloadRequest& req) const {
// Cache layout: cacheDir/pdbName/GUID+age/pdbName
QString path = cacheDir() + QStringLiteral("/%1/%2%3/%1")
.arg(req.pdbName, req.guidString, QString::number(req.age, 16));
if (QFile::exists(path))
return path;
return {};
}
QString SymbolDownloader::findLocal(const QString& moduleFullPath, const QString& pdbName) {
if (moduleFullPath.isEmpty() || pdbName.isEmpty())
return {};
// Check same directory as the module
QString dir = QFileInfo(moduleFullPath).absolutePath();
QString candidate = dir + QStringLiteral("/") + pdbName;
if (QFile::exists(candidate))
return candidate;
return {};
}
void SymbolDownloader::download(const DownloadRequest& req) {
// URL: https://msdl.microsoft.com/download/symbols/{pdbName}/{GUID}{age}/{pdbName}
QString url = QStringLiteral("https://msdl.microsoft.com/download/symbols/%1/%2%3/%1")
.arg(req.pdbName, req.guidString, QString::number(req.age, 16));
QUrl reqUrl(url);
QNetworkRequest netReq(reqUrl);
netReq.setHeader(QNetworkRequest::UserAgentHeader,
QStringLiteral("Microsoft-Symbol-Server/10.0.0.0"));
netReq.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
QNetworkRequest::NoLessSafeRedirectPolicy);
cancel(); // cancel any previous
m_activeReply = m_nam->get(netReq);
QString moduleName = req.moduleName;
QString pdbName = req.pdbName;
QString guidString = req.guidString;
uint32_t age = req.age;
connect(m_activeReply, &QNetworkReply::downloadProgress,
this, [this, moduleName](qint64 received, qint64 total) {
emit progress(moduleName, static_cast<int>(received), static_cast<int>(total));
});
connect(m_activeReply, &QNetworkReply::finished,
this, [this, moduleName, pdbName, guidString, age]() {
auto* reply = m_activeReply;
m_activeReply = nullptr;
if (!reply) return;
reply->deleteLater();
if (reply->error() != QNetworkReply::NoError) {
emit finished(moduleName, {}, false,
QStringLiteral("Download failed: %1").arg(reply->errorString()));
return;
}
int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
if (httpStatus != 200) {
emit finished(moduleName, {}, false,
QStringLiteral("HTTP %1").arg(httpStatus));
return;
}
QByteArray data = reply->readAll();
if (data.isEmpty()) {
emit finished(moduleName, {}, false, QStringLiteral("Empty response"));
return;
}
// Save to cache
QString dir = cacheDir() + QStringLiteral("/%1/%2%3")
.arg(pdbName, guidString, QString::number(age, 16));
QDir().mkpath(dir);
QString path = dir + QStringLiteral("/") + pdbName;
QFile f(path);
if (!f.open(QIODevice::WriteOnly)) {
emit finished(moduleName, {}, false,
QStringLiteral("Cannot write: %1").arg(f.errorString()));
return;
}
f.write(data);
f.close();
emit finished(moduleName, path, true, {});
});
}
void SymbolDownloader::cancel() {
if (m_activeReply) {
m_activeReply->abort();
m_activeReply->deleteLater();
m_activeReply = nullptr;
}
}
} // namespace rcx

50
src/symbol_downloader.h Normal file
View File

@@ -0,0 +1,50 @@
#pragma once
#include <QObject>
#include <QString>
#include <QVector>
#include <cstdint>
class QNetworkAccessManager;
class QNetworkReply;
namespace rcx {
class SymbolDownloader : public QObject {
Q_OBJECT
public:
explicit SymbolDownloader(QObject* parent = nullptr);
struct DownloadRequest {
QString moduleName; // display name (e.g. "ntoskrnl.exe")
QString pdbName; // PDB filename (e.g. "ntoskrnl.pdb")
QString guidString; // 32 hex chars, no dashes
uint32_t age = 0;
};
// Check if PDB exists in local cache. Returns path or empty.
QString findCached(const DownloadRequest& req) const;
// Check if PDB exists next to the module on disk. Returns path or empty.
static QString findLocal(const QString& moduleFullPath, const QString& pdbName);
// Start downloading a PDB from MS symbol server.
// Emits finished() when done (success or failure).
void download(const DownloadRequest& req);
// Cancel any in-progress download.
void cancel();
// Local symbol cache directory.
static QString cacheDir();
signals:
void progress(const QString& moduleName, int bytesReceived, int bytesTotal);
void finished(const QString& moduleName, const QString& localPath,
bool success, const QString& error);
private:
QNetworkAccessManager* m_nam = nullptr;
QNetworkReply* m_activeReply = nullptr;
};
} // namespace rcx

191
src/symbolstore.cpp Normal file
View File

@@ -0,0 +1,191 @@
#include "symbolstore.h"
#include "providers/provider.h"
#include <QDebug>
namespace rcx {
uint64_t SymbolStore::getModuleBase(const Provider* provider, const QString& canonical) const {
if (!provider)
return 0;
uint64_t base = provider->symbolToAddress(canonical);
if (base == 0)
base = provider->symbolToAddress(canonical + QStringLiteral(".exe"));
if (base == 0)
base = provider->symbolToAddress(canonical + QStringLiteral(".dll"));
if (base == 0)
base = provider->symbolToAddress(canonical + QStringLiteral(".sys"));
return base;
}
int SymbolStore::addModule(const QString& moduleName, const QString& pdbPath,
const QVector<QPair<QString, uint32_t>>& symbols) {
QString canonical = resolveAlias(moduleName);
PdbSymbolSet set;
set.pdbPath = pdbPath;
set.moduleName = canonical;
set.nameToRva.reserve(symbols.size());
set.rvaToName.reserve(symbols.size());
for (const auto& sym : symbols) {
if (set.nameToRva.contains(sym.first))
continue;
set.nameToRva.insert(sym.first, sym.second);
set.rvaToName.emplaceBack(sym.second, sym.first);
}
set.sortRvaIndex();
int count = set.nameToRva.size();
// Register the raw module name as an alias if it differs from canonical
QString rawLower = moduleName.toLower();
if (rawLower.endsWith(QStringLiteral(".exe")) || rawLower.endsWith(QStringLiteral(".dll")) ||
rawLower.endsWith(QStringLiteral(".sys")))
rawLower = rawLower.left(rawLower.lastIndexOf('.'));
if (rawLower != canonical)
m_aliases[rawLower] = canonical;
m_modules[canonical] = std::move(set);
qDebug() << "[SymbolStore] loaded" << count << "symbols for module" << canonical
<< "(from" << pdbPath << ")";
return count;
}
void SymbolStore::addModuleTypeIndices(const QString& moduleName,
const QHash<QString, uint32_t>& nameToTypeIndex) {
QString canonical = resolveAlias(moduleName);
auto it = m_modules.find(canonical);
if (it == m_modules.end()) return;
it->nameToTypeIndex = nameToTypeIndex;
}
uint32_t SymbolStore::typeIndexForSymbol(const QString& qualifiedSymbol) const {
int bangIdx = qualifiedSymbol.indexOf('!');
if (bangIdx <= 0 || bangIdx >= qualifiedSymbol.size() - 1)
return 0;
QString modPart = qualifiedSymbol.left(bangIdx);
QString symPart = qualifiedSymbol.mid(bangIdx + 1);
QString canonical = resolveAlias(modPart);
auto modIt = m_modules.find(canonical);
if (modIt == m_modules.end()) return 0;
return modIt->nameToTypeIndex.value(symPart, 0);
}
void SymbolStore::unloadModule(const QString& moduleName) {
QString canonical = resolveAlias(moduleName);
m_modules.remove(canonical);
}
uint64_t SymbolStore::resolve(const QString& token, const Provider* provider, bool* ok) const {
*ok = false;
// Check for "module!symbol" syntax
int bangIdx = token.indexOf('!');
if (bangIdx > 0 && bangIdx < token.size() - 1) {
QString modPart = token.left(bangIdx);
QString symPart = token.mid(bangIdx + 1);
QString canonical = resolveAlias(modPart);
auto modIt = m_modules.find(canonical);
if (modIt == m_modules.end())
return 0;
auto symIt = modIt->nameToRva.find(symPart);
if (symIt == modIt->nameToRva.end())
return 0;
uint32_t rva = *symIt;
uint64_t moduleBase = getModuleBase(provider, canonical);
// Also try the user-supplied module name form
if (moduleBase == 0)
moduleBase = getModuleBase(provider, modPart);
*ok = true;
return moduleBase + rva;
}
// Bare symbol — search all loaded modules
uint32_t foundRva = 0;
QString foundModule;
int matches = 0;
for (auto it = m_modules.begin(); it != m_modules.end(); ++it) {
auto symIt = it->nameToRva.find(token);
if (symIt != it->nameToRva.end()) {
foundRva = *symIt;
foundModule = it.key();
matches++;
if (matches > 1)
return 0; // ambiguous
}
}
if (matches == 1) {
uint64_t moduleBase = getModuleBase(provider, foundModule);
*ok = true;
return moduleBase + foundRva;
}
// Fallback: treat bare token as a module name (e.g. "ntdll" → ntdll base)
if (matches == 0) {
QString canonical = resolveAlias(token);
uint64_t moduleBase = getModuleBase(provider, canonical);
if (moduleBase != 0) {
*ok = true;
return moduleBase;
}
}
return 0;
}
QString SymbolStore::getSymbolForAddress(uint64_t addr, const Provider* provider) const {
if (m_modules.isEmpty() || !provider)
return {};
for (auto it = m_modules.begin(); it != m_modules.end(); ++it) {
const PdbSymbolSet& set = *it;
uint64_t moduleBase = getModuleBase(provider, set.moduleName);
if (moduleBase == 0)
continue;
if (addr < moduleBase)
continue;
uint32_t rva = static_cast<uint32_t>(addr - moduleBase);
if (set.rvaToName.isEmpty())
continue;
// Binary search: find last entry with RVA <= target
auto upper = std::upper_bound(set.rvaToName.begin(), set.rvaToName.end(), rva,
[](uint32_t val, const QPair<uint32_t, QString>& entry) {
return val < entry.first;
});
if (upper == set.rvaToName.begin())
continue;
--upper;
uint32_t displacement = rva - upper->first;
static constexpr uint32_t kMaxDisplacement = 0x1000;
if (displacement > kMaxDisplacement)
continue;
if (displacement == 0)
return set.moduleName + QStringLiteral("!") + upper->second;
return set.moduleName + QStringLiteral("!") + upper->second
+ QStringLiteral("+0x") + QString::number(displacement, 16);
}
return {};
}
void SymbolStore::addAlias(const QString& alias, const QString& canonicalModule) {
m_aliases[alias.toLower()] = canonicalModule.toLower();
}
} // namespace rcx

105
src/symbolstore.h Normal file
View File

@@ -0,0 +1,105 @@
#pragma once
#include <QString>
#include <QStringList>
#include <QHash>
#include <QVector>
#include <QPair>
#include <algorithm>
namespace rcx {
class Provider; // forward declaration
struct PdbSymbolSet {
QString pdbPath;
QString moduleName; // canonical lowercase name (e.g. "ntoskrnl")
QHash<QString, uint32_t> nameToRva;
QHash<QString, uint32_t> nameToTypeIndex; // symbol name → TPI typeIndex (0 = no type info)
QVector<QPair<uint32_t, QString>> rvaToName; // sorted by RVA for binary search
void sortRvaIndex() {
std::sort(rvaToName.begin(), rvaToName.end(),
[](const auto& a, const auto& b) { return a.first < b.first; });
}
};
class SymbolStore {
public:
static SymbolStore& instance() {
static SymbolStore s;
return s;
}
// Add a pre-extracted symbol set for a module.
// moduleName is the canonical name (e.g. "ntoskrnl").
// Returns the number of unique symbols stored.
int addModule(const QString& moduleName, const QString& pdbPath,
const QVector<QPair<QString, uint32_t>>& symbols);
// Store symbol→typeIndex mapping for a previously-added module.
// Called after addModule with the typeIndex data from PdbSymbol records.
void addModuleTypeIndices(const QString& moduleName,
const QHash<QString, uint32_t>& nameToTypeIndex);
// Look up the TPI typeIndex for a qualified symbol (e.g. "ntdll!g_pShimEngineModule").
// Returns 0 if not found or no type info available.
uint32_t typeIndexForSymbol(const QString& qualifiedSymbol) const;
// Unload symbols for a module.
void unloadModule(const QString& moduleName);
// Resolve a token from the expression parser.
// Handles "module!symbol" (qualified) and bare "symbol" (unqualified).
// Uses provider->symbolToAddress() to get the module's runtime base address.
uint64_t resolve(const QString& token, const Provider* provider, bool* ok) const;
// Reverse lookup: given an absolute address and a provider, find the nearest symbol.
// Returns "module!symbol" or "module!symbol+0xN", or empty if no match.
QString getSymbolForAddress(uint64_t addr, const Provider* provider) const;
// Check if any symbols are loaded.
bool hasSymbols() const { return !m_modules.isEmpty(); }
// List loaded module names.
QStringList loadedModules() const { return m_modules.keys(); }
// Number of loaded modules.
int moduleCount() const { return m_modules.size(); }
// Access module data by name (returns nullptr if not found).
const PdbSymbolSet* moduleData(const QString& moduleName) const {
QString canonical = resolveAlias(moduleName);
auto it = m_modules.find(canonical);
return it != m_modules.end() ? &*it : nullptr;
}
// Add a module alias (e.g. "nt" → "ntoskrnl").
void addAlias(const QString& alias, const QString& canonicalModule);
// Resolve alias to canonical module name (public for callers that need it)
QString resolveAlias(const QString& name) const {
QString lower = name.toLower();
if (lower.endsWith(QStringLiteral(".exe")) || lower.endsWith(QStringLiteral(".dll")) ||
lower.endsWith(QStringLiteral(".sys")))
lower = lower.left(lower.lastIndexOf('.'));
auto it = m_aliases.find(lower);
return it != m_aliases.end() ? *it : lower;
}
private:
SymbolStore() {
// Common Windows kernel aliases
m_aliases[QStringLiteral("nt")] = QStringLiteral("ntoskrnl");
m_aliases[QStringLiteral("ntkrnlmp")] = QStringLiteral("ntoskrnl");
m_aliases[QStringLiteral("ntkrnlpa")] = QStringLiteral("ntoskrnl");
m_aliases[QStringLiteral("ntkrpamp")] = QStringLiteral("ntoskrnl");
}
// Get the module base address, trying various name forms
uint64_t getModuleBase(const Provider* provider, const QString& canonical) const;
QHash<QString, PdbSymbolSet> m_modules; // canonical lowercase name → symbol set
QHash<QString, QString> m_aliases; // alias → canonical name
};
} // namespace rcx

View File

@@ -4,6 +4,7 @@
#include <QMouseEvent>
#include <QPainter>
#include <QStyle>
#include <QTimer>
#include <QWindow>
namespace rcx {
@@ -25,11 +26,23 @@ TitleBarWidget::TitleBarWidget(QWidget* parent)
m_appLabel->setAttribute(Qt::WA_TransparentForMouseEvents);
layout->addWidget(m_appLabel);
// Menu bar
// Menu bar — hidden on Linux; visible on Windows.
// On Linux, QMenuBar inside a custom widget collapses all items into an
// extension popup. We keep it hidden and mirror its menus as QToolButtons
// via finalizeMenuBar() after createMenus() populates it.
m_menuBar = new QMenuBar(this);
m_menuBar->setNativeMenuBar(false);
#ifdef __linux__
m_useToolButtons = true;
m_menuBar->hide();
m_menuBtnLayout = new QHBoxLayout;
m_menuBtnLayout->setContentsMargins(0, 0, 0, 0);
m_menuBtnLayout->setSpacing(0);
layout->addLayout(m_menuBtnLayout);
#else
m_menuBar->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Expanding);
layout->addWidget(m_menuBar);
#endif
layout->addStretch();
@@ -116,6 +129,17 @@ void TitleBarWidget::applyTheme(const Theme& theme) {
m_btnMin->setStyleSheet(btnStyle);
m_btnMax->setStyleSheet(btnStyle);
// Linux menu tool buttons
if (m_useToolButtons) {
QString menuBtnStyle = QStringLiteral(
"QToolButton { background: transparent; border: none; padding: 0 8px; color: %1; }"
"QToolButton:hover { background: %2; }"
"QToolButton::menu-indicator { image: none; }")
.arg(theme.text.name(), theme.hover.name());
for (auto* btn : m_menuButtons)
btn->setStyleSheet(menuBtnStyle);
}
// Close button: themed red hover
m_btnClose->setStyleSheet(QStringLiteral(
"QToolButton { background: transparent; border: none; }"
@@ -164,6 +188,58 @@ void TitleBarWidget::setMenuBarTitleCase(bool titleCase) {
action->setText("&" + result);
}
}
// Sync tool button labels on Linux
if (m_useToolButtons) {
auto actions = m_menuBar->actions();
for (int i = 0; i < m_menuButtons.size() && i < actions.size(); ++i)
m_menuButtons[i]->setText(actions[i]->text());
}
}
void TitleBarWidget::finalizeMenuBar() {
if (!m_useToolButtons) return;
// Create a QToolButton for each top-level menu in the hidden QMenuBar
for (auto* action : m_menuBar->actions()) {
if (!action->menu()) continue;
auto* btn = new QToolButton(this);
btn->setText(action->text());
btn->setMenu(action->menu());
btn->setPopupMode(QToolButton::InstantPopup);
btn->setAutoRaise(true);
btn->setFocusPolicy(Qt::NoFocus);
btn->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Expanding);
btn->setStyleSheet(QStringLiteral(
"QToolButton { background: transparent; border: none; padding: 0 8px; }"
"QToolButton:hover { background: %1; }"
"QToolButton::menu-indicator { image: none; }")
.arg(m_theme.hover.name()));
btn->installEventFilter(this);
btn->menu()->installEventFilter(this);
m_menuBtnLayout->addWidget(btn);
m_menuButtons.append(btn);
}
}
bool TitleBarWidget::eventFilter(QObject* obj, QEvent* event) {
if (!m_useToolButtons) return QWidget::eventFilter(obj, event);
// Watch for mouse movement inside an open QMenu — if the cursor moves
// over a sibling menu button, close this menu and open the other.
if (event->type() == QEvent::MouseMove) {
auto* menu = qobject_cast<QMenu*>(obj);
if (!menu || !menu->isVisible()) return false;
QPoint globalPos = QCursor::pos();
for (auto* btn : m_menuButtons) {
if (btn->menu() == menu) continue;
QRect btnRect(btn->mapToGlobal(QPoint(0, 0)), btn->size());
if (btnRect.contains(globalPos)) {
menu->close();
QTimer::singleShot(0, btn, [btn]() { btn->showMenu(); });
return true;
}
}
}
return QWidget::eventFilter(obj, event);
}
void TitleBarWidget::updateMaximizeIcon() {

View File

@@ -18,6 +18,7 @@ public:
void setShowIcon(bool show);
void setMenuBarTitleCase(bool titleCase);
bool menuBarTitleCase() const { return m_titleCase; }
void finalizeMenuBar();
void updateMaximizeIcon();
@@ -25,16 +26,20 @@ protected:
void mousePressEvent(QMouseEvent* event) override;
void mouseDoubleClickEvent(QMouseEvent* event) override;
void paintEvent(QPaintEvent* event) override;
bool eventFilter(QObject* obj, QEvent* event) override;
private:
QLabel* m_appLabel = nullptr;
QMenuBar* m_menuBar = nullptr;
QHBoxLayout* m_menuBtnLayout = nullptr;
QVector<QToolButton*> m_menuButtons;
QToolButton* m_btnMin = nullptr;
QToolButton* m_btnMax = nullptr;
QToolButton* m_btnClose = nullptr;
Theme m_theme;
bool m_titleCase = false;
bool m_useToolButtons = false;
QToolButton* makeChromeButton(const QString& iconPath);
void toggleMaximize();

View File

@@ -191,23 +191,26 @@ inline FeatureResult countFlagFeatures(uint32_t val,
// ── Pointer feature checker ──
inline FeatureResult countPtrFeatures64(uint64_t val) {
// Hard reject: common sentinel values are never pointers
// Hard reject: common sentinel values
if (val == 0 || val == 0xFFFFFFFFFFFFFFFFULL || val == 0x00000000FFFFFFFFULL)
return {0, 6};
return {0, 5};
int passed = 0, checked = 6;
// Feature 1: canonical 48-bit address (sign-extended from bit 47)
passed += (val <= 0x00007FFFFFFFFFFFULL
|| val >= 0xFFFF800000000000ULL) ? 1 : 0;
// Feature 2: aligned to 8 (heap/vtable allocations)
// Hard reject: non-canonical address — impossible to dereference on x64
// User-mode: 0x0000000000000000 0x00007FFFFFFFFFFF
// Kernel: 0xFFFF800000000000 0xFFFFFFFFFFFFFFFF
if (val > 0x00007FFFFFFFFFFFULL && val < 0xFFFF800000000000ULL)
return {0, 5};
int passed = 0, checked = 5;
// Feature 1: aligned to 8 (heap/vtable allocations)
passed += ((val & 7) == 0) ? 1 : 0;
// Feature 3: above null guard pages (real addresses >= 64KB)
// Feature 2: above null guard pages (real addresses >= 64KB)
passed += (val >= 0x10000) ? 1 : 0;
// Feature 4: has upper 32 bits (real 64-bit address, not a small constant)
// Feature 3: has upper 32 bits (real 64-bit address, not a small constant)
passed += ((val >> 32) != 0) ? 1 : 0;
// Feature 5: above 4GB (in real 64-bit address space, not a 32-bit value)
// Feature 4: above 4GB (in real 64-bit address space)
passed += (val > 0x100000000ULL) ? 1 : 0;
// Feature 6: user-mode address range (not kernel 0xFFFF800000000000+)
// Feature 5: user-mode address range (not kernel)
passed += (val < 0xFFFF800000000000ULL) ? 1 : 0;
return {passed, checked};
}
@@ -289,13 +292,13 @@ struct Candidate {
};
inline void addCandidate(QVector<Candidate>& out, NodeKind k, int score) {
if (score >= 25) out.append({{k}, score});
if (score >= 25) out.push_back(Candidate{{k}, score});
}
inline void addSplitCandidate(QVector<Candidate>& out, NodeKind k, int count, int score) {
if (score >= 25) {
QVector<NodeKind> kinds(count, k);
out.append({std::move(kinds), score});
out.push_back(Candidate{std::move(kinds), score});
}
}
@@ -308,32 +311,43 @@ inline void tryWhole8(const uint8_t* data, const InferHints& h, QVector<Candidat
if (h.ptrSize == 8)
addCandidate(out, NodeKind::Pointer64, featureScore(countPtrFeatures64(u64)));
// Double
// Double — rare in RE work; require strong evidence
{
double d; std::memcpy(&d, data, 8);
uint64_t exp = (u64 >> 52) & 0x7FF;
int passed = 0, checked = 3;
passed += std::isfinite(d) ? 1 : 0;
passed += (exp > 0 || (u64 & 0x7FFFFFFFFFFFFFFFull) == 0) ? 1 : 0;
double ad = std::fabs(d);
passed += (d == 0.0 || (ad >= 1e-6 && ad <= 1e12)) ? 1 : 0;
addCandidate(out, NodeKind::Double, featureScore({passed, checked}));
uint64_t mantissa = u64 & 0x000FFFFFFFFFFFFFull;
// Hard reject: outside plausible range [1e-6, 1e7] (matches float checker)
bool inRange = (d == 0.0 || (ad >= 1e-6 && ad <= 1e7));
// Hard reject: lower 32 zero with non-zero mantissa (two 32-bit fields)
bool splitField = ((u64 & 0xFFFFFFFF) == 0 && mantissa != 0);
if (inRange && !splitField) {
uint64_t exp = (u64 >> 52) & 0x7FF;
int passed = 0, checked = 4;
// Feature 1: finite
passed += std::isfinite(d) ? 1 : 0;
// Feature 2: non-denormal
passed += (exp > 0 || (u64 & 0x7FFFFFFFFFFFFFFFull) == 0) ? 1 : 0;
// Feature 3: has fractional part or is a small special value
double ip; double frac = std::fabs(std::modf(d, &ip));
passed += (frac > 0.001 || ad <= 1.0) ? 1 : 0;
// Feature 4: not a large exact integer (likely reinterpreted binary data)
passed += !(ad > 1000.0 && frac < 0.001) ? 1 : 0;
addCandidate(out, NodeKind::Double, featureScore({passed, checked}));
}
}
// UTF8
addCandidate(out, NodeKind::UTF8, featureScore(countStringFeatures(data, 8)));
// UInt64 / Int64
{
int passed = 0, checked = 4;
// Feature 1: fits in 32 bits (small constant, not an address)
passed += (u64 <= 0xFFFFFFFFull) ? 1 : 0;
// Feature 2: upper 32 bits are zero (confirms it's a small value, not a pointer)
passed += ((u64 >> 32) == 0) ? 1 : 0;
// Feature 3: non-zero
passed += (u64 != 0) ? 1 : 0;
// Feature 4: monotonic or very small (< 0x10000)
passed += (h.monotonic || u64 < 0x10000) ? 1 : 0;
// UInt64 / Int64 — only meaningful when value exceeds 32-bit range
if ((u64 >> 32) != 0) {
int passed = 0, checked = 3;
// Feature 1: non-zero (always true after guard)
passed += 1;
// Feature 2: reasonable magnitude (below kernel range)
passed += (u64 < 0x0000FFFFFFFFFFFFULL) ? 1 : 0;
// Feature 3: monotonic or page-aligned
passed += (h.monotonic || (u64 & 0xFFF) == 0) ? 1 : 0;
addCandidate(out, NodeKind::UInt64, featureScore({passed, checked}));
}
}
@@ -467,7 +481,7 @@ inline QVector<TypeSuggestion> pruneAndRank(QVector<Candidate>& cands, int maxRe
for (const auto& c : deduped) {
int str = strengthFromScore(c.score);
if (str > 0)
result.append({c.kinds, c.score, str});
result.push_back(TypeSuggestion{c.kinds, c.score, str});
}
return result;
}

View File

@@ -1030,7 +1030,7 @@ void TypeSelectorPopup::applyFilter(const QString& text) {
else if (t.category == TypeEntry::CatEnum) enumCount++;
else typeCount++;
if (catAllowed(t))
scored.append({i, sc, std::move(pos)});
scored.push_back(Scored{i, sc, std::move(pos)});
}
std::sort(scored.begin(), scored.end(),
[](const Scored& a, const Scored& b) { return a.score > b.score; });

View File

@@ -108,9 +108,9 @@ inline void buildProjectExplorer(QStandardItemModel* model,
const Node& n = tab.tree->nodes[idx];
if (n.kind != NodeKind::Struct) continue;
if (n.resolvedClassKeyword() == QStringLiteral("enum"))
enums.append({&n, tab.subPtr, tab.tree});
enums.push_back(Entry{&n, tab.subPtr, tab.tree});
else
types.append({&n, tab.subPtr, tab.tree});
types.push_back(Entry{&n, tab.subPtr, tab.tree});
}
}
@@ -152,7 +152,7 @@ inline void syncProjectExplorer(QStandardItemModel* model,
const Node& n = tab.tree->nodes[idx];
if (n.kind != NodeKind::Struct) continue;
bool ie = n.resolvedClassKeyword() == QStringLiteral("enum");
desired.append({n.id, &n, tab.subPtr, tab.tree, ie});
desired.push_back(Entry{n.id, &n, tab.subPtr, tab.tree, ie});
}
}

View File

@@ -202,7 +202,7 @@ void BenchProject::benchBuildWorkspaceModel()
// Build TabInfo array
QVector<TabInfo> tabs;
for (const auto& t : trees)
tabs.append({ &t, QStringLiteral("test"), nullptr });
tabs.push_back(TabInfo{ &t, QStringLiteral("test"), nullptr });
QStandardItemModel model;
const int ITERS = 20;
@@ -244,7 +244,7 @@ void BenchProject::benchWorkspaceSearch()
QVector<TabInfo> tabs;
for (const auto& t : trees)
tabs.append({ &t, QStringLiteral("test"), nullptr });
tabs.push_back(TabInfo{ &t, QStringLiteral("test"), nullptr });
QStandardItemModel model;
buildProjectExplorer(&model, tabs);

222
tests/grab_tabs.cpp Normal file
View File

@@ -0,0 +1,222 @@
#include <QtTest/QTest>
#include <QApplication>
#include <QMainWindow>
#include <QDockWidget>
#include <QTabBar>
#include <QTextEdit>
#include <QPixmap>
#include <QToolButton>
#include <QHBoxLayout>
#include <QProxyStyle>
#include <QStyleOptionTab>
#include <QSettings>
#include <QPainter>
#include "../src/themes/thememanager.h"
// Minimal replica of the real app's MenuBarStyle for dock tab painting
class TestTabStyle : public QProxyStyle {
public:
using QProxyStyle::QProxyStyle;
QSize sizeFromContents(ContentsType type, const QStyleOption* opt,
const QSize& sz, const QWidget* w) const override {
QSize s = QProxyStyle::sizeFromContents(type, opt, sz, w);
if (type == CT_TabBarTab) {
if (auto* tabBar = qobject_cast<const QTabBar*>(w)) {
if (tabBar->parent() && qobject_cast<const QMainWindow*>(tabBar->parent()))
s.setHeight(28);
}
}
return s;
}
void drawControl(ControlElement element, const QStyleOption* opt,
QPainter* p, const QWidget* w) const override {
// Tab shape — background, accent line, borders
if (element == CE_TabBarTabShape) {
if (auto* tab = qstyleoption_cast<const QStyleOptionTab*>(opt)) {
auto* tabBar = qobject_cast<const QTabBar*>(w);
if (tabBar && tabBar->parent() && qobject_cast<QMainWindow*>(tabBar->parent())) {
bool selected = tab->state & State_Selected;
bool hovered = tab->state & State_MouseOver;
QColor bg = tab->palette.color(QPalette::Window);
if (hovered && !selected)
bg = tab->palette.color(QPalette::Mid);
p->fillRect(tab->rect, bg);
if (selected)
p->fillRect(QRect(tab->rect.left(), tab->rect.top(),
tab->rect.width(), 2),
tab->palette.color(QPalette::Link));
p->setPen(tab->palette.color(QPalette::Dark));
p->drawLine(tab->rect.bottomLeft(), tab->rect.bottomRight());
return;
}
}
}
// Tab label — middle-elide long names, editor font
if (element == CE_TabBarTabLabel) {
if (auto* tab = qstyleoption_cast<const QStyleOptionTab*>(opt)) {
auto* tabBar = qobject_cast<const QTabBar*>(w);
if (tabBar && tabBar->parent() && qobject_cast<QMainWindow*>(tabBar->parent())) {
int tabIdx = -1;
for (int i = 0; i < tabBar->count(); ++i) {
if (tabBar->tabRect(i).contains(tab->rect.center())) { tabIdx = i; break; }
}
int btnWidth = 0;
if (tabIdx >= 0) {
auto* btn = tabBar->tabButton(tabIdx, QTabBar::RightSide);
if (btn) btnWidth = btn->sizeHint().width() + 4;
}
QRect textRect = tab->rect.adjusted(8, 0, -(8 + btnWidth), 0);
QFont f("JetBrains Mono", 10);
f.setFixedPitch(true);
p->setFont(f);
QFontMetrics fm(f);
QString text = (tabIdx >= 0) ? tabBar->tabText(tabIdx) : tab->text;
int maxW = textRect.width();
if (fm.horizontalAdvance(text) > maxW) {
int ellW = fm.horizontalAdvance(QStringLiteral("\u2026"));
int avail = maxW - ellW;
if (avail > 0) {
int half = avail / 2;
QString left, right;
for (int i = 0; i < text.size(); ++i)
if (fm.horizontalAdvance(text.left(i+1)) > half) { left = text.left(i); break; }
if (left.isEmpty()) left = text.left(1);
for (int i = text.size()-1; i >= 0; --i)
if (fm.horizontalAdvance(text.mid(i)) > half) { right = text.mid(i+1); break; }
if (right.isEmpty()) right = text.right(1);
text = left + QStringLiteral("\u2026") + right;
} else {
text = QStringLiteral("\u2026");
}
}
bool selected = tab->state & QStyle::State_Selected;
p->setPen(selected ? tab->palette.color(QPalette::Text)
: tab->palette.color(QPalette::WindowText));
p->drawText(textRect, Qt::AlignVCenter | Qt::AlignLeft, text);
return;
}
}
}
QProxyStyle::drawControl(element, opt, p, w);
}
};
class TabBtns : public QWidget {
public:
explicit TabBtns(const QColor& hover, QWidget* parent = nullptr) : QWidget(parent) {
auto* hl = new QHBoxLayout(this);
hl->setContentsMargins(2, 0, 0, 0);
hl->setSpacing(0);
QString style = QStringLiteral(
"QToolButton { border: none; padding: 1px; border-radius: 0px; }"
"QToolButton:hover { background: %1; }").arg(hover.name());
auto* pin = new QToolButton(this);
pin->setFixedSize(16, 16);
pin->setAutoRaise(true);
pin->setIcon(QIcon(":/vsicons/pin.svg"));
pin->setIconSize(QSize(12, 12));
pin->setStyleSheet(style);
hl->addWidget(pin);
auto* close = new QToolButton(this);
close->setFixedSize(16, 16);
close->setAutoRaise(true);
close->setIcon(QIcon(":/vsicons/close.svg"));
close->setIconSize(QSize(12, 12));
close->setStyleSheet(style);
hl->addWidget(close);
}
};
class GrabTabs : public QObject {
Q_OBJECT
private slots:
void grab() {
const auto& t = rcx::ThemeManager::instance().current();
// Install custom style (no stylesheet — all painting via style)
QApplication::setStyle(new TestTabStyle("Fusion"));
// Apply dark palette globally
QPalette pal;
pal.setColor(QPalette::Window, t.background);
pal.setColor(QPalette::WindowText, t.textDim);
pal.setColor(QPalette::Base, t.background);
pal.setColor(QPalette::Text, t.text);
pal.setColor(QPalette::Mid, t.hover);
pal.setColor(QPalette::Dark, t.border);
pal.setColor(QPalette::Link, t.indHoverSpan);
QApplication::setPalette(pal);
auto* win = new QMainWindow;
win->resize(700, 500);
win->setDockNestingEnabled(true);
win->setTabPosition(Qt::TopDockWidgetArea, QTabWidget::North);
auto* central = new QWidget(win);
central->setMaximumSize(0, 0);
central->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored);
win->setCentralWidget(central);
win->setStyleSheet(QStringLiteral(
"QMainWindow::separator { width: 0px; height: 0px; background: transparent; }"));
QStringList names = {
"shader_color_helper.hpp",
"shader_crypt.cpp",
"EPROCESS (class)",
"very_long_struct_name_that_should_elide.h"
};
QVector<QDockWidget*> docks;
for (const auto& name : names) {
auto* dock = new QDockWidget(name, win);
dock->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetMovable);
auto* emptyTitle = new QWidget(dock);
emptyTitle->setFixedHeight(0);
dock->setTitleBarWidget(emptyTitle);
dock->setWidget(new QTextEdit(dock));
if (!docks.isEmpty())
win->tabifyDockWidget(docks.last(), dock);
else
win->addDockWidget(Qt::TopDockWidgetArea, dock);
docks.append(dock);
}
// Select first tab
docks.first()->raise();
win->show();
QVERIFY(QTest::qWaitForWindowExposed(win));
QApplication::processEvents();
// No stylesheet on dock tab bars — painting handled by TestTabStyle
for (auto* tabBar : win->findChildren<QTabBar*>()) {
if (tabBar->parent() != win) continue;
tabBar->setStyleSheet(QString());
tabBar->setElideMode(Qt::ElideNone);
tabBar->setExpanding(false);
QPalette tp = tabBar->palette();
tp.setColor(QPalette::WindowText, t.textDim);
tp.setColor(QPalette::Text, t.text);
tp.setColor(QPalette::Window, t.background);
tp.setColor(QPalette::Mid, t.hover);
tp.setColor(QPalette::Dark, t.border);
tp.setColor(QPalette::Link, t.indHoverSpan);
tabBar->setPalette(tp);
for (int i = 0; i < tabBar->count(); ++i)
tabBar->setTabButton(i, QTabBar::RightSide, new TabBtns(t.hover, tabBar));
}
QApplication::processEvents();
QApplication::processEvents();
QPixmap shot = win->grab(QRect(0, 0, win->width(), 50));
shot.save(QStringLiteral("tab_screenshot.png"));
qDebug() << "Saved" << shot.size();
delete win;
}
};
QTEST_MAIN(GrabTabs)
#include "grab_tabs.moc"

View File

@@ -382,6 +382,30 @@ private slots:
QCOMPARE(r.value, 0x140000000ULL);
}
// -- Bare module.dll identifier --
void bareModuleDll() {
AddressParserCallbacks cbs;
cbs.resolveIdentifier = [](const QString& name, bool* ok) -> uint64_t {
*ok = (name == "client.dll");
return *ok ? 0x7FF600000000ULL : 0;
};
auto r = AddressParser::evaluate("client.dll + 0xFF", 8, &cbs);
QVERIFY(r.ok);
QCOMPARE(r.value, 0x7FF6000000FFULL);
}
void bareModuleExe() {
AddressParserCallbacks cbs;
cbs.resolveIdentifier = [](const QString& name, bool* ok) -> uint64_t {
*ok = (name == "cs2.exe");
return *ok ? 0x140000000ULL : 0;
};
auto r = AddressParser::evaluate("cs2.exe + 0xDE", 8, &cbs);
QVERIFY(r.ok);
QCOMPARE(r.value, 0x1400000DEULL);
}
// -- Validate with new syntax --
void validateIdentifier() {

View File

@@ -230,7 +230,7 @@ private slots:
// Only include the pointer-expanded ones (near vtable at 0x100)
if (lm.offsetAddr >= 0x100 && lm.offsetAddr < 0x200) {
int nodeIdx = lm.nodeIdx;
funcPtrs.append({i, lm.offsetAddr, lm.nodeKind,
funcPtrs.push_back(FuncInfo{i, lm.offsetAddr, lm.nodeKind,
nodeIdx >= 0 ? tree.nodes[nodeIdx].name : QString()});
}
}

View File

@@ -0,0 +1,397 @@
#include <QTest>
#include <QSignalSpy>
#include <QByteArray>
#include <cstring>
#include "providers/provider.h"
#include "scanner.h"
#include "../plugins/KernelMemory/KernelMemoryPlugin.h"
#ifdef _WIN32
#include <windows.h>
#include <tlhelp32.h>
#endif
using namespace rcx;
class TestKernelProvider : public QObject {
Q_OBJECT
private:
bool m_driverAvailable = false;
KernelMemoryPlugin* m_plugin = nullptr;
std::unique_ptr<Provider> m_provider;
uint32_t m_selfPid = 0;
private slots:
// ── Setup: try to load driver, skip tests if unavailable ──
void initTestCase()
{
m_plugin = new KernelMemoryPlugin();
#ifdef _WIN32
m_selfPid = GetCurrentProcessId();
// Try to open driver directly to see if it's available
HANDLE h = CreateFileA(RCX_DRV_USERMODE_PATH,
GENERIC_READ | GENERIC_WRITE,
0, nullptr, OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL, nullptr);
if (h != INVALID_HANDLE_VALUE) {
CloseHandle(h);
m_driverAvailable = true;
} else {
// Try loading via plugin
QString errorMsg;
QString target = QStringLiteral("km:%1:self").arg(m_selfPid);
m_provider = m_plugin->createProvider(target, &errorMsg);
if (m_provider && m_provider->isValid()) {
m_driverAvailable = true;
} else {
qWarning("Kernel driver not available: %s", qPrintable(errorMsg));
qWarning("Tests requiring the driver will be skipped.");
}
}
if (m_driverAvailable && !m_provider) {
QString target = QStringLiteral("km:%1:self").arg(m_selfPid);
m_provider = m_plugin->createProvider(target, nullptr);
}
#endif
}
void cleanupTestCase()
{
m_provider.reset();
delete m_plugin;
m_plugin = nullptr;
}
// ── 1. Plugin metadata (no driver needed) ──
void plugin_name()
{
QCOMPARE(QString::fromStdString(m_plugin->Name()), QStringLiteral("Kernel Memory"));
}
void plugin_loadType()
{
QCOMPARE(m_plugin->LoadType(), IPlugin::k_ELoadTypeManual);
}
void plugin_canHandle()
{
QVERIFY(m_plugin->canHandle(QStringLiteral("km:1234:test.exe")));
QVERIFY(m_plugin->canHandle(QStringLiteral("phys:0")));
QVERIFY(m_plugin->canHandle(QStringLiteral("msr:")));
QVERIFY(!m_plugin->canHandle(QStringLiteral("1234:test.exe")));
QVERIFY(!m_plugin->canHandle(QStringLiteral("file:test.bin")));
}
void provider_noDriver_invalid()
{
// Creating provider with invalid target should fail gracefully
QString err;
auto prov = m_plugin->createProvider(QStringLiteral("km:0:invalid"), &err);
// Either nullptr or invalid -- both are acceptable
if (prov) QVERIFY(!prov->isValid() || prov->size() == 0);
}
// ── 2. KUSER_SHARED_DATA validation (at 0x7FFE0000) ──
void kusd_ntMajorVersion()
{
if (!m_driverAvailable) QSKIP("Driver not loaded");
QVERIFY(m_provider);
// KUSER_SHARED_DATA.NtMajorVersion at offset 0x26C
uint32_t major = m_provider->readU32(0x7FFE0000 + 0x26C);
QCOMPARE(major, (uint32_t)10); // Windows 10/11
}
void kusd_ntMinorVersion()
{
if (!m_driverAvailable) QSKIP("Driver not loaded");
uint32_t minor = m_provider->readU32(0x7FFE0000 + 0x270);
QCOMPARE(minor, (uint32_t)0); // Windows 10+ has minor = 0
}
void kusd_ntBuildNumber()
{
if (!m_driverAvailable) QSKIP("Driver not loaded");
#ifdef _WIN32
// Cross-validate with RtlGetVersion
typedef NTSTATUS(NTAPI* RtlGetVersion_t)(PRTL_OSVERSIONINFOW);
auto pRtlGetVersion = (RtlGetVersion_t)GetProcAddress(
GetModuleHandleA("ntdll.dll"), "RtlGetVersion");
QVERIFY(pRtlGetVersion);
RTL_OSVERSIONINFOW osvi{};
osvi.dwOSVersionInfoSize = sizeof(osvi);
QCOMPARE(pRtlGetVersion(&osvi), (NTSTATUS)0);
uint32_t buildFromDriver = m_provider->readU32(0x7FFE0000 + 0x260);
QCOMPARE(buildFromDriver, (uint32_t)osvi.dwBuildNumber);
#endif
}
void kusd_systemTime_nonZero()
{
if (!m_driverAvailable) QSKIP("Driver not loaded");
uint64_t sysTime = m_provider->readU64(0x7FFE0000 + 0x14);
QVERIFY(sysTime != 0);
}
void kusd_tickCount_increasing()
{
if (!m_driverAvailable) QSKIP("Driver not loaded");
// TickCountMultiplier at 0x4, TickCount at 0x320
uint64_t tick1 = m_provider->readU64(0x7FFE0000 + 0x320);
QTest::qWait(120);
uint64_t tick2 = m_provider->readU64(0x7FFE0000 + 0x320);
QVERIFY2(tick2 > tick1,
qPrintable(QStringLiteral("tick1=%1 tick2=%2").arg(tick1).arg(tick2)));
}
void kusd_crossValidate_readProcessMemory()
{
if (!m_driverAvailable) QSKIP("Driver not loaded");
#ifdef _WIN32
// Read same KUSD page through driver and ReadProcessMemory
QByteArray driverBuf(256, 0);
m_provider->read(0x7FFE0000, driverBuf.data(), 256);
QByteArray rpmBuf(256, 0);
SIZE_T bytesRead = 0;
HANDLE self = GetCurrentProcess();
ReadProcessMemory(self, (LPCVOID)0x7FFE0000, rpmBuf.data(), 256, &bytesRead);
// NtMajorVersion (offset 0x26C relative = not in first 256 bytes, so compare what we have)
// Compare first 256 bytes -- should be identical
QCOMPARE(driverBuf, rpmBuf);
#endif
}
// ── 3. Self-read integration ──
void selfRead_mzHeader()
{
if (!m_driverAvailable) QSKIP("Driver not loaded");
#ifdef _WIN32
uint64_t selfBase = (uint64_t)GetModuleHandleA(nullptr);
QVERIFY(selfBase != 0);
uint8_t mz[2] = {};
m_provider->read(selfBase, mz, 2);
QCOMPARE(mz[0], (uint8_t)'M');
QCOMPARE(mz[1], (uint8_t)'Z');
#endif
}
void selfRead_peSignature()
{
if (!m_driverAvailable) QSKIP("Driver not loaded");
#ifdef _WIN32
uint64_t selfBase = (uint64_t)GetModuleHandleA(nullptr);
// PE offset at +0x3C
uint32_t peOffset = m_provider->readU32(selfBase + 0x3C);
QVERIFY(peOffset > 0 && peOffset < 0x1000);
// PE signature = "PE\0\0" = 0x00004550
uint32_t peSig = m_provider->readU32(selfBase + peOffset);
QCOMPARE(peSig, (uint32_t)0x00004550);
#endif
}
// ── 4. Scanner integration ──
void scanner_mzSigScan()
{
if (!m_driverAvailable) QSKIP("Driver not loaded");
#ifdef _WIN32
auto shared = std::shared_ptr<Provider>(m_provider.get(), [](Provider*){});
ScanRequest req;
req.pattern = QByteArray("\x4D\x5A", 2);
req.mask = QByteArray("\xFF\xFF", 2);
req.alignment = 1;
req.maxResults = 10;
// Constrain to our own module for speed
uint64_t selfBase = (uint64_t)GetModuleHandleA(nullptr);
req.startAddress = selfBase;
req.endAddress = selfBase + 0x1000;
ScanEngine engine;
QSignalSpy spy(&engine, &ScanEngine::finished);
engine.start(shared, req);
QVERIFY(spy.wait(5000));
auto results = spy.at(0).at(0).value<QVector<ScanResult>>();
QVERIFY(results.size() >= 1);
QCOMPARE(results[0].address, selfBase);
#endif
}
// ── 5. Region enumeration ──
void regions_selfProcess()
{
if (!m_driverAvailable) QSKIP("Driver not loaded");
auto regions = m_provider->enumerateRegions();
QVERIFY(regions.size() > 0);
// Should have at least one executable region (our code)
bool hasExec = false;
for (const auto& r : regions) {
if (r.executable) { hasExec = true; break; }
}
QVERIFY(hasExec);
}
// ── 6. PEB / modules ──
void peb_nonZero()
{
if (!m_driverAvailable) QSKIP("Driver not loaded");
QVERIFY(m_provider->peb() != 0);
}
void symbol_selfModule()
{
if (!m_driverAvailable) QSKIP("Driver not loaded");
#ifdef _WIN32
uint64_t selfBase = (uint64_t)GetModuleHandleA(nullptr);
QString sym = m_provider->getSymbol(selfBase + 0x100);
QVERIFY(!sym.isEmpty());
QVERIFY(sym.contains(QStringLiteral("+0x")));
#endif
}
// ── 7. CR3 / address translation ──
void cr3_nonZero()
{
if (!m_driverAvailable) QSKIP("Driver not loaded");
auto* kprov = dynamic_cast<KernelProcessProvider*>(m_provider.get());
QVERIFY(kprov);
uint64_t cr3 = kprov->getCr3();
QVERIFY2(cr3 != 0, "CR3 should be non-zero for a running process");
// CR3 should be page-aligned (low 12 bits cleared)
QCOMPARE(cr3 & 0xFFF, (uint64_t)0);
}
void vtop_kusd()
{
if (!m_driverAvailable) QSKIP("Driver not loaded");
auto* kprov = dynamic_cast<KernelProcessProvider*>(m_provider.get());
QVERIFY(kprov);
// KUSER_SHARED_DATA is at VA 0x7FFE0000 in every process
auto result = kprov->translateAddress(0x7FFE0000);
QVERIFY2(result.valid, "KUSER_SHARED_DATA should be mapped");
QVERIFY(result.physical != 0);
// PML4E and PDPTE should be present
QVERIFY(result.pml4e & 1); // Present bit
QVERIFY(result.pdpte & 1); // Present bit
}
void vtop_selfModule()
{
if (!m_driverAvailable) QSKIP("Driver not loaded");
#ifdef _WIN32
auto* kprov = dynamic_cast<KernelProcessProvider*>(m_provider.get());
QVERIFY(kprov);
uint64_t selfBase = (uint64_t)GetModuleHandleA(nullptr);
auto result = kprov->translateAddress(selfBase);
QVERIFY2(result.valid, "Own module base should be mapped");
QVERIFY(result.physical != 0);
// Cross-validate: read MZ header via physical address
// Read the first 2 bytes at the physical address using physical provider
auto physEntries = kprov->readPageTable(kprov->getCr3(), 0, 16);
QVERIFY(physEntries.size() > 0); // Should get at least some PML4 entries
#endif
}
void vtop_unmapped()
{
if (!m_driverAvailable) QSKIP("Driver not loaded");
auto* kprov = dynamic_cast<KernelProcessProvider*>(m_provider.get());
QVERIFY(kprov);
// Address 0 should not be mapped in user mode
auto result = kprov->translateAddress(0);
QVERIFY2(!result.valid, "Address 0 should not be mapped");
}
void readPageTable_cr3()
{
if (!m_driverAvailable) QSKIP("Driver not loaded");
auto* kprov = dynamic_cast<KernelProcessProvider*>(m_provider.get());
QVERIFY(kprov);
uint64_t cr3 = kprov->getCr3();
QVERIFY(cr3 != 0);
// Read the full PML4 table (512 entries)
auto entries = kprov->readPageTable(cr3, 0, 512);
QCOMPARE(entries.size(), 512);
// At least some entries should be present (kernel maps upper half)
int presentCount = 0;
for (const auto& e : entries) {
if (e & 1) presentCount++;
}
QVERIFY2(presentCount > 0,
qPrintable(QStringLiteral("Expected present PML4 entries, got 0")));
}
// ── 8. Ping ──
void ping_version()
{
if (!m_driverAvailable) QSKIP("Driver not loaded");
#ifdef _WIN32
HANDLE h = CreateFileA(RCX_DRV_USERMODE_PATH,
GENERIC_READ | GENERIC_WRITE,
0, nullptr, OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL, nullptr);
if (h == INVALID_HANDLE_VALUE) QSKIP("Cannot open driver handle");
RcxDrvPingResponse ping{};
DWORD br = 0;
BOOL ok = DeviceIoControl(h, IOCTL_RCX_PING, nullptr, 0,
&ping, sizeof(ping), &br, nullptr);
CloseHandle(h);
QVERIFY(ok);
QCOMPARE(ping.version, (uint32_t)RCX_DRV_VERSION);
#endif
}
};
QTEST_MAIN(TestKernelProvider)
#include "test_kernel_provider.moc"

View File

@@ -26,7 +26,7 @@ public:
if (!m_server->listen(name)) return false;
connect(m_server, &QLocalServer::newConnection, this, [this]() {
while (auto* s = m_server->nextPendingConnection()) {
m_clients.append({s, {}, false});
m_clients.push_back(Client{s, {}, false});
connect(s, &QLocalSocket::readyRead, this, [this, s]() { processSocket(s); });
connect(s, &QLocalSocket::disconnected, this, [this, s]() {
for (int i = 0; i < m_clients.size(); i++)

143
tests/test_project_dock.cpp Normal file
View File

@@ -0,0 +1,143 @@
#include <QtTest/QTest>
#include <QApplication>
#include <QMainWindow>
#include <QDockWidget>
#include <QTabWidget>
#include <QTextEdit>
// Replicates the real app layout: QTabWidget central widget, project dock in LeftDockWidgetArea.
class TestProjectDock : public QObject {
Q_OBJECT
private:
struct AppLayout {
QMainWindow* win;
QTabWidget* tabs;
QDockWidget* project;
};
AppLayout buildApp() {
auto* win = new QMainWindow;
win->resize(1280, 800);
// QTabWidget as central widget — same as real app
auto* tabs = new QTabWidget(win);
tabs->setTabsClosable(true);
tabs->setMovable(true);
tabs->setDocumentMode(true);
tabs->addTab(new QTextEdit(tabs), "Untitled");
win->setCentralWidget(tabs);
// Project dock — same as real app
auto* project = new QDockWidget("Project", win);
project->setObjectName("WorkspaceDock");
project->setAllowedAreas(Qt::AllDockWidgetAreas);
project->setFeatures(QDockWidget::DockWidgetClosable | QDockWidget::DockWidgetMovable);
project->setWidget(new QTextEdit(project));
win->addDockWidget(Qt::LeftDockWidgetArea, project);
project->hide();
return {win, tabs, project};
}
void showProject(AppLayout& a) {
if (a.project->isHidden() && !a.project->isFloating()) {
a.win->addDockWidget(Qt::LeftDockWidgetArea, a.project);
a.project->show();
a.win->resizeDocks({a.project}, {qMax(200, a.win->width() / 5)}, Qt::Horizontal);
} else {
a.project->show();
}
}
private slots:
void dockStartsLeft();
void dockWidthIsReasonable();
void dockStaysLeftAfterHideShow();
void dockRespectsDragAfterShow();
};
void TestProjectDock::dockStartsLeft()
{
auto app = buildApp();
app.win->show();
QTest::qWaitForWindowExposed(app.win);
showProject(app);
QApplication::processEvents();
// Project should be to the left of the central tab widget
QVERIFY2(app.project->x() < app.tabs->x(),
qPrintable(QString("Project x=%1, Tabs x=%2")
.arg(app.project->x()).arg(app.tabs->x())));
delete app.win;
}
void TestProjectDock::dockWidthIsReasonable()
{
auto app = buildApp();
app.win->show();
QTest::qWaitForWindowExposed(app.win);
showProject(app);
QApplication::processEvents();
int dockWidth = app.project->width();
int winWidth = app.win->width();
double ratio = (double)dockWidth / winWidth;
qDebug() << "Dock width:" << dockWidth << "Window width:" << winWidth
<< "Ratio:" << QString::number(ratio * 100, 'f', 1) + "%";
QVERIFY2(ratio < 0.40,
qPrintable(QString("Dock too wide: %1% of window").arg(ratio * 100, 0, 'f', 1)));
QVERIFY2(ratio > 0.10,
qPrintable(QString("Dock too narrow: %1% of window").arg(ratio * 100, 0, 'f', 1)));
delete app.win;
}
void TestProjectDock::dockStaysLeftAfterHideShow()
{
auto app = buildApp();
app.win->show();
QTest::qWaitForWindowExposed(app.win);
showProject(app);
QApplication::processEvents();
QVERIFY(app.project->x() < app.tabs->x());
app.project->hide();
QApplication::processEvents();
showProject(app);
QApplication::processEvents();
QVERIFY2(app.project->x() < app.tabs->x(),
qPrintable(QString("After re-show: Project x=%1, Tabs x=%2")
.arg(app.project->x()).arg(app.tabs->x())));
delete app.win;
}
void TestProjectDock::dockRespectsDragAfterShow()
{
auto app = buildApp();
app.win->show();
QTest::qWaitForWindowExposed(app.win);
showProject(app);
QApplication::processEvents();
QVERIFY(app.project->x() < app.tabs->x());
// Simulate user dragging to right
app.win->addDockWidget(Qt::RightDockWidgetArea, app.project);
QApplication::processEvents();
QCOMPARE(app.win->dockWidgetArea(app.project), Qt::RightDockWidgetArea);
// Dock is visible — showProject should NOT force it back to left
showProject(app);
QApplication::processEvents();
QCOMPARE(app.win->dockWidgetArea(app.project), Qt::RightDockWidgetArea);
delete app.win;
}
QTEST_MAIN(TestProjectDock)
#include "test_project_dock.moc"

View File

@@ -0,0 +1,307 @@
#include <QtTest/QTest>
#include <QFile>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <algorithm>
#include <random>
#include "core.h"
#include "imports/import_source.h"
#include "generator.h"
using namespace rcx;
class TestRoundtripWinSdk : public QObject {
Q_OBJECT
private:
NodeTree fullTree;
QVector<int> rootIndices;
private slots:
void initTestCase();
void importCount();
void pebOffsets();
void roundTrip30();
void generateRcx();
};
void TestRoundtripWinSdk::initTestCase()
{
QString path = QStringLiteral(WINSDK_HEADER_PATH);
QFile file(path);
QVERIFY2(file.open(QIODevice::ReadOnly | QIODevice::Text),
qPrintable("Cannot open " + path));
QString source = QString::fromUtf8(file.readAll());
QVERIFY(!source.isEmpty());
QString err;
fullTree = importFromSource(source, &err, 8);
for (int i = 0; i < fullTree.nodes.size(); i++) {
const auto& n = fullTree.nodes[i];
if (n.parentId == 0 && n.kind == NodeKind::Struct)
rootIndices.append(i);
}
qDebug() << "Imported" << fullTree.nodes.size() << "total nodes,"
<< rootIndices.size() << "root structs";
}
void TestRoundtripWinSdk::importCount()
{
QVERIFY2(rootIndices.size() >= 3000,
qPrintable(QString("Expected >= 3000 roots, got %1").arg(rootIndices.size())));
}
void TestRoundtripWinSdk::pebOffsets()
{
// Verify _PEB field offsets match WinDbg dt ntdll!_PEB
int pebIdx = -1;
for (int i = 0; i < fullTree.nodes.size(); i++) {
if (fullTree.nodes[i].parentId == 0 &&
fullTree.nodes[i].structTypeName == QStringLiteral("_PEB")) {
pebIdx = i;
break;
}
}
QVERIFY2(pebIdx >= 0, "Could not find _PEB root struct");
uint64_t pebId = fullTree.nodes[pebIdx].id;
// Collect direct children with offsets and sizes
struct ChildInfo { QString name; int offset; int size; NodeKind kind; };
QVector<ChildInfo> children;
for (int i = 0; i < fullTree.nodes.size(); i++) {
if (fullTree.nodes[i].parentId == pebId) {
int sz = sizeForKind(fullTree.nodes[i].kind);
if (sz == 0) sz = fullTree.structSpan(fullTree.nodes[i].id);
if (sz == 0) sz = 1;
children.push_back(ChildInfo{fullTree.nodes[i].name, fullTree.nodes[i].offset, sz, fullTree.nodes[i].kind});
}
}
// Sort by offset
std::sort(children.begin(), children.end(),
[](const ChildInfo& a, const ChildInfo& b) { return a.offset < b.offset; });
// Dump all children for diagnostics
for (const auto& c : children) {
qDebug() << " " << Qt::hex << c.offset << c.name
<< "kind=" << kindToString(c.kind) << "size=" << c.size;
}
// Check for overlaps
int overlapCount = 0;
for (int i = 1; i < children.size(); i++) {
int prevEnd = children[i-1].offset + children[i-1].size;
if (children[i].offset < prevEnd && children[i-1].kind != NodeKind::Struct) {
// Only flag overlaps where previous field has a known size (not struct references)
overlapCount++;
if (overlapCount <= 10)
qDebug() << " OVERLAP:" << children[i].name << "at" << Qt::hex << children[i].offset
<< "overlaps" << children[i-1].name << "(ends at" << Qt::hex << prevEnd << ")";
}
}
// Build name→offset map for field checks
QHash<QString, int> offsets;
QHash<QString, NodeKind> kinds;
for (const auto& c : children) {
offsets[c.name] = c.offset;
kinds[c.name] = c.kind;
}
int failCount = 0;
auto checkField = [&](const QString& name, int expected, bool mustBePointer = false) {
if (!offsets.contains(name)) {
qDebug() << " MISSING:" << name;
failCount++;
return;
}
if (offsets[name] != expected) {
qDebug() << " OFFSET MISMATCH:" << name << "got" << Qt::hex << offsets[name]
<< "expected" << Qt::hex << expected;
failCount++;
return;
}
if (mustBePointer) {
NodeKind k = kinds[name];
if (k != NodeKind::Pointer64 && k != NodeKind::Pointer32) {
qDebug() << " NOT POINTER:" << name << "kind=" << kindToString(k);
failCount++;
}
}
};
// Expected offsets computed from the source header layout (Vergilius-style)
// Note: This header has union ALIGN(8) { KernelCallbackTable; UserSharedInfoPtr; }
// after CrossProcessFlags, which shifts fields +0xC compared to some WinDbg versions.
checkField(QStringLiteral("InheritedAddressSpace"), 0x000);
checkField(QStringLiteral("ReadImageFileExecOptions"), 0x001);
checkField(QStringLiteral("BeingDebugged"), 0x002);
checkField(QStringLiteral("Mutant"), 0x008, true);
checkField(QStringLiteral("ImageBaseAddress"), 0x010, true);
checkField(QStringLiteral("Ldr"), 0x018, true);
checkField(QStringLiteral("ProcessParameters"), 0x020, true);
checkField(QStringLiteral("SubSystemData"), 0x028, true);
checkField(QStringLiteral("ProcessHeap"), 0x030, true);
checkField(QStringLiteral("FastPebLock"), 0x038, true);
checkField(QStringLiteral("AtlThunkSListPtr"), 0x040, true);
checkField(QStringLiteral("IFEOKey"), 0x048, true);
checkField(QStringLiteral("SystemReserved"), 0x060);
checkField(QStringLiteral("AtlThunkSListPtr32"), 0x064);
checkField(QStringLiteral("ApiSetMap"), 0x068, true);
checkField(QStringLiteral("TlsExpansionCounter"), 0x070);
checkField(QStringLiteral("TlsBitmap"), 0x078, true);
checkField(QStringLiteral("TlsBitmapBits"), 0x080);
checkField(QStringLiteral("ReadOnlySharedMemoryBase"), 0x088, true);
checkField(QStringLiteral("SharedData"), 0x090, true);
checkField(QStringLiteral("ReadOnlyStaticServerData"), 0x098, true);
checkField(QStringLiteral("AnsiCodePageData"), 0x0A0, true);
checkField(QStringLiteral("OemCodePageData"), 0x0A8, true);
checkField(QStringLiteral("UnicodeCaseTableData"), 0x0B0, true);
checkField(QStringLiteral("NumberOfProcessors"), 0x0B8);
checkField(QStringLiteral("NtGlobalFlag"), 0x0BC);
checkField(QStringLiteral("HeapSegmentReserve"), 0x0C8);
checkField(QStringLiteral("NumberOfHeaps"), 0x0E8);
checkField(QStringLiteral("MaximumNumberOfHeaps"), 0x0EC);
checkField(QStringLiteral("ProcessHeaps"), 0x0F0, true);
checkField(QStringLiteral("OSMajorVersion"), 0x118);
checkField(QStringLiteral("OSMinorVersion"), 0x11C);
checkField(QStringLiteral("OSBuildNumber"), 0x120);
checkField(QStringLiteral("SessionId"), 0x2C0);
checkField(QStringLiteral("CsrServerReadOnlySharedMemoryBase"), 0x380);
checkField(QStringLiteral("TppWorkerpListLock"), 0x388, true);
checkField(QStringLiteral("WaitOnAddressHashTable"), 0x3A0);
checkField(QStringLiteral("TelemetryCoverageHeader"), 0x7A0, true);
checkField(QStringLiteral("CloudFileFlags"), 0x7A8);
checkField(QStringLiteral("CloudFileDiagFlags"), 0x7AC);
checkField(QStringLiteral("PlaceholderCompatibilityMode"), 0x7B0);
checkField(QStringLiteral("LeapSecondData"), 0x7B8, true);
checkField(QStringLiteral("NtGlobalFlag2"), 0x7C4);
QVERIFY2(failCount == 0,
qPrintable(QString("%1 PEB field(s) have wrong offsets or are missing").arg(failCount)));
}
void TestRoundtripWinSdk::roundTrip30()
{
const int kRequired = 30;
// Deterministic shuffle
QVector<int> shuffled = rootIndices;
std::mt19937 rng(42);
std::shuffle(shuffled.begin(), shuffled.end(), rng);
int passCount = 0;
int failCount = 0;
int skipCount = 0;
for (int ri : shuffled) {
uint64_t rootId = fullTree.nodes[ri].id;
QString structName = fullTree.nodes[ri].structTypeName;
// Pass 1: export from full tree
QString cpp1 = renderCpp(fullTree, rootId, nullptr, true);
if (cpp1.isEmpty()) {
skipCount++;
continue;
}
// Pass 2: re-import
QString err;
NodeTree tree2 = importFromSource(cpp1, &err);
if (tree2.nodes.isEmpty()) {
skipCount++;
continue;
}
// Find the root in re-imported tree
int rootIdx2 = -1;
for (int i = 0; i < tree2.nodes.size(); i++) {
if (tree2.nodes[i].parentId == 0 && tree2.nodes[i].kind == NodeKind::Struct) {
if (tree2.nodes[i].structTypeName == structName) {
rootIdx2 = i;
break;
}
}
}
if (rootIdx2 < 0) {
// Take first root
for (int i = 0; i < tree2.nodes.size(); i++) {
if (tree2.nodes[i].parentId == 0 && tree2.nodes[i].kind == NodeKind::Struct) {
rootIdx2 = i;
break;
}
}
}
if (rootIdx2 < 0) {
skipCount++;
continue;
}
// Pass 3: re-export
QString cpp2 = renderCpp(tree2, tree2.nodes[rootIdx2].id, nullptr, true);
if (cpp1 == cpp2) {
passCount++;
if (passCount <= kRequired)
qDebug() << " PASS" << passCount << structName;
} else {
failCount++;
if (failCount <= 5) {
// Log first few failures for diagnostics
QStringList lines1 = cpp1.split('\n');
QStringList lines2 = cpp2.split('\n');
int diffLine = -1;
for (int i = 0; i < qMin(lines1.size(), lines2.size()); i++) {
if (lines1[i] != lines2[i]) { diffLine = i; break; }
}
if (diffLine >= 0) {
qDebug() << " FAIL" << structName << "first diff at line" << diffLine;
qDebug() << " cpp1:" << lines1[diffLine].left(120);
qDebug() << " cpp2:" << lines2[diffLine].left(120);
} else {
qDebug() << " FAIL" << structName << "line count differs:"
<< lines1.size() << "vs" << lines2.size();
}
}
}
if (passCount >= kRequired && failCount > 5)
break; // found enough passes and logged enough failures
}
qDebug() << "Round-trip results: pass=" << passCount
<< "fail=" << failCount << "skip=" << skipCount;
QVERIFY2(passCount >= kRequired,
qPrintable(QString("Need %1 stable round-trips, got %2")
.arg(kRequired).arg(passCount)));
}
void TestRoundtripWinSdk::generateRcx()
{
// Set all root structs collapsed
for (int ri : rootIndices)
fullTree.nodes[ri].collapsed = true;
fullTree.baseAddress = 0xFFFFF80000000000ULL;
QJsonObject json = fullTree.toJson();
QJsonDocument jdoc(json);
QByteArray data = jdoc.toJson(QJsonDocument::Indented);
QVERIFY2(data.size() > 1000000,
qPrintable(QString("RCX too small: %1 bytes").arg(data.size())));
QString outPath = QStringLiteral(WINSDK_RCX_OUTPUT);
QFile file(outPath);
QVERIFY2(file.open(QIODevice::WriteOnly | QIODevice::Truncate),
qPrintable("Cannot write " + outPath));
file.write(data);
file.close();
qDebug() << "Wrote" << data.size() << "bytes to" << outPath;
}
QTEST_MAIN(TestRoundtripWinSdk)
#include "test_roundtrip_winsdk.moc"

View File

@@ -644,8 +644,8 @@ private slots:
data[16] = 0xAA; // in region 1 (executable)
QVector<MemoryRegion> regions;
regions.append({0, 16, true, true, false, "heap"});
regions.append({16, 16, true, false, true, "code"});
regions.push_back(MemoryRegion{0, 16, true, true, false, "heap"});
regions.push_back(MemoryRegion{16, 16, true, false, true, "code"});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
@@ -671,8 +671,8 @@ private slots:
data[16] = 0xBB; // region 1 (not writable)
QVector<MemoryRegion> regions;
regions.append({0, 16, true, true, false, "data"});
regions.append({16, 16, true, false, true, "code"});
regions.push_back(MemoryRegion{0, 16, true, true, false, "data"});
regions.push_back(MemoryRegion{16, 16, true, false, true, "code"});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
@@ -698,9 +698,9 @@ private slots:
data[32] = 0xCC; // region 2: +w +x
QVector<MemoryRegion> regions;
regions.append({0, 16, true, true, false, "data"});
regions.append({16, 16, true, false, true, "code"});
regions.append({32, 16, true, true, true, "rwx"});
regions.push_back(MemoryRegion{0, 16, true, true, false, "data"});
regions.push_back(MemoryRegion{16, 16, true, false, true, "code"});
regions.push_back(MemoryRegion{32, 16, true, true, true, "rwx"});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
@@ -726,7 +726,7 @@ private slots:
data[0] = 0xDD;
QVector<MemoryRegion> regions;
regions.append({0, 16, true, true, true, "Game.exe"});
regions.push_back(MemoryRegion{0, 16, true, true, true, "Game.exe"});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
@@ -943,8 +943,8 @@ private slots:
void provider_customRegions() {
QVector<MemoryRegion> regs;
regs.append({0x1000, 0x2000, true, true, false, "heap"});
regs.append({0x3000, 0x1000, true, false, true, "code"});
regs.push_back(MemoryRegion{0x1000, 0x2000, true, true, false, "heap"});
regs.push_back(MemoryRegion{0x3000, 0x1000, true, false, true, "code"});
RegionProvider p(QByteArray(0x4000, '\0'), regs);
auto result = p.enumerateRegions();
@@ -982,9 +982,9 @@ private slots:
data[36] = 0xEE; // region 2
QVector<MemoryRegion> regions;
regions.append({0, 16, true, true, false, "region0"});
regions.append({16, 16, true, true, false, "region1"});
regions.append({32, 16, true, true, false, "region2"});
regions.push_back(MemoryRegion{0, 16, true, true, false, "region0"});
regions.push_back(MemoryRegion{16, 16, true, true, false, "region1"});
regions.push_back(MemoryRegion{32, 16, true, true, false, "region2"});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
@@ -1215,7 +1215,7 @@ private slots:
data[160] = char(0xCC);
data[210] = char(0xCC);
QVector<MemoryRegion> regions;
regions.append({100, 100, true, false, false, {}});
regions.push_back(MemoryRegion{100, 100, true, false, false, {}});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
@@ -1233,7 +1233,7 @@ private slots:
void scan_constrainRegions_noOverlap() {
QByteArray data(32, char(0xEE));
QVector<MemoryRegion> regions;
regions.append({0, 16, true, false, false, {}});
regions.push_back(MemoryRegion{0, 16, true, false, false, {}});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
@@ -1256,8 +1256,8 @@ private slots:
data[10] = char(0xDD);
data[35] = char(0xDD);
QVector<MemoryRegion> regions;
regions.append({0, 16, true, true, false, {}});
regions.append({32, 16, true, true, false, {}});
regions.push_back(MemoryRegion{0, 16, true, true, false, {}});
regions.push_back(MemoryRegion{32, 16, true, true, false, {}});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
@@ -1279,7 +1279,7 @@ private slots:
data[120] = char(0xAB);
data[160] = char(0xAB);
QVector<MemoryRegion> regions;
regions.append({100, 100, true, true, false, {}});
regions.push_back(MemoryRegion{100, 100, true, true, false, {}});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
@@ -1300,8 +1300,8 @@ private slots:
data[0x1500] = char(0xCC);
data[0x5500] = char(0xCC);
QVector<MemoryRegion> regions;
regions.append({0x1000, 0x1000, true, false, true, QString("game.exe")});
regions.append({0x5000, 0x1000, true, true, false, {}});
regions.push_back(MemoryRegion{0x1000, 0x1000, true, false, true, QString("game.exe")});
regions.push_back(MemoryRegion{0x5000, 0x1000, true, true, false, {}});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
@@ -1345,8 +1345,8 @@ private slots:
data[12] = char(0xEF);
data[20] = char(0xEF);
QVector<MemoryRegion> regions;
regions.append({0, 16, true, true, false, {}});
regions.append({16, 16, true, true, false, {}});
regions.push_back(MemoryRegion{0, 16, true, true, false, {}});
regions.push_back(MemoryRegion{16, 16, true, true, false, {}});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
@@ -1368,8 +1368,8 @@ private slots:
data[0x1100] = char(0xBB);
data[0x2100] = char(0xBB);
QVector<MemoryRegion> regions;
regions.append({0x1000, 0x1000, true, false, true, {}});
regions.append({0x2000, 0x1000, true, true, false, {}});
regions.push_back(MemoryRegion{0x1000, 0x1000, true, false, true, {}});
regions.push_back(MemoryRegion{0x2000, 0x1000, true, true, false, {}});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
@@ -1394,7 +1394,7 @@ private slots:
data[15] = char(0xAA); // inside region, should be found
data[25] = char(0xAA); // outside region, should NOT be found
QVector<MemoryRegion> regions;
regions.append({10, 10, true, true, false, {}});
regions.push_back(MemoryRegion{10, 10, true, true, false, {}});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
@@ -1415,7 +1415,7 @@ private slots:
data[5] = char(0xBB);
data[15] = char(0xBB);
QVector<MemoryRegion> regions;
regions.append({0, 32, true, true, false, {}});
regions.push_back(MemoryRegion{0, 32, true, true, false, {}});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
@@ -1506,7 +1506,7 @@ private slots:
QByteArray data(0x10000, 0);
data[0x8100] = char(0xFF);
QVector<MemoryRegion> regions;
regions.append({0x8000, 0x1000, true, true, false, {}});
regions.push_back(MemoryRegion{0x8000, 0x1000, true, true, false, {}});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
@@ -1581,7 +1581,7 @@ private slots:
QByteArray data(64, 0);
data[20] = char(0xFE);
QVector<MemoryRegion> regions;
regions.append({0, 64, true, true, false, {}});
regions.push_back(MemoryRegion{0, 64, true, true, false, {}});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
@@ -1602,7 +1602,7 @@ private slots:
QByteArray data(64, 0);
data[36] = char(0xDE); data[37] = char(0xAD); data[38] = char(0xBE); data[39] = char(0xEF);
QVector<MemoryRegion> regions;
regions.append({0, 64, true, true, false, {}});
regions.push_back(MemoryRegion{0, 64, true, true, false, {}});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
@@ -1624,7 +1624,7 @@ private slots:
QByteArray data(64, 0);
data[36] = char(0xDE); data[37] = char(0xAD); data[38] = char(0xBE); data[39] = char(0xEF);
QVector<MemoryRegion> regions;
regions.append({0, 64, true, true, false, {}});
regions.push_back(MemoryRegion{0, 64, true, true, false, {}});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
@@ -1643,7 +1643,7 @@ private slots:
// Region [0, 64). Constraint [30, 32). 4-byte pattern can't fit in 2 bytes.
QByteArray data(64, char(0xAA));
QVector<MemoryRegion> regions;
regions.append({0, 64, true, true, false, {}});
regions.push_back(MemoryRegion{0, 64, true, true, false, {}});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
@@ -1663,7 +1663,7 @@ private slots:
QByteArray data(64, 0);
data[30] = char(0x11); data[31] = char(0x22); data[32] = char(0x33); data[33] = char(0x44);
QVector<MemoryRegion> regions;
regions.append({0, 64, true, true, false, {}});
regions.push_back(MemoryRegion{0, 64, true, true, false, {}});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
@@ -1686,8 +1686,8 @@ private slots:
data[15] = char(0x77); // last byte of first region
data[16] = char(0x77); // first byte of second region
QVector<MemoryRegion> regions;
regions.append({0, 16, true, true, false, {}});
regions.append({16, 16, true, true, false, {}});
regions.push_back(MemoryRegion{0, 16, true, true, false, {}});
regions.push_back(MemoryRegion{16, 16, true, true, false, {}});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
@@ -1711,7 +1711,7 @@ private slots:
QByteArray data(64, 0);
data[10] = char(0xAA); data[11] = char(0xBB); data[12] = char(0xCC); data[13] = char(0xDD);
QVector<MemoryRegion> regions;
regions.append({0, 64, true, true, false, {}});
regions.push_back(MemoryRegion{0, 64, true, true, false, {}});
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);

View File

@@ -9,32 +9,35 @@
using namespace rcx;
// ─────────────────────────────────────────────────────────────────
// Test suite for the RcxTooltip callout widget
// Test suite for the RcxTooltip arrow 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)
// Validates:
// - Arrow direction auto-detection (above/below based on screen space)
// - Arrow X clamped to stay within rounded corners
// - WA_TranslucentBackground rendering (arrow + body have opaque pixels,
// corners are transparent)
// - Content sizing (title + separator + body)
// ─────────────────────────────────────────────────────────────────
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;
QWidget* m_window = nullptr;
RcxTooltip* m_tip = nullptr;
void showAndProcess(QWidget* trigger, const QString& text) {
RcxTooltip::instance()->showFor(trigger, text);
// Process events + allow paint to complete
QFont testFont() {
QFont f("JetBrains Mono", 12);
f.setFixedPitch(true);
return f;
}
void showAndProcess(const QPoint& anchor) {
m_tip->showAt(anchor);
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());
@@ -49,382 +52,180 @@ 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));
m_tip = new RcxTooltip(m_window);
const auto& t = ThemeManager::instance().current();
m_tip->setTheme(t.backgroundAlt, t.border, t.text, t.syntaxNumber, t.border);
}
void cleanupTestCase() {
RcxTooltip::instance()->dismiss();
m_tip->dismiss();
delete m_tip;
delete m_window;
m_window = nullptr;
}
void cleanup() {
RcxTooltip::instance()->dismiss();
m_tip->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);
QVERIFY(!m_tip->isVisible());
m_tip->populate("Title", "Body text", testFont());
showAndProcess(m_window->mapToGlobal(QPoint(400, 300)));
QVERIFY(m_tip->isVisible());
m_tip->dismiss();
QVERIFY(!m_tip->isVisible());
}
// ── Empty text / null trigger = dismiss ──
void testEmptyTextDismisses() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Test");
QVERIFY(tip->isVisible());
showAndProcess(m_btnMid, "");
QVERIFY(!tip->isVisible());
// ── Duplicate populate is no-op ──
void testDuplicatePopulateSkipped() {
m_tip->populate("Title", "Body", testFont());
showAndProcess(m_window->mapToGlobal(QPoint(400, 300)));
QPoint pos1 = m_tip->pos();
// Same content — populate returns early, position unchanged
m_tip->populate("Title", "Body", testFont());
QCOMPARE(m_tip->pos(), pos1);
}
void testNullTriggerDismisses() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Test");
QVERIFY(tip->isVisible());
showAndProcess(nullptr, "Test");
QVERIFY(!tip->isVisible());
// ── Arrow direction: below when room exists ──
void testArrowUpWhenBelow() {
m_tip->populate("Test", "Below", testFont());
// Anchor in middle of screen — plenty of room below
QPoint anchor = m_window->mapToGlobal(QPoint(400, 300));
showAndProcess(anchor);
QVERIFY(m_tip->isVisible());
// Arrow up (tooltip below anchor): widget top == anchor.y
QCOMPARE(m_tip->y(), anchor.y());
}
// ── 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() {
// ── Arrow direction: above when no room below ──
void testArrowDownWhenAbove() {
m_tip->populate("Test", "Above", testFont());
// Anchor near bottom of screen
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");
QPoint anchor(avail.center().x(), avail.bottom() - 5);
showAndProcess(anchor);
QVERIFY(m_tip->isVisible());
// Arrow down (tooltip above anchor): widget bottom == anchor.y
int tipBottom = m_tip->y() + m_tip->height();
QCOMPARE(tipBottom, anchor.y());
}
// ── Horizontal clamping ──
void testHorizontalClampLeft() {
m_tip->populate("Test", "Wide body text for clamping", testFont());
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();
QPoint anchor(avail.left() + 5, avail.center().y());
showAndProcess(anchor);
QVERIFY(m_tip->x() >= avail.left());
}
void testHorizontalClampRight() {
m_tip->populate("Test", "Wide body text for clamping", testFont());
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);
QPoint anchor(avail.right() - 5, avail.center().y());
showAndProcess(anchor);
QVERIFY(m_tip->x() + m_tip->width() <= avail.right() + 2);
}
// ── Constants ──
void testConstants() {
QCOMPARE(RcxTooltip::kArrowH, 6);
QCOMPARE(RcxTooltip::kArrowHalfW, 6);
QCOMPARE(RcxTooltip::kGap, 2);
QCOMPARE(RcxTooltip::kArrowH, 8);
QCOMPARE(RcxTooltip::kArrowW, 14);
QCOMPARE(RcxTooltip::kRadius, 6);
}
// ── Title-only vs title+body sizing ──
void testTitleOnlySizing() {
m_tip->dismiss();
m_tip->populate("", "Just body", testFont());
showAndProcess(m_window->mapToGlobal(QPoint(400, 300)));
int hNoTitle = m_tip->height();
m_tip->dismiss();
m_tip->populate("Title", "Just body", testFont());
showAndProcess(m_window->mapToGlobal(QPoint(400, 300)));
int hWithTitle = m_tip->height();
QVERIFY2(hWithTitle > hNoTitle,
"Tooltip with title should be taller than body-only");
}
// ── Multi-line body ──
void testMultilineBody() {
m_tip->dismiss();
m_tip->populate("Title", "Line 1", testFont());
showAndProcess(m_window->mapToGlobal(QPoint(400, 300)));
int h1 = m_tip->height();
m_tip->dismiss();
m_tip->populate("Title", "Line 1\nLine 2\nLine 3", testFont());
showAndProcess(m_window->mapToGlobal(QPoint(400, 300)));
int h3 = m_tip->height();
QVERIFY2(h3 > h1, "3-line tooltip should be taller than 1-line");
}
// ──────────────────────────────────────────────────────────────
// RENDERING VERIFICATION — catches invisible tooltip bugs
// RENDERING VERIFICATION — WA_TranslucentBackground works
// ──────────────────────────────────────────────────────────────
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());
void testBodyRendersOpaquePixels() {
m_tip->populate("Render", "Test body", testFont());
showAndProcess(m_window->mapToGlobal(QPoint(400, 300)));
QVERIFY(m_tip->isVisible());
// Force full opacity so grab gets real pixels
tip->setWindowOpacity(1.0);
QCoreApplication::processEvents();
QImage img = m_tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
QVERIFY(!img.isNull());
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 center of body for opaque pixels (avoid edges/corners)
QRect center(img.width() / 4, img.height() / 4,
img.width() / 2, img.height() / 2);
int opaque = countOpaquePixels(img, center);
int total = center.width() * center.height();
QVERIFY2(opaque > total / 2,
qPrintable(QStringLiteral("Body center has %1/%2 opaque pixels (<50%%)")
.arg(opaque).arg(total)));
}
// 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();
void testCornersAreTransparent() {
m_tip->populate("Corner", "Test", testFont());
showAndProcess(m_window->mapToGlobal(QPoint(400, 300)));
QVERIFY(m_tip->isVisible());
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)));
QImage img = m_tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
// Top-left 2x2 corner should be fully transparent (rounded corner)
QRect corner(0, 0, 2, 2);
int opaque = countOpaquePixels(img, corner);
QCOMPARE(opaque, 0);
}
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());
m_tip->populate("Arrow", "Test", testFont());
// Show below (arrow up) — arrow is in the top strip
showAndProcess(m_window->mapToGlobal(QPoint(400, 300)));
QVERIFY(m_tip->isVisible());
tip->setWindowOpacity(1.0);
QCoreApplication::processEvents();
QImage img = m_tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
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"));
// Arrow region: top kArrowH pixels, centered horizontally
int centerX = img.width() / 2;
QRect arrowRect(centerX - RcxTooltip::kArrowW / 2, 0,
RcxTooltip::kArrowW, RcxTooltip::kArrowH);
int opaque = countOpaquePixels(img, arrowRect);
QVERIFY2(opaque > 0,
qPrintable(QStringLiteral("Arrow region has 0 opaque pixels")));
}
};

View File

@@ -1,290 +1,106 @@
// Tests the full tooltip flow including DarkApp-style ToolTip interception.
// Verifies that QEvent::ToolTip fires and our custom tooltip appears.
// Tests RcxTooltip positioning and arrow direction across screen edges.
// Validates that the arrow tip touches the anchor point and the tooltip
// body stays within screen bounds.
#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;
RcxTooltip* m_tip = nullptr;
QFont testFont() {
QFont f("JetBrains Mono", 12);
f.setFixedPitch(true);
return f;
}
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());
m_tip = new RcxTooltip;
const auto& t = ThemeManager::instance().current();
m_tip->setTheme(t.backgroundAlt, t.border, t.text, t.syntaxNumber, t.border);
}
void cleanupTestCase() {
qApp->removeEventFilter(m_sim);
RcxTooltip::instance()->dismiss();
delete m_sim;
delete m_window;
LOG("=== TestTooltipEvent finished ===\n");
m_tip->dismiss();
delete m_tip;
}
void cleanup() {
RcxTooltip::instance()->dismiss();
m_tip->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);
// Arrow tip Y matches anchor Y when showing below
void testArrowTipMatchesAnchorBelow() {
m_tip->populate("Test", "Body", testFont());
QScreen* scr = QApplication::primaryScreen();
QPoint anchor = scr->availableGeometry().center();
m_tip->showAt(anchor);
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");
QVERIFY(m_tip->isVisible());
// Arrow up (tooltip below): widget top == anchor.y
QCOMPARE(m_tip->y(), anchor.y());
}
// 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);
// Arrow tip Y matches anchor Y when showing above
void testArrowTipMatchesAnchorAbove() {
m_tip->populate("Test", "Body", testFont());
QScreen* scr = QApplication::primaryScreen();
QRect avail = scr->availableGeometry();
QPoint anchor(avail.center().x(), avail.bottom() - 2);
m_tip->showAt(anchor);
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");
QVERIFY(m_tip->isVisible());
// Arrow down (tooltip above): widget bottom == anchor.y
QCOMPARE(m_tip->y() + m_tip->height(), anchor.y());
}
// 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);
// Tooltip stays within screen bounds at left edge
void testScreenLeftEdge() {
m_tip->populate("Test", "Wide body content for edge test", testFont());
QScreen* scr = QApplication::primaryScreen();
QRect avail = scr->availableGeometry();
QPoint anchor(avail.left() + 2, avail.center().y());
m_tip->showAt(anchor);
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");
QVERIFY(m_tip->x() >= avail.left());
}
// 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);
// Tooltip stays within screen bounds at right edge
void testScreenRightEdge() {
m_tip->populate("Test", "Wide body content for edge test", testFont());
QScreen* scr = QApplication::primaryScreen();
QRect avail = scr->availableGeometry();
QPoint anchor(avail.right() - 2, avail.center().y());
m_tip->showAt(anchor);
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();
QVERIFY(m_tip->x() + m_tip->width() <= avail.right() + 2);
}
// Switch to btn2
QPoint btn2Center = m_btn2->mapToGlobal(QPoint(60, 20));
QCursor::setPos(btn2Center);
// Content change triggers resize
void testContentResize() {
m_tip->populate("Short", "A", testFont());
m_tip->showAt(QPoint(500, 500));
QCoreApplication::processEvents();
QHelpEvent ev2(QEvent::ToolTip, QPoint(60, 20), btn2Center);
QApplication::sendEvent(m_btn2, &ev2);
int w1 = m_tip->width();
m_tip->dismiss();
m_tip->populate("Much Longer Title", "A much wider body line that should be larger", testFont());
m_tip->showAt(QPoint(500, 500));
QCoreApplication::processEvents();
QTest::qWait(100);
int w2 = m_tip->width();
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");
QVERIFY2(w2 > w1, "Wider content should produce a wider tooltip");
}
};

View File

@@ -1,251 +1,126 @@
// 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.
// Rendering verification for RcxTooltip.
// Grabs widget pixels to confirm WA_TranslucentBackground works correctly
// and the arrow/body are painted with the expected alpha.
#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;
RcxTooltip* m_tip = nullptr;
QFont testFont() {
QFont f("JetBrains Mono", 12);
f.setFixedPitch(true);
return f;
}
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() {
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());
m_tip = new RcxTooltip;
const auto& t = ThemeManager::instance().current();
m_tip->setTheme(t.backgroundAlt, t.border, t.text, t.syntaxNumber, t.border);
}
void cleanupTestCase() {
RcxTooltip::instance()->dismiss();
delete m_window;
LOG("=== TestTooltipUI finished ===\n");
m_tip->dismiss();
delete m_tip;
}
void cleanup() {
RcxTooltip::instance()->dismiss();
m_tip->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");
// Body center should be opaque (background painted)
void testBodyIsOpaque() {
m_tip->populate("Render Test", "Body content here", testFont());
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);
m_tip->showAt(scr->availableGeometry().center());
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();
QImage img = m_tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
QVERIFY(!img.isNull());
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");
// Center 50% of widget should be mostly opaque
QRect center(img.width() / 4, img.height() / 4,
img.width() / 2, img.height() / 2);
int opaque = countOpaquePixels(img, center);
int total = center.width() * center.height();
QVERIFY2(opaque > total * 0.8,
qPrintable(QStringLiteral("Body has %1/%2 opaque pixels — expected >80%%")
.arg(opaque).arg(total)));
}
// ─── 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
// Top-left corner should be transparent (rounded corner + WA_TranslucentBackground)
void testCornerTransparency() {
m_tip->populate("Corner", "Test", testFont());
QScreen* scr = QApplication::primaryScreen();
m_tip->showAt(scr->availableGeometry().center());
QCoreApplication::processEvents();
QTest::qWait(50);
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");
QImage img = m_tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
// When arrow is up, body starts at kArrowH. The corner at (0, kArrowH)
// should be transparent due to rounding.
QRect corner(0, 0, 2, 2);
int opaque = countOpaquePixels(img, corner);
QCOMPARE(opaque, 0);
}
// Arrow region should have some opaque pixels
void testArrowHasPixels() {
m_tip->populate("Arrow", "Test", testFont());
QScreen* scr = QApplication::primaryScreen();
m_tip->showAt(scr->availableGeometry().center());
QCoreApplication::processEvents();
QTest::qWait(50);
QImage img = m_tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
// Arrow is at top (m_up = true): check top kArrowH pixels around center
int cx = img.width() / 2;
QRect arrowRect(cx - RcxTooltip::kArrowW / 2, 0,
RcxTooltip::kArrowW, RcxTooltip::kArrowH);
int opaque = countOpaquePixels(img, arrowRect);
QVERIFY2(opaque > 0, "Arrow region has no opaque pixels");
}
// Grabbing after dismiss should not crash
void testDismissAndReshow() {
m_tip->populate("First", "Body", testFont());
QScreen* scr = QApplication::primaryScreen();
m_tip->showAt(scr->availableGeometry().center());
QCoreApplication::processEvents();
QVERIFY(m_tip->isVisible());
m_tip->dismiss();
QVERIFY(!m_tip->isVisible());
m_tip->populate("Second", "Different", testFont());
m_tip->showAt(scr->availableGeometry().center());
QCoreApplication::processEvents();
QVERIFY(m_tip->isVisible());
}
};