Compare commits

...

8 Commits

Author SHA1 Message Date
IChooseYou
c856ba2697 WinDbg plugin, ProcessMemoryWindows, dialog cleanup, and misc fixes
- Add WinDbgMemory plugin with debug server connection support
- Replace ProcessMemory plugin with Windows-specific ProcessMemoryWindows
- Simplify WinDbg dialog: single panel, no tabs, palette-based theming
- Fix example text visibility on dark themes (QPalette::Dark -> Disabled WindowText)
- Fix "file" -> "File" capitalization in source menu
- Add windbg_provider and com_security tests
2026-02-14 13:40:58 -07:00
IChooseYou
b44dc9e96b Update screenshot 2026-02-13 17:58:27 -07:00
IChooseYou
0f2ded471f Update screenshot 2026-02-13 17:56:07 -07:00
IChooseYou
c9377c3afd Show Icon uses 24x24 icon instead of 16x16 2026-02-13 17:47:14 -07:00
IChooseYou
a86912add1 Theme system overhaul, UI polish, and VS2022 Dark theme
- Replaced hardcoded theme factories with JSON files + CMake build step
- Shared ThemeFieldMeta table for DRY serialization and editor UI
- Fixed live preview (auto-triggers on color change, no toggle button)
- Fixed duplicate theme entries when editing built-in themes
- Moved title bar from icon to bold "Reclass" text with View > Show Icon toggle
- MDI tabs: 24px height, unicode close button styled like TypeSelectorPopup
- Added VS2022 Dark theme with purple accent colors
- Status bar padding, removed monospace font overrides on tabs/statusbar
- Default startup opens Ball demo + Unnamed hex64 tabs
2026-02-13 16:23:12 -07:00
Sen66
5a9a6b754f Expand recursive structs instead of silently skipping cycle fields
Self-referential struct children (e.g. Ball containing Ball) now render
as collapsed struct headers with a cycle marker. Clicking the fold margin
materializes the referenced children and auto-expands the recursive child,
allowing unlimited depth exploration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:11:10 +01:00
Sen66
0df52e82b8 Added custom title bar & border color when focused 2026-02-13 19:09:11 +01:00
Sen66
9a342286ee codicon.ttf isn't actually used 2026-02-13 19:01:13 +01:00
32 changed files with 2462 additions and 421 deletions

View File

@@ -60,6 +60,8 @@ add_executable(Reclass
src/themes/themeeditor.h src/themes/themeeditor.h
src/themes/themeeditor.cpp src/themes/themeeditor.cpp
src/mainwindow.h src/mainwindow.h
src/titlebar.h
src/titlebar.cpp
src/mcp/mcp_bridge.h src/mcp/mcp_bridge.h
src/mcp/mcp_bridge.cpp src/mcp/mcp_bridge.cpp
$<$<PLATFORM_ID:Windows>:src/app.rc> $<$<PLATFORM_ID:Windows>:src/app.rc>
@@ -83,6 +85,14 @@ endif()
add_executable(ReclassMcpBridge tools/rcx-mcp-stdio.cpp) add_executable(ReclassMcpBridge tools/rcx-mcp-stdio.cpp)
target_link_libraries(ReclassMcpBridge PRIVATE ${QT}::Core ${QT}::Network) target_link_libraries(ReclassMcpBridge PRIVATE ${QT}::Core ${QT}::Network)
# Copy built-in theme JSON files to build directory
file(GLOB _theme_files "${CMAKE_SOURCE_DIR}/src/themes/defaults/*.json")
file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/themes")
foreach(_tf ${_theme_files})
get_filename_component(_name ${_tf} NAME)
configure_file(${_tf} "${CMAKE_BINARY_DIR}/themes/${_name}" COPYONLY)
endforeach()
include(deploy) include(deploy)
add_custom_target(screenshot ALL add_custom_target(screenshot ALL
@@ -247,6 +257,24 @@ if(BUILD_TESTING)
target_link_libraries(test_theme PRIVATE ${QT}::Widgets ${QT}::Test) target_link_libraries(test_theme PRIVATE ${QT}::Widgets ${QT}::Test)
add_test(NAME test_theme COMMAND test_theme) add_test(NAME test_theme COMMAND test_theme)
add_executable(test_windbg_provider tests/test_windbg_provider.cpp
plugins/WinDbgMemory/WinDbgMemoryPlugin.cpp)
target_include_directories(test_windbg_provider PRIVATE src plugins/WinDbgMemory)
target_link_libraries(test_windbg_provider PRIVATE
${QT}::Widgets ${QT}::Concurrent ${QT}::Test)
if(WIN32)
target_link_libraries(test_windbg_provider PRIVATE dbgeng ole32)
endif()
add_test(NAME test_windbg_provider COMMAND test_windbg_provider)
# Standalone test: proves whether CoInitializeSecurity is needed for DebugConnect
# Requires a running WinDbg debug server on port 5055
if(WIN32)
add_executable(test_com_security tests/test_com_security.cpp)
target_link_libraries(test_com_security PRIVATE dbgeng ole32 version)
add_test(NAME test_com_security COMMAND test_com_security)
endif()
# Deploy Qt runtime DLLs for tests (run windeployqt on a representative test exe # Deploy Qt runtime DLLs for tests (run windeployqt on a representative test exe
# that links the broadest set of Qt modules; all test exes share the same output dir) # that links the broadest set of Qt modules; all test exes share the same output dir)
if(TARGET ${QT}::windeployqt) if(TARGET ${QT}::windeployqt)
@@ -260,4 +288,5 @@ if(BUILD_TESTING)
) )
endif() endif()
endif() endif()
add_subdirectory(plugins/ProcessMemory) add_subdirectory(plugins/ProcessMemoryWindows)
add_subdirectory(plugins/WinDbgMemory)

View File

@@ -1,5 +1,5 @@
cmake_minimum_required(VERSION 3.20) cmake_minimum_required(VERSION 3.20)
project(ProcessMemoryPlugin LANGUAGES CXX) project(ProcessMemoryWindowsPlugin LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
@@ -12,36 +12,36 @@ set(CMAKE_AUTOUIC ON)
# Plugin sources # Plugin sources
set(PLUGIN_SOURCES set(PLUGIN_SOURCES
ProcessMemoryPlugin.h ProcessMemoryWindowsPlugin.h
ProcessMemoryPlugin.cpp ProcessMemoryWindowsPlugin.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../../src/processpicker.h ${CMAKE_CURRENT_SOURCE_DIR}/../../src/processpicker.h
${CMAKE_CURRENT_SOURCE_DIR}/../../src/processpicker.cpp ${CMAKE_CURRENT_SOURCE_DIR}/../../src/processpicker.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../../src/processpicker.ui ${CMAKE_CURRENT_SOURCE_DIR}/../../src/processpicker.ui
) )
# Create shared library (DLL) # Create shared library (DLL)
add_library(ProcessMemoryPlugin SHARED ${PLUGIN_SOURCES}) add_library(ProcessMemoryWindowsPlugin SHARED ${PLUGIN_SOURCES})
# Link Qt # Link Qt
target_link_libraries(ProcessMemoryPlugin PRIVATE ${QT}::Widgets ${_QT_WINEXTRAS}) target_link_libraries(ProcessMemoryWindowsPlugin PRIVATE ${QT}::Widgets ${_QT_WINEXTRAS})
# Platform-specific linking # Platform-specific linking
if(WIN32) if(WIN32)
target_link_libraries(ProcessMemoryPlugin PRIVATE psapi shell32) target_link_libraries(ProcessMemoryWindowsPlugin PRIVATE psapi shell32)
endif() endif()
# On Linux, hide all symbols by default so only RCX_PLUGIN_EXPORT-marked ones are exported # On Linux, hide all symbols by default so only RCX_PLUGIN_EXPORT-marked ones are exported
if(UNIX AND NOT APPLE) if(UNIX AND NOT APPLE)
target_compile_options(ProcessMemoryPlugin PRIVATE -fvisibility=hidden) target_compile_options(ProcessMemoryWindowsPlugin PRIVATE -fvisibility=hidden)
endif() endif()
# Include directories # Include directories
target_include_directories(ProcessMemoryPlugin PRIVATE target_include_directories(ProcessMemoryWindowsPlugin PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../../src ${CMAKE_CURRENT_SOURCE_DIR}/../../src
) )
# Output to Plugins folder # Output to Plugins folder
set_target_properties(ProcessMemoryPlugin PROPERTIES set_target_properties(ProcessMemoryWindowsPlugin PROPERTIES
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Plugins" LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Plugins" RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Plugins"
) )

View File

@@ -1,4 +1,4 @@
#include "ProcessMemoryPlugin.h" #include "ProcessMemoryWindowsPlugin.h"
#include "../../src/processpicker.h" #include "../../src/processpicker.h"
@@ -32,12 +32,12 @@
#endif #endif
// ────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────
// ProcessMemoryProvider implementation // ProcessMemoryWindowsProvider implementation
// ────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────
#ifdef _WIN32 #ifdef _WIN32
ProcessMemoryProvider::ProcessMemoryProvider(uint32_t pid, const QString& processName) ProcessMemoryWindowsProvider::ProcessMemoryWindowsProvider(uint32_t pid, const QString& processName)
: m_handle(nullptr) : m_handle(nullptr)
, m_pid(pid) , m_pid(pid)
, m_processName(processName) , m_processName(processName)
@@ -60,7 +60,7 @@ ProcessMemoryProvider::ProcessMemoryProvider(uint32_t pid, const QString& proces
cacheModules(); cacheModules();
} }
bool ProcessMemoryProvider::read(uint64_t addr, void* buf, int len) const bool ProcessMemoryWindowsProvider::read(uint64_t addr, void* buf, int len) const
{ {
if (!m_handle || len <= 0) return false; if (!m_handle || len <= 0) return false;
@@ -71,7 +71,7 @@ bool ProcessMemoryProvider::read(uint64_t addr, void* buf, int len) const
return bytesRead > 0; return bytesRead > 0;
} }
bool ProcessMemoryProvider::write(uint64_t addr, const void* buf, int len) bool ProcessMemoryWindowsProvider::write(uint64_t addr, const void* buf, int len)
{ {
if (!m_handle || !m_writable || len <= 0) return false; if (!m_handle || !m_writable || len <= 0) return false;
@@ -81,7 +81,7 @@ bool ProcessMemoryProvider::write(uint64_t addr, const void* buf, int len)
return false; return false;
} }
QString ProcessMemoryProvider::getSymbol(uint64_t addr) const QString ProcessMemoryWindowsProvider::getSymbol(uint64_t addr) const
{ {
for (const auto& mod : m_modules) for (const auto& mod : m_modules)
{ {
@@ -96,7 +96,7 @@ QString ProcessMemoryProvider::getSymbol(uint64_t addr) const
return {}; return {};
} }
void ProcessMemoryProvider::cacheModules() void ProcessMemoryWindowsProvider::cacheModules()
{ {
HMODULE mods[1024]; HMODULE mods[1024];
DWORD needed = 0; DWORD needed = 0;
@@ -126,7 +126,7 @@ void ProcessMemoryProvider::cacheModules()
#elif defined(__linux__) #elif defined(__linux__)
ProcessMemoryProvider::ProcessMemoryProvider(uint32_t pid, const QString& processName) ProcessMemoryWindowsProvider::ProcessMemoryWindowsProvider(uint32_t pid, const QString& processName)
: m_fd(-1) : m_fd(-1)
, m_pid(pid) , m_pid(pid)
, m_processName(processName) , m_processName(processName)
@@ -152,7 +152,7 @@ ProcessMemoryProvider::ProcessMemoryProvider(uint32_t pid, const QString& proces
} }
bool ProcessMemoryProvider::read(uint64_t addr, void* buf, int len) const bool ProcessMemoryWindowsProvider::read(uint64_t addr, void* buf, int len) const
{ {
if (m_fd < 0 || len <= 0) return false; if (m_fd < 0 || len <= 0) return false;
@@ -176,7 +176,7 @@ bool ProcessMemoryProvider::read(uint64_t addr, void* buf, int len) const
return nread == static_cast<ssize_t>(len); return nread == static_cast<ssize_t>(len);
} }
bool ProcessMemoryProvider::write(uint64_t addr, const void* buf, int len) bool ProcessMemoryWindowsProvider::write(uint64_t addr, const void* buf, int len)
{ {
if (m_fd < 0 || !m_writable || len <= 0) return false; if (m_fd < 0 || !m_writable || len <= 0) return false;
@@ -200,7 +200,7 @@ bool ProcessMemoryProvider::write(uint64_t addr, const void* buf, int len)
return nwritten == static_cast<ssize_t>(len); return nwritten == static_cast<ssize_t>(len);
} }
QString ProcessMemoryProvider::getSymbol(uint64_t addr) const QString ProcessMemoryWindowsProvider::getSymbol(uint64_t addr) const
{ {
for (const auto& mod : m_modules) for (const auto& mod : m_modules)
{ {
@@ -215,7 +215,7 @@ QString ProcessMemoryProvider::getSymbol(uint64_t addr) const
return {}; return {};
} }
void ProcessMemoryProvider::cacheModules() void ProcessMemoryWindowsProvider::cacheModules()
{ {
// Parse /proc/<pid>/maps to discover loaded modules // Parse /proc/<pid>/maps to discover loaded modules
QString mapsPath = QStringLiteral("/proc/%1/maps").arg(m_pid); QString mapsPath = QStringLiteral("/proc/%1/maps").arg(m_pid);
@@ -288,7 +288,7 @@ void ProcessMemoryProvider::cacheModules()
#endif // platform #endif // platform
ProcessMemoryProvider::~ProcessMemoryProvider() ProcessMemoryWindowsProvider::~ProcessMemoryWindowsProvider()
{ {
#ifdef _WIN32 #ifdef _WIN32
if (m_handle) if (m_handle)
@@ -299,7 +299,7 @@ ProcessMemoryProvider::~ProcessMemoryProvider()
#endif #endif
} }
int ProcessMemoryProvider::size() const int ProcessMemoryWindowsProvider::size() const
{ {
#ifdef _WIN32 #ifdef _WIN32
return m_handle ? 0x10000 : 0; return m_handle ? 0x10000 : 0;
@@ -309,22 +309,22 @@ int ProcessMemoryProvider::size() const
} }
// ────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────
// ProcessMemoryPlugin implementation // ProcessMemoryWindowsPlugin implementation
// ────────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────────
QIcon ProcessMemoryPlugin::Icon() const QIcon ProcessMemoryWindowsPlugin::Icon() const
{ {
return qApp->style()->standardIcon(QStyle::SP_ComputerIcon); return qApp->style()->standardIcon(QStyle::SP_ComputerIcon);
} }
bool ProcessMemoryPlugin::canHandle(const QString& target) const bool ProcessMemoryWindowsPlugin::canHandle(const QString& target) const
{ {
// Target format: "pid:name" or just "pid" // Target format: "pid:name" or just "pid"
QRegularExpression re("^\\d+"); QRegularExpression re("^\\d+");
return re.match(target).hasMatch(); return re.match(target).hasMatch();
} }
std::unique_ptr<rcx::Provider> ProcessMemoryPlugin::createProvider(const QString& target, QString* errorMsg) std::unique_ptr<rcx::Provider> ProcessMemoryWindowsPlugin::createProvider(const QString& target, QString* errorMsg)
{ {
// Parse target: "pid:name" or just "pid" // Parse target: "pid:name" or just "pid"
QStringList parts = target.split(':'); QStringList parts = target.split(':');
@@ -339,7 +339,7 @@ std::unique_ptr<rcx::Provider> ProcessMemoryPlugin::createProvider(const QString
QString name = parts.size() > 1 ? parts[1] : QString("PID %1").arg(pid); QString name = parts.size() > 1 ? parts[1] : QString("PID %1").arg(pid);
auto provider = std::make_unique<ProcessMemoryProvider>(pid, name); auto provider = std::make_unique<ProcessMemoryWindowsProvider>(pid, name);
if (!provider->isValid()) if (!provider->isValid())
{ {
if (errorMsg) if (errorMsg)
@@ -352,7 +352,7 @@ std::unique_ptr<rcx::Provider> ProcessMemoryPlugin::createProvider(const QString
return provider; return provider;
} }
uint64_t ProcessMemoryPlugin::getInitialBaseAddress(const QString& target) const uint64_t ProcessMemoryWindowsPlugin::getInitialBaseAddress(const QString& target) const
{ {
#ifdef _WIN32 #ifdef _WIN32
// Parse PID from target // Parse PID from target
@@ -409,7 +409,7 @@ uint64_t ProcessMemoryPlugin::getInitialBaseAddress(const QString& target) const
#endif #endif
} }
bool ProcessMemoryPlugin::selectTarget(QWidget* parent, QString* target) bool ProcessMemoryWindowsPlugin::selectTarget(QWidget* parent, QString* target)
{ {
// Use custom process enumeration from plugin // Use custom process enumeration from plugin
QVector<PluginProcessInfo> pluginProcesses = enumerateProcesses(); QVector<PluginProcessInfo> pluginProcesses = enumerateProcesses();
@@ -440,7 +440,7 @@ bool ProcessMemoryPlugin::selectTarget(QWidget* parent, QString* target)
return false; return false;
} }
QVector<PluginProcessInfo> ProcessMemoryPlugin::enumerateProcesses() QVector<PluginProcessInfo> ProcessMemoryWindowsPlugin::enumerateProcesses()
{ {
QVector<PluginProcessInfo> processes; QVector<PluginProcessInfo> processes;
@@ -543,5 +543,5 @@ QVector<PluginProcessInfo> ProcessMemoryPlugin::enumerateProcesses()
extern "C" RCX_PLUGIN_EXPORT IPlugin* CreatePlugin() extern "C" RCX_PLUGIN_EXPORT IPlugin* CreatePlugin()
{ {
return new ProcessMemoryPlugin(); return new ProcessMemoryWindowsPlugin();
} }

View File

@@ -5,14 +5,14 @@
#include <cstdint> #include <cstdint>
/** /**
* Process memory provider * Process memory provider (Windows)
* Reads/writes memory from a live process using platform APIs * Reads/writes memory from a live process using Windows platform APIs
*/ */
class ProcessMemoryProvider : public rcx::Provider class ProcessMemoryWindowsProvider : public rcx::Provider
{ {
public: public:
ProcessMemoryProvider(uint32_t pid, const QString& processName); ProcessMemoryWindowsProvider(uint32_t pid, const QString& processName);
~ProcessMemoryProvider() override; ~ProcessMemoryWindowsProvider() override;
// Required overrides // Required overrides
bool read(uint64_t addr, void* buf, int len) const override; bool read(uint64_t addr, void* buf, int len) const override;
@@ -57,15 +57,15 @@ private:
}; };
/** /**
* Plugin that provides ProcessMemoryProvider * Plugin that provides ProcessMemoryWindowsProvider
*/ */
class ProcessMemoryPlugin : public IProviderPlugin class ProcessMemoryWindowsPlugin : public IProviderPlugin
{ {
public: public:
std::string Name() const override { return "Process Memory"; } std::string Name() const override { return "Process Memory Windows"; }
std::string Version() const override { return "1.0.0"; } std::string Version() const override { return "1.0.0"; }
std::string Author() const override { return "Reclass"; } std::string Author() const override { return "Reclass"; }
std::string Description() const override { return "Read and write memory from local running processes"; } std::string Description() const override { return "Read and write memory from local running processes (Windows)"; }
k_ELoadType LoadType() const override { return k_ELoadTypeAuto; } k_ELoadType LoadType() const override { return k_ELoadTypeAuto; }
QIcon Icon() const override; QIcon Icon() const override;

View File

@@ -0,0 +1,34 @@
cmake_minimum_required(VERSION 3.20)
project(WinDbgMemoryPlugin LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Qt is found by the parent project; QT variable (Qt5 or Qt6) is inherited
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
# Plugin sources
set(PLUGIN_SOURCES
WinDbgMemoryPlugin.h
WinDbgMemoryPlugin.cpp
)
# Create shared library (DLL)
add_library(WinDbgMemoryPlugin SHARED ${PLUGIN_SOURCES})
# Link Qt + DbgEng
target_link_libraries(WinDbgMemoryPlugin PRIVATE ${QT}::Widgets dbgeng ole32)
# Include directories
target_include_directories(WinDbgMemoryPlugin PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../../src
)
# Output to Plugins folder
set_target_properties(WinDbgMemoryPlugin PROPERTIES
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Plugins"
)

View File

@@ -0,0 +1,510 @@
#include "WinDbgMemoryPlugin.h"
#include <QStyle>
#include <QApplication>
#include <QMessageBox>
#include <QDialog>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLineEdit>
#include <QPushButton>
#include <QLabel>
#include <QDebug>
#include <QClipboard>
#include <QGuiApplication>
#ifdef _WIN32
#include <windows.h>
#include <initguid.h>
#include <dbgeng.h>
#pragma comment(lib, "dbgeng.lib")
#endif
// ──────────────────────────────────────────────────────────────────────────
// Thread dispatch helper
// ──────────────────────────────────────────────────────────────────────────
template<typename Fn>
void WinDbgMemoryProvider::dispatchToOwner(Fn&& fn) const
{
if (!m_dispatcher) { fn(); return; }
if (QThread::currentThread() == m_dispatcher->thread()) {
// Already on the owning thread — call directly
fn();
} else {
// Marshal to the owning thread and block until done
QMetaObject::invokeMethod(m_dispatcher, std::forward<Fn>(fn),
Qt::BlockingQueuedConnection);
}
}
// ──────────────────────────────────────────────────────────────────────────
// WinDbgMemoryProvider implementation
// ──────────────────────────────────────────────────────────────────────────
WinDbgMemoryProvider::WinDbgMemoryProvider(const QString& target)
{
// Create a dedicated thread for all DbgEng COM operations.
// DbgEng's remote transport (TCP/named-pipe) is thread-affine — all
// calls must happen on the thread that called DebugConnect/DebugCreate.
// A private thread with its own event loop guarantees:
// 1. dispatchToOwner() works from any calling thread (main, thread-pool, etc.)
// 2. No deadlock — the DbgEng thread is never blocked by the caller
m_dbgThread = new QThread();
m_dbgThread->setObjectName(QStringLiteral("DbgEngThread"));
m_dbgThread->start();
m_dispatcher = new DbgEngDispatcher();
m_dispatcher->moveToThread(m_dbgThread);
#ifdef _WIN32
// Run all DbgEng initialization on the dedicated thread.
// BlockingQueuedConnection blocks us until the lambda finishes,
// so member variables written inside are visible after the call.
dispatchToOwner([this, &target]() {
HRESULT hr;
qDebug() << "[WinDbg] Opening target:" << target
<< "on DbgEng thread" << QThread::currentThread();
if (target.startsWith("tcp:", Qt::CaseInsensitive)
|| target.startsWith("npipe:", Qt::CaseInsensitive))
{
// ── Remote: connect to existing WinDbg debug server ──
QByteArray connUtf8 = target.toUtf8();
qDebug() << "[WinDbg] DebugConnect:" << target;
hr = DebugConnect(connUtf8.constData(), IID_IDebugClient, (void**)&m_client);
qDebug() << "[WinDbg] DebugConnect hr=" << Qt::hex << (unsigned long)hr
<< "client=" << (void*)m_client;
if (FAILED(hr) || !m_client) {
qWarning() << "[WinDbg] DebugConnect FAILED hr=0x" << Qt::hex << (unsigned long)hr;
return;
}
m_isRemote = true;
}
else
{
// ── Local: create debug client for pid/dump ──
hr = DebugCreate(IID_IDebugClient, (void**)&m_client);
qDebug() << "[WinDbg] DebugCreate hr=" << Qt::hex << (unsigned long)hr
<< "client=" << (void*)m_client;
if (FAILED(hr) || !m_client) {
qWarning() << "[WinDbg] DebugCreate FAILED hr=0x" << Qt::hex << (unsigned long)hr;
return;
}
if (target.startsWith("pid:", Qt::CaseInsensitive))
{
bool ok = false;
ULONG pid = target.mid(4).trimmed().toULong(&ok);
if (!ok || pid == 0) {
qWarning() << "[WinDbg] Invalid PID in target:" << target;
cleanup();
return;
}
qDebug() << "[WinDbg] Attaching to PID" << pid << "(non-invasive)";
hr = m_client->AttachProcess(
0, pid,
DEBUG_ATTACH_NONINVASIVE | DEBUG_ATTACH_NONINVASIVE_NO_SUSPEND);
qDebug() << "[WinDbg] AttachProcess hr=" << Qt::hex << (unsigned long)hr;
if (FAILED(hr)) {
qWarning() << "[WinDbg] AttachProcess FAILED";
cleanup();
return;
}
}
else if (target.startsWith("dump:", Qt::CaseInsensitive))
{
QString path = target.mid(5).trimmed();
QByteArray pathUtf8 = path.toUtf8();
qDebug() << "[WinDbg] Opening dump file:" << path;
hr = m_client->OpenDumpFile(pathUtf8.constData());
qDebug() << "[WinDbg] OpenDumpFile hr=" << Qt::hex << (unsigned long)hr;
if (FAILED(hr)) {
qWarning() << "[WinDbg] OpenDumpFile FAILED";
cleanup();
return;
}
}
else
{
qWarning() << "[WinDbg] Unknown target format:" << target;
cleanup();
return;
}
}
initInterfaces();
// WaitForEvent to finalize the attach/dump load.
// For remote connections the server session is already active — skip.
if (m_control && !m_isRemote) {
qDebug() << "[WinDbg] WaitForEvent...";
hr = m_control->WaitForEvent(0, 10000);
qDebug() << "[WinDbg] WaitForEvent hr=" << Qt::hex << (unsigned long)hr;
}
querySessionInfo();
});
#else
Q_UNUSED(target);
#endif
}
void WinDbgMemoryProvider::initInterfaces()
{
#ifdef _WIN32
if (!m_client) return;
HRESULT hr;
hr = m_client->QueryInterface(IID_IDebugDataSpaces, (void**)&m_dataSpaces);
qDebug() << "[WinDbg] IDebugDataSpaces hr=" << Qt::hex << (unsigned long)hr
<< "ptr=" << (void*)m_dataSpaces;
hr = m_client->QueryInterface(IID_IDebugControl, (void**)&m_control);
qDebug() << "[WinDbg] IDebugControl hr=" << Qt::hex << (unsigned long)hr
<< "ptr=" << (void*)m_control;
hr = m_client->QueryInterface(IID_IDebugSymbols, (void**)&m_symbols);
qDebug() << "[WinDbg] IDebugSymbols hr=" << Qt::hex << (unsigned long)hr
<< "ptr=" << (void*)m_symbols;
if (!m_dataSpaces) {
qWarning() << "[WinDbg] No IDebugDataSpaces — cleaning up";
cleanup();
}
#endif
}
void WinDbgMemoryProvider::querySessionInfo()
{
#ifdef _WIN32
if (!m_client) return;
HRESULT hr;
if (m_control) {
ULONG debugClass = 0, debugQualifier = 0;
hr = m_control->GetDebuggeeType(&debugClass, &debugQualifier);
qDebug() << "[WinDbg] GetDebuggeeType hr=" << Qt::hex << (unsigned long)hr
<< "class=" << debugClass << "qualifier=" << debugQualifier;
if (SUCCEEDED(hr)) {
m_isLive = (debugQualifier < DEBUG_DUMP_SMALL);
m_writable = m_isLive;
}
}
if (m_symbols) {
ULONG numModules = 0, numUnloaded = 0;
hr = m_symbols->GetNumberModules(&numModules, &numUnloaded);
qDebug() << "[WinDbg] GetNumberModules hr=" << Qt::hex << (unsigned long)hr
<< "loaded=" << numModules << "unloaded=" << numUnloaded;
if (SUCCEEDED(hr) && numModules > 0) {
char modName[256] = {};
ULONG modSize = 0;
hr = m_symbols->GetModuleNames(0, 0, nullptr, 0, nullptr,
modName, sizeof(modName), &modSize,
nullptr, 0, nullptr);
if (SUCCEEDED(hr) && modSize > 0)
m_name = QString::fromUtf8(modName);
}
}
if (m_name.isEmpty())
m_name = m_isLive ? QStringLiteral("DbgEng (Live)") : QStringLiteral("DbgEng (Dump)");
if (m_symbols) {
ULONG numModules = 0, numUnloaded = 0;
hr = m_symbols->GetNumberModules(&numModules, &numUnloaded);
if (SUCCEEDED(hr) && numModules > 0) {
ULONG64 moduleBase = 0;
hr = m_symbols->GetModuleByIndex(0, &moduleBase);
qDebug() << "[WinDbg] Module 0 base=" << Qt::hex << moduleBase;
if (SUCCEEDED(hr))
m_base = moduleBase;
}
}
if (m_base && m_dataSpaces) {
uint8_t probe[2] = {};
ULONG got = 0;
hr = m_dataSpaces->ReadVirtual(m_base, probe, 2, &got);
qDebug() << "[WinDbg] Probe read at" << Qt::hex << m_base
<< "hr=" << (unsigned long)hr << "got=" << got
<< "bytes:" << (int)probe[0] << (int)probe[1];
if (FAILED(hr) || got == 0) {
qWarning() << "[WinDbg] Probe read FAILED — cleaning up";
cleanup();
return;
}
}
qDebug() << "[WinDbg] Ready. name=" << m_name
<< "base=" << Qt::hex << m_base << "isLive=" << m_isLive;
#endif
}
WinDbgMemoryProvider::~WinDbgMemoryProvider()
{
#ifdef _WIN32
// Dispatch COM cleanup to the DbgEng thread (thread-affine release)
if (m_dbgThread && m_dbgThread->isRunning() && m_dispatcher) {
dispatchToOwner([this]() {
if (m_client) {
if (m_isRemote)
m_client->EndSession(DEBUG_END_DISCONNECT);
else
m_client->DetachProcesses();
}
cleanup();
});
} else {
// Thread not running — clean up directly (best-effort)
if (m_client) {
if (m_isRemote)
m_client->EndSession(DEBUG_END_DISCONNECT);
else
m_client->DetachProcesses();
}
cleanup();
}
#else
cleanup();
#endif
// Stop the dedicated thread
if (m_dbgThread) {
m_dbgThread->quit();
m_dbgThread->wait(3000);
delete m_dbgThread;
m_dbgThread = nullptr;
}
delete m_dispatcher;
m_dispatcher = nullptr;
}
void WinDbgMemoryProvider::cleanup()
{
#ifdef _WIN32
if (m_symbols) { m_symbols->Release(); m_symbols = nullptr; }
if (m_control) { m_control->Release(); m_control = nullptr; }
if (m_dataSpaces) { m_dataSpaces->Release(); m_dataSpaces = nullptr; }
if (m_client) { m_client->Release(); m_client = nullptr; }
#endif
}
bool WinDbgMemoryProvider::read(uint64_t addr, void* buf, int len) const
{
#ifdef _WIN32
if (!m_dataSpaces || len <= 0) return false;
bool result = false;
dispatchToOwner([&]() {
ULONG bytesRead = 0;
HRESULT hr = m_dataSpaces->ReadVirtual(m_base + addr, buf, (ULONG)len, &bytesRead);
if (FAILED(hr) || (int)bytesRead < len)
memset((char*)buf + bytesRead, 0, len - bytesRead);
result = bytesRead > 0;
});
return result;
#else
Q_UNUSED(addr); Q_UNUSED(buf); Q_UNUSED(len);
return false;
#endif
}
bool WinDbgMemoryProvider::write(uint64_t addr, const void* buf, int len)
{
#ifdef _WIN32
if (!m_dataSpaces || !m_writable || len <= 0) return false;
bool result = false;
dispatchToOwner([&]() {
ULONG bytesWritten = 0;
HRESULT hr = m_dataSpaces->WriteVirtual(m_base + addr, const_cast<void*>(buf),
(ULONG)len, &bytesWritten);
result = SUCCEEDED(hr) && bytesWritten == (ULONG)len;
});
return result;
#else
Q_UNUSED(addr); Q_UNUSED(buf); Q_UNUSED(len);
return false;
#endif
}
int WinDbgMemoryProvider::size() const
{
#ifdef _WIN32
return m_dataSpaces ? 0x10000 : 0;
#else
return 0;
#endif
}
bool WinDbgMemoryProvider::isReadable(uint64_t /*addr*/, int len) const
{
#ifdef _WIN32
// DbgEng's ReadVirtual can read any mapped virtual address.
return m_dataSpaces != nullptr && len >= 0;
#else
return false;
#endif
}
QString WinDbgMemoryProvider::getSymbol(uint64_t addr) const
{
#ifdef _WIN32
if (!m_symbols) return {};
QString result;
dispatchToOwner([&]() {
char nameBuf[512] = {};
ULONG nameSize = 0;
ULONG64 displacement = 0;
HRESULT hr = m_symbols->GetNameByOffset(m_base + addr, nameBuf, sizeof(nameBuf),
&nameSize, &displacement);
if (SUCCEEDED(hr) && nameSize > 0) {
result = QString::fromUtf8(nameBuf);
if (displacement > 0)
result += QStringLiteral("+0x%1").arg(displacement, 0, 16);
}
});
return result;
#else
Q_UNUSED(addr);
return {};
#endif
}
// ──────────────────────────────────────────────────────────────────────────
// WinDbgMemoryPlugin implementation
// ──────────────────────────────────────────────────────────────────────────
QIcon WinDbgMemoryPlugin::Icon() const
{
return qApp->style()->standardIcon(QStyle::SP_DriveNetIcon);
}
bool WinDbgMemoryPlugin::canHandle(const QString& target) const
{
return target.startsWith("tcp:", Qt::CaseInsensitive)
|| target.startsWith("npipe:", Qt::CaseInsensitive)
|| target.startsWith("pid:", Qt::CaseInsensitive)
|| target.startsWith("dump:", Qt::CaseInsensitive);
}
std::unique_ptr<rcx::Provider> WinDbgMemoryPlugin::createProvider(const QString& target, QString* errorMsg)
{
auto provider = std::make_unique<WinDbgMemoryProvider>(target);
if (!provider->isValid())
{
if (errorMsg) {
if (target.startsWith("tcp:", Qt::CaseInsensitive)
|| target.startsWith("npipe:", Qt::CaseInsensitive))
*errorMsg = QString("Failed to connect to debug server.\n\n"
"Target: %1\n\n"
"Make sure WinDbg is running with a matching .server command\n"
"(e.g. .server tcp:port=5055) and the port/pipe is reachable.")
.arg(target);
else if (target.startsWith("pid:", Qt::CaseInsensitive))
*errorMsg = QString("Failed to attach to process.\n\n"
"Target: %1\n\n"
"Make sure the process is running and you have "
"sufficient privileges (try Run as Administrator).")
.arg(target);
else
*errorMsg = QString("Failed to open dump file.\n\n"
"Target: %1\n\n"
"Make sure the file exists and is a valid dump.")
.arg(target);
}
return nullptr;
}
return provider;
}
uint64_t WinDbgMemoryPlugin::getInitialBaseAddress(const QString& target) const
{
Q_UNUSED(target);
return 0;
}
bool WinDbgMemoryPlugin::selectTarget(QWidget* parent, QString* target)
{
QDialog dlg(parent);
dlg.setWindowTitle("WinDbg Settings");
dlg.resize(460, 260);
QPalette dlgPal = qApp->palette();
dlg.setPalette(dlgPal);
dlg.setAutoFillBackground(true);
auto* layout = new QVBoxLayout(&dlg);
layout->addWidget(new QLabel(
"Connect to a running WinDbg debug server.\n"
"In WinDbg, run: .server tcp:port=5055"));
layout->addSpacing(8);
layout->addWidget(new QLabel("Connection string:"));
auto* connEdit = new QLineEdit;
connEdit->setPlaceholderText("tcp:Port=5055,Server=localhost");
connEdit->setText("tcp:Port=5055,Server=localhost");
layout->addWidget(connEdit);
layout->addSpacing(4);
layout->addWidget(new QLabel("Run one of these in WinDbg first:"));
auto addExample = [&](const QString& text) {
auto* row = new QHBoxLayout;
auto* label = new QLabel(text);
QPalette lp = dlgPal;
lp.setColor(QPalette::WindowText, dlgPal.color(QPalette::Disabled, QPalette::WindowText));
label->setPalette(lp);
label->setTextInteractionFlags(Qt::TextSelectableByMouse);
row->addWidget(label, 1);
auto* copyBtn = new QPushButton("Copy");
copyBtn->setFixedWidth(50);
copyBtn->setToolTip("Copy to clipboard");
QObject::connect(copyBtn, &QPushButton::clicked, [text]() {
QGuiApplication::clipboard()->setText(text);
});
row->addWidget(copyBtn);
layout->addLayout(row);
};
addExample(".server tcp:port=5055");
addExample(".server npipe:pipe=reclass");
layout->addStretch();
auto* btnLayout = new QHBoxLayout;
btnLayout->addStretch();
auto* okBtn = new QPushButton("OK");
auto* cancelBtn = new QPushButton("Cancel");
btnLayout->addWidget(okBtn);
btnLayout->addWidget(cancelBtn);
layout->addLayout(btnLayout);
QObject::connect(okBtn, &QPushButton::clicked, &dlg, &QDialog::accept);
QObject::connect(cancelBtn, &QPushButton::clicked, &dlg, &QDialog::reject);
if (dlg.exec() != QDialog::Accepted)
return false;
QString conn = connEdit->text().trimmed();
if (conn.isEmpty()) return false;
*target = conn;
return true;
}
// ──────────────────────────────────────────────────────────────────────────
// Plugin factory
// ──────────────────────────────────────────────────────────────────────────
extern "C" RCX_PLUGIN_EXPORT IPlugin* CreatePlugin()
{
return new WinDbgMemoryPlugin();
}

View File

@@ -0,0 +1,122 @@
#pragma once
#include "../../src/iplugin.h"
#include "../../src/core.h"
#include <cstdint>
#include <QObject>
#include <QThread>
// Forward declarations for DbgEng COM interfaces
struct IDebugClient;
struct IDebugDataSpaces;
struct IDebugControl;
struct IDebugSymbols;
/**
* WinDbg memory provider
*
* Uses DbgEng to read memory from:
* - An existing WinDbg debug server via DebugConnect (tcp/npipe)
* - A live process by PID via DebugCreate (non-invasive attach)
* - A crash dump (.dmp) file via DebugCreate
*
* Target string format:
* "tcp:Port=5055,Server=localhost" - connect to WinDbg debug server (TCP)
* "npipe:Pipe=name,Server=localhost" - connect to WinDbg debug server (named pipe)
* "pid:1234" - attach to process 1234
* "dump:C:/path/to/file.dmp" - open dump file
*
* Threading: All DbgEng COM calls are dispatched to the thread that created
* the connection (DebugConnect/DebugCreate). This is required because the
* remote transport (TCP/named-pipe) binds to the creating thread. The
* controller's background refresh threads call read() which transparently
* marshals to the owning thread via BlockingQueuedConnection.
*/
// Helper QObject that lives on the DbgEng-owning thread.
// Used as a target for QMetaObject::invokeMethod to marshal calls.
class DbgEngDispatcher : public QObject {
Q_OBJECT
public:
using QObject::QObject;
};
class WinDbgMemoryProvider : public rcx::Provider
{
public:
/// Create a provider from a target string
WinDbgMemoryProvider(const QString& target);
~WinDbgMemoryProvider() override;
// Required overrides
bool read(uint64_t addr, void* buf, int len) const override;
int size() const override;
// Optional overrides
bool isReadable(uint64_t addr, int len) const override;
bool write(uint64_t addr, const void* buf, int len) override;
bool isWritable() const override { return m_writable; }
QString name() const override { return m_name; }
QString kind() const override { return QStringLiteral("WinDbg"); }
QString getSymbol(uint64_t addr) const override;
bool isLive() const override { return m_isLive; }
uint64_t base() const override { return m_base; }
void setBase(uint64_t b) override { m_base = b; }
private:
void initInterfaces(); // get IDebugDataSpaces/Control/Symbols from client
void querySessionInfo(); // determine live/dump, writable, name, base
void cleanup();
// Marshal a lambda to the DbgEng-owning thread. If already on that
// thread, calls directly. Otherwise blocks via QueuedConnection.
template<typename Fn>
void dispatchToOwner(Fn&& fn) const;
IDebugClient* m_client = nullptr;
IDebugDataSpaces* m_dataSpaces = nullptr;
IDebugControl* m_control = nullptr;
IDebugSymbols* m_symbols = nullptr;
QString m_name;
uint64_t m_base = 0;
bool m_isLive = false;
bool m_writable = false;
bool m_isRemote = false; // true when connected via DebugConnect (tcp/npipe)
// Dedicated thread for DbgEng COM operations. The remote TCP/pipe
// transport is thread-affine — all calls must happen on the thread
// that called DebugConnect. A private thread with its own event loop
// ensures dispatchToOwner() works from any calling thread (including
// QtConcurrent workers and the main/GUI thread) without deadlock.
QThread* m_dbgThread = nullptr;
DbgEngDispatcher* m_dispatcher = nullptr;
};
/**
* Plugin that provides WinDbgMemoryProvider
*
* Uses DbgEng to read memory via:
* - Remote connection to an existing WinDbg debug server (tcp/npipe)
* - Local non-invasive attach to a live process (pid)
* - Local crash dump file (dump)
*/
class WinDbgMemoryPlugin : public IProviderPlugin
{
public:
std::string Name() const override { return "WinDbg Memory"; }
std::string Version() const override { return "2.0.0"; }
std::string Author() const override { return "Reclass"; }
std::string Description() const override { return "Read memory via DbgEng (live process attach or crash dump)"; }
k_ELoadType LoadType() const override { return k_ELoadTypeAuto; }
QIcon Icon() const override;
bool canHandle(const QString& target) const override;
std::unique_ptr<rcx::Provider> createProvider(const QString& target, QString* errorMsg) override;
uint64_t getInitialBaseAddress(const QString& target) const override;
bool selectTarget(QWidget* parent, QString* target) override;
};
// Plugin export
extern "C" RCX_PLUGIN_EXPORT IPlugin* CreatePlugin();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 140 KiB

After

Width:  |  Height:  |  Size: 60 KiB

View File

@@ -345,12 +345,36 @@ void composeParent(ComposeState& state, const NodeTree& tree,
std::sort(refChildren.begin(), refChildren.end(), [&](int a, int b) { std::sort(refChildren.begin(), refChildren.end(), [&](int a, int b) {
return tree.nodes[a].offset < tree.nodes[b].offset; return tree.nodes[a].offset < tree.nodes[b].offset;
}); });
// Use the referenced struct's scope widths (children come from there)
uint64_t refScopeId = node.refId;
for (int childIdx : refChildren) { for (int childIdx : refChildren) {
// Skip self-referential children (e.g. struct Ball has a field of type Ball) const Node& child = tree.nodes[childIdx];
if (state.visiting.contains(tree.nodes[childIdx].id)) // Self-referential child → show as collapsed struct (non-expandable)
if (state.visiting.contains(child.id)) {
int typeW = state.effectiveTypeW(refScopeId);
int nameW = state.effectiveNameW(refScopeId);
LineMeta lm;
lm.nodeIdx = nodeIdx; // parent struct — materialize target
lm.nodeId = child.id;
lm.depth = childDepth;
lm.lineKind = LineKind::Header;
lm.offsetText = fmt::fmtOffsetMargin(
tree.baseAddress + absAddr + child.offset, false,
state.offsetHexDigits);
lm.offsetAddr = tree.baseAddress + absAddr + child.offset;
lm.nodeKind = child.kind;
lm.foldHead = true;
lm.foldCollapsed = true;
lm.foldLevel = computeFoldLevel(childDepth, true);
lm.markerMask = (1u << M_STRUCT_BG) | (1u << M_CYCLE);
lm.effectiveTypeW = typeW;
lm.effectiveNameW = nameW;
state.emitLine(fmt::fmtStructHeader(child, childDepth,
/*collapsed=*/true, typeW, nameW), lm);
continue; continue;
}
composeNode(state, tree, prov, childIdx, childDepth, composeNode(state, tree, prov, childIdx, childDepth,
absAddr, node.refId, false, node.id); absAddr, node.refId, false, refScopeId);
} }
} }
} }
@@ -435,8 +459,13 @@ void composeNode(ComposeState& state, const NodeTree& tree,
ptrVal = (node.kind == NodeKind::Pointer32) ptrVal = (node.kind == NodeKind::Pointer32)
? (uint64_t)prov.readU32(absAddr) : prov.readU64(absAddr); ? (uint64_t)prov.readU32(absAddr) : prov.readU64(absAddr);
if (ptrVal != 0) { if (ptrVal != 0) {
uint64_t pBase = ptrToProviderAddr(tree, ptrVal); // Treat sentinel values as invalid pointers
if (pBase == UINT64_MAX) ptrVal = 0; // ptr below base: invalid if (ptrVal == UINT64_MAX || (node.kind == NodeKind::Pointer32 && ptrVal == 0xFFFFFFFF))
ptrVal = 0;
else {
uint64_t pBase = ptrToProviderAddr(tree, ptrVal);
if (pBase == UINT64_MAX) ptrVal = 0; // ptr below base: invalid
}
} }
} }

View File

@@ -293,6 +293,9 @@ void RcxController::connectEditor(RcxEditor* editor) {
break; break;
case EditTarget::BaseAddress: { case EditTarget::BaseAddress: {
QString s = text.trimmed(); QString s = text.trimmed();
s.remove('`'); // WinDbg backtick separators (e.g. 7ff6`6cce0000)
s.remove('\n');
s.remove('\r');
// Support simple equations: 0x10+0x4, 0x100-0x10, etc. // Support simple equations: 0x10+0x4, 0x100-0x10, etc.
uint64_t newBase = 0; uint64_t newBase = 0;
bool ok = true; bool ok = true;
@@ -347,7 +350,7 @@ void RcxController::connectEditor(RcxEditor* editor) {
if (text.startsWith(QStringLiteral("#saved:"))) { if (text.startsWith(QStringLiteral("#saved:"))) {
int idx = text.mid(7).toInt(); int idx = text.mid(7).toInt();
switchToSavedSource(idx); switchToSavedSource(idx);
} else if (text == QStringLiteral("file")) { } else if (text == QStringLiteral("File")) {
auto* w = qobject_cast<QWidget*>(parent()); auto* w = qobject_cast<QWidget*>(parent());
QString path = QFileDialog::getOpenFileName(w, "Load Binary Data", {}, "All Files (*)"); QString path = QFileDialog::getOpenFileName(w, "Load Binary Data", {}, "All Files (*)");
if (!path.isEmpty()) { if (!path.isEmpty()) {
@@ -792,6 +795,44 @@ void RcxController::toggleCollapse(int nodeIdx) {
cmd::Collapse{node.id, node.collapsed, !node.collapsed})); cmd::Collapse{node.id, node.collapsed, !node.collapsed}));
} }
void RcxController::materializeRefChildren(int nodeIdx) {
if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return;
auto& tree = m_doc->tree;
// Snapshot values before addNode invalidates references
const uint64_t parentId = tree.nodes[nodeIdx].id;
const uint64_t refId = tree.nodes[nodeIdx].refId;
const NodeKind parentKind = tree.nodes[nodeIdx].kind;
const QString parentName = tree.nodes[nodeIdx].name;
if (refId == 0) return;
if (!tree.childrenOf(parentId).isEmpty()) return; // already materialized
// Clone all children of the referenced struct as real children of this struct
QVector<int> refChildren = tree.childrenOf(refId);
for (int ci : refChildren) {
Node copy = tree.nodes[ci];
copy.id = 0; // auto-assign new ID
copy.parentId = parentId;
copy.collapsed = true; // start collapsed
tree.addNode(copy);
}
tree.invalidateIdCache();
// Auto-expand the self-referential child (the one that was the cycle)
// so the user gets expand in a single click
QVector<int> newChildren = tree.childrenOf(parentId);
for (int ci : newChildren) {
auto& c = tree.nodes[ci];
if (c.kind == parentKind && c.name == parentName && c.refId == refId) {
c.collapsed = false;
break;
}
}
refresh();
}
void RcxController::applyCommand(const Command& command, bool isUndo) { void RcxController::applyCommand(const Command& command, bool isUndo) {
auto& tree = m_doc->tree; auto& tree = m_doc->tree;
@@ -872,7 +913,7 @@ void RcxController::applyCommand(const Command& command, bool isUndo) {
qWarning() << "WriteBytes failed at address" << QString::number(c.addr, 16); qWarning() << "WriteBytes failed at address" << QString::number(c.addr, 16);
// Patch snapshot so compose sees the new value immediately // Patch snapshot so compose sees the new value immediately
if (m_snapshotProv) if (m_snapshotProv)
m_snapshotProv->patchSnapshot(c.addr, bytes.constData(), bytes.size()); m_snapshotProv->patchPages(c.addr, bytes.constData(), bytes.size());
} else if constexpr (std::is_same_v<T, cmd::ChangeArrayMeta>) { } else if constexpr (std::is_same_v<T, cmd::ChangeArrayMeta>) {
int idx = tree.indexOfId(c.nodeId); int idx = tree.indexOfId(c.nodeId);
if (idx >= 0) { if (idx >= 0) {
@@ -1858,15 +1899,66 @@ void RcxController::pushSavedSourcesToEditors() {
void RcxController::setupAutoRefresh() { void RcxController::setupAutoRefresh() {
m_refreshTimer = new QTimer(this); m_refreshTimer = new QTimer(this);
m_refreshTimer->setInterval(2000); m_refreshTimer->setInterval(660);
connect(m_refreshTimer, &QTimer::timeout, this, &RcxController::onRefreshTick); connect(m_refreshTimer, &QTimer::timeout, this, &RcxController::onRefreshTick);
m_refreshTimer->start(); m_refreshTimer->start();
m_refreshWatcher = new QFutureWatcher<QByteArray>(this); m_refreshWatcher = new QFutureWatcher<PageMap>(this);
connect(m_refreshWatcher, &QFutureWatcher<QByteArray>::finished, connect(m_refreshWatcher, &QFutureWatcher<PageMap>::finished,
this, &RcxController::onReadComplete); this, &RcxController::onReadComplete);
} }
// Recursively collect memory ranges for a struct and its pointer targets.
// memBase is the provider-relative address where this struct's data lives.
void RcxController::collectPointerRanges(
uint64_t structId, uint64_t memBase,
int depth, int maxDepth,
QSet<uint64_t>& visited,
QVector<QPair<uint64_t,int>>& ranges) const
{
if (depth >= maxDepth) return;
uint64_t key = memBase ^ (structId * 0x9E3779B97F4A7C15ULL);
if (visited.contains(key)) return;
visited.insert(key);
int span = m_doc->tree.structSpan(structId);
if (span <= 0) return;
ranges.append({memBase, span});
if (!m_snapshotProv) return;
// Walk children looking for non-collapsed pointers
QVector<int> children = m_doc->tree.childrenOf(structId);
for (int ci : children) {
const Node& child = m_doc->tree.nodes[ci];
if (child.kind != NodeKind::Pointer32 && child.kind != NodeKind::Pointer64)
continue;
if (child.collapsed || child.refId == 0) continue;
uint64_t ptrAddr = memBase + child.offset;
int ptrSize = child.byteSize();
if (!m_snapshotProv->isReadable(ptrAddr, ptrSize)) continue;
uint64_t ptrVal = (child.kind == NodeKind::Pointer32)
? (uint64_t)m_snapshotProv->readU32(ptrAddr)
: m_snapshotProv->readU64(ptrAddr);
if (ptrVal == 0 || ptrVal == UINT64_MAX || ptrVal < m_doc->tree.baseAddress) continue;
uint64_t pBase = ptrVal - m_doc->tree.baseAddress;
collectPointerRanges(child.refId, pBase, depth + 1, maxDepth,
visited, ranges);
}
// Embedded struct references (struct node with refId but no own children)
int idx = m_doc->tree.indexOfId(structId);
if (idx >= 0) {
const Node& sn = m_doc->tree.nodes[idx];
if (sn.kind == NodeKind::Struct && sn.refId != 0 && children.isEmpty())
collectPointerRanges(sn.refId, memBase, depth, maxDepth,
visited, ranges);
}
}
void RcxController::onRefreshTick() { void RcxController::onRefreshTick() {
if (m_readInFlight) return; if (m_readInFlight) return;
if (!m_doc->provider || !m_doc->provider->isLive()) return; if (!m_doc->provider || !m_doc->provider->isLive()) return;
@@ -1877,75 +1969,120 @@ void RcxController::onRefreshTick() {
int extent = computeDataExtent(); int extent = computeDataExtent();
if (extent <= 0) return; if (extent <= 0) return;
// Collect all needed ranges: main struct + pointer targets
QVector<QPair<uint64_t,int>> ranges;
ranges.append({0, extent});
if (m_snapshotProv) {
QSet<uint64_t> visited;
uint64_t rootId = m_viewRootId;
if (rootId == 0 && !m_doc->tree.nodes.isEmpty())
rootId = m_doc->tree.nodes[0].id;
collectPointerRanges(rootId, 0, 0, 4, visited, ranges);
}
m_readInFlight = true; m_readInFlight = true;
m_readGen = m_refreshGen; m_readGen = m_refreshGen;
// Capture shared_ptr copy — keeps provider alive during async read
auto prov = m_doc->provider; auto prov = m_doc->provider;
uint64_t base = prov->base(); qDebug() << "[Refresh] reading" << ranges.size() << "ranges from base"
qDebug() << "[Refresh] reading" << extent << "bytes from base" << Qt::hex << base; << Qt::hex << prov->base();
m_refreshWatcher->setFuture(QtConcurrent::run([prov, extent]() -> QByteArray { m_refreshWatcher->setFuture(QtConcurrent::run([prov, ranges]() -> PageMap {
return prov->readBytes(0, extent); constexpr uint64_t kPageSize = 4096;
constexpr uint64_t kPageMask = ~(kPageSize - 1);
PageMap pages;
for (const auto& r : ranges) {
uint64_t pageStart = r.first & kPageMask;
uint64_t end = r.first + r.second;
uint64_t pageEnd = (end + kPageSize - 1) & kPageMask;
for (uint64_t p = pageStart; p < pageEnd; p += kPageSize) {
if (!pages.contains(p))
pages[p] = prov->readBytes(p, static_cast<int>(kPageSize));
}
}
return pages;
})); }));
} }
void RcxController::onReadComplete() { void RcxController::onReadComplete() {
m_readInFlight = false; m_readInFlight = false;
// Stale read (provider changed while we were reading) — discard
if (m_readGen != m_refreshGen) return; if (m_readGen != m_refreshGen) return;
QByteArray newData = m_refreshWatcher->result(); PageMap newPages;
try {
newPages = m_refreshWatcher->result();
} catch (const std::exception& e) {
qWarning() << "[Refresh] async read threw:" << e.what();
return;
} catch (...) {
qWarning() << "[Refresh] async read threw unknown exception";
return;
}
// Fast path: no changes at all — skip full recompose // All-zero guard: if page 0 is all zeros and we already have data, discard
if (!m_prevSnapshot.isEmpty() && m_prevSnapshot.size() == newData.size() if (!m_prevPages.isEmpty() && newPages.contains(0)) {
&& memcmp(m_prevSnapshot.constData(), newData.constData(), newData.size()) == 0) const QByteArray& p0 = newPages.value(0);
bool allZero = true;
for (int i = 0; i < p0.size(); ++i) {
if (p0[i] != 0) { allZero = false; break; }
}
if (allZero) {
qDebug() << "[Refresh] discarding all-zero page-0, keeping stale snapshot";
return;
}
}
// Fast path: no changes at all
if (newPages == m_prevPages)
return; return;
// Compute which byte offsets changed // Compute which byte offsets changed (for change highlighting).
// Skip on first snapshot — nothing to compare against.
m_changedOffsets.clear(); m_changedOffsets.clear();
if (!m_prevSnapshot.isEmpty()) { if (!m_prevPages.isEmpty()) {
int compareLen = qMin(m_prevSnapshot.size(), newData.size()); for (auto it = newPages.constBegin(); it != newPages.constEnd(); ++it) {
const char* oldP = m_prevSnapshot.constData(); uint64_t pageAddr = it.key();
const char* newP = newData.constData(); const QByteArray& newPage = it.value();
for (int i = 0; i < compareLen; i++) { auto oldIt = m_prevPages.constFind(pageAddr);
if (oldP[i] != newP[i]) if (oldIt == m_prevPages.constEnd())
m_changedOffsets.insert(i); continue; // new page, no previous data to diff against
const QByteArray& oldPage = oldIt.value();
int cmpLen = qMin(oldPage.size(), newPage.size());
for (int i = 0; i < cmpLen; ++i) {
if (oldPage[i] != newPage[i])
m_changedOffsets.insert(static_cast<int64_t>(pageAddr) + i);
}
} }
// Bytes beyond old snapshot are all "new"
for (int i = compareLen; i < newData.size(); i++)
m_changedOffsets.insert(i);
} }
m_prevSnapshot = newData;
// Update or create snapshot provider int mainExtent = computeDataExtent();
m_prevPages = newPages;
if (m_snapshotProv) if (m_snapshotProv)
m_snapshotProv->updateSnapshot(std::move(newData)); m_snapshotProv->updatePages(std::move(newPages), mainExtent);
else else
m_snapshotProv = std::make_unique<SnapshotProvider>(m_doc->provider, std::move(newData)); m_snapshotProv = std::make_unique<SnapshotProvider>(
m_doc->provider, std::move(newPages), mainExtent);
refresh(); refresh();
// Clear changed offsets after refresh consumed them
m_changedOffsets.clear(); m_changedOffsets.clear();
} }
int RcxController::computeDataExtent() const { int RcxController::computeDataExtent() const {
// Prefer tree-based extent: exact bytes needed for rendering static constexpr int64_t kMaxMainExtent = 16 * 1024 * 1024; // 16 MB cap
int64_t treeExtent = 0; int64_t treeExtent = 0;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) { for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
const Node& node = m_doc->tree.nodes[i]; const Node& node = m_doc->tree.nodes[i];
int64_t off = m_doc->tree.computeOffset(i); int64_t off = m_doc->tree.computeOffset(i);
// byteSize() returns 0 for Array-of-Struct/Array; use structSpan() for containers
int sz = (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) int sz = (node.kind == NodeKind::Struct || node.kind == NodeKind::Array)
? m_doc->tree.structSpan(node.id) : node.byteSize(); ? m_doc->tree.structSpan(node.id) : node.byteSize();
int64_t end = off + sz; int64_t end = off + sz;
if (end > treeExtent) treeExtent = end; if (end > treeExtent) treeExtent = end;
} }
// Clamp to max int (readBytes takes int length) if (treeExtent > 0) return static_cast<int>(qMin(treeExtent, kMaxMainExtent));
if (treeExtent > 0) return (int)qMin(treeExtent, (int64_t)std::numeric_limits<int>::max());
// Fallback: provider size (empty tree)
int provSize = m_doc->provider->size(); int provSize = m_doc->provider->size();
if (provSize > 0) return provSize; if (provSize > 0) return provSize;
return 0; return 0;
@@ -1955,7 +2092,7 @@ void RcxController::resetSnapshot() {
m_refreshGen++; m_refreshGen++;
m_readInFlight = false; m_readInFlight = false;
m_snapshotProv.reset(); m_snapshotProv.reset();
m_prevSnapshot.clear(); m_prevPages.clear();
m_changedOffsets.clear(); m_changedOffsets.clear();
} }
@@ -1965,7 +2102,10 @@ void RcxController::handleMarginClick(RcxEditor* editor, int margin,
if (!lm) return; if (!lm) return;
if (lm->foldHead && (margin == 0 || margin == 1)) { if (lm->foldHead && (margin == 0 || margin == 1)) {
toggleCollapse(lm->nodeIdx); if (lm->markerMask & (1u << M_CYCLE))
materializeRefChildren(lm->nodeIdx);
else
toggleCollapse(lm->nodeIdx);
} else if (margin == 0 || margin == 1) { } else if (margin == 0 || margin == 1) {
emit nodeSelected(lm->nodeIdx); emit nodeSelected(lm->nodeIdx);
} }

View File

@@ -89,6 +89,7 @@ public:
void insertNode(uint64_t parentId, int offset, NodeKind kind, const QString& name); void insertNode(uint64_t parentId, int offset, NodeKind kind, const QString& name);
void removeNode(int nodeIdx); void removeNode(int nodeIdx);
void toggleCollapse(int nodeIdx); void toggleCollapse(int nodeIdx);
void materializeRefChildren(int nodeIdx);
void setNodeValue(int nodeIdx, int subLine, const QString& text, bool isAscii = false); void setNodeValue(int nodeIdx, int subLine, const QString& text, bool isAscii = false);
void duplicateNode(int nodeIdx); void duplicateNode(int nodeIdx);
void showContextMenu(RcxEditor* editor, int line, int nodeIdx, int subLine, const QPoint& globalPos); void showContextMenu(RcxEditor* editor, int line, int nodeIdx, int subLine, const QPoint& globalPos);
@@ -140,10 +141,11 @@ private:
TypeSelectorPopup* m_cachedPopup = nullptr; TypeSelectorPopup* m_cachedPopup = nullptr;
// ── Auto-refresh state ── // ── Auto-refresh state ──
using PageMap = QHash<uint64_t, QByteArray>;
QTimer* m_refreshTimer = nullptr; QTimer* m_refreshTimer = nullptr;
QFutureWatcher<QByteArray>* m_refreshWatcher = nullptr; QFutureWatcher<PageMap>* m_refreshWatcher = nullptr;
std::unique_ptr<SnapshotProvider> m_snapshotProv; std::unique_ptr<SnapshotProvider> m_snapshotProv;
QByteArray m_prevSnapshot; PageMap m_prevPages;
QSet<int64_t> m_changedOffsets; QSet<int64_t> m_changedOffsets;
uint64_t m_refreshGen = 0; uint64_t m_refreshGen = 0;
uint64_t m_readGen = 0; uint64_t m_readGen = 0;
@@ -165,6 +167,10 @@ private:
void onReadComplete(); void onReadComplete();
int computeDataExtent() const; int computeDataExtent() const;
void resetSnapshot(); void resetSnapshot();
void collectPointerRanges(uint64_t structId, uint64_t memBase,
int depth, int maxDepth,
QSet<uint64_t>& visited,
QVector<QPair<uint64_t,int>>& ranges) const;
}; };
} // namespace rcx } // namespace rcx

View File

@@ -14,6 +14,7 @@
#include <QCursor> #include <QCursor>
#include <QMenu> #include <QMenu>
#include <QApplication> #include <QApplication>
#include <QClipboard>
#include "themes/thememanager.h" #include "themes/thememanager.h"
namespace rcx { namespace rcx {
@@ -1603,6 +1604,22 @@ bool RcxEditor::handleEditKey(QKeyEvent* ke) {
case Qt::Key_End: case Qt::Key_End:
m_sci->setCursorPosition(m_editState.line, editEndCol()); m_sci->setCursorPosition(m_editState.line, editEndCol());
return true; return true;
case Qt::Key_V:
if (ke->modifiers() & Qt::ControlModifier) {
// Sanitized paste: strip newlines (and backticks for base addresses)
QString clip = QApplication::clipboard()->text();
clip.remove('\n');
clip.remove('\r');
if (m_editState.target == EditTarget::BaseAddress)
clip.remove('`');
if (!clip.isEmpty()) {
QByteArray utf8 = clip.toUtf8();
m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACESEL,
(uintptr_t)0, utf8.constData());
}
return true;
}
return false;
default: default:
return false; return false;
} }
@@ -1961,7 +1978,7 @@ void RcxEditor::showSourcePicker() {
int zoom = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_GETZOOM); int zoom = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_GETZOOM);
menuFont.setPointSize(menuFont.pointSize() + zoom); menuFont.setPointSize(menuFont.pointSize() + zoom);
menu.setFont(menuFont); menu.setFont(menuFont);
menu.addAction("file"); menu.addAction("File");
// Add all registered providers from global registry // Add all registered providers from global registry
const auto& providers = ProviderRegistry::instance().providers(); const auto& providers = ProviderRegistry::instance().providers();

Binary file not shown.

View File

@@ -46,6 +46,7 @@
#ifdef _WIN32 #ifdef _WIN32
#include <windows.h> #include <windows.h>
#include <windowsx.h>
#include <dwmapi.h> #include <dwmapi.h>
#include <dbghelp.h> #include <dbghelp.h>
#include <cstdio> #include <cstdio>
@@ -230,6 +231,21 @@ static void applyGlobalTheme(const rcx::Theme& theme) {
qApp->setStyleSheet(QString()); qApp->setStyleSheet(QString());
} }
class BorderOverlay : public QWidget {
public:
QColor color;
explicit BorderOverlay(QWidget* parent) : QWidget(parent) {
setAttribute(Qt::WA_TransparentForMouseEvents);
setAttribute(Qt::WA_NoSystemBackground);
setFocusPolicy(Qt::NoFocus);
}
void paintEvent(QPaintEvent*) override {
QPainter p(this);
p.setPen(color);
p.drawRect(0, 0, width() - 1, height() - 1);
}
};
namespace rcx { namespace rcx {
// MainWindow class declaration is in mainwindow.h // MainWindow class declaration is in mainwindow.h
@@ -238,6 +254,32 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
setWindowTitle("Reclass"); setWindowTitle("Reclass");
resize(1200, 800); resize(1200, 800);
// Frameless window with system menu (Alt+Space) and min/max/close support
setWindowFlags(Qt::FramelessWindowHint | Qt::WindowSystemMenuHint
| Qt::WindowMinMaxButtonsHint);
// Custom title bar (replaces native menu bar area in QMainWindow)
m_titleBar = new TitleBarWidget(this);
m_titleBar->applyTheme(ThemeManager::instance().current());
setMenuWidget(m_titleBar);
#ifdef _WIN32
// 1px top margin preserves DWM drop shadow on the frameless window
{
auto hwnd = reinterpret_cast<HWND>(winId());
MARGINS margins = {0, 0, 1, 0};
DwmExtendFrameIntoClientArea(hwnd, &margins);
}
#endif
// Border overlay — draws a 1px colored border on top of everything
auto* overlay = new BorderOverlay(this);
m_borderOverlay = overlay;
overlay->color = ThemeManager::instance().current().borderFocused;
overlay->setGeometry(rect());
overlay->raise();
overlay->show();
m_mdiArea = new QMdiArea(this); m_mdiArea = new QMdiArea(this);
m_mdiArea->setViewMode(QMdiArea::TabbedView); m_mdiArea->setViewMode(QMdiArea::TabbedView);
m_mdiArea->setTabsClosable(true); m_mdiArea->setTabsClosable(true);
@@ -246,21 +288,13 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
const auto& t = ThemeManager::instance().current(); const auto& t = ThemeManager::instance().current();
m_mdiArea->setStyleSheet(QStringLiteral( m_mdiArea->setStyleSheet(QStringLiteral(
"QTabBar::tab {" "QTabBar::tab {"
" background: %1; color: %2; padding: 6px 16px; border: none;" " background: %1; color: %2; padding: 0px 16px; border: none; height: 24px;"
"}" "}"
"QTabBar::tab:selected { color: %3; background: %4; }" "QTabBar::tab:selected { color: %3; background: %4; }"
"QTabBar::tab:hover { color: %3; background: %5; }") "QTabBar::tab:hover { color: %3; background: %5; }")
.arg(t.background.name(), t.textMuted.name(), t.text.name(), .arg(t.background.name(), t.textMuted.name(), t.text.name(),
t.backgroundAlt.name(), t.hover.name())); t.backgroundAlt.name(), t.hover.name()));
} }
{
QSettings settings("Reclass", "Reclass");
QString fontName = settings.value("font", "JetBrains Mono").toString();
QFont f(fontName, 12);
f.setFixedPitch(true);
if (auto* tb = m_mdiArea->findChild<QTabBar*>())
tb->setFont(f);
}
setCentralWidget(m_mdiArea); setCentralWidget(m_mdiArea);
createWorkspaceDock(); createWorkspaceDock();
@@ -306,7 +340,7 @@ QIcon MainWindow::makeIcon(const QString& svgPath) {
void MainWindow::createMenus() { void MainWindow::createMenus() {
// File // File
auto* file = menuBar()->addMenu("&File"); auto* file = m_titleBar->menuBar()->addMenu("&File");
file->addAction("&New", this, &MainWindow::newDocument, QKeySequence::New); file->addAction("&New", this, &MainWindow::newDocument, QKeySequence::New);
file->addAction("New &Tab", this, &MainWindow::newFile, QKeySequence(Qt::CTRL | Qt::Key_T)); file->addAction("New &Tab", this, &MainWindow::newFile, QKeySequence(Qt::CTRL | Qt::Key_T));
file->addAction(makeIcon(":/vsicons/folder-opened.svg"), "&Open...", this, &MainWindow::openFile, QKeySequence::Open); file->addAction(makeIcon(":/vsicons/folder-opened.svg"), "&Open...", this, &MainWindow::openFile, QKeySequence::Open);
@@ -321,14 +355,14 @@ void MainWindow::createMenus() {
file->addAction(makeIcon(":/vsicons/close.svg"), "E&xit", this, &QMainWindow::close, QKeySequence(Qt::Key_Close)); file->addAction(makeIcon(":/vsicons/close.svg"), "E&xit", this, &QMainWindow::close, QKeySequence(Qt::Key_Close));
// Edit // Edit
auto* edit = menuBar()->addMenu("&Edit"); auto* edit = m_titleBar->menuBar()->addMenu("&Edit");
edit->addAction(makeIcon(":/vsicons/arrow-left.svg"), "&Undo", this, &MainWindow::undo, QKeySequence::Undo); edit->addAction(makeIcon(":/vsicons/arrow-left.svg"), "&Undo", this, &MainWindow::undo, QKeySequence::Undo);
edit->addAction(makeIcon(":/vsicons/arrow-right.svg"), "&Redo", this, &MainWindow::redo, QKeySequence::Redo); edit->addAction(makeIcon(":/vsicons/arrow-right.svg"), "&Redo", this, &MainWindow::redo, QKeySequence::Redo);
edit->addSeparator(); edit->addSeparator();
edit->addAction("&Type Aliases...", this, &MainWindow::showTypeAliasesDialog); edit->addAction("&Type Aliases...", this, &MainWindow::showTypeAliasesDialog);
// View // View
auto* view = menuBar()->addMenu("&View"); auto* view = m_titleBar->menuBar()->addMenu("&View");
view->addAction(makeIcon(":/vsicons/split-horizontal.svg"), "Split &Horizontal", this, &MainWindow::splitView); view->addAction(makeIcon(":/vsicons/split-horizontal.svg"), "Split &Horizontal", this, &MainWindow::splitView);
view->addAction(makeIcon(":/vsicons/chrome-close.svg"), "&Unsplit", this, &MainWindow::unsplitView); view->addAction(makeIcon(":/vsicons/chrome-close.svg"), "&Unsplit", this, &MainWindow::unsplitView);
view->addSeparator(); view->addSeparator();
@@ -368,10 +402,19 @@ void MainWindow::createMenus() {
themeMenu->addAction("Edit Theme...", this, &MainWindow::editTheme); themeMenu->addAction("Edit Theme...", this, &MainWindow::editTheme);
view->addSeparator(); view->addSeparator();
auto* actShowIcon = view->addAction("Show &Icon");
actShowIcon->setCheckable(true);
actShowIcon->setChecked(settings.value("showIcon", false).toBool());
if (actShowIcon->isChecked()) m_titleBar->setShowIcon(true);
connect(actShowIcon, &QAction::toggled, this, [this](bool checked) {
m_titleBar->setShowIcon(checked);
QSettings s("Reclass", "Reclass");
s.setValue("showIcon", checked);
});
view->addAction(m_workspaceDock->toggleViewAction()); view->addAction(m_workspaceDock->toggleViewAction());
// Node // Node
auto* node = menuBar()->addMenu("&Node"); auto* node = m_titleBar->menuBar()->addMenu("&Node");
node->addAction(makeIcon(":/vsicons/add.svg"), "&Add Field", this, &MainWindow::addNode, QKeySequence(Qt::Key_Insert)); node->addAction(makeIcon(":/vsicons/add.svg"), "&Add Field", this, &MainWindow::addNode, QKeySequence(Qt::Key_Insert));
node->addAction(makeIcon(":/vsicons/remove.svg"), "&Remove Field", this, &MainWindow::removeNode, QKeySequence::Delete); node->addAction(makeIcon(":/vsicons/remove.svg"), "&Remove Field", this, &MainWindow::removeNode, QKeySequence::Delete);
node->addAction(makeIcon(":/vsicons/symbol-structure.svg"), "Change &Type", this, &MainWindow::changeNodeType, QKeySequence(Qt::Key_T)); node->addAction(makeIcon(":/vsicons/symbol-structure.svg"), "Change &Type", this, &MainWindow::changeNodeType, QKeySequence(Qt::Key_T));
@@ -379,17 +422,18 @@ void MainWindow::createMenus() {
node->addAction(makeIcon(":/vsicons/files.svg"), "D&uplicate", this, &MainWindow::duplicateNodeAction)->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_D)); node->addAction(makeIcon(":/vsicons/files.svg"), "D&uplicate", this, &MainWindow::duplicateNodeAction)->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_D));
// Plugins // Plugins
auto* plugins = menuBar()->addMenu("&Plugins"); auto* plugins = m_titleBar->menuBar()->addMenu("&Plugins");
plugins->addAction("&Manage Plugins...", this, &MainWindow::showPluginsDialog); plugins->addAction("&Manage Plugins...", this, &MainWindow::showPluginsDialog);
// Help // Help
auto* help = menuBar()->addMenu("&Help"); auto* help = m_titleBar->menuBar()->addMenu("&Help");
help->addAction(makeIcon(":/vsicons/question.svg"), "&About Reclass", this, &MainWindow::about); help->addAction(makeIcon(":/vsicons/question.svg"), "&About Reclass", this, &MainWindow::about);
} }
void MainWindow::createStatusBar() { void MainWindow::createStatusBar() {
m_statusLabel = new QLabel("Ready"); m_statusLabel = new QLabel("Ready");
m_statusLabel->setContentsMargins(10, 0, 0, 0); m_statusLabel->setContentsMargins(10, 0, 0, 0);
statusBar()->setContentsMargins(0, 4, 0, 4);
statusBar()->addWidget(m_statusLabel, 1); statusBar()->addWidget(m_statusLabel, 1);
{ {
const auto& t = ThemeManager::instance().current(); const auto& t = ThemeManager::instance().current();
@@ -399,20 +443,9 @@ void MainWindow::createStatusBar() {
statusBar()->setPalette(sbPal); statusBar()->setPalette(sbPal);
statusBar()->setAutoFillBackground(true); statusBar()->setAutoFillBackground(true);
} }
QSettings settings("Reclass", "Reclass");
QString fontName = settings.value("font", "JetBrains Mono").toString();
QFont f(fontName, 12);
f.setFixedPitch(true);
statusBar()->setFont(f);
} }
void MainWindow::applyTabWidgetStyle(QTabWidget* tw) { void MainWindow::applyTabWidgetStyle(QTabWidget* tw) {
QSettings settings("Reclass", "Reclass");
QString fontName = settings.value("font", "JetBrains Mono").toString();
QFont tabFont(fontName, 12);
tabFont.setFixedPitch(true);
tw->tabBar()->setFont(tabFont);
const auto& t = ThemeManager::instance().current(); const auto& t = ThemeManager::instance().current();
tw->setStyleSheet(QStringLiteral( tw->setStyleSheet(QStringLiteral(
"QTabWidget::pane { border: none; }" "QTabWidget::pane { border: none; }"
@@ -426,6 +459,37 @@ void MainWindow::applyTabWidgetStyle(QTabWidget* tw) {
tw->tabBar()->setExpanding(false); tw->tabBar()->setExpanding(false);
} }
void MainWindow::styleTabCloseButtons() {
auto* tabBar = m_mdiArea->findChild<QTabBar*>();
if (!tabBar) return;
const auto& t = ThemeManager::instance().current();
QString style = QStringLiteral(
"QToolButton { color: %1; border: none; padding: 0px 4px 2px 4px; font-size: 12px; }"
"QToolButton:hover { color: %2; }")
.arg(t.textDim.name(), t.indHoverSpan.name());
auto subs = m_mdiArea->subWindowList();
for (int i = 0; i < tabBar->count() && i < subs.size(); i++) {
auto* existing = qobject_cast<QToolButton*>(
tabBar->tabButton(i, QTabBar::RightSide));
if (existing && existing->text() == QStringLiteral("\u2715")) {
// Already our button, just restyle
existing->setStyleSheet(style);
continue;
}
// Replace with ✕ text button
auto* btn = new QToolButton(tabBar);
btn->setText(QStringLiteral("\u2715"));
btn->setAutoRaise(true);
btn->setCursor(Qt::PointingHandCursor);
btn->setStyleSheet(style);
QMdiSubWindow* sub = subs[i];
connect(btn, &QToolButton::clicked, sub, &QMdiSubWindow::close);
tabBar->setTabButton(i, QTabBar::RightSide, btn);
}
}
MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) { MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) {
SplitPane pane; SplitPane pane;
@@ -614,6 +678,7 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) {
ctrl->refresh(); ctrl->refresh();
rebuildWorkspaceModel(); rebuildWorkspaceModel();
styleTabCloseButtons();
return sub; return sub;
} }
@@ -720,7 +785,41 @@ void MainWindow::newDocument() {
} }
void MainWindow::selfTest() { void MainWindow::selfTest() {
// Tab 1: Ball demo
project_new(); project_new();
// Tab 2: Unnamed struct with hex64 fields
{
auto* doc = new RcxDocument(this);
QByteArray data(256, '\0');
doc->loadData(data);
doc->tree.baseAddress = 0x00400000;
Node s;
s.kind = NodeKind::Struct;
s.name = "instance";
s.structTypeName = "Unnamed";
s.parentId = 0;
s.offset = 0;
int si = doc->tree.addNode(s);
uint64_t sId = doc->tree.nodes[si].id;
for (int i = 0; i < 16; i++) {
Node n;
n.kind = NodeKind::Hex64;
n.name = QStringLiteral("field_%1").arg(i * 8, 2, 16, QChar('0'));
n.parentId = sId;
n.offset = i * 8;
doc->tree.addNode(n);
}
createTab(doc);
rebuildWorkspaceModel();
}
// Focus Ball tab
if (auto* first = m_mdiArea->subWindowList().value(0))
m_mdiArea->setActiveSubWindow(first);
} }
void MainWindow::openFile() { void MainWindow::openFile() {
@@ -879,16 +978,25 @@ void MainWindow::toggleMcp() {
void MainWindow::applyTheme(const Theme& theme) { void MainWindow::applyTheme(const Theme& theme) {
applyGlobalTheme(theme); applyGlobalTheme(theme);
// Custom title bar
m_titleBar->applyTheme(theme);
// Update border overlay color
updateBorderColor(isActiveWindow() ? theme.borderFocused : theme.border);
// MDI area tabs // MDI area tabs
m_mdiArea->setStyleSheet(QStringLiteral( m_mdiArea->setStyleSheet(QStringLiteral(
"QTabBar::tab {" "QTabBar::tab {"
" background: %1; color: %2; padding: 6px 16px; border: none;" " background: %1; color: %2; padding: 0px 16px; border: none; height: 24px;"
"}" "}"
"QTabBar::tab:selected { color: %3; background: %4; }" "QTabBar::tab:selected { color: %3; background: %4; }"
"QTabBar::tab:hover { color: %3; background: %5; }") "QTabBar::tab:hover { color: %3; background: %5; }")
.arg(theme.background.name(), theme.textMuted.name(), theme.text.name(), .arg(theme.background.name(), theme.textMuted.name(), theme.text.name(),
theme.backgroundAlt.name(), theme.hover.name())); theme.backgroundAlt.name(), theme.hover.name()));
// Re-style ✕ close buttons on MDI tabs
styleTabCloseButtons();
// Status bar // Status bar
{ {
QPalette sbPal = statusBar()->palette(); QPalette sbPal = statusBar()->palette();
@@ -910,16 +1018,7 @@ void MainWindow::editTheme() {
int idx = tm.currentIndex(); int idx = tm.currentIndex();
ThemeEditor dlg(idx, this); ThemeEditor dlg(idx, this);
if (dlg.exec() == QDialog::Accepted) { if (dlg.exec() == QDialog::Accepted) {
tm.revertPreview(); tm.updateTheme(dlg.selectedIndex(), dlg.result());
int selectedIdx = dlg.selectedIndex();
Theme edited = dlg.result();
// Switch to selected theme first (if changed)
if (selectedIdx != idx && selectedIdx >= 0 && selectedIdx < tm.themes().size())
tm.setCurrent(selectedIdx);
// Apply edits
int applyIdx = selectedIdx >= 0 ? selectedIdx : idx;
if (applyIdx >= 0 && applyIdx < tm.themes().size())
tm.updateTheme(applyIdx, edited);
} else { } else {
tm.revertPreview(); tm.revertPreview();
} }
@@ -943,9 +1042,6 @@ void MainWindow::setEditorFont(const QString& fontName) {
} }
pane.rendered->setMarginsFont(f); pane.rendered->setMarginsFont(f);
} }
// Update per-pane tab bar font
if (pane.tabWidget)
applyTabWidgetStyle(pane.tabWidget);
} }
} }
// Sync workspace tree font // Sync workspace tree font
@@ -953,11 +1049,6 @@ void MainWindow::setEditorFont(const QString& fontName) {
m_workspaceTree->setFont(f); m_workspaceTree->setFont(f);
// Sync status bar font // Sync status bar font
statusBar()->setFont(f); statusBar()->setFont(f);
// Sync MDI tab bar font
if (auto* tb = m_mdiArea->findChild<QTabBar*>())
tb->setFont(f);
// Sync menu bar / menu font via global stylesheet
applyGlobalTheme(ThemeManager::instance().current());
} }
RcxController* MainWindow::activeController() const { RcxController* MainWindow::activeController() const {
@@ -984,6 +1075,7 @@ MainWindow::TabState* MainWindow::tabByIndex(int index) {
} }
void MainWindow::updateWindowTitle() { void MainWindow::updateWindowTitle() {
QString title;
auto* sub = m_mdiArea->activeSubWindow(); auto* sub = m_mdiArea->activeSubWindow();
if (sub && m_tabs.contains(sub)) { if (sub && m_tabs.contains(sub)) {
auto& tab = m_tabs[sub]; auto& tab = m_tabs[sub];
@@ -991,10 +1083,11 @@ void MainWindow::updateWindowTitle() {
? rootName(tab.doc->tree, tab.ctrl->viewRootId()) ? rootName(tab.doc->tree, tab.ctrl->viewRootId())
: QFileInfo(tab.doc->filePath).fileName(); : QFileInfo(tab.doc->filePath).fileName();
if (tab.doc->modified) name += " *"; if (tab.doc->modified) name += " *";
setWindowTitle(name + " - Reclass"); title = name + " - Reclass";
} else { } else {
setWindowTitle("Reclass"); title = "Reclass";
} }
setWindowTitle(title);
} }
// ── Rendered view setup ── // ── Rendered view setup ──
@@ -1474,6 +1567,29 @@ void MainWindow::showPluginsDialog() {
dialog.exec(); dialog.exec();
} }
void MainWindow::changeEvent(QEvent* event) {
QMainWindow::changeEvent(event);
if (event->type() == QEvent::ActivationChange) {
const auto& t = ThemeManager::instance().current();
updateBorderColor(isActiveWindow() ? t.borderFocused : t.border);
}
if (event->type() == QEvent::WindowStateChange)
m_titleBar->updateMaximizeIcon();
}
void MainWindow::resizeEvent(QResizeEvent* event) {
QMainWindow::resizeEvent(event);
if (m_borderOverlay) {
m_borderOverlay->setGeometry(rect());
m_borderOverlay->raise();
}
}
void MainWindow::updateBorderColor(const QColor& color) {
static_cast<BorderOverlay*>(m_borderOverlay)->color = color;
m_borderOverlay->update();
}
} // namespace rcx } // namespace rcx
// ── Entry point ── // ── Entry point ──

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include "controller.h" #include "controller.h"
#include "titlebar.h"
#include "pluginmanager.h" #include "pluginmanager.h"
#include <QMainWindow> #include <QMainWindow>
#include <QMdiArea> #include <QMdiArea>
@@ -59,11 +60,13 @@ public:
private: private:
enum ViewMode { VM_Reclass, VM_Rendered }; enum ViewMode { VM_Reclass, VM_Rendered };
QMdiArea* m_mdiArea; QMdiArea* m_mdiArea;
QLabel* m_statusLabel; QLabel* m_statusLabel;
PluginManager m_pluginManager; TitleBarWidget* m_titleBar = nullptr;
McpBridge* m_mcp = nullptr; QWidget* m_borderOverlay = nullptr;
QAction* m_mcpAction = nullptr; PluginManager m_pluginManager;
McpBridge* m_mcp = nullptr;
QAction* m_mcpAction = nullptr;
struct SplitPane { struct SplitPane {
QTabWidget* tabWidget = nullptr; QTabWidget* tabWidget = nullptr;
@@ -104,6 +107,7 @@ private:
SplitPane createSplitPane(TabState& tab); SplitPane createSplitPane(TabState& tab);
void applyTheme(const Theme& theme); void applyTheme(const Theme& theme);
void applyTabWidgetStyle(QTabWidget* tw); void applyTabWidgetStyle(QTabWidget* tw);
void styleTabCloseButtons();
SplitPane* findPaneByTabWidget(QTabWidget* tw); SplitPane* findPaneByTabWidget(QTabWidget* tw);
SplitPane* findActiveSplitPane(); SplitPane* findActiveSplitPane();
RcxEditor* activePaneEditor(); RcxEditor* activePaneEditor();
@@ -114,6 +118,11 @@ private:
QStandardItemModel* m_workspaceModel = nullptr; QStandardItemModel* m_workspaceModel = nullptr;
void createWorkspaceDock(); void createWorkspaceDock();
void rebuildWorkspaceModel(); void rebuildWorkspaceModel();
void updateBorderColor(const QColor& color);
protected:
void changeEvent(QEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
}; };
} // namespace rcx } // namespace rcx

View File

@@ -1,28 +1,65 @@
#pragma once #pragma once
#include "provider.h" #include "provider.h"
#include <QHash>
#include <memory> #include <memory>
namespace rcx { namespace rcx {
// Provider that reads from a cached QByteArray snapshot but delegates // Page-based snapshot provider.
// metadata (name, kind, getSymbol) to the underlying real provider. //
// Used for async refresh: worker thread reads bulk data into a snapshot, // During async refresh the controller reads pages for the main struct and
// UI thread composes against it without blocking. // every reachable pointer target. Compose reads entirely from this page
// table — no fallback to the real provider, no blocking I/O on the UI
// thread. Pages that were never fetched (truly invalid pointers) simply
// read as zeros.
class SnapshotProvider : public Provider { class SnapshotProvider : public Provider {
std::shared_ptr<Provider> m_real; std::shared_ptr<Provider> m_real;
QByteArray m_data; QHash<uint64_t, QByteArray> m_pages; // page-aligned addr → 4096-byte page
int m_mainExtent = 0; // logical size of the main struct range
static constexpr uint64_t kPageSize = 4096;
static constexpr uint64_t kPageMask = ~(kPageSize - 1);
public: public:
SnapshotProvider(std::shared_ptr<Provider> real, QByteArray snapshot) using PageMap = QHash<uint64_t, QByteArray>;
: m_real(std::move(real)), m_data(std::move(snapshot)) {}
SnapshotProvider(std::shared_ptr<Provider> real, PageMap pages, int mainExtent)
: m_real(std::move(real))
, m_pages(std::move(pages))
, m_mainExtent(mainExtent) {}
bool read(uint64_t addr, void* buf, int len) const override { bool read(uint64_t addr, void* buf, int len) const override {
if (!isReadable(addr, len)) return false; if (len <= 0) return false;
std::memcpy(buf, m_data.constData() + addr, len); char* out = static_cast<char*>(buf);
uint64_t cur = addr;
int remaining = len;
while (remaining > 0) {
uint64_t pageAddr = cur & kPageMask;
int pageOff = static_cast<int>(cur - pageAddr);
int chunk = qMin(remaining, static_cast<int>(kPageSize - pageOff));
auto it = m_pages.constFind(pageAddr);
if (it != m_pages.constEnd()) {
std::memcpy(out, it->constData() + pageOff, chunk);
} else {
std::memset(out, 0, chunk);
}
out += chunk;
cur += chunk;
remaining -= chunk;
}
return true; return true;
} }
int size() const override { return m_data.size(); } bool isReadable(uint64_t addr, int len) const override {
if (len <= 0) return (len == 0);
uint64_t end = addr + static_cast<uint64_t>(len);
for (uint64_t p = addr & kPageMask; p < end; p += kPageSize) {
if (!m_pages.contains(p)) return false;
}
return true;
}
int size() const override { return m_mainExtent; }
bool isWritable() const override { return m_real ? m_real->isWritable() : false; } bool isWritable() const override { return m_real ? m_real->isWritable() : false; }
bool isLive() const override { return m_real ? m_real->isLive() : false; } bool isLive() const override { return m_real ? m_real->isLive() : false; }
QString name() const override { return m_real ? m_real->name() : QString(); } QString name() const override { return m_real ? m_real->name() : QString(); }
@@ -34,21 +71,36 @@ public:
bool write(uint64_t addr, const void* buf, int len) override { bool write(uint64_t addr, const void* buf, int len) override {
if (!m_real) return false; if (!m_real) return false;
bool ok = m_real->write(addr, buf, len); bool ok = m_real->write(addr, buf, len);
if (ok && isReadable(addr, len)) if (ok) patchPages(addr, buf, len);
std::memcpy(m_data.data() + addr, buf, len);
return ok; return ok;
} }
// Update the entire snapshot (called after async read completes) // Replace the entire page table (called after async read completes)
void updateSnapshot(QByteArray data) { m_data = std::move(data); } void updatePages(PageMap pages, int mainExtent) {
m_pages = std::move(pages);
// Patch specific bytes in the snapshot (called after user writes a value) m_mainExtent = mainExtent;
void patchSnapshot(uint64_t addr, const void* buf, int len) {
if (isReadable(addr, len))
std::memcpy(m_data.data() + addr, buf, len);
} }
const QByteArray& snapshot() const { return m_data; } // Patch specific bytes in existing pages (called after user writes a value)
void patchPages(uint64_t addr, const void* buf, int len) {
const char* src = static_cast<const char*>(buf);
uint64_t cur = addr;
int remaining = len;
while (remaining > 0) {
uint64_t pageAddr = cur & kPageMask;
int pageOff = static_cast<int>(cur - pageAddr);
int chunk = qMin(remaining, static_cast<int>(kPageSize - pageOff));
auto it = m_pages.find(pageAddr);
if (it != m_pages.end()) {
std::memcpy(it->data() + pageOff, src, chunk);
}
src += chunk;
cur += chunk;
remaining -= chunk;
}
}
const PageMap& pages() const { return m_pages; }
}; };
} // namespace rcx } // namespace rcx

View File

@@ -3,6 +3,7 @@
<file alias="chevron-right.png">icons/chevron-right.png</file> <file alias="chevron-right.png">icons/chevron-right.png</file>
<file alias="chevron-down.png">icons/chevron-down.png</file> <file alias="chevron-down.png">icons/chevron-down.png</file>
<file alias="class.png">icons/class.png</file> <file alias="class.png">icons/class.png</file>
</qresource> </qresource>
<qresource prefix="/fonts"> <qresource prefix="/fonts">
<file alias="JetBrainsMono.ttf">fonts/JetBrainsMono.ttf</file> <file alias="JetBrainsMono.ttf">fonts/JetBrainsMono.ttf</file>
@@ -20,6 +21,9 @@
<file alias="arrow-right.svg">vsicons/arrow-right.svg</file> <file alias="arrow-right.svg">vsicons/arrow-right.svg</file>
<file alias="split-horizontal.svg">vsicons/split-horizontal.svg</file> <file alias="split-horizontal.svg">vsicons/split-horizontal.svg</file>
<file alias="chrome-close.svg">vsicons/chrome-close.svg</file> <file alias="chrome-close.svg">vsicons/chrome-close.svg</file>
<file alias="chrome-minimize.svg">vsicons/chrome-minimize.svg</file>
<file alias="chrome-maximize.svg">vsicons/chrome-maximize.svg</file>
<file alias="chrome-restore.svg">vsicons/chrome-restore.svg</file>
<file alias="text-size.svg">vsicons/text-size.svg</file> <file alias="text-size.svg">vsicons/text-size.svg</file>
<file alias="add.svg">vsicons/add.svg</file> <file alias="add.svg">vsicons/add.svg</file>
<file alias="remove.svg">vsicons/remove.svg</file> <file alias="remove.svg">vsicons/remove.svg</file>

View File

@@ -0,0 +1,29 @@
{
"name": "Reclass Dark",
"background": "#1e1e1e",
"backgroundAlt": "#252526",
"surface": "#2a2d2e",
"border": "#3c3c3c",
"borderFocused": "#888888",
"button": "#333333",
"text": "#d4d4d4",
"textDim": "#858585",
"textMuted": "#585858",
"textFaint": "#505050",
"hover": "#2b2b2b",
"selected": "#232323",
"selection": "#2b2b2b",
"syntaxKeyword": "#569cd6",
"syntaxNumber": "#b5cea8",
"syntaxString": "#ce9178",
"syntaxComment": "#6a9955",
"syntaxPreproc": "#c586c0",
"syntaxType": "#4EC9B0",
"indHoverSpan": "#E6B450",
"indCmdPill": "#2a2a2a",
"indDataChanged": "#8fbc7a",
"indHintGreen": "#5a8248",
"markerPtr": "#f44747",
"markerCycle": "#e5a00d",
"markerError": "#7a2e2e"
}

View File

@@ -0,0 +1,29 @@
{
"name": "VS2022 Dark",
"background": "#1e1e1e",
"backgroundAlt": "#2d2d30",
"surface": "#333337",
"border": "#3f3f46",
"borderFocused": "#b180d7",
"button": "#3f3f46",
"text": "#dcdcdc",
"textDim": "#858585",
"textMuted": "#636369",
"textFaint": "#4d4d55",
"hover": "#3e3e42",
"selected": "#2d2d30",
"selection": "#264f78",
"syntaxKeyword": "#569cd6",
"syntaxNumber": "#b5cea8",
"syntaxString": "#d69d85",
"syntaxComment": "#57a64a",
"syntaxPreproc": "#9b9b9b",
"syntaxType": "#4ec9b0",
"indHoverSpan": "#b180d7",
"indCmdPill": "#2d2d30",
"indDataChanged": "#8fbc7a",
"indHintGreen": "#5a8248",
"markerPtr": "#f44747",
"markerCycle": "#e5a00d",
"markerError": "#7a2e2e"
}

View File

@@ -0,0 +1,29 @@
{
"name": "Warm",
"background": "#212121",
"backgroundAlt": "#2a2a2a",
"surface": "#2a2a2a",
"border": "#373737",
"borderFocused": "#888888",
"button": "#373737",
"text": "#AAA99F",
"textDim": "#7a7a6e",
"textMuted": "#555550",
"textFaint": "#464646",
"hover": "#373737",
"selected": "#2d2d2d",
"selection": "#21213A",
"syntaxKeyword": "#AA9565",
"syntaxNumber": "#AAA98C",
"syntaxString": "#6B3B21",
"syntaxComment": "#464646",
"syntaxPreproc": "#AA9565",
"syntaxType": "#6B959F",
"indHoverSpan": "#AA9565",
"indCmdPill": "#2a2a2a",
"indDataChanged": "#6B959F",
"indHintGreen": "#464646",
"markerPtr": "#6B3B21",
"markerCycle": "#AA9565",
"markerError": "#3C2121"
}

View File

@@ -1,119 +1,56 @@
#include "theme.h" #include "theme.h"
#include <type_traits>
namespace rcx { namespace rcx {
// ── Field table for DRY serialization ── // ── Shared field metadata (serialization + editor UI) ──
struct ColorField { const char* key; QColor Theme::*ptr; }; const ThemeFieldMeta kThemeFields[] = {
{"background", "Background", "Chrome", &Theme::background},
static const ColorField kFields[] = { {"backgroundAlt", "Background Alt", "Chrome", &Theme::backgroundAlt},
{"background", &Theme::background}, {"surface", "Surface", "Chrome", &Theme::surface},
{"backgroundAlt", &Theme::backgroundAlt}, {"border", "Border", "Chrome", &Theme::border},
{"surface", &Theme::surface}, {"borderFocused", "Border Focused", "Chrome", &Theme::borderFocused},
{"border", &Theme::border}, {"button", "Button", "Chrome", &Theme::button},
{"button", &Theme::button}, {"text", "Text", "Text", &Theme::text},
{"text", &Theme::text}, {"textDim", "Text Dim", "Text", &Theme::textDim},
{"textDim", &Theme::textDim}, {"textMuted", "Text Muted", "Text", &Theme::textMuted},
{"textMuted", &Theme::textMuted}, {"textFaint", "Text Faint", "Text", &Theme::textFaint},
{"textFaint", &Theme::textFaint}, {"hover", "Hover", "Interactive", &Theme::hover},
{"hover", &Theme::hover}, {"selected", "Selected", "Interactive", &Theme::selected},
{"selected", &Theme::selected}, {"selection", "Selection", "Interactive", &Theme::selection},
{"selection", &Theme::selection}, {"syntaxKeyword", "Keyword", "Syntax", &Theme::syntaxKeyword},
{"syntaxKeyword", &Theme::syntaxKeyword}, {"syntaxNumber", "Number", "Syntax", &Theme::syntaxNumber},
{"syntaxNumber", &Theme::syntaxNumber}, {"syntaxString", "String", "Syntax", &Theme::syntaxString},
{"syntaxString", &Theme::syntaxString}, {"syntaxComment", "Comment", "Syntax", &Theme::syntaxComment},
{"syntaxComment", &Theme::syntaxComment}, {"syntaxPreproc", "Preprocessor", "Syntax", &Theme::syntaxPreproc},
{"syntaxPreproc", &Theme::syntaxPreproc}, {"syntaxType", "Type", "Syntax", &Theme::syntaxType},
{"syntaxType", &Theme::syntaxType}, {"indHoverSpan", "Hover Span", "Indicators", &Theme::indHoverSpan},
{"indHoverSpan", &Theme::indHoverSpan}, {"indCmdPill", "Cmd Pill", "Indicators", &Theme::indCmdPill},
{"indCmdPill", &Theme::indCmdPill}, {"indDataChanged","Data Changed", "Indicators", &Theme::indDataChanged},
{"indDataChanged",&Theme::indDataChanged}, {"indHintGreen", "Hint Green", "Indicators", &Theme::indHintGreen},
{"indHintGreen", &Theme::indHintGreen}, {"markerPtr", "Pointer", "Markers", &Theme::markerPtr},
{"markerPtr", &Theme::markerPtr}, {"markerCycle", "Cycle", "Markers", &Theme::markerCycle},
{"markerCycle", &Theme::markerCycle}, {"markerError", "Error", "Markers", &Theme::markerError},
{"markerError", &Theme::markerError},
}; };
const int kThemeFieldCount = static_cast<int>(std::extent_v<decltype(kThemeFields)>);
QJsonObject Theme::toJson() const { QJsonObject Theme::toJson() const {
QJsonObject o; QJsonObject o;
o["name"] = name; o["name"] = name;
for (const auto& f : kFields) for (int i = 0; i < kThemeFieldCount; i++)
o[f.key] = (this->*f.ptr).name(); o[kThemeFields[i].key] = (this->*kThemeFields[i].ptr).name();
return o; return o;
} }
Theme Theme::fromJson(const QJsonObject& o) { Theme Theme::fromJson(const QJsonObject& o) {
Theme t = reclassDark(); Theme t;
t.name = o["name"].toString(t.name); t.name = o["name"].toString("Untitled");
for (const auto& f : kFields) { for (int i = 0; i < kThemeFieldCount; i++) {
if (o.contains(f.key)) if (o.contains(kThemeFields[i].key))
t.*f.ptr = QColor(o[f.key].toString()); t.*kThemeFields[i].ptr = QColor(o[kThemeFields[i].key].toString());
} }
return t; return t;
} }
// ── Built-in themes ──
Theme Theme::reclassDark() {
Theme t;
t.name = "Reclass Dark";
t.background = QColor("#1e1e1e");
t.backgroundAlt = QColor("#252526");
t.surface = QColor("#2a2d2e");
t.border = QColor("#3c3c3c");
t.button = QColor("#333333");
t.text = QColor("#d4d4d4");
t.textDim = QColor("#858585");
t.textMuted = QColor("#585858");
t.textFaint = QColor("#505050");
t.hover = QColor("#2b2b2b");
t.selected = QColor("#232323");
t.selection = QColor("#2b2b2b");
t.syntaxKeyword = QColor("#569cd6");
t.syntaxNumber = QColor("#b5cea8");
t.syntaxString = QColor("#ce9178");
t.syntaxComment = QColor("#6a9955");
t.syntaxPreproc = QColor("#c586c0");
t.syntaxType = QColor("#4EC9B0");
t.indHoverSpan = QColor("#E6B450");
t.indCmdPill = QColor("#2a2a2a");
t.indDataChanged= QColor("#8fbc7a");
t.indHintGreen = QColor("#5a8248");
t.markerPtr = QColor("#f44747");
t.markerCycle = QColor("#e5a00d");
t.markerError = QColor("#7a2e2e");
return t;
}
Theme Theme::warm() {
Theme t;
t.name = "Warm";
t.background = QColor("#212121");
t.backgroundAlt = QColor("#2a2a2a");
t.surface = QColor("#2a2a2a");
t.border = QColor("#373737");
t.button = QColor("#373737");
t.text = QColor("#AAA99F");
t.textDim = QColor("#7a7a6e");
t.textMuted = QColor("#555550");
t.textFaint = QColor("#464646");
t.hover = QColor("#373737");
t.selected = QColor("#2d2d2d");
t.selection = QColor("#21213A");
t.syntaxKeyword = QColor("#AA9565");
t.syntaxNumber = QColor("#AAA98C");
t.syntaxString = QColor("#6B3B21");
t.syntaxComment = QColor("#464646");
t.syntaxPreproc = QColor("#AA9565");
t.syntaxType = QColor("#6B959F");
t.indHoverSpan = QColor("#AA9565");
t.indCmdPill = QColor("#2a2a2a");
t.indDataChanged= QColor("#6B959F");
t.indHintGreen = QColor("#464646");
t.markerPtr = QColor("#6B3B21");
t.markerCycle = QColor("#AA9565");
t.markerError = QColor("#3C2121");
return t;
}
} // namespace rcx } // namespace rcx

View File

@@ -13,6 +13,7 @@ struct Theme {
QColor backgroundAlt; // panels, tab selected, tooltips QColor backgroundAlt; // panels, tab selected, tooltips
QColor surface; // alternateBase QColor surface; // alternateBase
QColor border; // separators, menu borders QColor border; // separators, menu borders
QColor borderFocused; // window border when focused
QColor button; // button bg QColor button; // button bg
// ── Text ── // ── Text ──
@@ -47,9 +48,18 @@ struct Theme {
QJsonObject toJson() const; QJsonObject toJson() const;
static Theme fromJson(const QJsonObject& obj); static Theme fromJson(const QJsonObject& obj);
static Theme reclassDark();
static Theme warm();
}; };
// ── Shared field metadata (serialization + editor UI) ──
struct ThemeFieldMeta {
const char* key; // JSON key
const char* label; // display label
const char* group; // section group name
QColor Theme::*ptr;
};
extern const ThemeFieldMeta kThemeFields[];
extern const int kThemeFieldCount;
} // namespace rcx } // namespace rcx

View File

@@ -6,6 +6,7 @@
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QColorDialog> #include <QColorDialog>
#include <QComboBox> #include <QComboBox>
#include <cstring>
namespace rcx { namespace rcx {
@@ -70,7 +71,7 @@ ThemeEditor::ThemeEditor(int themeIndex, QWidget* parent)
: QStringLiteral("File: %1").arg(path)); : QStringLiteral("File: %1").arg(path));
mainLayout->addWidget(m_fileInfoLabel); mainLayout->addWidget(m_fileInfoLabel);
// ── Scrollable area for swatches + contrast ── // ── Scrollable area for swatches ──
auto* scroll = new QScrollArea; auto* scroll = new QScrollArea;
scroll->setWidgetResizable(true); scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame); scroll->setFrameShape(QFrame::NoFrame);
@@ -79,83 +80,49 @@ ThemeEditor::ThemeEditor(int themeIndex, QWidget* parent)
scrollLayout->setContentsMargins(0, 0, 6, 0); // right margin for scrollbar scrollLayout->setContentsMargins(0, 0, 6, 0); // right margin for scrollbar
scrollLayout->setSpacing(2); scrollLayout->setSpacing(2);
// ── Color swatches ── // ── Color swatches (driven by kThemeFields) ──
struct FieldDef { const char* label; QColor Theme::*ptr; }; const char* currentGroup = nullptr;
for (int fi = 0; fi < kThemeFieldCount; fi++) {
const auto& f = kThemeFields[fi];
auto addGroup = [&](const QString& title, std::initializer_list<FieldDef> fields) { // Section header on group change
scrollLayout->addWidget(makeSectionLabel(title)); if (!currentGroup || std::strcmp(currentGroup, f.group) != 0) {
for (const auto& f : fields) { scrollLayout->addWidget(makeSectionLabel(QString::fromLatin1(f.group)));
int idx = m_swatches.size(); currentGroup = f.group;
auto* row = new QHBoxLayout;
row->setSpacing(6);
row->setContentsMargins(8, 1, 0, 1);
auto* lbl = new QLabel(QString::fromLatin1(f.label));
lbl->setFixedWidth(120);
row->addWidget(lbl);
auto* swatchBtn = new QPushButton;
swatchBtn->setFixedSize(32, 18);
swatchBtn->setCursor(Qt::PointingHandCursor);
connect(swatchBtn, &QPushButton::clicked, this, [this, idx]() { pickColor(idx); });
row->addWidget(swatchBtn);
auto* hexLbl = new QLabel;
hexLbl->setFixedWidth(60);
hexLbl->setStyleSheet(QStringLiteral("color: #aaa; font-size: 10px;"));
row->addWidget(hexLbl);
row->addStretch();
SwatchEntry se;
se.label = f.label;
se.field = f.ptr;
se.swatchBtn = swatchBtn;
se.hexLabel = hexLbl;
m_swatches.append(se);
scrollLayout->addLayout(row);
} }
};
addGroup("Chrome", { int idx = m_swatches.size();
{"Background", &Theme::background},
{"Background Alt", &Theme::backgroundAlt}, auto* row = new QHBoxLayout;
{"Surface", &Theme::surface}, row->setSpacing(6);
{"Border", &Theme::border}, row->setContentsMargins(8, 1, 0, 1);
{"Button", &Theme::button},
}); auto* lbl = new QLabel(QString::fromLatin1(f.label));
addGroup("Text", { lbl->setFixedWidth(120);
{"Text", &Theme::text}, row->addWidget(lbl);
{"Text Dim", &Theme::textDim},
{"Text Muted", &Theme::textMuted}, auto* swatchBtn = new QPushButton;
{"Text Faint", &Theme::textFaint}, swatchBtn->setFixedSize(32, 18);
}); swatchBtn->setCursor(Qt::PointingHandCursor);
addGroup("Interactive", { connect(swatchBtn, &QPushButton::clicked, this, [this, idx]() { pickColor(idx); });
{"Hover", &Theme::hover}, row->addWidget(swatchBtn);
{"Selected", &Theme::selected},
{"Selection", &Theme::selection}, auto* hexLbl = new QLabel;
}); hexLbl->setFixedWidth(60);
addGroup("Syntax", { hexLbl->setStyleSheet(QStringLiteral("color: #aaa; font-size: 10px;"));
{"Keyword", &Theme::syntaxKeyword}, row->addWidget(hexLbl);
{"Number", &Theme::syntaxNumber},
{"String", &Theme::syntaxString}, row->addStretch();
{"Comment", &Theme::syntaxComment},
{"Preprocessor", &Theme::syntaxPreproc}, SwatchEntry se;
{"Type", &Theme::syntaxType}, se.label = f.label;
}); se.field = f.ptr;
addGroup("Indicators", { se.swatchBtn = swatchBtn;
{"Hover Span", &Theme::indHoverSpan}, se.hexLabel = hexLbl;
{"Cmd Pill", &Theme::indCmdPill}, m_swatches.append(se);
{"Data Changed", &Theme::indDataChanged},
{"Hint Green", &Theme::indHintGreen}, scrollLayout->addLayout(row);
}); }
addGroup("Markers", {
{"Pointer", &Theme::markerPtr},
{"Cycle", &Theme::markerCycle},
{"Error", &Theme::markerError},
});
scrollLayout->addStretch(); scrollLayout->addStretch();
scroll->setWidget(scrollWidget); scroll->setWidget(scrollWidget);
@@ -163,28 +130,21 @@ ThemeEditor::ThemeEditor(int themeIndex, QWidget* parent)
// ── Bottom bar ── // ── Bottom bar ──
auto* bottomRow = new QHBoxLayout; auto* bottomRow = new QHBoxLayout;
m_previewBtn = new QPushButton(QStringLiteral("Live Preview"));
m_previewBtn->setCheckable(true);
connect(m_previewBtn, &QPushButton::toggled, this, [this](bool) { togglePreview(); });
bottomRow->addWidget(m_previewBtn);
bottomRow->addStretch(); bottomRow->addStretch();
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, [this]() { connect(buttons, &QDialogButtonBox::rejected, this, [this]() {
if (m_previewing) { ThemeManager::instance().revertPreview();
ThemeManager::instance().revertPreview();
m_previewing = false;
}
reject(); reject();
}); });
bottomRow->addWidget(buttons); bottomRow->addWidget(buttons);
mainLayout->addLayout(bottomRow); mainLayout->addLayout(bottomRow);
// Initial update // Initial swatch update + start live preview
for (int i = 0; i < m_swatches.size(); i++) for (int i = 0; i < m_swatches.size(); i++)
updateSwatch(i); updateSwatch(i);
tm.previewTheme(m_theme);
} }
// ── Load a different theme into the editor ── // ── Load a different theme into the editor ──
@@ -206,8 +166,7 @@ void ThemeEditor::loadTheme(int index) {
for (int i = 0; i < m_swatches.size(); i++) for (int i = 0; i < m_swatches.size(); i++)
updateSwatch(i); updateSwatch(i);
if (m_previewing) tm.previewTheme(m_theme);
tm.previewTheme(m_theme);
} }
// ── Swatch update ── // ── Swatch update ──
@@ -230,19 +189,8 @@ void ThemeEditor::pickColor(int idx) {
if (c.isValid()) { if (c.isValid()) {
m_theme.*s.field = c; m_theme.*s.field = c;
updateSwatch(idx); updateSwatch(idx);
if (m_previewing) ThemeManager::instance().previewTheme(m_theme);
ThemeManager::instance().previewTheme(m_theme);
} }
} }
// ── Live preview toggle ──
void ThemeEditor::togglePreview() {
m_previewing = m_previewBtn->isChecked();
if (m_previewing)
ThemeManager::instance().previewTheme(m_theme);
else
ThemeManager::instance().revertPreview();
}
} // namespace rcx } // namespace rcx

View File

@@ -36,14 +36,10 @@ private:
QComboBox* m_themeCombo = nullptr; QComboBox* m_themeCombo = nullptr;
QLineEdit* m_nameEdit = nullptr; QLineEdit* m_nameEdit = nullptr;
QLabel* m_fileInfoLabel = nullptr; QLabel* m_fileInfoLabel = nullptr;
QPushButton* m_previewBtn = nullptr;
bool m_previewing = false;
void loadTheme(int index); void loadTheme(int index);
void rebuildSwatches(QVBoxLayout* swatchLayout);
void updateSwatch(int idx); void updateSwatch(int idx);
void pickColor(int idx); void pickColor(int idx);
void togglePreview();
}; };
} // namespace rcx } // namespace rcx

View File

@@ -4,6 +4,7 @@
#include <QFile> #include <QFile>
#include <QJsonDocument> #include <QJsonDocument>
#include <QStandardPaths> #include <QStandardPaths>
#include <QCoreApplication>
namespace rcx { namespace rcx {
@@ -13,18 +14,40 @@ ThemeManager& ThemeManager::instance() {
} }
ThemeManager::ThemeManager() { ThemeManager::ThemeManager() {
m_builtIn.append(Theme::reclassDark()); loadBuiltInThemes();
m_builtIn.append(Theme::warm());
loadUserThemes(); loadUserThemes();
QSettings settings("Reclass", "Reclass"); QSettings settings("Reclass", "Reclass");
QString saved = settings.value("theme", m_builtIn[0].name).toString(); QString fallback = m_builtIn.isEmpty() ? QString() : m_builtIn[0].name;
QString saved = settings.value("theme", fallback).toString();
auto all = themes(); auto all = themes();
for (int i = 0; i < all.size(); i++) { for (int i = 0; i < all.size(); i++) {
if (all[i].name == saved) { m_currentIdx = i; break; } if (all[i].name == saved) { m_currentIdx = i; break; }
} }
} }
// ── Load built-in themes from JSON files next to the executable ──
QString ThemeManager::builtInDir() const {
return QCoreApplication::applicationDirPath() + "/themes";
}
void ThemeManager::loadBuiltInThemes() {
m_builtIn.clear();
QDir dir(builtInDir());
if (!dir.exists()) return;
for (const QString& name : dir.entryList({"*.json"}, QDir::Files, QDir::Name)) {
QFile f(dir.filePath(name));
if (!f.open(QIODevice::ReadOnly)) continue;
QJsonDocument jdoc = QJsonDocument::fromJson(f.readAll());
if (jdoc.isObject())
m_builtIn.append(Theme::fromJson(jdoc.object()));
}
m_builtInDefaults = m_builtIn;
}
// ── themes / current ──
QVector<Theme> ThemeManager::themes() const { QVector<Theme> ThemeManager::themes() const {
QVector<Theme> all = m_builtIn; QVector<Theme> all = m_builtIn;
all.append(m_user); all.append(m_user);
@@ -37,7 +60,10 @@ const Theme& ThemeManager::current() const {
int userIdx = m_currentIdx - m_builtIn.size(); int userIdx = m_currentIdx - m_builtIn.size();
if (userIdx >= 0 && userIdx < m_user.size()) if (userIdx >= 0 && userIdx < m_user.size())
return m_user[userIdx]; return m_user[userIdx];
return m_builtIn[0]; if (!m_builtIn.isEmpty())
return m_builtIn[0];
static const Theme empty;
return empty;
} }
void ThemeManager::setCurrent(int index) { void ThemeManager::setCurrent(int index) {
@@ -55,17 +81,20 @@ void ThemeManager::addTheme(const Theme& theme) {
} }
void ThemeManager::updateTheme(int index, const Theme& theme) { void ThemeManager::updateTheme(int index, const Theme& theme) {
m_previewing = false; // commit any active preview
if (index < builtInCount()) { if (index < builtInCount()) {
// Can't overwrite built-in; save as user theme instead m_builtIn[index] = theme;
m_user.append(theme); m_currentIdx = index;
} else { } else {
int ui = index - builtInCount(); int ui = index - builtInCount();
if (ui >= 0 && ui < m_user.size()) if (ui >= 0 && ui < m_user.size())
m_user[ui] = theme; m_user[ui] = theme;
} }
saveUserThemes(); saveUserThemes();
if (index == m_currentIdx) QSettings settings("Reclass", "Reclass");
emit themeChanged(current()); settings.setValue("theme", current().name);
emit themeChanged(current());
} }
void ThemeManager::removeTheme(int index) { void ThemeManager::removeTheme(int index) {
@@ -82,7 +111,9 @@ void ThemeManager::removeTheme(int index) {
saveUserThemes(); saveUserThemes();
} }
QString ThemeManager::themesDir() const { // ── User theme persistence ──
QString ThemeManager::userDir() const {
QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation) QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)
+ "/themes"; + "/themes";
QDir().mkpath(dir); QDir().mkpath(dir);
@@ -91,37 +122,69 @@ QString ThemeManager::themesDir() const {
void ThemeManager::loadUserThemes() { void ThemeManager::loadUserThemes() {
m_user.clear(); m_user.clear();
QDir dir(themesDir()); QDir dir(userDir());
for (const QString& name : dir.entryList({"*.json"}, QDir::Files)) { for (const QString& name : dir.entryList({"*.json"}, QDir::Files)) {
QFile f(dir.filePath(name)); QFile f(dir.filePath(name));
if (!f.open(QIODevice::ReadOnly)) continue; if (!f.open(QIODevice::ReadOnly)) continue;
QJsonDocument jdoc = QJsonDocument::fromJson(f.readAll()); QJsonDocument jdoc = QJsonDocument::fromJson(f.readAll());
if (jdoc.isObject()) if (!jdoc.isObject()) continue;
m_user.append(Theme::fromJson(jdoc.object())); Theme t = Theme::fromJson(jdoc.object());
// If this overrides a built-in (same name), replace it in-place
bool isOverride = false;
for (int i = 0; i < m_builtIn.size(); i++) {
if (m_builtIn[i].name == t.name) {
m_builtIn[i] = t;
isOverride = true;
break;
}
}
if (!isOverride)
m_user.append(t);
} }
} }
void ThemeManager::saveUserThemes() const { void ThemeManager::saveUserThemes() const {
QString dir = themesDir(); QString dir = userDir();
// Remove old files
QDir d(dir); QDir d(dir);
for (const QString& name : d.entryList({"*.json"}, QDir::Files)) for (const QString& name : d.entryList({"*.json"}, QDir::Files))
d.remove(name); d.remove(name);
// Write current user themes
// Save modified built-ins (compare against on-disk originals)
for (int i = 0; i < m_builtIn.size() && i < m_builtInDefaults.size(); i++) {
if (m_builtIn[i].toJson() != m_builtInDefaults[i].toJson()) {
QString filename = m_builtIn[i].name.toLower().replace(' ', '_') + ".json";
QFile f(dir + "/" + filename);
if (f.open(QIODevice::WriteOnly))
f.write(QJsonDocument(m_builtIn[i].toJson()).toJson(QJsonDocument::Indented));
}
}
// Save user themes
for (int i = 0; i < m_user.size(); i++) { for (int i = 0; i < m_user.size(); i++) {
QString filename = m_user[i].name.toLower().replace(' ', '_') + ".json"; QString filename = m_user[i].name.toLower().replace(' ', '_') + ".json";
QFile f(dir + "/" + filename); QFile f(dir + "/" + filename);
if (!f.open(QIODevice::WriteOnly)) continue; if (f.open(QIODevice::WriteOnly))
f.write(QJsonDocument(m_user[i].toJson()).toJson(QJsonDocument::Indented)); f.write(QJsonDocument(m_user[i].toJson()).toJson(QJsonDocument::Indented));
} }
} }
QString ThemeManager::themeFilePath(int index) const { QString ThemeManager::themeFilePath(int index) const {
if (index < builtInCount()) return {}; if (index < builtInCount()) {
// Built-in has a user override file only if modified
if (index < m_builtInDefaults.size()
&& m_builtIn[index].toJson() != m_builtInDefaults[index].toJson()) {
QString filename = m_builtIn[index].name.toLower().replace(' ', '_') + ".json";
return userDir() + "/" + filename;
}
// Show the built-in source file
QString filename = m_builtIn[index].name.toLower().replace(' ', '_') + ".json";
return builtInDir() + "/" + filename;
}
int ui = index - builtInCount(); int ui = index - builtInCount();
if (ui < 0 || ui >= m_user.size()) return {}; if (ui < 0 || ui >= m_user.size()) return {};
QString filename = m_user[ui].name.toLower().replace(' ', '_') + ".json"; QString filename = m_user[ui].name.toLower().replace(' ', '_') + ".json";
return themesDir() + "/" + filename; return userDir() + "/" + filename;
} }
void ThemeManager::previewTheme(const Theme& theme) { void ThemeManager::previewTheme(const Theme& theme) {

View File

@@ -31,14 +31,17 @@ signals:
private: private:
ThemeManager(); ThemeManager();
QVector<Theme> m_builtIn; QVector<Theme> m_builtIn; // built-in themes (possibly overridden)
QVector<Theme> m_builtInDefaults; // originals loaded from disk
QVector<Theme> m_user; QVector<Theme> m_user;
int m_currentIdx = 0; int m_currentIdx = 0;
int builtInCount() const { return m_builtIn.size(); } int builtInCount() const { return m_builtIn.size(); }
QString themesDir() const; void loadBuiltInThemes();
QString builtInDir() const;
QString userDir() const;
bool m_previewing = false; bool m_previewing = false;
Theme m_savedTheme; // stashed current theme during preview Theme m_savedTheme;
}; };
} // namespace rcx } // namespace rcx

159
src/titlebar.cpp Normal file
View File

@@ -0,0 +1,159 @@
#include "titlebar.h"
#include "themes/thememanager.h"
#include <QMouseEvent>
#include <QPainter>
#include <QStyle>
#include <QWindow>
namespace rcx {
TitleBarWidget::TitleBarWidget(QWidget* parent)
: QWidget(parent)
, m_theme(ThemeManager::instance().current())
{
setFixedHeight(32);
auto* layout = new QHBoxLayout(this);
layout->setContentsMargins(0, 0, 0, 0);
layout->setSpacing(0);
// App name
m_appLabel = new QLabel(QStringLiteral("Reclass"), this);
m_appLabel->setContentsMargins(10, 0, 4, 0);
m_appLabel->setAlignment(Qt::AlignVCenter);
m_appLabel->setAttribute(Qt::WA_TransparentForMouseEvents);
layout->addWidget(m_appLabel);
// Menu bar
m_menuBar = new QMenuBar(this);
m_menuBar->setNativeMenuBar(false);
m_menuBar->setSizePolicy(QSizePolicy::Minimum, QSizePolicy::Expanding);
layout->addWidget(m_menuBar);
layout->addStretch();
// Chrome buttons
m_btnMin = makeChromeButton(":/vsicons/chrome-minimize.svg");
m_btnMax = makeChromeButton(":/vsicons/chrome-maximize.svg");
m_btnClose = makeChromeButton(":/vsicons/chrome-close.svg");
layout->addWidget(m_btnMin);
layout->addWidget(m_btnMax);
layout->addWidget(m_btnClose);
connect(m_btnMin, &QToolButton::clicked, this, [this]() {
window()->showMinimized();
});
connect(m_btnMax, &QToolButton::clicked, this, [this]() {
toggleMaximize();
});
connect(m_btnClose, &QToolButton::clicked, this, [this]() {
window()->close();
});
}
QToolButton* TitleBarWidget::makeChromeButton(const QString& iconPath) {
auto* btn = new QToolButton(this);
btn->setIcon(QIcon(iconPath));
btn->setIconSize(QSize(16, 16));
btn->setFixedSize(46, 32);
btn->setAutoRaise(true);
btn->setFocusPolicy(Qt::NoFocus);
return btn;
}
void TitleBarWidget::applyTheme(const Theme& theme) {
m_theme = theme;
// Title bar background
setAutoFillBackground(true);
QPalette pal = palette();
pal.setColor(QPalette::Window, theme.background);
setPalette(pal);
// App label
m_appLabel->setStyleSheet(
QStringLiteral("QLabel { color: %1; font-size: 12px; font-weight: bold; }")
.arg(theme.textDim.name()));
// Menu bar styling — transparent background, themed text
m_menuBar->setStyleSheet(
QStringLiteral(
"QMenuBar { background: transparent; border: none; }"
"QMenuBar::item { background: transparent; color: %1; padding: 8px 8px 4px 8px; }"
"QMenuBar::item:selected { background: %2; }"
"QMenuBar::item:pressed { background: %2; }")
.arg(theme.textDim.name(), theme.hover.name()));
// Chrome buttons
QString btnStyle = QStringLiteral(
"QToolButton { background: transparent; border: none; }"
"QToolButton:hover { background: %1; }")
.arg(theme.hover.name());
m_btnMin->setStyleSheet(btnStyle);
m_btnMax->setStyleSheet(btnStyle);
// Close button: red hover
m_btnClose->setStyleSheet(QStringLiteral(
"QToolButton { background: transparent; border: none; }"
"QToolButton:hover { background: #c42b1c; }"));
update();
}
void TitleBarWidget::setShowIcon(bool show) {
if (show) {
m_appLabel->setText(QString());
m_appLabel->setPixmap(QIcon(":/icons/class.png").pixmap(24, 24));
} else {
m_appLabel->setPixmap(QPixmap());
m_appLabel->setText(QStringLiteral("Reclass"));
m_appLabel->setStyleSheet(
QStringLiteral("QLabel { color: %1; font-size: 12px; font-weight: bold; }")
.arg(m_theme.textDim.name()));
}
}
void TitleBarWidget::updateMaximizeIcon() {
if (window()->isMaximized())
m_btnMax->setIcon(QIcon(":/vsicons/chrome-restore.svg"));
else
m_btnMax->setIcon(QIcon(":/vsicons/chrome-maximize.svg"));
}
void TitleBarWidget::toggleMaximize() {
if (window()->isMaximized())
window()->showNormal();
else
window()->showMaximized();
updateMaximizeIcon();
}
void TitleBarWidget::mousePressEvent(QMouseEvent* event) {
if (event->button() == Qt::LeftButton) {
window()->windowHandle()->startSystemMove();
event->accept();
} else {
QWidget::mousePressEvent(event);
}
}
void TitleBarWidget::mouseDoubleClickEvent(QMouseEvent* event) {
if (event->button() == Qt::LeftButton) {
toggleMaximize();
event->accept();
} else {
QWidget::mouseDoubleClickEvent(event);
}
}
void TitleBarWidget::paintEvent(QPaintEvent* event) {
QWidget::paintEvent(event);
// 1px bottom border
QPainter p(this);
p.setPen(m_theme.border);
p.drawLine(0, height() - 1, width() - 1, height() - 1);
}
} // namespace rcx

40
src/titlebar.h Normal file
View File

@@ -0,0 +1,40 @@
#pragma once
#include "themes/theme.h"
#include <QWidget>
#include <QMenuBar>
#include <QToolButton>
#include <QLabel>
#include <QHBoxLayout>
namespace rcx {
class TitleBarWidget : public QWidget {
Q_OBJECT
public:
explicit TitleBarWidget(QWidget* parent = nullptr);
QMenuBar* menuBar() const { return m_menuBar; }
void applyTheme(const Theme& theme);
void setShowIcon(bool show);
void updateMaximizeIcon();
protected:
void mousePressEvent(QMouseEvent* event) override;
void mouseDoubleClickEvent(QMouseEvent* event) override;
void paintEvent(QPaintEvent* event) override;
private:
QLabel* m_appLabel = nullptr;
QMenuBar* m_menuBar = nullptr;
QToolButton* m_btnMin = nullptr;
QToolButton* m_btnMax = nullptr;
QToolButton* m_btnClose = nullptr;
Theme m_theme;
QToolButton* makeChromeButton(const QString& iconPath);
void toggleMaximize();
};
} // namespace rcx

185
tests/test_com_security.cpp Normal file
View File

@@ -0,0 +1,185 @@
/**
* test_com_security.cpp — DebugConnect transport diagnostic
*
* Tests EVERY transport to find what works from MinGW:
* 1. TCP to WinDbg .server (port 5055)
* 2. Named pipe to WinDbg .server
* 3. TCP with various COM security configs
* 4. DebugCreate local (baseline)
*
* SETUP: In WinDbg, run BOTH of these:
* .server tcp:port=5055
* .server npipe:pipe=reclass
*
* Then run this test.
*/
#include <cstdio>
#include <cstdlib>
#include <cstring>
#ifdef _WIN32
#include <windows.h>
#include <objbase.h>
#include <initguid.h>
#include <dbgeng.h>
#endif
#ifdef _WIN32
static void try_connect(const char* label, const char* connStr)
{
printf(" %-40s → ", label);
fflush(stdout);
IDebugClient* client = nullptr;
HRESULT hr = DebugConnect(connStr, IID_IDebugClient, (void**)&client);
if (SUCCEEDED(hr) && client) {
printf("SUCCESS (hr=0x%08lX)\n", (unsigned long)hr);
// Try to get data spaces and read something
IDebugDataSpaces* ds = nullptr;
IDebugSymbols* sym = nullptr;
IDebugControl* ctrl = nullptr;
client->QueryInterface(IID_IDebugDataSpaces, (void**)&ds);
client->QueryInterface(IID_IDebugSymbols, (void**)&sym);
client->QueryInterface(IID_IDebugControl, (void**)&ctrl);
if (ctrl) {
HRESULT hrWait = ctrl->WaitForEvent(0, 5000);
printf(" WaitForEvent: hr=0x%08lX\n", (unsigned long)hrWait);
}
if (sym) {
ULONG numMods = 0, numUnloaded = 0;
sym->GetNumberModules(&numMods, &numUnloaded);
printf(" Modules: %lu loaded\n", numMods);
if (numMods > 0 && ds) {
ULONG64 base = 0;
sym->GetModuleByIndex(0, &base);
unsigned char buf[2] = {};
ULONG got = 0;
ds->ReadVirtual(base, buf, 2, &got);
printf(" Read at 0x%llX: got=%lu bytes=[%02X %02X]\n",
(unsigned long long)base, got, buf[0], buf[1]);
}
}
if (sym) sym->Release();
if (ds) ds->Release();
if (ctrl) ctrl->Release();
client->Release();
} else {
char buf[256] = {};
FormatMessageA(FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS,
nullptr, (DWORD)hr, 0, buf, sizeof(buf), nullptr);
for (char* p = buf + strlen(buf) - 1; p >= buf && (*p == '\r' || *p == '\n'); --p)
*p = '\0';
printf("FAIL hr=0x%08lX (%s)\n", (unsigned long)hr, buf);
}
}
#endif
int main()
{
#ifdef _WIN32
char hostname[256] = {};
DWORD hsize = sizeof(hostname);
GetComputerNameA(hostname, &hsize);
printf("=== DebugConnect Transport Diagnostic ===\n");
printf("Machine: %s\n\n", hostname);
// ── Baseline: DebugCreate (local) ──
printf("[1] DebugCreate (local, no network)\n");
{
IDebugClient* client = nullptr;
HRESULT hr = DebugCreate(IID_IDebugClient, (void**)&client);
printf(" DebugCreate: %s (hr=0x%08lX)\n\n",
SUCCEEDED(hr) ? "OK" : "FAIL", (unsigned long)hr);
if (client) client->Release();
}
// ── TCP variants ──
printf("[2] TCP connections (need: .server tcp:port=5055)\n");
try_connect("tcp:Port=5055,Server=localhost",
"tcp:Port=5055,Server=localhost");
try_connect("tcp:Port=5055,Server=127.0.0.1",
"tcp:Port=5055,Server=127.0.0.1");
{
char conn[512];
snprintf(conn, sizeof(conn), "tcp:Port=5055,Server=%s", hostname);
try_connect(conn, conn);
}
printf("\n");
// ── Named pipe variants ──
printf("[3] Named pipe connections (need: .server npipe:pipe=reclass)\n");
try_connect("npipe:Pipe=reclass,Server=localhost",
"npipe:Pipe=reclass,Server=localhost");
{
char conn[512];
snprintf(conn, sizeof(conn), "npipe:Pipe=reclass,Server=%s", hostname);
try_connect(conn, conn);
}
try_connect("npipe:Pipe=reclass",
"npipe:Pipe=reclass");
printf("\n");
// ── TCP with COM security ──
printf("[4] TCP with explicit COM init (MTA + IMPERSONATE)\n");
{
// This runs in-process so CoInitialize affects subsequent calls
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
CoInitializeSecurity(
nullptr, -1, nullptr, nullptr,
RPC_C_AUTHN_LEVEL_DEFAULT,
RPC_C_IMP_LEVEL_IMPERSONATE,
nullptr, EOAC_NONE, nullptr);
try_connect("tcp:Port=5055,Server=localhost (MTA+SEC)",
"tcp:Port=5055,Server=localhost");
try_connect("npipe:Pipe=reclass (MTA+SEC)",
"npipe:Pipe=reclass,Server=localhost");
CoUninitialize();
}
printf("\n");
// ── Check if dbgeng.dll is the system one ──
printf("[5] DbgEng DLL info\n");
{
HMODULE hmod = GetModuleHandleA("dbgeng.dll");
if (hmod) {
char path[MAX_PATH] = {};
GetModuleFileNameA(hmod, path, MAX_PATH);
printf(" dbgeng.dll loaded from: %s\n", path);
// Get version
DWORD verSize = GetFileVersionInfoSizeA(path, nullptr);
if (verSize > 0) {
auto* verData = (char*)malloc(verSize);
if (GetFileVersionInfoA(path, 0, verSize, verData)) {
VS_FIXEDFILEINFO* fileInfo = nullptr;
UINT len = 0;
if (VerQueryValueA(verData, "\\", (void**)&fileInfo, &len)) {
printf(" Version: %d.%d.%d.%d\n",
HIWORD(fileInfo->dwFileVersionMS),
LOWORD(fileInfo->dwFileVersionMS),
HIWORD(fileInfo->dwFileVersionLS),
LOWORD(fileInfo->dwFileVersionLS));
}
}
free(verData);
}
} else {
printf(" dbgeng.dll not loaded yet\n");
}
}
printf("\n=== Done ===\n");
return 0;
#else
printf("Windows only.\n");
return 0;
#endif
}

65
tests/test_dbgconnect.cpp Normal file
View File

@@ -0,0 +1,65 @@
#include <cstdio>
#include <cstdint>
#include <windows.h>
#include <initguid.h>
#include <dbgeng.h>
int main()
{
const char* connStr = "tcp:Port=5057,Server=localhost";
printf("Attempting DebugConnect(\"%s\")...\n", connStr);
IDebugClient* client = nullptr;
HRESULT hr = DebugConnect(connStr, IID_IDebugClient, (void**)&client);
printf("DebugConnect returned: 0x%08lX\n", hr);
if (SUCCEEDED(hr) && client) {
printf("Connected! Getting IDebugDataSpaces...\n");
IDebugDataSpaces* ds = nullptr;
hr = client->QueryInterface(IID_IDebugDataSpaces, (void**)&ds);
printf("QueryInterface(IDebugDataSpaces) = 0x%08lX\n", hr);
if (ds) {
IDebugControl* ctrl = nullptr;
client->QueryInterface(IID_IDebugControl, (void**)&ctrl);
if (ctrl) {
printf("Waiting for event...\n");
hr = ctrl->WaitForEvent(0, 5000);
printf("WaitForEvent = 0x%08lX\n", hr);
ctrl->Release();
}
// Try to read 2 bytes
IDebugSymbols* sym = nullptr;
client->QueryInterface(IID_IDebugSymbols, (void**)&sym);
if (sym) {
ULONG numMods = 0, numUnloaded = 0;
hr = sym->GetNumberModules(&numMods, &numUnloaded);
printf("GetNumberModules = 0x%08lX, numMods=%lu\n", hr, numMods);
if (numMods > 0) {
ULONG64 base = 0;
hr = sym->GetModuleByIndex(0, &base);
printf("Module[0] base = 0x%llX (hr=0x%08lX)\n", base, hr);
if (SUCCEEDED(hr) && base) {
uint8_t buf[4] = {};
ULONG got = 0;
hr = ds->ReadVirtual(base, buf, 4, &got);
printf("ReadVirtual(%llX, 4) = 0x%08lX, got=%lu, data=[%02X %02X %02X %02X]\n",
base, hr, got, buf[0], buf[1], buf[2], buf[3]);
}
}
sym->Release();
}
ds->Release();
}
client->Release();
} else {
printf("DebugConnect FAILED. hr=0x%08lX\n", hr);
}
return 0;
}

View File

@@ -11,31 +11,37 @@ class TestTheme : public QObject {
Q_OBJECT Q_OBJECT
private slots: private slots:
void builtInThemes() { void builtInThemes() {
Theme dark = Theme::reclassDark(); auto& tm = ThemeManager::instance();
QCOMPARE(dark.name, "Reclass Dark"); auto all = tm.themes();
QVERIFY(dark.background.isValid()); QVERIFY(all.size() >= 2);
QVERIFY(dark.text.isValid());
QVERIFY(dark.syntaxKeyword.isValid());
QVERIFY(dark.markerError.isValid());
Theme warm = Theme::warm(); // Find themes by name
QCOMPARE(warm.name, "Warm"); const Theme* dark = nullptr;
QVERIFY(warm.background.isValid()); const Theme* warm = nullptr;
QVERIFY(warm.text.isValid()); for (const auto& t : all) {
QCOMPARE(warm.background, QColor("#212121")); if (t.name == "Reclass Dark") dark = &t;
QCOMPARE(warm.selection, QColor("#21213A")); if (t.name == "Warm") warm = &t;
QCOMPARE(warm.syntaxKeyword, QColor("#AA9565")); }
QCOMPARE(warm.syntaxType, QColor("#6B959F")); QVERIFY(dark);
} QCOMPARE(dark->name, QString("Reclass Dark"));
QVERIFY(dark->background.isValid());
QVERIFY(dark->text.isValid());
QVERIFY(dark->syntaxKeyword.isValid());
QVERIFY(dark->markerError.isValid());
void selectionColorFixed() { QVERIFY(warm);
Theme dark = Theme::reclassDark(); QCOMPARE(warm->name, QString("Warm"));
QCOMPARE(dark.selection, QColor("#2b2b2b")); QVERIFY(warm->background.isValid());
QVERIFY(dark.selection != QColor("#264f78")); QVERIFY(warm->text.isValid());
QCOMPARE(warm->background, QColor("#212121"));
QCOMPARE(warm->selection, QColor("#21213A"));
QCOMPARE(warm->syntaxKeyword, QColor("#AA9565"));
QCOMPARE(warm->syntaxType, QColor("#6B959F"));
} }
void jsonRoundTrip() { void jsonRoundTrip() {
Theme orig = Theme::reclassDark(); auto& tm = ThemeManager::instance();
Theme orig = tm.themes()[0];
QJsonObject json = orig.toJson(); QJsonObject json = orig.toJson();
Theme loaded = Theme::fromJson(json); Theme loaded = Theme::fromJson(json);
@@ -54,7 +60,12 @@ private slots:
} }
void jsonRoundTripWarm() { void jsonRoundTripWarm() {
Theme orig = Theme::warm(); auto& tm = ThemeManager::instance();
auto all = tm.themes();
Theme orig;
for (const auto& t : all)
if (t.name == "Warm") { orig = t; break; }
QJsonObject json = orig.toJson(); QJsonObject json = orig.toJson();
Theme loaded = Theme::fromJson(json); Theme loaded = Theme::fromJson(json);
@@ -70,21 +81,27 @@ private slots:
sparse["background"] = "#ff0000"; sparse["background"] = "#ff0000";
Theme t = Theme::fromJson(sparse); Theme t = Theme::fromJson(sparse);
QCOMPARE(t.name, "Sparse"); QCOMPARE(t.name, QString("Sparse"));
QCOMPARE(t.background, QColor("#ff0000")); QCOMPARE(t.background, QColor("#ff0000"));
// Missing fields fall back to reclassDark defaults // Missing fields are default (invalid) QColor
Theme defaults = Theme::reclassDark(); QVERIFY(!t.text.isValid());
QCOMPARE(t.text, defaults.text); QVERIFY(!t.syntaxKeyword.isValid());
QCOMPARE(t.syntaxKeyword, defaults.syntaxKeyword); QVERIFY(!t.markerError.isValid());
QCOMPARE(t.markerError, defaults.markerError);
} }
void themeManagerHasBuiltIns() { void themeManagerHasBuiltIns() {
auto& tm = ThemeManager::instance(); auto& tm = ThemeManager::instance();
auto all = tm.themes(); auto all = tm.themes();
QVERIFY(all.size() >= 2); QVERIFY(all.size() >= 3);
QCOMPARE(all[0].name, "Reclass Dark"); QCOMPARE(all[0].name, QString("Reclass Dark"));
QCOMPARE(all[1].name, "Warm"); // VS2022 Dark and Warm are also loaded (order depends on filename sort)
bool hasVs = false, hasWarm = false;
for (const auto& t : all) {
if (t.name == "VS2022 Dark") hasVs = true;
if (t.name == "Warm") hasWarm = true;
}
QVERIFY(hasVs);
QVERIFY(hasWarm);
} }
void themeManagerSwitch() { void themeManagerSwitch() {
@@ -108,12 +125,12 @@ private slots:
int initialCount = tm.themes().size(); int initialCount = tm.themes().size();
// Add // Add
Theme custom = Theme::reclassDark(); Theme custom = tm.themes()[0];
custom.name = "Test Custom"; custom.name = "Test Custom";
custom.background = QColor("#ff0000"); custom.background = QColor("#ff0000");
tm.addTheme(custom); tm.addTheme(custom);
QCOMPARE(tm.themes().size(), initialCount + 1); QCOMPARE(tm.themes().size(), initialCount + 1);
QCOMPARE(tm.themes().last().name, "Test Custom"); QCOMPARE(tm.themes().last().name, QString("Test Custom"));
// Update // Update
int idx = tm.themes().size() - 1; int idx = tm.themes().size() - 1;

View File

@@ -0,0 +1,463 @@
#include <QTest>
#include <QByteArray>
#include <QProcess>
#include <QThread>
#include <QtConcurrent>
#include <QFuture>
#include <cstring>
#include "providers/provider.h"
#include "../plugins/WinDbgMemory/WinDbgMemoryPlugin.h"
#ifdef _WIN32
#include <windows.h>
#include <tlhelp32.h>
#include <initguid.h>
#include <dbgeng.h>
#endif
using namespace rcx;
static const char* CDB_PATH = "C:\\Program Files (x86)\\Windows Kits\\10\\Debuggers\\x64\\cdb.exe";
static const int DBG_PORT = 5055;
class TestWinDbgProvider : public QObject {
Q_OBJECT
private:
QProcess* m_cdbProcess = nullptr;
uint32_t m_notepadPid = 0;
bool m_weSpawnedNotepad = false;
QString m_connString;
static uint32_t findProcess(const wchar_t* name)
{
#ifdef _WIN32
HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snap == INVALID_HANDLE_VALUE) return 0;
PROCESSENTRY32W entry;
entry.dwSize = sizeof(entry);
uint32_t pid = 0;
if (Process32FirstW(snap, &entry)) {
do {
if (_wcsicmp(entry.szExeFile, name) == 0) {
pid = entry.th32ProcessID;
break;
}
} while (Process32NextW(snap, &entry));
}
CloseHandle(snap);
return pid;
#else
Q_UNUSED(name); return 0;
#endif
}
static uint32_t launchNotepad()
{
#ifdef _WIN32
STARTUPINFOW si{};
si.cb = sizeof(si);
PROCESS_INFORMATION pi{};
if (CreateProcessW(L"C:\\Windows\\notepad.exe", nullptr, nullptr, nullptr,
FALSE, 0, nullptr, nullptr, &si, &pi)) {
WaitForInputIdle(pi.hProcess, 3000);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
return pi.dwProcessId;
}
return 0;
#else
return 0;
#endif
}
static void terminateProcess(uint32_t pid)
{
#ifdef _WIN32
HANDLE h = OpenProcess(PROCESS_TERMINATE, FALSE, pid);
if (h) { TerminateProcess(h, 0); CloseHandle(h); }
#else
Q_UNUSED(pid);
#endif
}
private slots:
// ── Fixture ──
/// Try a quick DebugConnect to see if the port is already serving.
static bool canConnect(const QString& connStr)
{
#ifdef _WIN32
IDebugClient* probe = nullptr;
QByteArray utf8 = connStr.toUtf8();
HRESULT hr = DebugConnect(utf8.constData(), IID_IDebugClient, (void**)&probe);
if (SUCCEEDED(hr) && probe) {
probe->EndSession(DEBUG_END_DISCONNECT);
probe->Release();
return true;
}
return false;
#else
Q_UNUSED(connStr);
return false;
#endif
}
void initTestCase()
{
m_connString = QString("tcp:Port=%1,Server=localhost").arg(DBG_PORT);
// If a debug server is already listening (e.g. WinDbg with .server),
// skip launching our own cdb.exe.
if (canConnect(m_connString)) {
qDebug() << "Debug server already running on port" << DBG_PORT << "— using it";
return;
}
// No server running — launch cdb ourselves
m_notepadPid = findProcess(L"notepad.exe");
if (m_notepadPid == 0) {
m_notepadPid = launchNotepad();
m_weSpawnedNotepad = true;
}
QVERIFY2(m_notepadPid != 0, "Need notepad.exe running");
qDebug() << "Using notepad.exe PID:" << m_notepadPid;
m_cdbProcess = new QProcess(this);
QStringList args;
args << "-server" << QString("tcp:port=%1").arg(DBG_PORT)
<< "-pv"
<< "-p" << QString::number(m_notepadPid);
m_cdbProcess->setProgram(CDB_PATH);
m_cdbProcess->setArguments(args);
m_cdbProcess->start();
QVERIFY2(m_cdbProcess->waitForStarted(5000), "Failed to start cdb.exe");
QThread::sleep(3);
qDebug() << "cdb.exe debug server started on port" << DBG_PORT;
}
void cleanupTestCase()
{
if (m_cdbProcess) {
m_cdbProcess->write("q\n");
if (!m_cdbProcess->waitForFinished(5000))
m_cdbProcess->kill();
delete m_cdbProcess;
m_cdbProcess = nullptr;
}
if (m_weSpawnedNotepad && m_notepadPid)
terminateProcess(m_notepadPid);
}
// ── Plugin metadata ──
void plugin_name()
{
WinDbgMemoryPlugin plugin;
QCOMPARE(plugin.Name(), std::string("WinDbg Memory"));
}
void plugin_version()
{
WinDbgMemoryPlugin plugin;
QCOMPARE(plugin.Version(), std::string("2.0.0"));
}
void plugin_canHandle_tcp()
{
WinDbgMemoryPlugin plugin;
QVERIFY(plugin.canHandle("tcp:Port=5055,Server=localhost"));
QVERIFY(plugin.canHandle("TCP:Port=1234,Server=10.0.0.1"));
}
void plugin_canHandle_npipe()
{
WinDbgMemoryPlugin plugin;
QVERIFY(plugin.canHandle("npipe:Pipe=test,Server=localhost"));
}
void plugin_canHandle_pid()
{
WinDbgMemoryPlugin plugin;
QVERIFY(plugin.canHandle("pid:1234"));
}
void plugin_canHandle_dump()
{
WinDbgMemoryPlugin plugin;
QVERIFY(plugin.canHandle("dump:C:/test.dmp"));
}
void plugin_canHandle_invalid()
{
WinDbgMemoryPlugin plugin;
QVERIFY(!plugin.canHandle(""));
QVERIFY(!plugin.canHandle("1234"));
QVERIFY(!plugin.canHandle("file:///test.bin"));
}
// ── Connection failure ──
void provider_connect_badPort()
{
WinDbgMemoryProvider prov("tcp:Port=59999,Server=localhost");
QVERIFY(!prov.isValid());
QCOMPARE(prov.size(), 0);
}
void provider_connect_badPipe()
{
WinDbgMemoryProvider prov("npipe:Pipe=nonexistent_reclass_test_pipe,Server=localhost");
QVERIFY(!prov.isValid());
QCOMPARE(prov.size(), 0);
}
void plugin_createProvider_badConnection()
{
WinDbgMemoryPlugin plugin;
QString error;
auto prov = plugin.createProvider("tcp:Port=59999,Server=localhost", &error);
QVERIFY(prov == nullptr);
QVERIFY(!error.isEmpty());
}
// ── Connect and read (main thread) ──
void provider_connect_valid()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY2(prov.isValid(), "Should connect to cdb debug server");
QCOMPARE(prov.kind(), QStringLiteral("WinDbg"));
QVERIFY(prov.size() > 0);
}
void provider_name()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
QVERIFY(!prov.name().isEmpty());
qDebug() << "Provider name:" << prov.name();
}
void provider_isLive()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
QVERIFY(prov.isLive());
}
void provider_baseAddress()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
QVERIFY2(prov.base() != 0, "Should have a non-zero base from first module");
qDebug() << "Base address:" << QString("0x%1").arg(prov.base(), 0, 16);
}
void provider_setBase()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
uint64_t orig = prov.base();
prov.setBase(0x1000);
QCOMPARE(prov.base(), (uint64_t)0x1000);
prov.setBase(orig);
QCOMPARE(prov.base(), orig);
}
// ── Read: MZ header on main thread ──
void provider_read_mz_mainThread()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
uint8_t buf[2] = {};
bool ok = prov.read(0, buf, 2);
QVERIFY2(ok, "Failed to read from debug session (main thread)");
QCOMPARE(buf[0], (uint8_t)'M');
QCOMPARE(buf[1], (uint8_t)'Z');
}
// ── Read: MZ header from a background thread (the actual failure case) ──
void provider_read_mz_backgroundThread()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
// Simulate what the controller's refresh does:
// read from a QtConcurrent worker thread.
QFuture<QByteArray> future = QtConcurrent::run([&prov]() -> QByteArray {
return prov.readBytes(0, 128);
});
future.waitForFinished();
QByteArray data = future.result();
QCOMPARE(data.size(), 128);
QCOMPARE((uint8_t)data[0], (uint8_t)'M');
QCOMPARE((uint8_t)data[1], (uint8_t)'Z');
}
// ── Read: bulk data from background thread ──
void provider_read_4k_backgroundThread()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
QFuture<QByteArray> future = QtConcurrent::run([&prov]() -> QByteArray {
return prov.readBytes(0, 4096);
});
future.waitForFinished();
QByteArray data = future.result();
QCOMPARE(data.size(), 4096);
QCOMPARE((uint8_t)data[0], (uint8_t)'M');
QCOMPARE((uint8_t)data[1], (uint8_t)'Z');
// Verify it's not all zeros (the old failure mode)
bool allZero = true;
for (int i = 0; i < data.size(); ++i) {
if (data[i] != 0) { allZero = false; break; }
}
QVERIFY2(!allZero, "Data is all zeros — background thread read failed");
}
// ── Multiple sequential background reads (simulates refresh timer) ──
void provider_read_multipleRefreshes()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
for (int i = 0; i < 5; ++i) {
QFuture<QByteArray> future = QtConcurrent::run([&prov]() -> QByteArray {
return prov.readBytes(0, 128);
});
future.waitForFinished();
QByteArray data = future.result();
QCOMPARE(data.size(), 128);
QCOMPARE((uint8_t)data[0], (uint8_t)'M');
QCOMPARE((uint8_t)data[1], (uint8_t)'Z');
}
}
// ── Read helpers ──
void provider_readU16()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
QCOMPARE(prov.readU16(0), (uint16_t)0x5A4D); // "MZ" little-endian
}
void provider_read_peSignature()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
uint32_t peOffset = prov.readU32(0x3C);
QVERIFY2(peOffset > 0 && peOffset < 0x1000, "PE offset should be reasonable");
uint8_t sig[4] = {};
bool ok = prov.read(peOffset, sig, 4);
QVERIFY(ok);
QCOMPARE(sig[0], (uint8_t)'P');
QCOMPARE(sig[1], (uint8_t)'E');
QCOMPARE(sig[2], (uint8_t)0);
QCOMPARE(sig[3], (uint8_t)0);
}
// ── Edge cases ──
void provider_read_zeroLength()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
uint8_t buf = 0xFF;
QVERIFY(!prov.read(0, &buf, 0));
}
void provider_read_negativeLength()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
uint8_t buf = 0xFF;
QVERIFY(!prov.read(0, &buf, -1));
}
// ── getSymbol ──
void provider_getSymbol()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
QString sym = prov.getSymbol(0);
qDebug() << "Symbol at base+0:" << sym;
// Should not crash; may or may not resolve
}
void provider_getSymbol_backgroundThread()
{
WinDbgMemoryProvider prov(m_connString);
QVERIFY(prov.isValid());
QFuture<QString> future = QtConcurrent::run([&prov]() -> QString {
return prov.getSymbol(0);
});
future.waitForFinished();
// Should not crash from background thread
qDebug() << "Symbol (bg thread):" << future.result();
}
// ── createProvider full flow ──
void plugin_createProvider_valid()
{
WinDbgMemoryPlugin plugin;
QString error;
auto prov = plugin.createProvider(m_connString, &error);
QVERIFY2(prov != nullptr, qPrintable("createProvider failed: " + error));
QVERIFY(prov->isValid());
uint8_t mz[2] = {};
QVERIFY(prov->read(0, mz, 2));
QCOMPARE(mz[0], (uint8_t)'M');
QCOMPARE(mz[1], (uint8_t)'Z');
}
// ── Multiple concurrent connections ──
void provider_multipleConcurrent()
{
WinDbgMemoryProvider prov1(m_connString);
WinDbgMemoryProvider prov2(m_connString);
QVERIFY(prov1.isValid());
QVERIFY(prov2.isValid());
QCOMPARE(prov1.readU16(0), (uint16_t)0x5A4D);
QCOMPARE(prov2.readU16(0), (uint16_t)0x5A4D);
}
// ── Factory ──
void factory_createPlugin()
{
IPlugin* raw = CreatePlugin();
QVERIFY(raw != nullptr);
QCOMPARE(raw->Type(), IPlugin::ProviderPlugin);
QCOMPARE(raw->Name(), std::string("WinDbg Memory"));
delete raw;
}
};
QTEST_MAIN(TestWinDbgProvider)
#include "test_windbg_provider.moc"