mirror of
https://github.com/NohamR/Reclass.git
synced 2026-05-10 19:59:21 +00:00
Compare commits
19 Commits
snapshot-1
...
mac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4727df3e9 | ||
|
|
dc6963e0d5 | ||
|
|
cb10bc8a82 | ||
|
|
b5521bd638 | ||
|
|
89d6e1944b | ||
|
|
7528d1bbbb | ||
|
|
4f2288048e | ||
|
|
97b6f55e1f | ||
|
|
6a30e0a402 | ||
|
|
1501a1542c | ||
|
|
4f82b39785 | ||
|
|
009ddc951c | ||
|
|
5921af2b4f | ||
|
|
5ded192990 | ||
|
|
54bee5022b | ||
|
|
5d2d324946 | ||
|
|
5b2cf1ae1f | ||
|
|
f1a36f2ad3 | ||
|
|
665138e688 |
@@ -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)
|
||||
|
||||
@@ -22,12 +22,18 @@ Built with C++17, Qt 6 (Qt 5 also supported), and QScintilla. The entire editor
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
## 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
BIN
docs/README_PIC4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
BIN
docs/README_PIC5.png
Normal file
BIN
docs/README_PIC5.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 61 KiB |
BIN
docs/README_PIC6.png
Normal file
BIN
docs/README_PIC6.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 312 KiB |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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++)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
13
src/core.h
13
src/core.h
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
126
src/examples/PageTables.rcx
Normal 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"]
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
193
src/imports/pe_debug_info.cpp
Normal file
193
src/imports/pe_debug_info.cpp
Normal 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
|
||||
20
src/imports/pe_debug_info.h
Normal file
20
src/imports/pe_debug_info.h
Normal 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
|
||||
1001
src/main.cpp
1001
src/main.cpp
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
342
src/rcxtooltip.h
342
src/rcxtooltip.h
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
123
src/symbol_downloader.cpp
Normal 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
50
src/symbol_downloader.h
Normal 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
191
src/symbolstore.cpp
Normal 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
105
src/symbolstore.h
Normal 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
|
||||
@@ -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() {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; });
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
222
tests/grab_tabs.cpp
Normal 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"
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()});
|
||||
}
|
||||
}
|
||||
|
||||
397
tests/test_kernel_provider.cpp
Normal file
397
tests/test_kernel_provider.cpp
Normal 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"
|
||||
@@ -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
143
tests/test_project_dock.cpp
Normal 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"
|
||||
307
tests/test_roundtrip_winsdk.cpp
Normal file
307
tests/test_roundtrip_winsdk.cpp
Normal 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"
|
||||
@@ -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);
|
||||
|
||||
@@ -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")));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user