Compare commits

...

17 Commits

Author SHA1 Message Date
computron
71bc51cbab fix: guard Windows-only setDarkTitleBar and fix deprecated addAction arg order 2026-02-15 11:28:39 -07:00
computron
60a97ab81b fix: add missing Unix headers for Linux build 2026-02-15 11:20:28 -07:00
IChooseYou
bb00e75019 CI: win64 + linux64 builds, guard Windows-only targets for cross-platform 2026-02-15 11:16:09 -07:00
IChooseYou
c038c59e34 CI: add write permission for releases 2026-02-15 11:01:31 -07:00
IChooseYou
862f76b984 CI: auto-upload build zip to latest release 2026-02-15 10:24:46 -07:00
sysadmin
818285a76e CI: skip editor/windbg/com tests that need display or debug tools 2026-02-15 09:49:34 -07:00
sysadmin
ef5e2ebdb9 CI: fix Qt6 install - remove invalid module, pin aqtversion, use 6.8.1 LTS 2026-02-15 09:39:52 -07:00
sysadmin
75fedd2222 CI: switch to Qt6 2026-02-15 09:34:48 -07:00
sysadmin
389745e501 Add Windows CI build 2026-02-15 09:30:49 -07:00
sysadmin
1473a58742 IChooseYou 2026-02-15 09:23:17 -07:00
untitled
4192a4dad3 Hide project tree by default, remove 1px menu border, darken hover/selected theme colors 2026-02-15 08:29:59 -07:00
IChooseYou
4c6bb9564f Fix 7 verified bugs: ref invalidation, bounds check, double refresh, dangling pointer, undo bypass, overflow, hash collision
- BUG-1 (HIGH): Replace dangling QVector reference with local copies in applyTypePopupResult
- BUG-2 (MEDIUM): Add missing upper-bound check in EditTarget::Name handler
- BUG-5 (LOW): Remove redundant unconditional refresh() at end of applyTypePopupResult
- BUG-6 (LOW): Use QPointer for m_cachedPopup to auto-null on parent destruction
- BUG-7 (LOW): Rewrite materializeRefChildren to use undo macro (cmd::Insert + cmd::Collapse)
- BUG-8 (LOW): Guard against integer overflow in byteSize() and clamp arrayLen/strLen in fromJson
- BUG-9 (LOW): Use QPair<uint64_t,uint64_t> key in collectPointerRanges visited set
2026-02-15 08:16:52 -07:00
Sen66
0ef9841f90 Added options dialog 2026-02-15 03:24:12 +01:00
IChooseYOu
0a8244dad4 Single-click type chooser, popup warmup fix, rename ProcessMemory plugin
- Type chooser popup now opens on single click (no need to pre-select node)
- Fix ~170ms first-open delay by pre-initializing Qt popup subsystem at startup
- Rename ProcessMemoryWindows -> ProcessMemory (already supports Linux)
2026-02-14 16:08:44 -07:00
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
39 changed files with 2891 additions and 688 deletions

153
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,153 @@
name: Build
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: write
jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Qt6
uses: jurplel/install-qt-action@v4
with:
version: '6.8.1'
arch: 'win64_msvc2022_64'
cache: true
aqtversion: '==3.1.21'
- uses: ilammy/msvc-dev-cmd@v1
with:
arch: x64
- name: Configure
run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
- name: Build
run: cmake --build build
- name: Test
run: ctest --test-dir build --output-on-failure --exclude-regex "test_editor|test_windbg_provider|test_com_security"
- name: Upload artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: Reclass-Windows-x64
path: |
build/Reclass.exe
build/ReclassMcpBridge.exe
build/Plugins/
build/*.dll
build/platforms/
build/styles/
build/imageformats/
build/iconengines/
build/themes/
build/screenshot.png
- name: Package release zip
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
shell: bash
run: |
mkdir -p release
cp build/Reclass.exe release/
cp build/ReclassMcpBridge.exe release/
cp build/*.dll release/ 2>/dev/null || true
cp -r build/platforms release/ 2>/dev/null || true
cp -r build/styles release/ 2>/dev/null || true
cp -r build/imageformats release/ 2>/dev/null || true
cp -r build/iconengines release/ 2>/dev/null || true
cp -r build/Plugins release/ 2>/dev/null || true
cp -r build/themes release/ 2>/dev/null || true
cp build/screenshot.png release/ 2>/dev/null || true
cd release && 7z a ../win64-reclass-latest.zip *
- name: Update win64 release
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: softprops/action-gh-release@v2
with:
tag_name: latest-win64
name: win64
body: |
Windows x64 build from main branch.
Commit: ${{ github.sha }}
prerelease: true
files: win64-reclass-latest.zip
make_latest: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Qt6
uses: jurplel/install-qt-action@v4
with:
version: '6.8.1'
cache: true
aqtversion: '==3.1.21'
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y ninja-build libgl1-mesa-dev
- name: Configure
run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
- name: Build
run: cmake --build build
- name: Test
run: xvfb-run ctest --test-dir build --output-on-failure --exclude-regex "test_editor"
env:
QT_QPA_PLATFORM: offscreen
- name: Upload artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: Reclass-Linux-x64
path: |
build/Reclass
build/ReclassMcpBridge
- name: Package release tarball
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
mkdir -p release
cp build/Reclass release/
cp build/ReclassMcpBridge release/
cp -r build/themes release/ 2>/dev/null || true
tar czf linux64-reclass-latest.tar.gz -C release .
- name: Update linux64 release
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: softprops/action-gh-release@v2
with:
tag_name: latest-linux64
name: linux64
body: |
Linux x64 build from main branch.
Commit: ${{ github.sha }}
prerelease: true
files: linux64-reclass-latest.tar.gz
make_latest: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

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/optionsdialog.h
src/optionsdialog.cpp
src/titlebar.h src/titlebar.h
src/titlebar.cpp src/titlebar.cpp
src/mcp/mcp_bridge.h src/mcp/mcp_bridge.h
@@ -95,12 +97,14 @@ endforeach()
include(deploy) include(deploy)
add_custom_target(screenshot ALL if(TARGET deploy)
COMMAND Reclass --screenshot ${CMAKE_BINARY_DIR}/screenshot.png add_custom_target(screenshot ALL
DEPENDS Reclass deploy COMMAND Reclass --screenshot ${CMAKE_BINARY_DIR}/screenshot.png
WORKING_DIRECTORY ${CMAKE_BINARY_DIR} DEPENDS Reclass deploy
COMMENT "Capturing UI screenshot with class open..." WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
) COMMENT "Capturing UI screenshot with class open..."
)
endif()
set(_combine_script "${CMAKE_BINARY_DIR}/combine_sources.cmake") set(_combine_script "${CMAKE_BINARY_DIR}/combine_sources.cmake")
file(WRITE ${_combine_script} " file(WRITE ${_combine_script} "
@@ -257,6 +261,23 @@ 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)
if(WIN32)
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 dbgeng ole32)
add_test(NAME test_windbg_provider COMMAND test_windbg_provider)
endif()
# 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)
@@ -270,4 +291,7 @@ if(BUILD_TESTING)
) )
endif() endif()
endif() endif()
add_subdirectory(plugins/ProcessMemory) if(WIN32)
add_subdirectory(plugins/ProcessMemory)
add_subdirectory(plugins/WinDbgMemory)
endif()

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: 63 KiB

View File

@@ -14,8 +14,9 @@ constexpr uint64_t kGoldenRatio = 0x9E3779B97F4A7C15ULL;
struct ComposeState { struct ComposeState {
QString text; QString text;
QVector<LineMeta> meta; QVector<LineMeta> meta;
QSet<uint64_t> visiting; // cycle detection for struct recursion QSet<uint64_t> visiting; // cycle detection for struct recursion
QSet<qulonglong> ptrVisiting; // cycle guard for pointer expansions QSet<qulonglong> ptrVisiting; // cycle guard for pointer expansions
QSet<uint64_t> virtualPtrRefs; // refIds currently being virtually expanded via pointer deref
int currentLine = 0; int currentLine = 0;
int typeW = kColType; // global type column width (fallback) int typeW = kColType; // global type column width (fallback)
int nameW = kColName; // global name column width (fallback) int nameW = kColName; // global name column width (fallback)
@@ -64,7 +65,6 @@ uint32_t computeMarkers(const Node& node, const Provider& /*prov*/,
uint64_t /*addr*/, bool isCont, int /*depth*/) { uint64_t /*addr*/, bool isCont, int /*depth*/) {
uint32_t mask = 0; uint32_t mask = 0;
if (isCont) mask |= (1u << M_CONT); if (isCont) mask |= (1u << M_CONT);
if (node.kind == NodeKind::Padding) mask |= (1u << M_PAD);
// No ambient validation markers — errors only shown during inline editing. // No ambient validation markers — errors only shown during inline editing.
return mask; return mask;
} }
@@ -118,14 +118,7 @@ void composeLeaf(ComposeState& state, const NodeTree& tree,
int typeW = state.effectiveTypeW(scopeId); int typeW = state.effectiveTypeW(scopeId);
int nameW = state.effectiveNameW(scopeId); int nameW = state.effectiveNameW(scopeId);
// Line count: padding wraps at 8 bytes per line int numLines = linesForKind(node.kind);
int numLines;
if (node.kind == NodeKind::Padding) {
int totalBytes = qMax(1, node.arrayLen);
numLines = (totalBytes + 7) / 8;
} else {
numLines = linesForKind(node.kind);
}
// Resolve pointer target name for display // Resolve pointer target name for display
QString ptrTypeOverride; QString ptrTypeOverride;
@@ -156,12 +149,7 @@ void composeLeaf(ComposeState& state, const NodeTree& tree,
// Set byte count for hex preview lines (used for per-byte change highlighting) // Set byte count for hex preview lines (used for per-byte change highlighting)
if (isHexPreview(node.kind)) { if (isHexPreview(node.kind)) {
if (node.kind == NodeKind::Padding) { lm.lineByteCount = sizeForKind(node.kind);
int totalSz = qMax(1, node.arrayLen);
lm.lineByteCount = qMin(8, totalSz - sub * 8);
} else {
lm.lineByteCount = sizeForKind(node.kind);
}
} }
QString lineText = fmt::fmtNodeLine(node, prov, absAddr, depth, sub, QString lineText = fmt::fmtNodeLine(node, prov, absAddr, depth, sub,
@@ -430,37 +418,55 @@ void composeNode(ComposeState& state, const NodeTree& tree,
QString ptrTargetName = resolvePointerTarget(tree, node.refId); QString ptrTargetName = resolvePointerTarget(tree, node.refId);
QString ptrTypeOverride = fmt::pointerTypeName(node.kind, ptrTargetName); QString ptrTypeOverride = fmt::pointerTypeName(node.kind, ptrTargetName);
// Check if this pointer has materialized children (from materializeRefChildren)
QVector<int> ptrChildren = state.childMap.value(node.id);
bool hasMaterialized = !ptrChildren.isEmpty();
// Force collapsed if this refId is already being virtually expanded
// (prevents infinite recursion in virtual expansion mode).
// Materialized children bypass this — they are real tree nodes with
// independent collapsed state, so recursion is bounded by the tree.
bool forceCollapsed = !hasMaterialized
&& state.virtualPtrRefs.contains(node.refId);
bool effectiveCollapsed = node.collapsed || forceCollapsed;
// Emit merged fold header: "Type* Name {" (expanded) or "Type* Name -> val" (collapsed) // Emit merged fold header: "Type* Name {" (expanded) or "Type* Name -> val" (collapsed)
{ {
LineMeta lm; LineMeta lm;
lm.nodeIdx = nodeIdx; lm.nodeIdx = nodeIdx;
lm.nodeId = node.id; lm.nodeId = node.id;
lm.depth = depth; lm.depth = depth;
lm.lineKind = node.collapsed ? LineKind::Field : LineKind::Header; lm.lineKind = effectiveCollapsed ? LineKind::Field : LineKind::Header;
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false, state.offsetHexDigits); lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false, state.offsetHexDigits);
lm.offsetAddr = tree.baseAddress + absAddr; lm.offsetAddr = tree.baseAddress + absAddr;
lm.nodeKind = node.kind; lm.nodeKind = node.kind;
lm.foldHead = true; lm.foldHead = true;
lm.foldCollapsed = node.collapsed; lm.foldCollapsed = effectiveCollapsed;
lm.foldLevel = computeFoldLevel(depth, true); lm.foldLevel = computeFoldLevel(depth, true);
lm.markerMask = computeMarkers(node, prov, absAddr, false, depth); lm.markerMask = computeMarkers(node, prov, absAddr, false, depth);
if (forceCollapsed) lm.markerMask |= (1u << M_CYCLE);
lm.effectiveTypeW = typeW; lm.effectiveTypeW = typeW;
lm.effectiveNameW = nameW; lm.effectiveNameW = nameW;
lm.pointerTargetName = ptrTargetName; lm.pointerTargetName = ptrTargetName;
state.emitLine(fmt::fmtPointerHeader(node, depth, node.collapsed, state.emitLine(fmt::fmtPointerHeader(node, depth, effectiveCollapsed,
prov, absAddr, ptrTypeOverride, prov, absAddr, ptrTypeOverride,
typeW, nameW), lm); typeW, nameW), lm);
} }
if (!node.collapsed) { if (!effectiveCollapsed) {
int sz = node.byteSize(); int sz = node.byteSize();
uint64_t ptrVal = 0; uint64_t ptrVal = 0;
if (prov.isValid() && sz > 0 && prov.isReadable(absAddr, sz)) { if (prov.isValid() && sz > 0 && prov.isReadable(absAddr, sz)) {
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
}
} }
} }
@@ -475,18 +481,42 @@ void composeNode(ComposeState& state, const NodeTree& tree,
if (!ptrReadable) if (!ptrReadable)
pBase = (uint64_t)0 - tree.baseAddress; pBase = (uint64_t)0 - tree.baseAddress;
qulonglong key = pBase ^ (node.refId * kGoldenRatio); if (hasMaterialized) {
if (!state.ptrVisiting.contains(key)) { // Render materialized children at the pointer target address.
state.ptrVisiting.insert(key); // These are real tree nodes with independent state — use rootId
int refIdx = tree.indexOfId(node.refId); // so resolveAddr computes offsets relative to the pointer target.
if (refIdx >= 0) { std::sort(ptrChildren.begin(), ptrChildren.end(), [&](int a, int b) {
const Node& ref = tree.nodes[refIdx]; return tree.nodes[a].offset < tree.nodes[b].offset;
if (ref.kind == NodeKind::Struct || ref.kind == NodeKind::Array) });
composeParent(state, tree, childProv, refIdx, for (int childIdx : ptrChildren) {
depth, pBase, ref.id, composeNode(state, tree, childProv, childIdx, depth + 1,
/*isArrayChild=*/true); pBase, node.id, false, node.id);
}
} else {
// Virtual expansion via ref struct definition.
// Temporarily remove the ref struct from visiting so composeParent
// doesn't hit the struct-level cycle guard. The ptrVisiting mechanism
// handles actual address-level pointer cycles, and virtualPtrRefs
// prevents infinite virtual recursion (inner self-referential pointers
// are force-collapsed with M_CYCLE for the user to materialize).
qulonglong key = pBase ^ (node.refId * kGoldenRatio);
if (!state.ptrVisiting.contains(key)) {
state.ptrVisiting.insert(key);
int refIdx = tree.indexOfId(node.refId);
if (refIdx >= 0) {
const Node& ref = tree.nodes[refIdx];
if (ref.kind == NodeKind::Struct || ref.kind == NodeKind::Array) {
bool wasVisiting = state.visiting.remove(node.refId);
state.virtualPtrRefs.insert(node.refId);
composeParent(state, tree, childProv, refIdx,
depth, pBase, ref.id,
/*isArrayChild=*/true);
state.virtualPtrRefs.remove(node.refId);
if (wasVisiting) state.visiting.insert(node.refId);
}
}
state.ptrVisiting.remove(key);
} }
state.ptrVisiting.remove(key);
} }
// Footer for pointer fold // Footer for pointer fold
@@ -566,7 +596,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
// Include struct/array names - they now use columnar layout too // Include struct/array names - they now use columnar layout too
int maxNameLen = kMinNameW; int maxNameLen = kMinNameW;
for (const Node& node : tree.nodes) { for (const Node& node : tree.nodes) {
// Skip hex/padding (they show ASCII preview, not name column) // Skip hex (they show ASCII preview, not name column)
if (isHexPreview(node.kind)) continue; if (isHexPreview(node.kind)) continue;
maxNameLen = qMax(maxNameLen, (int)node.name.size()); maxNameLen = qMax(maxNameLen, (int)node.name.size());
} }
@@ -585,7 +615,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
const Node& child = tree.nodes[childIdx]; const Node& child = tree.nodes[childIdx];
scopeMaxType = qMax(scopeMaxType, (int)nodeTypeName(child).size()); scopeMaxType = qMax(scopeMaxType, (int)nodeTypeName(child).size());
// Name width (skip hex/padding, but include containers) // Name width (skip hex, but include containers)
if (!isHexPreview(child.kind)) { if (!isHexPreview(child.kind)) {
scopeMaxName = qMax(scopeMaxName, (int)child.name.size()); scopeMaxName = qMax(scopeMaxName, (int)child.name.size());
} }
@@ -617,7 +647,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
const Node& child = tree.nodes[childIdx]; const Node& child = tree.nodes[childIdx];
rootMaxType = qMax(rootMaxType, (int)nodeTypeName(child).size()); rootMaxType = qMax(rootMaxType, (int)nodeTypeName(child).size());
// Name width (skip hex/padding, include containers) // Name width (skip hex, include containers)
if (!isHexPreview(child.kind)) { if (!isHexPreview(child.kind)) {
rootMaxName = qMax(rootMaxName, (int)child.name.size()); rootMaxName = qMax(rootMaxName, (int)child.name.size());
} }

View File

@@ -178,6 +178,14 @@ RcxEditor* RcxController::addSplitEditor(QWidget* parent) {
editor->applyDocument(m_lastResult); editor->applyDocument(m_lastResult);
} }
updateCommandRow(); updateCommandRow();
// Eagerly pre-warm the type popup so first click isn't slow (~350ms cold start).
if (!m_cachedPopup) {
QTimer::singleShot(0, this, [this, editor]() {
if (!m_cachedPopup && !m_editors.isEmpty())
ensurePopup(editor);
});
}
return editor; return editor;
} }
@@ -226,8 +234,9 @@ void RcxController::connectEditor(RcxEditor* editor) {
switch (target) { switch (target) {
case EditTarget::Name: { case EditTarget::Name: {
if (text.isEmpty()) break; if (text.isEmpty()) break;
if (nodeIdx >= m_doc->tree.nodes.size()) break;
const Node& node = m_doc->tree.nodes[nodeIdx]; const Node& node = m_doc->tree.nodes[nodeIdx];
// ASCII edit on Hex/Padding nodes // ASCII edit on Hex nodes
if (isHexPreview(node.kind)) { if (isHexPreview(node.kind)) {
setNodeValue(nodeIdx, subLine, text, /*isAscii=*/true); setNodeValue(nodeIdx, subLine, text, /*isAscii=*/true);
} else { } else {
@@ -293,6 +302,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 +359,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()) {
@@ -599,7 +611,7 @@ void RcxController::refresh() {
if (isHexPreview(node.kind)) { if (isHexPreview(node.kind)) {
// Per-byte tracking for hex preview nodes // Per-byte tracking for hex preview nodes
int lineOff = (node.kind == NodeKind::Padding) ? lm.subLine * 8 : 0; int lineOff = 0;
int byteCount = lm.lineByteCount; int byteCount = lm.lineByteCount;
for (int b = 0; b < byteCount; b++) { for (int b = 0; b < byteCount; b++) {
if (m_changedOffsets.contains(offset + lineOff + b)) { if (m_changedOffsets.contains(offset + lineOff + b)) {
@@ -796,38 +808,52 @@ void RcxController::materializeRefChildren(int nodeIdx) {
if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return; if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return;
auto& tree = m_doc->tree; auto& tree = m_doc->tree;
// Snapshot values before addNode invalidates references // Snapshot values before any mutation invalidates references
const uint64_t parentId = tree.nodes[nodeIdx].id; const uint64_t parentId = tree.nodes[nodeIdx].id;
const uint64_t refId = tree.nodes[nodeIdx].refId; const uint64_t refId = tree.nodes[nodeIdx].refId;
const NodeKind parentKind = tree.nodes[nodeIdx].kind; const NodeKind parentKind = tree.nodes[nodeIdx].kind;
const QString parentName = tree.nodes[nodeIdx].name; const QString parentName = tree.nodes[nodeIdx].name;
if (refId == 0) return; if (refId == 0) return;
if (!tree.childrenOf(parentId).isEmpty()) return; // already materialized if (!tree.childrenOf(parentId).isEmpty()) return; // already materialized
// Clone all children of the referenced struct as real children of this struct // Collect children to clone (copy by value to avoid reference invalidation)
QVector<int> refChildren = tree.childrenOf(refId); QVector<int> refChildren = tree.childrenOf(refId);
if (refChildren.isEmpty()) return;
QVector<Node> clones;
clones.reserve(refChildren.size());
for (int ci : refChildren) { for (int ci : refChildren) {
Node copy = tree.nodes[ci]; Node copy = tree.nodes[ci]; // copy by value before any mutation
copy.id = 0; // auto-assign new ID copy.id = tree.reserveId();
copy.parentId = parentId; copy.parentId = parentId;
copy.collapsed = true; // start collapsed copy.collapsed = true;
tree.addNode(copy); clones.append(copy);
}
// Wrap all mutations in an undo macro
bool wasSuppressed = m_suppressRefresh;
m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QStringLiteral("Materialize ref children"));
for (const Node& clone : clones) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::Insert{clone, {}}));
} }
tree.invalidateIdCache();
// Auto-expand the self-referential child (the one that was the cycle) // Auto-expand the self-referential child (the one that was the cycle)
// so the user gets expand in a single click // so the user gets expand in a single click
QVector<int> newChildren = tree.childrenOf(parentId); for (const Node& clone : clones) {
for (int ci : newChildren) { if (clone.kind == parentKind && clone.name == parentName && clone.refId == refId) {
auto& c = tree.nodes[ci]; m_doc->undoStack.push(new RcxCommand(this,
if (c.kind == parentKind && c.name == parentName && c.refId == refId) { cmd::Collapse{clone.id, true, false}));
c.collapsed = false;
break; break;
} }
} }
refresh(); m_doc->undoStack.endMacro();
m_suppressRefresh = wasSuppressed;
if (!m_suppressRefresh) refresh();
} }
void RcxController::applyCommand(const Command& command, bool isUndo) { void RcxController::applyCommand(const Command& command, bool isUndo) {
@@ -910,7 +936,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) {
@@ -1090,23 +1116,23 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
// Quick-convert suggestions for Hex nodes // Quick-convert suggestions for Hex nodes
bool addedQuickConvert = false; bool addedQuickConvert = false;
if (node.kind == NodeKind::Hex64) { if (node.kind == NodeKind::Hex64) {
menu.addAction(icon("symbol-numeric.svg"), "Change to uint64_t", [this, nodeId]() { menu.addAction("Change to uint64_t", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId); int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) changeNodeKind(ni, NodeKind::UInt64); if (ni >= 0) changeNodeKind(ni, NodeKind::UInt64);
}); });
menu.addAction(icon("symbol-numeric.svg"), "Change to uint32_t", [this, nodeId]() { menu.addAction("Change to uint32_t", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId); int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) changeNodeKind(ni, NodeKind::UInt32); if (ni >= 0) changeNodeKind(ni, NodeKind::UInt32);
}); });
addedQuickConvert = true; addedQuickConvert = true;
} else if (node.kind == NodeKind::Hex32) { } else if (node.kind == NodeKind::Hex32) {
menu.addAction(icon("symbol-numeric.svg"), "Change to uint32_t", [this, nodeId]() { menu.addAction("Change to uint32_t", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId); int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) changeNodeKind(ni, NodeKind::UInt32); if (ni >= 0) changeNodeKind(ni, NodeKind::UInt32);
}); });
addedQuickConvert = true; addedQuickConvert = true;
} else if (node.kind == NodeKind::Hex16) { } else if (node.kind == NodeKind::Hex16) {
menu.addAction(icon("symbol-numeric.svg"), "Change to int16_t", [this, nodeId]() { menu.addAction("Change to int16_t", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId); int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) changeNodeKind(ni, NodeKind::Int16); if (ni >= 0) changeNodeKind(ni, NodeKind::Int16);
}); });
@@ -1116,7 +1142,6 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
menu.addSeparator(); menu.addSeparator();
bool isEditable = node.kind != NodeKind::Struct && node.kind != NodeKind::Array bool isEditable = node.kind != NodeKind::Struct && node.kind != NodeKind::Array
&& node.kind != NodeKind::Padding
&& m_doc->provider->isWritable(); && m_doc->provider->isWritable();
if (isEditable) { if (isEditable) {
menu.addAction(icon("edit.svg"), "Edit &Value\tEnter", [editor, line]() { menu.addAction(icon("edit.svg"), "Edit &Value\tEnter", [editor, line]() {
@@ -1132,6 +1157,51 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
editor->beginInlineEdit(EditTarget::Type, line); editor->beginInlineEdit(EditTarget::Type, line);
}); });
// Convert to Hex nodes (decompose non-hex types into Hex64/32/16/8)
if (!isHexNode(node.kind) && node.kind != NodeKind::Struct && node.kind != NodeKind::Array) {
menu.addAction("Convert to &Hex", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni < 0) return;
const Node& n = m_doc->tree.nodes[ni];
int totalSize = n.byteSize();
if (totalSize <= 0) return;
uint64_t parentId = n.parentId;
int baseOffset = n.offset;
bool wasSuppressed = m_suppressRefresh;
m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QStringLiteral("Convert to Hex"));
// Remove the original node
QVector<Node> subtree;
subtree.append(n);
m_doc->undoStack.push(new RcxCommand(this,
cmd::Remove{nodeId, subtree, {}}));
// Insert hex nodes to fill the space (largest first)
int padOffset = baseOffset;
int gap = totalSize;
while (gap > 0) {
NodeKind padKind;
int padSize;
if (gap >= 8) { padKind = NodeKind::Hex64; padSize = 8; }
else if (gap >= 4) { padKind = NodeKind::Hex32; padSize = 4; }
else if (gap >= 2) { padKind = NodeKind::Hex16; padSize = 2; }
else { padKind = NodeKind::Hex8; padSize = 1; }
insertNode(parentId, padOffset, padKind,
QString("pad_%1").arg(padOffset, 2, 16, QChar('0')));
padOffset += padSize;
gap -= padSize;
}
m_doc->undoStack.endMacro();
m_suppressRefresh = wasSuppressed;
if (!m_suppressRefresh) refresh();
});
}
menu.addSeparator(); menu.addSeparator();
if (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) { if (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) {
@@ -1393,17 +1463,17 @@ void RcxController::performRealignment(uint64_t structId, int targetAlign) {
return tree.nodes[a].offset < tree.nodes[b].offset; return tree.nodes[a].offset < tree.nodes[b].offset;
}); });
// Separate into real nodes (non-Padding) and padding nodes // Separate into real nodes (non-hex) and hex filler nodes
struct NodeInfo { uint64_t id; int offset; int size; }; struct NodeInfo { uint64_t id; int offset; int size; };
QVector<NodeInfo> realNodes; QVector<NodeInfo> realNodes;
QVector<uint64_t> padIds; QVector<uint64_t> hexIds;
for (int ci : kids) { for (int ci : kids) {
const Node& child = tree.nodes[ci]; const Node& child = tree.nodes[ci];
int sz = (child.kind == NodeKind::Struct || child.kind == NodeKind::Array) int sz = (child.kind == NodeKind::Struct || child.kind == NodeKind::Array)
? tree.structSpan(child.id) : child.byteSize(); ? tree.structSpan(child.id) : child.byteSize();
if (child.kind == NodeKind::Padding) if (isHexNode(child.kind))
padIds.append(child.id); hexIds.append(child.id);
else else
realNodes.append({child.id, child.offset, sz}); realNodes.append({child.id, child.offset, sz});
} }
@@ -1436,7 +1506,7 @@ void RcxController::performRealignment(uint64_t structId, int targetAlign) {
} }
// Check if anything actually changes // Check if anything actually changes
if (offChanges.isEmpty() && padIds.isEmpty() && padsNeeded.isEmpty()) if (offChanges.isEmpty() && hexIds.isEmpty() && padsNeeded.isEmpty())
return; return;
// Apply as undoable macro // Apply as undoable macro
@@ -1444,14 +1514,14 @@ void RcxController::performRealignment(uint64_t structId, int targetAlign) {
m_suppressRefresh = true; m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QStringLiteral("Realign to %1").arg(targetAlign)); m_doc->undoStack.beginMacro(QStringLiteral("Realign to %1").arg(targetAlign));
// 1. Remove all existing Padding nodes (no offset adjustments — we recompute) // 1. Remove all existing hex filler nodes (no offset adjustments — we recompute)
for (uint64_t pid : padIds) { for (uint64_t hid : hexIds) {
int idx = tree.indexOfId(pid); int idx = tree.indexOfId(hid);
if (idx < 0) continue; if (idx < 0) continue;
QVector<Node> subtree; QVector<Node> subtree;
subtree.append(tree.nodes[idx]); subtree.append(tree.nodes[idx]);
m_doc->undoStack.push(new RcxCommand(this, m_doc->undoStack.push(new RcxCommand(this,
cmd::Remove{pid, subtree, {}})); cmd::Remove{hid, subtree, {}}));
} }
// 2. Reposition real nodes // 2. Reposition real nodes
@@ -1460,15 +1530,28 @@ void RcxController::performRealignment(uint64_t structId, int targetAlign) {
cmd::ChangeOffset{oc.id, oc.oldOff, oc.newOff})); cmd::ChangeOffset{oc.id, oc.oldOff, oc.newOff}));
} }
// 3. Insert new padding in gaps // 3. Insert hex nodes to fill gaps (largest first for alignment)
for (const auto& pi : padsNeeded) { for (const auto& pi : padsNeeded) {
Node pad; int padOffset = pi.offset;
pad.kind = NodeKind::Padding; int gap = pi.size;
pad.parentId = structId; while (gap > 0) {
pad.offset = pi.offset; NodeKind padKind;
pad.arrayLen = pi.size; int padSize;
pad.id = tree.reserveId(); if (gap >= 8) { padKind = NodeKind::Hex64; padSize = 8; }
m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{pad})); else if (gap >= 4) { padKind = NodeKind::Hex32; padSize = 4; }
else if (gap >= 2) { padKind = NodeKind::Hex16; padSize = 2; }
else { padKind = NodeKind::Hex8; padSize = 1; }
Node pad;
pad.kind = padKind;
pad.parentId = structId;
pad.offset = padOffset;
pad.name = QString("pad_%1").arg(padOffset, 2, 16, QChar('0'));
pad.id = tree.reserveId();
m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{pad}));
padOffset += padSize;
gap -= padSize;
}
} }
m_doc->undoStack.endMacro(); m_doc->undoStack.endMacro();
@@ -1572,7 +1655,6 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
auto addPrimitives = [&](bool enabled, bool excludeStructArrayPad) { auto addPrimitives = [&](bool enabled, bool excludeStructArrayPad) {
for (const auto& m : kKindMeta) { for (const auto& m : kKindMeta) {
if (m.kind == NodeKind::Padding) continue;
if (excludeStructArrayPad && if (excludeStructArrayPad &&
(m.kind == NodeKind::Struct || m.kind == NodeKind::Array)) (m.kind == NodeKind::Struct || m.kind == NodeKind::Array))
continue; continue;
@@ -1707,6 +1789,10 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
}); });
connect(popup, &TypeSelectorPopup::createNewTypeRequested, connect(popup, &TypeSelectorPopup::createNewTypeRequested,
this, [this, mode, nodeIdx]() { this, [this, mode, nodeIdx]() {
bool wasSuppressed = m_suppressRefresh;
m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QStringLiteral("Create new type"));
Node n; Node n;
n.kind = NodeKind::Struct; n.kind = NodeKind::Struct;
n.name = QString(); n.name = QString();
@@ -1714,6 +1800,16 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
n.offset = 0; n.offset = 0;
n.id = m_doc->tree.reserveId(); n.id = m_doc->tree.reserveId();
m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{n})); m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{n}));
// Populate with default hex nodes (8 x Hex64 = 64 bytes)
for (int i = 0; i < 8; i++) {
insertNode(n.id, i * 8, NodeKind::Hex64,
QString("field_%1").arg(i * 8, 2, 16, QChar('0')));
}
m_doc->undoStack.endMacro();
m_suppressRefresh = wasSuppressed;
TypeEntry newEntry; TypeEntry newEntry;
newEntry.entryKind = TypeEntry::Composite; newEntry.entryKind = TypeEntry::Composite;
newEntry.structId = n.id; newEntry.structId = n.id;
@@ -1732,14 +1828,22 @@ void RcxController::applyTypePopupResult(TypePopupMode mode, int nodeIdx,
} }
if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return; if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return;
const Node& node = m_doc->tree.nodes[nodeIdx];
// BUG-1 fix: Copy needed fields to locals before any mutation.
// changeNodeKind() can trigger insertNode() → addNode() → nodes.append(),
// which may reallocate the QVector, invalidating any reference into it.
const uint64_t nodeId = m_doc->tree.nodes[nodeIdx].id;
const NodeKind nodeKind = m_doc->tree.nodes[nodeIdx].kind;
const NodeKind elemKind = m_doc->tree.nodes[nodeIdx].elementKind;
const uint64_t nodeRefId = m_doc->tree.nodes[nodeIdx].refId;
const int arrLen = m_doc->tree.nodes[nodeIdx].arrayLen;
// Parse the full text for modifiers (e.g. "int32_t[10]", "Ball*") // Parse the full text for modifiers (e.g. "int32_t[10]", "Ball*")
TypeSpec spec = parseTypeSpec(fullText); TypeSpec spec = parseTypeSpec(fullText);
if (mode == TypePopupMode::FieldType) { if (mode == TypePopupMode::FieldType) {
if (entry.entryKind == TypeEntry::Primitive) { if (entry.entryKind == TypeEntry::Primitive) {
if (entry.primitiveKind != node.kind) if (entry.primitiveKind != nodeKind)
changeNodeKind(nodeIdx, entry.primitiveKind); changeNodeKind(nodeIdx, entry.primitiveKind);
} else if (entry.entryKind == TypeEntry::Composite) { } else if (entry.entryKind == TypeEntry::Composite) {
bool wasSuppressed = m_suppressRefresh; bool wasSuppressed = m_suppressRefresh;
@@ -1748,34 +1852,34 @@ void RcxController::applyTypePopupResult(TypePopupMode mode, int nodeIdx,
if (spec.isPointer) { if (spec.isPointer) {
// Pointer modifier: e.g. "Material*" → Pointer64 + refId // Pointer modifier: e.g. "Material*" → Pointer64 + refId
if (node.kind != NodeKind::Pointer64) if (nodeKind != NodeKind::Pointer64)
changeNodeKind(nodeIdx, NodeKind::Pointer64); changeNodeKind(nodeIdx, NodeKind::Pointer64);
int idx = m_doc->tree.indexOfId(node.id); int idx = m_doc->tree.indexOfId(nodeId);
if (idx >= 0 && m_doc->tree.nodes[idx].refId != entry.structId) if (idx >= 0 && m_doc->tree.nodes[idx].refId != entry.structId)
m_doc->undoStack.push(new RcxCommand(this, m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangePointerRef{node.id, m_doc->tree.nodes[idx].refId, entry.structId})); cmd::ChangePointerRef{nodeId, m_doc->tree.nodes[idx].refId, entry.structId}));
} else if (spec.arrayCount > 0) { } else if (spec.arrayCount > 0) {
// Array modifier: e.g. "Material[10]" → Array + Struct element // Array modifier: e.g. "Material[10]" → Array + Struct element
if (node.kind != NodeKind::Array) if (nodeKind != NodeKind::Array)
changeNodeKind(nodeIdx, NodeKind::Array); changeNodeKind(nodeIdx, NodeKind::Array);
int idx = m_doc->tree.indexOfId(node.id); int idx = m_doc->tree.indexOfId(nodeId);
if (idx >= 0) { if (idx >= 0) {
auto& n = m_doc->tree.nodes[idx]; auto& n = m_doc->tree.nodes[idx];
if (n.elementKind != NodeKind::Struct || n.arrayLen != spec.arrayCount) if (n.elementKind != NodeKind::Struct || n.arrayLen != spec.arrayCount)
m_doc->undoStack.push(new RcxCommand(this, m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeArrayMeta{node.id, n.elementKind, NodeKind::Struct, cmd::ChangeArrayMeta{nodeId, n.elementKind, NodeKind::Struct,
n.arrayLen, spec.arrayCount})); n.arrayLen, spec.arrayCount}));
if (n.refId != entry.structId) if (n.refId != entry.structId)
m_doc->undoStack.push(new RcxCommand(this, m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangePointerRef{node.id, n.refId, entry.structId})); cmd::ChangePointerRef{nodeId, n.refId, entry.structId}));
} }
} else { } else {
// Plain struct: e.g. "Material" → Struct + structTypeName + refId + collapsed // Plain struct: e.g. "Material" → Struct + structTypeName + refId + collapsed
if (node.kind != NodeKind::Struct) if (nodeKind != NodeKind::Struct)
changeNodeKind(nodeIdx, NodeKind::Struct); changeNodeKind(nodeIdx, NodeKind::Struct);
int idx = m_doc->tree.indexOfId(node.id); int idx = m_doc->tree.indexOfId(nodeId);
if (idx >= 0) { if (idx >= 0) {
int refIdx = m_doc->tree.indexOfId(entry.structId); int refIdx = m_doc->tree.indexOfId(entry.structId);
QString targetName; QString targetName;
@@ -1786,11 +1890,11 @@ void RcxController::applyTypePopupResult(TypePopupMode mode, int nodeIdx,
QString oldTypeName = m_doc->tree.nodes[idx].structTypeName; QString oldTypeName = m_doc->tree.nodes[idx].structTypeName;
if (oldTypeName != targetName) if (oldTypeName != targetName)
m_doc->undoStack.push(new RcxCommand(this, m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeStructTypeName{node.id, oldTypeName, targetName})); cmd::ChangeStructTypeName{nodeId, oldTypeName, targetName}));
// Set refId so compose can expand the referenced struct's children // Set refId so compose can expand the referenced struct's children
if (m_doc->tree.nodes[idx].refId != entry.structId) if (m_doc->tree.nodes[idx].refId != entry.structId)
m_doc->undoStack.push(new RcxCommand(this, m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangePointerRef{node.id, m_doc->tree.nodes[idx].refId, entry.structId})); cmd::ChangePointerRef{nodeId, m_doc->tree.nodes[idx].refId, entry.structId}));
// ChangePointerRef auto-sets collapsed=true when refId != 0 // ChangePointerRef auto-sets collapsed=true when refId != 0
} }
} }
@@ -1801,33 +1905,32 @@ void RcxController::applyTypePopupResult(TypePopupMode mode, int nodeIdx,
} }
} else if (mode == TypePopupMode::ArrayElement) { } else if (mode == TypePopupMode::ArrayElement) {
if (entry.entryKind == TypeEntry::Primitive) { if (entry.entryKind == TypeEntry::Primitive) {
if (entry.primitiveKind != node.elementKind) { if (entry.primitiveKind != elemKind) {
m_doc->undoStack.push(new RcxCommand(this, m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeArrayMeta{node.id, cmd::ChangeArrayMeta{nodeId,
node.elementKind, entry.primitiveKind, elemKind, entry.primitiveKind,
node.arrayLen, node.arrayLen})); arrLen, arrLen}));
} }
} else if (entry.entryKind == TypeEntry::Composite) { } else if (entry.entryKind == TypeEntry::Composite) {
if (node.elementKind != NodeKind::Struct || node.refId != entry.structId) { if (elemKind != NodeKind::Struct || nodeRefId != entry.structId) {
m_doc->undoStack.push(new RcxCommand(this, m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeArrayMeta{node.id, cmd::ChangeArrayMeta{nodeId,
node.elementKind, NodeKind::Struct, elemKind, NodeKind::Struct,
node.arrayLen, node.arrayLen})); arrLen, arrLen}));
if (node.refId != entry.structId) { if (nodeRefId != entry.structId) {
m_doc->undoStack.push(new RcxCommand(this, m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangePointerRef{node.id, node.refId, entry.structId})); cmd::ChangePointerRef{nodeId, nodeRefId, entry.structId}));
} }
} }
} }
} else if (mode == TypePopupMode::PointerTarget) { } else if (mode == TypePopupMode::PointerTarget) {
// "void" entry → refId 0; composite entry → real structId // "void" entry → refId 0; composite entry → real structId
uint64_t realRefId = (entry.entryKind == TypeEntry::Composite) ? entry.structId : 0; uint64_t realRefId = (entry.entryKind == TypeEntry::Composite) ? entry.structId : 0;
if (realRefId != node.refId) { if (realRefId != nodeRefId) {
m_doc->undoStack.push(new RcxCommand(this, m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangePointerRef{node.id, node.refId, realRefId})); cmd::ChangePointerRef{nodeId, nodeRefId, realRefId}));
} }
} }
refresh();
} }
void RcxController::attachViaPlugin(const QString& providerIdentifier, const QString& target) { void RcxController::attachViaPlugin(const QString& providerIdentifier, const QString& target) {
@@ -1896,15 +1999,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<QPair<uint64_t,uint64_t>>& visited,
QVector<QPair<uint64_t,int>>& ranges) const
{
if (depth >= maxDepth) return;
QPair<uint64_t,uint64_t> key{structId, memBase};
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;
@@ -1915,75 +2069,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<QPair<uint64_t,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, 99, 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;
@@ -1993,7 +2192,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();
} }

View File

@@ -7,6 +7,7 @@
#include <QUndoCommand> #include <QUndoCommand>
#include <QTimer> #include <QTimer>
#include <QFutureWatcher> #include <QFutureWatcher>
#include <QPointer>
#include <memory> #include <memory>
namespace rcx { namespace rcx {
@@ -138,13 +139,14 @@ private:
int m_activeSourceIdx = -1; int m_activeSourceIdx = -1;
// ── Cached type selector popup (avoids ~350ms cold-start on first show) ── // ── Cached type selector popup (avoids ~350ms cold-start on first show) ──
TypeSelectorPopup* m_cachedPopup = nullptr; QPointer<TypeSelectorPopup> m_cachedPopup;
// ── 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;
@@ -166,6 +168,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<QPair<uint64_t,uint64_t>>& visited,
QVector<QPair<uint64_t,int>>& ranges) const;
}; };
} // namespace rcx } // namespace rcx

View File

@@ -27,7 +27,6 @@ enum class NodeKind : uint8_t {
Pointer32, Pointer64, Pointer32, Pointer64,
Vec2, Vec3, Vec4, Mat4x4, Vec2, Vec3, Vec4, Mat4x4,
UTF8, UTF16, UTF8, UTF16,
Padding,
Struct, Array Struct, Array
}; };
@@ -37,11 +36,11 @@ inline uint qHash(rcx::NodeKind key, uint seed = 0) { return ::qHash(static_cast
#endif #endif
namespace rcx { // reopen namespace rcx { // reopen
// ── Kind flags (replaces repeated Hex/Padding switches) ── // ── Kind flags (replaces repeated Hex switches) ──
enum KindFlags : uint32_t { enum KindFlags : uint32_t {
KF_None = 0, KF_None = 0,
KF_HexPreview = 1 << 0, // Hex8..Hex64 + Padding (ASCII+hex layout) KF_HexPreview = 1 << 0, // Hex8..Hex64 (ASCII+hex layout)
KF_Container = 1 << 1, // Struct/Array KF_Container = 1 << 1, // Struct/Array
KF_String = 1 << 2, // UTF8/UTF16 KF_String = 1 << 2, // UTF8/UTF16
KF_Vector = 1 << 3, // Vec2/3/4 KF_Vector = 1 << 3, // Vec2/3/4
@@ -84,7 +83,6 @@ inline constexpr KindMeta kKindMeta[] = {
{NodeKind::Mat4x4, "Mat4x4", "mat4x4", 64, 4, 4, KF_None}, {NodeKind::Mat4x4, "Mat4x4", "mat4x4", 64, 4, 4, KF_None},
{NodeKind::UTF8, "UTF8", "char[]", 1, 1, 1, KF_String}, {NodeKind::UTF8, "UTF8", "char[]", 1, 1, 1, KF_String},
{NodeKind::UTF16, "UTF16", "wchar_t[]", 2, 1, 2, KF_String}, {NodeKind::UTF16, "UTF16", "wchar_t[]", 2, 1, 2, KF_String},
{NodeKind::Padding, "Padding", "pad", 1, 1, 1, KF_HexPreview},
{NodeKind::Struct, "Struct", "struct", 0, 1, 1, KF_Container}, {NodeKind::Struct, "Struct", "struct", 0, 1, 1, KF_Container},
{NodeKind::Array, "Array", "array", 0, 1, 1, KF_Container}, {NodeKind::Array, "Array", "array", 0, 1, 1, KF_Container},
}; };
@@ -155,7 +153,6 @@ inline QStringList allTypeNamesForUI(bool stripBrackets = false) {
enum Marker : int { enum Marker : int {
M_CONT = 0, M_CONT = 0,
M_PAD = 1,
M_PTR0 = 2, M_PTR0 = 2,
M_CYCLE = 3, M_CYCLE = 3,
M_ERR = 4, M_ERR = 4,
@@ -187,9 +184,12 @@ struct Node {
int byteSize() const { int byteSize() const {
switch (kind) { switch (kind) {
case NodeKind::UTF8: return strLen; case NodeKind::UTF8: return strLen;
case NodeKind::UTF16: return strLen * 2; case NodeKind::UTF16: return qMin(strLen, INT_MAX / 2) * 2;
case NodeKind::Padding: return qMax(1, arrayLen); case NodeKind::Array: {
case NodeKind::Array: return arrayLen * sizeForKind(elementKind); int elemSz = sizeForKind(elementKind);
if (elemSz <= 0) return 0;
return qMin(arrayLen, INT_MAX / elemSz) * elemSz;
}
default: return sizeForKind(kind); default: return sizeForKind(kind);
} }
} }
@@ -221,8 +221,8 @@ struct Node {
n.classKeyword = o["classKeyword"].toString(); n.classKeyword = o["classKeyword"].toString();
n.parentId = o["parentId"].toString("0").toULongLong(); n.parentId = o["parentId"].toString("0").toULongLong();
n.offset = o["offset"].toInt(0); n.offset = o["offset"].toInt(0);
n.arrayLen = o["arrayLen"].toInt(1); n.arrayLen = qBound(1, o["arrayLen"].toInt(1), 1000000);
n.strLen = o["strLen"].toInt(64); n.strLen = qBound(1, o["strLen"].toInt(64), 1000000);
n.collapsed = o["collapsed"].toBool(false); n.collapsed = o["collapsed"].toBool(false);
n.refId = o["refId"].toString("0").toULongLong(); n.refId = o["refId"].toString("0").toULongLong();
n.elementKind = kindFromString(o["elementKind"].toString("UInt8")); n.elementKind = kindFromString(o["elementKind"].toString("UInt8"));
@@ -535,7 +535,7 @@ inline ColumnSpan nameSpanFor(const LineMeta& lm, int typeW = kColType, int name
int ind = kFoldCol + lm.depth * 3; int ind = kFoldCol + lm.depth * 3;
int start = ind + typeW + kSepWidth; int start = ind + typeW + kSepWidth;
// Hex/Padding: ASCII preview occupies the name column (padded to nameW) // Hex: ASCII preview occupies the name column (padded to nameW)
if (isHexPreview(lm.nodeKind)) if (isHexPreview(lm.nodeKind))
return {start, start + nameW, true}; return {start, start + nameW, true};
@@ -547,9 +547,9 @@ inline ColumnSpan valueSpanFor(const LineMeta& lm, int /*lineLength*/, int typeW
lm.lineKind == LineKind::ArrayElementSeparator) return {}; lm.lineKind == LineKind::ArrayElementSeparator) return {};
int ind = kFoldCol + lm.depth * 3; int ind = kFoldCol + lm.depth * 3;
// Hex/Padding uses nameW for ASCII column (same as regular name column) // Hex uses nameW for ASCII column (same as regular name column)
bool isHexPad = isHexPreview(lm.nodeKind); bool isHex = isHexPreview(lm.nodeKind);
int valWidth = isHexPad ? 23 : kColValue; int valWidth = isHex ? 23 : kColValue;
int prefixW = typeW + nameW + 2 * kSepWidth; int prefixW = typeW + nameW + 2 * kSepWidth;
@@ -567,8 +567,8 @@ inline ColumnSpan commentSpanFor(const LineMeta& lm, int lineLength, int typeW =
if (lm.lineKind == LineKind::Header || lm.lineKind == LineKind::Footer) return {}; if (lm.lineKind == LineKind::Header || lm.lineKind == LineKind::Footer) return {};
int ind = kFoldCol + lm.depth * 3; int ind = kFoldCol + lm.depth * 3;
bool isHexPad = isHexPreview(lm.nodeKind); bool isHex = isHexPreview(lm.nodeKind);
int valWidth = isHexPad ? 23 : kColValue; int valWidth = isHex ? 23 : kColValue;
int prefixW = typeW + nameW + 2 * kSepWidth; int prefixW = typeW + nameW + 2 * kSepWidth;
int start; int start;

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 {
@@ -140,7 +141,7 @@ void RcxEditor::setupScintilla() {
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE, m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_EDITABLE, 5 /*INDIC_HIDDEN*/); IND_EDITABLE, 5 /*INDIC_HIDDEN*/);
// Hex/Padding node dim indicator — overrides text color // Hex node dim indicator — overrides text color
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE, m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_HEX_DIM, 17 /*INDIC_TEXTFORE*/); IND_HEX_DIM, 17 /*INDIC_TEXTFORE*/);
@@ -240,9 +241,6 @@ void RcxEditor::setupMarkers() {
// M_CONT (0): continuation line (metadata only, no visual) // M_CONT (0): continuation line (metadata only, no visual)
m_sci->markerDefine(QsciScintilla::Invisible, M_CONT); m_sci->markerDefine(QsciScintilla::Invisible, M_CONT);
// M_PAD (1): padding line (metadata only, no visual)
m_sci->markerDefine(QsciScintilla::Invisible, M_PAD);
// M_PTR0 (2): right triangle // M_PTR0 (2): right triangle
m_sci->markerDefine(QsciScintilla::RightTriangle, M_PTR0); m_sci->markerDefine(QsciScintilla::RightTriangle, M_PTR0);
@@ -1037,9 +1035,6 @@ bool RcxEditor::resolvedSpanFor(int line, EditTarget t,
if (lm->nodeIdx < 0) return false; if (lm->nodeIdx < 0) return false;
// Padding: reject value editing (hex bytes are display-only)
if (t == EditTarget::Value && lm->nodeKind == NodeKind::Padding)
return false;
// Hex nodes: only Type is editable (ASCII preview + hex bytes are display-only) // Hex nodes: only Type is editable (ASCII preview + hex bytes are display-only)
if ((t == EditTarget::Name || t == EditTarget::Value) && isHexNode(lm->nodeKind)) if ((t == EditTarget::Name || t == EditTarget::Value) && isHexNode(lm->nodeKind))
return false; return false;
@@ -1220,9 +1215,6 @@ static bool hitTestTarget(QsciScintilla* sci,
} }
return false; return false;
} }
// Padding nodes: hex bytes are display-only, not editable
if (outTarget == EditTarget::Value && lm.nodeKind == NodeKind::Padding)
return false;
// Hex nodes: only Type is editable (ASCII preview + hex bytes are display-only) // Hex nodes: only Type is editable (ASCII preview + hex bytes are display-only)
if ((outTarget == EditTarget::Name || outTarget == EditTarget::Value) && isHexNode(lm.nodeKind)) if ((outTarget == EditTarget::Name || outTarget == EditTarget::Value) && isHexNode(lm.nodeKind))
return false; return false;
@@ -1329,7 +1321,15 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
// Single-click on editable token of already-selected node → edit // Single-click on editable token of already-selected node → edit
int tLine, tCol; EditTarget t; int tLine, tCol; EditTarget t;
if (hitTestTarget(m_sci, m_meta, me->pos(), tLine, tCol, t)) { if (hitTestTarget(m_sci, m_meta, me->pos(), tLine, tCol, t)) {
if (alreadySelected && plain) { // Type/ArrayElementType/PointerTarget open a dismissible popup
// (not inline text edit), so allow on first click without
// requiring the node to be pre-selected.
bool isPopupTarget = (t == EditTarget::Type
|| t == EditTarget::ArrayElementType
|| t == EditTarget::PointerTarget);
if ((alreadySelected || isPopupTarget) && plain) {
if (!alreadySelected)
emit nodeClicked(h.line, h.nodeId, me->modifiers());
m_pendingClickNodeId = 0; m_pendingClickNodeId = 0;
return beginInlineEdit(t, tLine, tCol); return beginInlineEdit(t, tLine, tCol);
} }
@@ -1603,6 +1603,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;
} }
@@ -1656,9 +1672,6 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
(target == EditTarget::BaseAddress || target == EditTarget::Source (target == EditTarget::BaseAddress || target == EditTarget::Source
|| target == EditTarget::RootClassType || target == EditTarget::RootClassName))) || target == EditTarget::RootClassType || target == EditTarget::RootClassName)))
return false; return false;
// Padding: reject value editing (display-only hex bytes)
if (target == EditTarget::Value && lm->nodeKind == NodeKind::Padding)
return false;
// Hex nodes: only Type is editable (ASCII preview + hex bytes are display-only) // Hex nodes: only Type is editable (ASCII preview + hex bytes are display-only)
if ((target == EditTarget::Name || target == EditTarget::Value) && isHexNode(lm->nodeKind)) if ((target == EditTarget::Name || target == EditTarget::Value) && isHexNode(lm->nodeKind))
return false; return false;
@@ -1961,7 +1974,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();

View File

@@ -293,7 +293,6 @@ static QString readValueImpl(const Node& node, const Provider& prov,
line += QStringLiteral("]"); line += QStringLiteral("]");
return line; return line;
} }
case NodeKind::Padding: return display ? hexVal(prov.readU8(addr)) : rawHex(prov.readU8(addr), 2);
case NodeKind::UTF8: { case NodeKind::UTF8: {
QByteArray bytes = prov.readBytes(addr, node.strLen); QByteArray bytes = prov.readBytes(addr, node.strLen);
int end = bytes.indexOf('\0'); int end = bytes.indexOf('\0');
@@ -344,21 +343,8 @@ QString fmtNodeLine(const Node& node, const Provider& prov,
return ind + QString(prefixW, ' ') + val + cmtSuffix; return ind + QString(prefixW, ' ') + val + cmtSuffix;
} }
// Hex nodes and Padding: hex byte preview (ASCII padded to colName to align with value column) // Hex nodes: hex byte preview (ASCII padded to colName to align with value column)
if (isHexPreview(node.kind)) { if (isHexPreview(node.kind)) {
if (node.kind == NodeKind::Padding) {
const int totalSz = qMax(1, node.arrayLen);
const int lineOff = subLine * 8;
const int lineBytes = qMin(8, totalSz - lineOff);
QByteArray b = prov.isReadable(addr + lineOff, lineBytes)
? prov.readBytes(addr + lineOff, lineBytes) : QByteArray(lineBytes, '\0');
QString ascii = bytesToAscii(b, lineBytes).leftJustified(colName, ' ');
QString hex = bytesToHex(b, lineBytes).leftJustified(23, ' '); // 8*3-1
if (subLine == 0)
return ind + type + SEP + ascii + SEP + hex + cmtSuffix;
return ind + QString(colType + (int)SEP.size(), ' ') + ascii + SEP + hex + cmtSuffix;
}
// Hex8..Hex64: single line, ASCII padded to colName so hex column aligns with value column
const int sz = sizeForKind(node.kind); const int sz = sizeForKind(node.kind);
QByteArray b = prov.isReadable(addr, sz) QByteArray b = prov.isReadable(addr, sz)
? prov.readBytes(addr, sz) : QByteArray(sz, '\0'); ? prov.readBytes(addr, sz) : QByteArray(sz, '\0');

View File

@@ -50,7 +50,6 @@ static QString cTypeName(NodeKind kind) {
case NodeKind::Mat4x4: return QStringLiteral("float"); case NodeKind::Mat4x4: return QStringLiteral("float");
case NodeKind::UTF8: return QStringLiteral("char"); case NodeKind::UTF8: return QStringLiteral("char");
case NodeKind::UTF16: return QStringLiteral("wchar_t"); case NodeKind::UTF16: return QStringLiteral("wchar_t");
case NodeKind::Padding: return QStringLiteral("uint8_t");
default: return QStringLiteral("uint8_t"); default: return QStringLiteral("uint8_t");
} }
} }
@@ -123,8 +122,6 @@ static QString emitField(GenContext& ctx, const Node& node) {
return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::UTF8), name).arg(node.strLen) + oc; return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::UTF8), name).arg(node.strLen) + oc;
case NodeKind::UTF16: case NodeKind::UTF16:
return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::UTF16), name).arg(node.strLen) + oc; return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::UTF16), name).arg(node.strLen) + oc;
case NodeKind::Padding:
return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::Padding), name).arg(qMax(1, node.arrayLen)) + oc;
case NodeKind::Pointer32: { case NodeKind::Pointer32: {
if (node.refId != 0) { if (node.refId != 0) {
int refIdx = tree.indexOfId(node.refId); int refIdx = tree.indexOfId(node.refId);
@@ -169,7 +166,7 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) {
auto emitPadRun = [&](int offset, int size) { auto emitPadRun = [&](int offset, int size) {
if (size <= 0) return; if (size <= 0) return;
ctx.output += QStringLiteral(" %1 %2[0x%3];%4\n") ctx.output += QStringLiteral(" %1 %2[0x%3];%4\n")
.arg(ctx.cType(NodeKind::Padding)) .arg(QStringLiteral("uint8_t"))
.arg(ctx.uniquePadName()) .arg(ctx.uniquePadName())
.arg(QString::number(size, 16).toUpper()) .arg(QString::number(size, 16).toUpper())
.arg(offsetComment(offset)); .arg(offsetComment(offset));

View File

@@ -43,6 +43,7 @@
#include <QDesktopServices> #include <QDesktopServices>
#include "themes/thememanager.h" #include "themes/thememanager.h"
#include "themes/themeeditor.h" #include "themes/themeeditor.h"
#include "optionsdialog.h"
#ifdef _WIN32 #ifdef _WIN32
#include <windows.h> #include <windows.h>
@@ -141,7 +142,9 @@ public:
if ((w->windowFlags() & Qt::Window) == Qt::Window if ((w->windowFlags() & Qt::Window) == Qt::Window
&& !w->property("DarkTitleBar").toBool()) { && !w->property("DarkTitleBar").toBool()) {
w->setProperty("DarkTitleBar", true); w->setProperty("DarkTitleBar", true);
#ifdef _WIN32
setDarkTitleBar(w); setDarkTitleBar(w);
#endif
} }
} }
return QApplication::notify(receiver, event); return QApplication::notify(receiver, event);
@@ -160,6 +163,13 @@ public:
s = QSize(s.width() + 24, s.height() + 4); s = QSize(s.width() + 24, s.height() + 4);
return s; return s;
} }
int pixelMetric(PixelMetric metric, const QStyleOption* opt,
const QWidget* w) const override {
// Kill the 1px frame margin Fusion reserves around QMenu contents
if (metric == PM_MenuPanelWidth)
return 0;
return QProxyStyle::pixelMetric(metric, opt, w);
}
void drawPrimitive(PrimitiveElement elem, const QStyleOption* opt, void drawPrimitive(PrimitiveElement elem, const QStyleOption* opt,
QPainter* p, const QWidget* w) const override { QPainter* p, const QWidget* w) const override {
// Kill Fusion's 3D bevel on QMenu — the OS drop shadow is enough // Kill Fusion's 3D bevel on QMenu — the OS drop shadow is enough
@@ -205,7 +215,7 @@ static void applyGlobalTheme(const rcx::Theme& theme) {
QPalette pal; QPalette pal;
pal.setColor(QPalette::Window, theme.background); pal.setColor(QPalette::Window, theme.background);
pal.setColor(QPalette::WindowText, theme.text); pal.setColor(QPalette::WindowText, theme.text);
pal.setColor(QPalette::Base, theme.backgroundAlt); pal.setColor(QPalette::Base, theme.background);
pal.setColor(QPalette::AlternateBase, theme.surface); pal.setColor(QPalette::AlternateBase, theme.surface);
pal.setColor(QPalette::Text, theme.text); pal.setColor(QPalette::Text, theme.text);
pal.setColor(QPalette::Button, theme.button); pal.setColor(QPalette::Button, theme.button);
@@ -301,6 +311,11 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
createMenus(); createMenus();
createStatusBar(); createStatusBar();
// Restore menu bar title case setting (after menus are created)
{
bool titleCase = QSettings("Reclass", "Reclass").value("menuBarTitleCase", true).toBool();
m_titleBar->setMenuBarTitleCase(titleCase);
}
// MenuBarStyle is set as app style in main() — covers both QMenuBar and QMenu // MenuBarStyle is set as app style in main() — covers both QMenuBar and QMenu
@@ -310,9 +325,10 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
// Load plugins // Load plugins
m_pluginManager.LoadPlugins(); m_pluginManager.LoadPlugins();
// MCP bridge (on by default) // Start MCP bridge
m_mcp = new McpBridge(this, this); m_mcp = new McpBridge(this, this);
m_mcp->start(); if (QSettings("Reclass", "Reclass").value("autoStartMcp", false).toBool())
m_mcp->start();
connect(m_mdiArea, &QMdiArea::subWindowActivated, connect(m_mdiArea, &QMdiArea::subWindowActivated,
this, [this](QMdiSubWindow*) { this, [this](QMdiSubWindow*) {
@@ -341,23 +357,25 @@ QIcon MainWindow::makeIcon(const QString& svgPath) {
void MainWindow::createMenus() { void MainWindow::createMenus() {
// File // File
auto* file = m_titleBar->menuBar()->addMenu("&File"); auto* file = m_titleBar->menuBar()->addMenu("&File");
file->addAction("&New", this, &MainWindow::newDocument, QKeySequence::New); file->addAction("&New", QKeySequence::New, this, &MainWindow::newDocument);
file->addAction("New &Tab", this, &MainWindow::newFile, QKeySequence(Qt::CTRL | Qt::Key_T)); file->addAction("New &Tab", QKeySequence(Qt::CTRL | Qt::Key_T), this, &MainWindow::newFile);
file->addAction(makeIcon(":/vsicons/folder-opened.svg"), "&Open...", this, &MainWindow::openFile, QKeySequence::Open); file->addAction(makeIcon(":/vsicons/folder-opened.svg"), "&Open...", QKeySequence::Open, this, &MainWindow::openFile);
file->addSeparator(); file->addSeparator();
file->addAction(makeIcon(":/vsicons/save.svg"), "&Save", this, &MainWindow::saveFile, QKeySequence::Save); file->addAction(makeIcon(":/vsicons/save.svg"), "&Save", QKeySequence::Save, this, &MainWindow::saveFile);
file->addAction(makeIcon(":/vsicons/save-as.svg"), "Save &As...", this, &MainWindow::saveFileAs, QKeySequence::SaveAs); file->addAction(makeIcon(":/vsicons/save-as.svg"), "Save &As...", QKeySequence::SaveAs, this, &MainWindow::saveFileAs);
file->addSeparator(); file->addSeparator();
file->addAction(makeIcon(":/vsicons/export.svg"), "Export &C++ Header...", this, &MainWindow::exportCpp); file->addAction(makeIcon(":/vsicons/export.svg"), "Export &C++ Header...", this, &MainWindow::exportCpp);
file->addSeparator(); file->addSeparator();
m_mcpAction = file->addAction("Stop &MCP Server", this, &MainWindow::toggleMcp); m_mcpAction = file->addAction(QSettings("Reclass", "Reclass").value("autoStartMcp", false).toBool() ? "Stop &MCP Server" : "Start &MCP Server", this, &MainWindow::toggleMcp);
file->addSeparator(); file->addSeparator();
file->addAction(makeIcon(":/vsicons/close.svg"), "E&xit", this, &QMainWindow::close, QKeySequence(Qt::Key_Close)); file->addAction(makeIcon(":/vsicons/settings-gear.svg"), "&Options...", this, &MainWindow::showOptionsDialog);
file->addSeparator();
file->addAction(makeIcon(":/vsicons/close.svg"), "E&xit", QKeySequence(Qt::Key_Close), this, &QMainWindow::close);
// Edit // Edit
auto* edit = m_titleBar->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", QKeySequence::Undo, this, &MainWindow::undo);
edit->addAction(makeIcon(":/vsicons/arrow-right.svg"), "&Redo", this, &MainWindow::redo, QKeySequence::Redo); edit->addAction(makeIcon(":/vsicons/arrow-right.svg"), "&Redo", QKeySequence::Redo, this, &MainWindow::redo);
edit->addSeparator(); edit->addSeparator();
edit->addAction("&Type Aliases...", this, &MainWindow::showTypeAliasesDialog); edit->addAction("&Type Aliases...", this, &MainWindow::showTypeAliasesDialog);
@@ -415,10 +433,10 @@ void MainWindow::createMenus() {
// Node // Node
auto* node = m_titleBar->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", QKeySequence(Qt::Key_Insert), this, &MainWindow::addNode);
node->addAction(makeIcon(":/vsicons/remove.svg"), "&Remove Field", this, &MainWindow::removeNode, QKeySequence::Delete); node->addAction(makeIcon(":/vsicons/remove.svg"), "&Remove Field", QKeySequence::Delete, this, &MainWindow::removeNode);
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", QKeySequence(Qt::Key_T), this, &MainWindow::changeNodeType);
node->addAction(makeIcon(":/vsicons/edit.svg"), "Re&name", this, &MainWindow::renameNodeAction, QKeySequence(Qt::Key_F2)); node->addAction(makeIcon(":/vsicons/edit.svg"), "Re&name", QKeySequence(Qt::Key_F2), this, &MainWindow::renameNodeAction);
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
@@ -465,7 +483,7 @@ void MainWindow::styleTabCloseButtons() {
const auto& t = ThemeManager::instance().current(); const auto& t = ThemeManager::instance().current();
QString style = QStringLiteral( QString style = QStringLiteral(
"QToolButton { color: %1; border: none; padding: 0px 4px; font-size: 12px; }" "QToolButton { color: %1; border: none; padding: 0px 4px 2px 4px; font-size: 12px; }"
"QToolButton:hover { color: %2; }") "QToolButton:hover { color: %2; }")
.arg(t.textDim.name(), t.indHoverSpan.name()); .arg(t.textDim.name(), t.indHoverSpan.name());
@@ -665,6 +683,7 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) {
sub->setWindowTitle(rootName(it2->doc->tree, it2->ctrl->viewRootId())); sub->setWindowTitle(rootName(it2->doc->tree, it2->ctrl->viewRootId()));
} }
updateWindowTitle(); updateWindowTitle();
rebuildWorkspaceModel();
}); });
}); });
@@ -737,6 +756,25 @@ static void buildBallDemo(NodeTree& tree) {
// Material[2] materials at offset 128 (112 + 16 for float[4]) // Material[2] materials at offset 128 (112 + 16 for float[4])
{ Node n; n.kind = NodeKind::Array; n.name = "materials"; n.parentId = ballId; n.offset = 128; n.elementKind = NodeKind::Struct; n.arrayLen = 2; n.refId = matId; tree.addNode(n); } { Node n; n.kind = NodeKind::Array; n.name = "materials"; n.parentId = ballId; n.offset = 128; n.elementKind = NodeKind::Struct; n.arrayLen = 2; n.refId = matId; tree.addNode(n); }
// Unnamed struct (128 bytes of hex64 fields)
Node unnamed;
unnamed.kind = NodeKind::Struct;
unnamed.name = "instance";
unnamed.structTypeName = "Unnamed";
unnamed.parentId = 0;
unnamed.offset = 0;
int ui = tree.addNode(unnamed);
uint64_t unnamedId = tree.nodes[ui].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 = unnamedId;
n.offset = i * 8;
tree.addNode(n);
}
} }
void MainWindow::newFile() { void MainWindow::newFile() {
@@ -785,41 +823,7 @@ 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() {
@@ -1005,6 +1009,13 @@ void MainWindow::applyTheme(const Theme& theme) {
statusBar()->setPalette(sbPal); statusBar()->setPalette(sbPal);
} }
// Workspace tree: text color matches menu bar
if (m_workspaceTree) {
QPalette tp = m_workspaceTree->palette();
tp.setColor(QPalette::Text, theme.textDim);
m_workspaceTree->setPalette(tp);
}
// Split pane tab widgets // Split pane tab widgets
for (auto& state : m_tabs) { for (auto& state : m_tabs) {
for (auto& pane : state.panes) { for (auto& pane : state.panes) {
@@ -1024,6 +1035,39 @@ void MainWindow::editTheme() {
} }
} }
// TODO: when adding more and more options, this func becomes very clunky. Fix
void MainWindow::showOptionsDialog() {
auto& tm = ThemeManager::instance();
OptionsResult current;
current.themeIndex = tm.currentIndex();
current.fontName = QSettings("Reclass", "Reclass").value("font", "JetBrains Mono").toString();
current.menuBarTitleCase = m_titleBar->menuBarTitleCase();
current.safeMode = QSettings("Reclass", "Reclass").value("safeMode", false).toBool();
current.autoStartMcp = QSettings("Reclass", "Reclass").value("autoStartMcp", false).toBool();
OptionsDialog dlg(current, this);
if (dlg.exec() != QDialog::Accepted) return; // OptionsDialog doesn't apply anything. Only apply on OK
auto r = dlg.result();
if (r.themeIndex != current.themeIndex)
tm.setCurrent(r.themeIndex);
if (r.fontName != current.fontName)
setEditorFont(r.fontName);
if (r.menuBarTitleCase != current.menuBarTitleCase) {
m_titleBar->setMenuBarTitleCase(r.menuBarTitleCase);
QSettings("Reclass", "Reclass").setValue("menuBarTitleCase", r.menuBarTitleCase);
}
if (r.safeMode != current.safeMode)
QSettings("Reclass", "Reclass").setValue("safeMode", r.safeMode);
if (r.autoStartMcp != current.autoStartMcp)
QSettings("Reclass", "Reclass").setValue("autoStartMcp", r.autoStartMcp);
}
void MainWindow::setEditorFont(const QString& fontName) { void MainWindow::setEditorFont(const QString& fontName) {
QSettings settings("Reclass", "Reclass"); QSettings settings("Reclass", "Reclass");
settings.setValue("font", fontName); settings.setValue("font", fontName);
@@ -1380,7 +1424,7 @@ void MainWindow::project_close(QMdiSubWindow* sub) {
// ── Workspace Dock ── // ── Workspace Dock ──
void MainWindow::createWorkspaceDock() { void MainWindow::createWorkspaceDock() {
m_workspaceDock = new QDockWidget("Workspace", this); m_workspaceDock = new QDockWidget("Project Tree", this);
m_workspaceDock->setObjectName("WorkspaceDock"); m_workspaceDock->setObjectName("WorkspaceDock");
m_workspaceDock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea); m_workspaceDock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea);
@@ -1390,81 +1434,51 @@ void MainWindow::createWorkspaceDock() {
m_workspaceTree->setModel(m_workspaceModel); m_workspaceTree->setModel(m_workspaceModel);
m_workspaceTree->setHeaderHidden(true); m_workspaceTree->setHeaderHidden(true);
m_workspaceTree->setEditTriggers(QAbstractItemView::NoEditTriggers); m_workspaceTree->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_workspaceTree->setExpandsOnDoubleClick(false);
// Match editor font m_workspaceTree->setMouseTracking(true);
{
QSettings settings("Reclass", "Reclass");
QString fontName = settings.value("font", "JetBrains Mono").toString();
QFont f(fontName, 12);
f.setFixedPitch(true);
m_workspaceTree->setFont(f);
}
m_workspaceDock->setWidget(m_workspaceTree); m_workspaceDock->setWidget(m_workspaceTree);
addDockWidget(Qt::LeftDockWidgetArea, m_workspaceDock); addDockWidget(Qt::LeftDockWidgetArea, m_workspaceDock);
m_workspaceDock->hide(); m_workspaceDock->hide();
connect(m_workspaceTree, &QTreeView::doubleClicked, this, [this](const QModelIndex& index) { connect(m_workspaceTree, &QTreeView::doubleClicked, this, [this](const QModelIndex& index) {
// Data roles: UserRole=QMdiSubWindow*, UserRole+1=structId, UserRole+2=nodeId auto structIdVar = index.data(Qt::UserRole + 1);
uint64_t structId = structIdVar.isValid() ? structIdVar.toULongLong() : 0;
if (structId == rcx::kGroupSentinel) {
// "Project" folder: toggle expand/collapse
m_workspaceTree->setExpanded(index, !m_workspaceTree->isExpanded(index));
return;
}
auto subVar = index.data(Qt::UserRole); auto subVar = index.data(Qt::UserRole);
if (!subVar.isValid()) return; if (!subVar.isValid()) return;
auto* sub = static_cast<QMdiSubWindow*>(subVar.value<void*>()); auto* sub = static_cast<QMdiSubWindow*>(subVar.value<void*>());
if (!sub || !m_tabs.contains(sub)) return; if (!sub || !m_tabs.contains(sub)) return;
m_mdiArea->setActiveSubWindow(sub); m_mdiArea->setActiveSubWindow(sub);
auto structIdVar = index.data(Qt::UserRole + 1); // Type/Enum node: navigate to it
auto nodeIdVar = index.data(Qt::UserRole + 2); auto& tree = m_tabs[sub].doc->tree;
int ni = tree.indexOfId(structId);
if (structIdVar.isValid()) { if (ni >= 0) tree.nodes[ni].collapsed = false;
// Double-clicked a struct: set as view root m_tabs[sub].ctrl->setViewRootId(structId);
uint64_t structId = structIdVar.toULongLong(); m_tabs[sub].ctrl->scrollToNodeId(structId);
auto& tree = m_tabs[sub].doc->tree;
int ni = tree.indexOfId(structId);
if (ni >= 0) tree.nodes[ni].collapsed = false;
m_tabs[sub].ctrl->setViewRootId(structId);
m_tabs[sub].ctrl->scrollToNodeId(structId);
} else if (nodeIdVar.isValid()) {
// Double-clicked a field: find its root struct, set as view root, scroll to field
uint64_t nodeId = nodeIdVar.toULongLong();
auto& tree = m_tabs[sub].doc->tree;
// Walk up to find root struct
uint64_t rootId = 0;
uint64_t cur = nodeId;
while (cur != 0) {
int idx = tree.indexOfId(cur);
if (idx < 0) break;
if (tree.nodes[idx].parentId == 0) { rootId = cur; break; }
cur = tree.nodes[idx].parentId;
}
if (rootId != 0) {
int ri = tree.indexOfId(rootId);
if (ri >= 0) tree.nodes[ri].collapsed = false;
m_tabs[sub].ctrl->setViewRootId(rootId);
}
m_tabs[sub].ctrl->scrollToNodeId(nodeId);
} else if (!index.parent().isValid()) {
// Double-clicked project root: clear view root to show all
m_tabs[sub].ctrl->setViewRootId(0);
}
}); });
} }
void MainWindow::rebuildWorkspaceModel() { void MainWindow::rebuildWorkspaceModel() {
m_workspaceModel->clear(); QVector<rcx::TabInfo> tabs;
for (auto it = m_tabs.begin(); it != m_tabs.end(); ++it) {
auto* sub = m_mdiArea->activeSubWindow(); TabState& tab = it.value();
if (!sub || !m_tabs.contains(sub)) return; QString name = tab.doc->filePath.isEmpty()
? rootName(tab.doc->tree, tab.ctrl->viewRootId())
TabState& tab = m_tabs[sub]; : QFileInfo(tab.doc->filePath).fileName();
QString tabName = tab.doc->filePath.isEmpty() tabs.append({ &tab.doc->tree, name, static_cast<void*>(it.key()) });
? rootName(tab.doc->tree, tab.ctrl->viewRootId()) }
: QFileInfo(tab.doc->filePath).fileName(); rcx::buildProjectExplorer(m_workspaceModel, tabs);
m_workspaceTree->expandToDepth(1);
buildWorkspaceModel(m_workspaceModel, tab.doc->tree, tabName,
static_cast<void*>(sub));
m_workspaceTree->expandAll();
} }
void MainWindow::showPluginsDialog() { void MainWindow::showPluginsDialog() {

View File

@@ -49,6 +49,7 @@ private slots:
void exportCpp(); void exportCpp();
void showTypeAliasesDialog(); void showTypeAliasesDialog();
void editTheme(); void editTheme();
void showOptionsDialog();
public: public:
// Project Lifecycle API // Project Lifecycle API

View File

@@ -248,7 +248,7 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
"collapse: {op:'collapse', nodeId:'ID', collapsed:true}. " "collapse: {op:'collapse', nodeId:'ID', collapsed:true}. "
"Insert ops get auto-assigned IDs; use $0, $1 etc. to reference them in later ops. " "Insert ops get auto-assigned IDs; use $0, $1 etc. to reference them in later ops. "
"Kinds: Hex8 Hex16 Hex32 Hex64 Int8 Int16 Int32 Int64 UInt8 UInt16 UInt32 UInt64 " "Kinds: Hex8 Hex16 Hex32 Hex64 Int8 Int16 Int32 Int64 UInt8 UInt16 UInt32 UInt64 "
"Float Double Bool Pointer32 Pointer64 Vec2 Vec3 Vec4 Mat4x4 UTF8 UTF16 Padding Struct Array"}, "Float Double Bool Pointer32 Pointer64 Vec2 Vec3 Vec4 Mat4x4 UTF8 UTF16 Struct Array"},
{"inputSchema", QJsonObject{ {"inputSchema", QJsonObject{
{"type", "object"}, {"type", "object"},
{"properties", QJsonObject{ {"properties", QJsonObject{

327
src/optionsdialog.cpp Normal file
View File

@@ -0,0 +1,327 @@
#include "optionsdialog.h"
#include "themes/thememanager.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QFormLayout>
#include <QDialogButtonBox>
#include <QPushButton>
#include <QGroupBox>
#include <QLabel>
#include <QTreeWidgetItem>
#include <QGraphicsDropShadowEffect>
#include <QEvent>
#include <functional>
namespace rcx {
OptionsDialog::OptionsDialog(const OptionsResult& current, QWidget* parent)
: QDialog(parent)
{
setWindowTitle("Options");
setFixedSize(700, 450);
const auto& t = ThemeManager::instance().current();
auto* mainLayout = new QVBoxLayout(this);
mainLayout->setSpacing(8);
mainLayout->setContentsMargins(10, 10, 10, 10);
// -- Middle: left column (search + tree) | right column (pages) --
auto* middleLayout = new QHBoxLayout;
middleLayout->setSpacing(8);
// Left column: search bar + tree
auto* leftColumn = new QVBoxLayout;
leftColumn->setSpacing(4);
m_search = new QLineEdit;
m_search->setPlaceholderText("Search Options (Ctrl+E)");
m_search->setClearButtonEnabled(true);
connect(m_search, &QLineEdit::textChanged, this, &OptionsDialog::filterTree);
leftColumn->addWidget(m_search);
m_tree = new QTreeWidget;
m_tree->setHeaderHidden(true);
m_tree->setRootIsDecorated(true);
m_tree->setFixedWidth(200);
auto* envItem = new QTreeWidgetItem(m_tree, {"Environment"});
auto* generalItem = new QTreeWidgetItem(envItem, {"General"});
m_tree->expandAll();
m_tree->setCurrentItem(generalItem);
leftColumn->addWidget(m_tree, 1);
middleLayout->addLayout(leftColumn);
// Right column: stacked pages with group boxes
m_pages = new QStackedWidget;
// -- General page --
auto* generalPage = new QWidget;
auto* generalLayout = new QVBoxLayout(generalPage);
generalLayout->setContentsMargins(0, 0, 0, 0);
generalLayout->setSpacing(8);
// Visual Experience group box
auto* visualGroup = new QGroupBox("Visual Experience");
auto* visualLayout = new QFormLayout(visualGroup);
visualLayout->setSpacing(8);
visualLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
m_themeCombo = new QComboBox;
auto& tm = ThemeManager::instance();
for (const auto& theme : tm.themes())
m_themeCombo->addItem(theme.name);
m_themeCombo->setCurrentIndex(current.themeIndex);
m_themeCombo->setObjectName("themeCombo");
visualLayout->addRow("Color theme:", m_themeCombo);
m_fontCombo = new QComboBox;
m_fontCombo->addItem("JetBrains Mono");
m_fontCombo->addItem("Consolas");
m_fontCombo->setCurrentText(current.fontName);
m_fontCombo->setObjectName("fontCombo");
visualLayout->addRow("Editor Font:", m_fontCombo);
m_titleCaseCheck = new QCheckBox("Apply title case styling to menu bar");
m_titleCaseCheck->setChecked(current.menuBarTitleCase);
visualLayout->addRow(m_titleCaseCheck);
generalLayout->addWidget(visualGroup);
// Safe Mode group box
auto* safeModeGroup = new QGroupBox("Preview Features");
auto* safeModeLayout = new QVBoxLayout(safeModeGroup);
safeModeLayout->setSpacing(4);
m_safeModeCheck = new QCheckBox("Safe Mode");
m_safeModeCheck->setChecked(current.safeMode);
m_safeModeCheck->setStyleSheet(QStringLiteral(
"QCheckBox { font-weight: bold; }"));
safeModeLayout->addWidget(m_safeModeCheck);
auto* safeModeDesc = new QLabel(
"Enable to use the default OS icon for this application and "
"create the window with the name of the executable file.");
safeModeDesc->setWordWrap(true);
safeModeDesc->setContentsMargins(20, 0, 0, 0); // indent under checkbox
safeModeLayout->addWidget(safeModeDesc);
generalLayout->addWidget(safeModeGroup);
generalLayout->addStretch();
m_pages->addWidget(generalPage); // index 0
m_pageKeywords[generalItem] = collectPageKeywords(generalPage);
// -- AI Features page --
auto* aiItem = new QTreeWidgetItem(envItem, {"AI Features"});
auto* aiPage = new QWidget;
auto* aiLayout = new QVBoxLayout(aiPage);
aiLayout->setContentsMargins(0, 0, 0, 0);
aiLayout->setSpacing(8);
auto* mcpGroup = new QGroupBox("MCP Server");
auto* mcpLayout = new QVBoxLayout(mcpGroup);
mcpLayout->setSpacing(4);
m_autoMcpCheck = new QCheckBox("Auto-start MCP server");
m_autoMcpCheck->setChecked(current.autoStartMcp);
m_autoMcpCheck->setStyleSheet(QStringLiteral(
"QCheckBox { font-weight: bold; }"));
mcpLayout->addWidget(m_autoMcpCheck);
auto* mcpDesc = new QLabel(
"Automatically start the MCP bridge server when the application launches, "
"allowing external AI tools to connect and interact with the editor.");
mcpDesc->setWordWrap(true);
mcpDesc->setContentsMargins(20, 0, 0, 0);
mcpLayout->addWidget(mcpDesc);
aiLayout->addWidget(mcpGroup);
aiLayout->addStretch();
m_pages->addWidget(aiPage); // index 1
m_pageKeywords[aiItem] = collectPageKeywords(aiPage);
middleLayout->addWidget(m_pages, 1);
mainLayout->addLayout(middleLayout, 1);
// Tree <-> page connection
m_itemPageIndex[generalItem] = 0;
m_itemPageIndex[aiItem] = 1;
connect(m_tree, &QTreeWidget::currentItemChanged, this,
[this](QTreeWidgetItem* item, QTreeWidgetItem*) {
if (!item) return;
auto it = m_itemPageIndex.find(item);
if (it != m_itemPageIndex.end())
m_pages->setCurrentIndex(it.value());
});
// -- Button box --
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
mainLayout->addWidget(buttons);
// -- Styling --
// Combo boxes: set directly so the popup (top-level widget) inherits it
QString comboStyle = QStringLiteral(
"QComboBox {"
" background: %1; color: %2; border: 1px solid %3;"
" padding: 3px 8px; font-size: 12px;"
"}"
"QComboBox::drop-down {"
" border: none; border-left: 1px solid %3;"
" width: 20px;"
"}"
"QComboBox::down-arrow {"
" image: url(:/vsicons/chevron-down.svg);"
" width: 12px; height: 12px;"
"}"
"QComboBox QAbstractItemView {"
" background: %1; color: %2; border: 1px solid %3;"
" selection-background-color: %4;"
"}")
.arg(t.backgroundAlt.name(), t.text.name(),
t.border.name(), t.hover.name());
m_themeCombo->setStyleSheet(comboStyle);
m_fontCombo->setStyleSheet(comboStyle);
// Dialog-wide stylesheet for everything else
setStyleSheet(QStringLiteral(
"QDialog { background: %1; }"
"QLineEdit {"
" background: %2; color: %3; border: 1px solid %4;"
" padding: 4px 8px; font-size: 12px;"
"}"
"QTreeWidget {"
" background: %2; color: %3; border: 1px solid %4;"
" font-size: 12px; outline: none;"
"}"
"QTreeWidget::item { padding: 3px 0; outline: none; }"
"QTreeWidget::item:selected { background: %5; color: %3; }"
"QTreeWidget::item:hover { background: %6; }"
"QGroupBox {"
" color: %3; border: 1px solid %4;"
" margin-top: 8px; padding: 12px 8px 8px 8px;"
" font-size: 12px; font-weight: bold;"
"}"
"QGroupBox::title {"
" subcontrol-origin: margin;"
" left: 8px; padding: 0 4px;"
"}"
"QLabel { color: %3; font-size: 12px; }"
"QCheckBox { color: %3; font-size: 12px; spacing: 6px; }"
"QPushButton {"
" background: %2; color: %3; border: 1px solid %4;"
" padding: 5px 16px; min-width: 70px; font-size: 12px;"
" outline: none;"
"}"
"QPushButton:hover { background: %6; }"
"QPushButton:pressed { background: %1; }"
"QPushButton:focus { outline: none; }")
.arg(t.background.name(), // %1
t.backgroundAlt.name(), // %2
t.text.name(), // %3
t.border.name(), // %4
t.selection.name(), // %5
t.hover.name())); // %6
// Install hover shadow on interactive widgets (not buttons — they use stylesheet hover)
for (auto* w : {static_cast<QWidget*>(m_search),
static_cast<QWidget*>(m_themeCombo),
static_cast<QWidget*>(m_fontCombo),
static_cast<QWidget*>(m_titleCaseCheck),
static_cast<QWidget*>(m_safeModeCheck),
static_cast<QWidget*>(m_autoMcpCheck)})
w->installEventFilter(this);
m_shadowColor = t.text;
m_shadowColor.setAlpha(80);
}
bool OptionsDialog::eventFilter(QObject* obj, QEvent* event) {
if (event->type() == QEvent::Enter) {
auto* w = qobject_cast<QWidget*>(obj);
if (w && !w->graphicsEffect()) {
auto* shadow = new QGraphicsDropShadowEffect(w);
shadow->setBlurRadius(12);
shadow->setOffset(0, 0);
shadow->setColor(m_shadowColor);
w->setGraphicsEffect(shadow);
}
} else if (event->type() == QEvent::Leave) {
auto* w = qobject_cast<QWidget*>(obj);
if (w)
w->setGraphicsEffect(nullptr);
}
return QDialog::eventFilter(obj, event);
}
OptionsResult OptionsDialog::result() const {
OptionsResult r;
r.themeIndex = m_themeCombo->currentIndex();
r.fontName = m_fontCombo->currentText();
r.menuBarTitleCase = m_titleCaseCheck->isChecked();
r.safeMode = m_safeModeCheck->isChecked();
r.autoStartMcp = m_autoMcpCheck->isChecked();
return r;
}
QStringList OptionsDialog::collectPageKeywords(QWidget* page) {
QStringList keywords;
for (auto* child : page->findChildren<QWidget*>()) {
if (auto* label = qobject_cast<QLabel*>(child))
keywords << label->text();
else if (auto* cb = qobject_cast<QCheckBox*>(child))
keywords << cb->text();
else if (auto* gb = qobject_cast<QGroupBox*>(child))
keywords << gb->title();
else if (auto* combo = qobject_cast<QComboBox*>(child)) {
for (int i = 0; i < combo->count(); ++i)
keywords << combo->itemText(i);
}
}
return keywords;
}
void OptionsDialog::filterTree(const QString& text) {
std::function<bool(QTreeWidgetItem*)> filter = [&](QTreeWidgetItem* item) -> bool {
bool anyChildVisible = false;
for (int i = 0; i < item->childCount(); ++i) {
if (filter(item->child(i)))
anyChildVisible = true;
}
bool selfMatch = item->text(0).contains(text, Qt::CaseInsensitive);
if (!selfMatch) {
for (const auto& kw : m_pageKeywords.value(item)) {
if (kw.contains(text, Qt::CaseInsensitive)) {
selfMatch = true;
break;
}
}
}
bool visible = selfMatch || anyChildVisible;
item->setHidden(!visible);
if (visible && item->childCount() > 0)
item->setExpanded(true);
return visible;
};
for (int i = 0; i < m_tree->topLevelItemCount(); ++i)
filter(m_tree->topLevelItem(i));
}
} // namespace rcx

53
src/optionsdialog.h Normal file
View File

@@ -0,0 +1,53 @@
#pragma once
#include "themes/theme.h"
#include <QDialog>
#include <QLineEdit>
#include <QTreeWidget>
#include <QStackedWidget>
#include <QComboBox>
#include <QCheckBox>
#include <QHash>
#include <QColor>
namespace rcx {
struct OptionsResult {
int themeIndex = 0;
QString fontName;
bool menuBarTitleCase = true;
bool safeMode = false;
bool autoStartMcp = false;
};
class OptionsDialog : public QDialog {
Q_OBJECT
public:
explicit OptionsDialog(const OptionsResult& current, QWidget* parent = nullptr);
OptionsResult result() const;
protected:
bool eventFilter(QObject* obj, QEvent* event) override;
private:
void filterTree(const QString& text);
static QStringList collectPageKeywords(QWidget* page);
QLineEdit* m_search = nullptr;
QTreeWidget* m_tree = nullptr;
QStackedWidget* m_pages = nullptr;
QComboBox* m_themeCombo = nullptr;
QComboBox* m_fontCombo = nullptr;
QCheckBox* m_titleCaseCheck = nullptr;
QCheckBox* m_safeModeCheck = nullptr;
QCheckBox* m_autoMcpCheck = nullptr;
QColor m_shadowColor;
// searchable keywords per leaf tree item
QHash<QTreeWidgetItem*, QStringList> m_pageKeywords;
// tree item → stacked widget page index
QHash<QTreeWidgetItem*, int> m_itemPageIndex;
};
} // 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

@@ -47,5 +47,9 @@
<file alias="selection.svg">vsicons/list-selection.svg</file> <file alias="selection.svg">vsicons/list-selection.svg</file>
<file alias="symbol-numeric.svg">vsicons/symbol-numeric.svg</file> <file alias="symbol-numeric.svg">vsicons/symbol-numeric.svg</file>
<file alias="symbol-ruler.svg">vsicons/symbol-ruler.svg</file> <file alias="symbol-ruler.svg">vsicons/symbol-ruler.svg</file>
<file alias="settings-gear.svg">vsicons/settings-gear.svg</file>
<file alias="chevron-down.svg">vsicons/chevron-down.svg</file>
<file alias="folder.svg">vsicons/folder.svg</file>
<file alias="symbol-enum.svg">vsicons/symbol-enum.svg</file>
</qresource> </qresource>
</RCC> </RCC>

View File

@@ -10,8 +10,8 @@
"textDim": "#858585", "textDim": "#858585",
"textMuted": "#585858", "textMuted": "#585858",
"textFaint": "#505050", "textFaint": "#505050",
"hover": "#2b2b2b", "hover": "#1e1e1e",
"selected": "#232323", "selected": "#1e1e1e",
"selection": "#2b2b2b", "selection": "#2b2b2b",
"syntaxKeyword": "#569cd6", "syntaxKeyword": "#569cd6",
"syntaxNumber": "#b5cea8", "syntaxNumber": "#b5cea8",

View File

@@ -10,8 +10,8 @@
"textDim": "#858585", "textDim": "#858585",
"textMuted": "#636369", "textMuted": "#636369",
"textFaint": "#4d4d55", "textFaint": "#4d4d55",
"hover": "#3e3e42", "hover": "#2c2c2f",
"selected": "#2d2d30", "selected": "#262629",
"selection": "#264f78", "selection": "#264f78",
"syntaxKeyword": "#569cd6", "syntaxKeyword": "#569cd6",
"syntaxNumber": "#b5cea8", "syntaxNumber": "#b5cea8",

View File

@@ -10,8 +10,8 @@
"textDim": "#7a7a6e", "textDim": "#7a7a6e",
"textMuted": "#555550", "textMuted": "#555550",
"textFaint": "#464646", "textFaint": "#464646",
"hover": "#373737", "hover": "#282828",
"selected": "#2d2d2d", "selected": "#262626",
"selection": "#21213A", "selection": "#21213A",
"syntaxKeyword": "#AA9565", "syntaxKeyword": "#AA9565",
"syntaxNumber": "#AAA98C", "syntaxNumber": "#AAA98C",

View File

@@ -20,6 +20,7 @@ TitleBarWidget::TitleBarWidget(QWidget* parent)
// App name // App name
m_appLabel = new QLabel(QStringLiteral("Reclass"), this); m_appLabel = new QLabel(QStringLiteral("Reclass"), this);
m_appLabel->setContentsMargins(10, 0, 4, 0); m_appLabel->setContentsMargins(10, 0, 4, 0);
m_appLabel->setAlignment(Qt::AlignVCenter);
m_appLabel->setAttribute(Qt::WA_TransparentForMouseEvents); m_appLabel->setAttribute(Qt::WA_TransparentForMouseEvents);
layout->addWidget(m_appLabel); layout->addWidget(m_appLabel);
@@ -113,6 +114,33 @@ void TitleBarWidget::setShowIcon(bool show) {
} }
} }
void TitleBarWidget::setMenuBarTitleCase(bool titleCase) {
m_titleCase = titleCase;
for (QAction* action : m_menuBar->actions()) {
QString text = action->text();
QString clean = text;
clean.remove('&');
if (titleCase) {
QString result;
bool capitalizeNext = true;
for (int i = 0; i < clean.length(); ++i) {
QChar ch = clean[i];
if (ch.isLetter()) {
result += capitalizeNext ? ch.toUpper() : ch.toLower();
capitalizeNext = false;
} else {
result += ch;
if (ch.isSpace()) capitalizeNext = true;
}
}
action->setText("&" + result);
} else {
action->setText("&" + clean.toUpper());
}
}
}
void TitleBarWidget::updateMaximizeIcon() { void TitleBarWidget::updateMaximizeIcon() {
if (window()->isMaximized()) if (window()->isMaximized())
m_btnMax->setIcon(QIcon(":/vsicons/chrome-restore.svg")); m_btnMax->setIcon(QIcon(":/vsicons/chrome-restore.svg"));

View File

@@ -16,6 +16,8 @@ public:
QMenuBar* menuBar() const { return m_menuBar; } QMenuBar* menuBar() const { return m_menuBar; }
void applyTheme(const Theme& theme); void applyTheme(const Theme& theme);
void setShowIcon(bool show); void setShowIcon(bool show);
void setMenuBarTitleCase(bool titleCase);
bool menuBarTitleCase() const { return m_titleCase; }
void updateMaximizeIcon(); void updateMaximizeIcon();
@@ -32,6 +34,7 @@ private:
QToolButton* m_btnClose = nullptr; QToolButton* m_btnClose = nullptr;
Theme m_theme; Theme m_theme;
bool m_titleCase = true;
QToolButton* makeChromeButton(const QString& iconPath); QToolButton* makeChromeButton(const QString& iconPath);
void toggleMaximize(); void toggleMaximize();

View File

@@ -16,6 +16,7 @@
#include <QApplication> #include <QApplication>
#include <QScreen> #include <QScreen>
#include <QIntValidator> #include <QIntValidator>
#include <QElapsedTimer>
#include "themes/thememanager.h" #include "themes/thememanager.h"
namespace rcx { namespace rcx {
@@ -121,7 +122,7 @@ public:
return; return;
} }
// 18px gutter: side triangle if current // Gutter: side triangle if current
if (m_hasCurrent && m_filtered && row >= 0 && row < m_filtered->size()) { if (m_hasCurrent && m_filtered && row >= 0 && row < m_filtered->size()) {
const TypeEntry& entry = (*m_filtered)[row]; const TypeEntry& entry = (*m_filtered)[row];
bool isCurrent = false; bool isCurrent = false;
@@ -130,13 +131,13 @@ public:
else if (m_current->entryKind == TypeEntry::Composite && entry.entryKind == TypeEntry::Composite) else if (m_current->entryKind == TypeEntry::Composite && entry.entryKind == TypeEntry::Composite)
isCurrent = (entry.structId == m_current->structId); isCurrent = (entry.structId == m_current->structId);
if (isCurrent) { if (isCurrent) {
painter->setPen(t.syntaxType); painter->setPen(t.text);
painter->setFont(m_font); painter->setFont(m_font);
painter->drawText(QRect(x, y, 18, h), Qt::AlignCenter, painter->drawText(QRect(x, y, 10, h), Qt::AlignCenter,
QString(QChar(0x25B8))); QString(QChar(0x25B8)));
} }
} }
x += 18; x += 10;
// Icon 16x16 — only for composite entries // Icon 16x16 — only for composite entries
bool hasIcon = (m_filtered && row >= 0 && row < m_filtered->size() bool hasIcon = (m_filtered && row >= 0 && row < m_filtered->size()
@@ -368,6 +369,7 @@ TypeSelectorPopup::TypeSelectorPopup(QWidget* parent)
m_listView->setFrameShape(QFrame::NoFrame); m_listView->setFrameShape(QFrame::NoFrame);
m_listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff); m_listView->setHorizontalScrollBarPolicy(Qt::ScrollBarAlwaysOff);
m_listView->setMouseTracking(true); m_listView->setMouseTracking(true);
m_listView->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_listView->viewport()->setAttribute(Qt::WA_Hover, true); m_listView->viewport()->setAttribute(Qt::WA_Hover, true);
m_listView->installEventFilter(this); m_listView->installEventFilter(this);
@@ -384,10 +386,33 @@ TypeSelectorPopup::TypeSelectorPopup(QWidget* parent)
} }
void TypeSelectorPopup::warmUp() { void TypeSelectorPopup::warmUp() {
// One-time per-process cost (~170ms): Qt lazily initializes the style/font/DLL
// subsystem the first time a popup with complex children is shown. Pre-pay it
// by briefly showing a throwaway dummy popup with a QListView, then show+hide
// ourselves.
{
auto* primer = new QFrame(nullptr, Qt::Popup | Qt::FramelessWindowHint);
primer->resize(300, 400);
auto* lay = new QVBoxLayout(primer);
lay->addWidget(new QLabel(QStringLiteral("x")));
lay->addWidget(new QLineEdit);
auto* model = new QStringListModel(primer);
QStringList items; for (int i = 0; i < 10; i++) items << QStringLiteral("x");
model->setStringList(items);
auto* lv = new QListView;
lv->setModel(model);
lay->addWidget(lv);
primer->show();
QApplication::processEvents();
primer->hide();
QApplication::processEvents();
delete primer;
}
TypeEntry dummy; TypeEntry dummy;
dummy.entryKind = TypeEntry::Primitive; dummy.entryKind = TypeEntry::Primitive;
dummy.primitiveKind = NodeKind::Hex8; dummy.primitiveKind = NodeKind::Hex8;
dummy.displayName = "warmup"; dummy.displayName = QStringLiteral("warmup");
setTypes({dummy}); setTypes({dummy});
popup(QPoint(-9999, -9999)); popup(QPoint(-9999, -9999));
hide(); hide();
@@ -467,7 +492,7 @@ void TypeSelectorPopup::popup(const QPoint& globalPos) {
QString text = t.classKeyword.isEmpty() QString text = t.classKeyword.isEmpty()
? t.displayName ? t.displayName
: (t.classKeyword + QStringLiteral(" ") + t.displayName); : (t.classKeyword + QStringLiteral(" ") + t.displayName);
int w = 18 + 20 + fm.horizontalAdvance(text) + 16; int w = 10 + 20 + fm.horizontalAdvance(text) + 16;
if (w > maxTextW) maxTextW = w; if (w > maxTextW) maxTextW = w;
} }
int popupW = qBound(280, maxTextW + 24, 500); int popupW = qBound(280, maxTextW + 24, 500);

View File

@@ -1,62 +1,76 @@
#pragma once #pragma once
#include "core.h" #include "core.h"
#include <QIcon>
#include <QStandardItemModel> #include <QStandardItemModel>
#include <QStandardItem> #include <QStandardItem>
#include <algorithm> #include <algorithm>
namespace rcx { namespace rcx {
// Recursively add children of parentId as tree items under parentItem. struct TabInfo {
inline void addWorkspaceChildren(QStandardItem* parentItem, const NodeTree* tree;
const NodeTree& tree, QString name;
uint64_t parentId, void* subPtr; // QMdiSubWindow* as void*
void* subPtr) { };
QVector<int> children = tree.childrenOf(parentId);
std::sort(children.begin(), children.end(), [&](int a, int b) {
return tree.nodes[a].offset < tree.nodes[b].offset;
});
for (int idx : children) { // Sentinel value stored in UserRole+1 to mark the Project group node.
const Node& node = tree.nodes[idx]; static constexpr uint64_t kGroupSentinel = ~uint64_t(0);
// Skip hex preview nodes — they are padding/filler, not meaningful fields inline void buildProjectExplorer(QStandardItemModel* model,
if (isHexNode(node.kind)) continue; const QVector<TabInfo>& tabs) {
QString display;
if (node.kind == NodeKind::Struct) {
QString typeName = node.structTypeName.isEmpty()
? node.name : node.structTypeName;
display = QStringLiteral("%1 (%2)")
.arg(typeName, node.resolvedClassKeyword());
} else {
display = QStringLiteral("%1 (%2)")
.arg(node.name, QString::fromLatin1(kindToString(node.kind)));
}
auto* item = new QStandardItem(display);
item->setData(QVariant::fromValue(subPtr), Qt::UserRole);
if (node.kind == NodeKind::Struct)
item->setData(QVariant::fromValue(node.id), Qt::UserRole + 1);
item->setData(QVariant::fromValue(node.id), Qt::UserRole + 2); // nodeId for scroll
if (node.kind == NodeKind::Struct)
addWorkspaceChildren(item, tree, node.id, subPtr);
parentItem->appendRow(item);
}
}
inline void buildWorkspaceModel(QStandardItemModel* model,
const NodeTree& tree,
const QString& projectName,
void* subPtr = nullptr) {
model->clear(); model->clear();
model->setHorizontalHeaderLabels({QStringLiteral("Name")}); model->setHorizontalHeaderLabels({QStringLiteral("Name")});
auto* projectItem = new QStandardItem(projectName); // Single "Project" root with folder icon
projectItem->setData(QVariant::fromValue(subPtr), Qt::UserRole); void* firstSub = tabs.isEmpty() ? nullptr : tabs[0].subPtr;
auto* projectItem = new QStandardItem(QIcon(":/vsicons/folder.svg"),
QStringLiteral("Project"));
projectItem->setData(QVariant::fromValue(firstSub), Qt::UserRole);
projectItem->setData(QVariant::fromValue(kGroupSentinel), Qt::UserRole + 1);
addWorkspaceChildren(projectItem, tree, 0, subPtr); // Collect all top-level structs/enums across all tabs
QVector<std::pair<const Node*, void*>> types, enums;
for (const auto& tab : tabs) {
QVector<int> topLevel = tab.tree->childrenOf(0);
for (int idx : topLevel) {
const Node& n = tab.tree->nodes[idx];
if (n.kind != NodeKind::Struct) continue;
if (n.resolvedClassKeyword() == QStringLiteral("enum"))
enums.append({&n, tab.subPtr});
else
types.append({&n, tab.subPtr});
}
}
auto nameOf = [](const Node* n) {
return n->structTypeName.isEmpty() ? n->name : n->structTypeName;
};
auto cmpName = [&](const std::pair<const Node*, void*>& a,
const std::pair<const Node*, void*>& b) {
return nameOf(a.first).compare(nameOf(b.first), Qt::CaseInsensitive) < 0;
};
std::sort(types.begin(), types.end(), cmpName);
std::sort(enums.begin(), enums.end(), cmpName);
for (const auto& [n, subPtr] : types) {
QString display = QStringLiteral("%1 (%2)")
.arg(nameOf(n), n->resolvedClassKeyword());
auto* item = new QStandardItem(
QIcon(":/vsicons/symbol-structure.svg"), display);
item->setData(QVariant::fromValue(subPtr), Qt::UserRole);
item->setData(QVariant::fromValue(n->id), Qt::UserRole + 1);
projectItem->appendRow(item);
}
for (const auto& [n, subPtr] : enums) {
QString display = QStringLiteral("%1 (%2)")
.arg(nameOf(n), n->resolvedClassKeyword());
auto* item = new QStandardItem(
QIcon(":/vsicons/symbol-enum.svg"), display);
item->setData(QVariant::fromValue(subPtr), Qt::UserRole);
item->setData(QVariant::fromValue(n->id), Qt::UserRole + 1);
projectItem->appendRow(item);
}
model->appendRow(projectItem); model->appendRow(projectItem);
} }

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
}

View File

@@ -89,7 +89,7 @@ private slots:
QCOMPARE(result.meta[2].lineKind, LineKind::Footer); QCOMPARE(result.meta[2].lineKind, LineKind::Footer);
} }
void testPaddingMarker() { void testHexNodeCompose() {
NodeTree tree; NodeTree tree;
tree.baseAddress = 0; tree.baseAddress = 0;
@@ -100,19 +100,18 @@ private slots:
int ri = tree.addNode(root); int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id; uint64_t rootId = tree.nodes[ri].id;
Node pad; Node hex;
pad.kind = NodeKind::Padding; hex.kind = NodeKind::Hex8;
pad.name = "pad"; hex.name = "pad";
pad.parentId = rootId; hex.parentId = rootId;
pad.offset = 0; hex.offset = 0;
tree.addNode(pad); tree.addNode(hex);
NullProvider prov; NullProvider prov;
ComposeResult result = compose(tree, prov); ComposeResult result = compose(tree, prov);
// CommandRow + padding + root footer = 3 // CommandRow + hex node + root footer = 3
QCOMPARE(result.meta.size(), 3); QCOMPARE(result.meta.size(), 3);
QVERIFY(result.meta[1].markerMask & (1u << M_PAD));
QCOMPARE(result.meta[1].depth, 1); QCOMPARE(result.meta[1].depth, 1);
// Line 2 is root footer // Line 2 is root footer

View File

@@ -34,9 +34,8 @@ static void buildSmallTree(NodeTree& tree) {
field(0, NodeKind::UInt32, "field_u32"); // 4 bytes field(0, NodeKind::UInt32, "field_u32"); // 4 bytes
field(4, NodeKind::Float, "field_float"); // 4 bytes field(4, NodeKind::Float, "field_float"); // 4 bytes
field(8, NodeKind::UInt8, "field_u8"); // 1 byte field(8, NodeKind::UInt8, "field_u8"); // 1 byte
field(9, NodeKind::Padding, "pad0"); // 3 bytes padding field(9, NodeKind::Hex16, "pad0"); // 2 bytes
// Set padding arrayLen = 3 for 3-byte padding field(11, NodeKind::Hex8, "pad1"); // 1 byte
tree.nodes.last().arrayLen = 3;
field(12, NodeKind::Hex32, "field_hex"); // 4 bytes field(12, NodeKind::Hex32, "field_hex"); // 4 bytes
} }
@@ -282,47 +281,6 @@ private slots:
QVERIFY(newIdx >= 0); QVERIFY(newIdx >= 0);
} }
// ── Test: Padding value edit is effectively blocked at controller level ──
void testPaddingValueEditIsBlocked() {
// Find the padding node
int padIdx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].kind == NodeKind::Padding) { padIdx = i; break; }
}
QVERIFY(padIdx >= 0);
uint64_t addr = m_doc->tree.computeOffset(padIdx);
// Read original data at padding offset
int padSize = m_doc->tree.nodes[padIdx].byteSize();
QByteArray origData = m_doc->provider->readBytes(addr, padSize);
// The context menu blocks Padding editing, so the controller's setNodeValue
// would only be called if the editing UI somehow allows it. But let's verify
// the editor correctly blocks it.
// Find padding line in composed output
ComposeResult result = m_doc->compose();
int paddingLine = -1;
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].nodeKind == NodeKind::Padding &&
result.meta[i].lineKind == LineKind::Field) {
paddingLine = i;
break;
}
}
QVERIFY(paddingLine >= 0);
m_editor->applyDocument(result);
QApplication::processEvents();
// beginInlineEdit(Value) on Padding line must be rejected
QVERIFY(!m_editor->beginInlineEdit(EditTarget::Value, paddingLine));
QVERIFY(!m_editor->isEditing());
// Data must be unchanged
QByteArray afterData = m_doc->provider->readBytes(addr, padSize);
QCOMPARE(afterData, origData);
}
// ── Test: setNodeValue with Hex32 (space-separated hex bytes) ── // ── Test: setNodeValue with Hex32 (space-separated hex bytes) ──
void testSetNodeValueHex() { void testSetNodeValueHex() {
int idx = -1; int idx = -1;

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

@@ -170,9 +170,10 @@ static NodeTree makeTestTree() {
n.parentId = rootId; n.offset = off; n.parentId = rootId; n.offset = off;
tree.addNode(n); tree.addNode(n);
}; };
auto pad = [&](int off, int len, const char* name) { auto pad = [&](int off, int /*len*/, const char* name) {
Node n; n.kind = NodeKind::Padding; n.name = name; // 4-byte padding → Hex32 (all usages in this test pass len=4)
n.parentId = rootId; n.offset = off; n.arrayLen = len; Node n; n.kind = NodeKind::Hex32; n.name = name;
n.parentId = rootId; n.offset = off;
tree.addNode(n); tree.addNode(n);
}; };
auto arr = [&](int off, NodeKind ek, int len, const char* name) { auto arr = [&](int off, NodeKind ek, int len, const char* name) {
@@ -278,8 +279,8 @@ static NodeTree makeTestTree() {
n.kind = NodeKind::UInt16; n.name = "Length"; n.offset = 0; tree.addNode(n); n.kind = NodeKind::UInt16; n.name = "Length"; n.offset = 0; tree.addNode(n);
n.kind = NodeKind::UInt16; n.name = "MaximumLength"; n.offset = 2; tree.addNode(n); n.kind = NodeKind::UInt16; n.name = "MaximumLength"; n.offset = 2; tree.addNode(n);
n.kind = NodeKind::Padding; n.name = "Pad"; n.kind = NodeKind::Hex32; n.name = "Pad";
n.offset = 4; n.arrayLen = 4; tree.addNode(n); n.offset = 4; n.arrayLen = 1; tree.addNode(n);
n.kind = NodeKind::Pointer64; n.name = "Buffer"; n.offset = 8; n.arrayLen = 1; n.kind = NodeKind::Pointer64; n.name = "Buffer"; n.offset = 8; n.arrayLen = 1;
tree.addNode(n); tree.addNode(n);
} }
@@ -751,70 +752,6 @@ private slots:
m_editor->applyDocument(m_result); m_editor->applyDocument(m_result);
} }
// ── Test: Padding line rejects value editing ──
void testPaddingLineRejectsValueEdit() {
m_editor->applyDocument(m_result);
// Find a Padding line in the composed output
int paddingLine = -1;
for (int i = 0; i < m_result.meta.size(); i++) {
if (m_result.meta[i].nodeKind == NodeKind::Padding &&
m_result.meta[i].lineKind == LineKind::Field) {
paddingLine = i;
break;
}
}
QVERIFY2(paddingLine >= 0, "Should have at least one Padding line in test tree");
const LineMeta* lm = m_editor->metaForLine(paddingLine);
QVERIFY(lm);
QCOMPARE(lm->nodeKind, NodeKind::Padding);
// Value edit on Padding MUST be rejected (the bug fix)
QVERIFY2(!m_editor->beginInlineEdit(EditTarget::Value, paddingLine),
"Value edit should be rejected on Padding lines");
QVERIFY(!m_editor->isEditing());
// Name edit on Padding SHOULD succeed (ASCII preview column is editable)
bool ok = m_editor->beginInlineEdit(EditTarget::Name, paddingLine);
QVERIFY2(ok, "Name edit should be allowed on Padding lines (ASCII preview)");
QVERIFY(m_editor->isEditing());
m_editor->cancelInlineEdit();
// Type edit on Padding SHOULD succeed (emits popup signal)
QSignalSpy typeSpy(m_editor, &RcxEditor::typePickerRequested);
ok = m_editor->beginInlineEdit(EditTarget::Type, paddingLine);
QVERIFY2(ok, "Type edit should be allowed on Padding lines");
QCOMPARE(typeSpy.count(), 1);
}
// ── Test: resolvedSpanFor rejects Value on Padding (defense-in-depth) ──
void testPaddingLineRejectsValueSpan() {
m_editor->applyDocument(m_result);
// Find a Padding line
int paddingLine = -1;
for (int i = 0; i < m_result.meta.size(); i++) {
if (m_result.meta[i].nodeKind == NodeKind::Padding &&
m_result.meta[i].lineKind == LineKind::Field) {
paddingLine = i;
break;
}
}
QVERIFY(paddingLine >= 0);
const LineMeta* lm = m_editor->metaForLine(paddingLine);
QVERIFY(lm);
// valueSpanFor returns valid (shared with Hex via KF_HexPreview)
ColumnSpan vs = RcxEditor::valueSpan(*lm, 200);
QVERIFY2(vs.valid, "valueSpanFor should return valid for Padding (shared HexPreview flag)");
// But beginInlineEdit should still reject it
QVERIFY(!m_editor->beginInlineEdit(EditTarget::Value, paddingLine));
QVERIFY(!m_editor->isEditing());
}
// ── Test: value edit commit fires signal with typed text ── // ── Test: value edit commit fires signal with typed text ──
void testValueEditCommitUpdatesSignal() { void testValueEditCommitUpdatesSignal() {
m_editor->applyDocument(m_result); m_editor->applyDocument(m_result);
@@ -823,8 +760,6 @@ private slots:
const LineMeta* lm = m_editor->metaForLine(kFirstDataLine); const LineMeta* lm = m_editor->metaForLine(kFirstDataLine);
QVERIFY(lm); QVERIFY(lm);
QCOMPARE(lm->lineKind, LineKind::Field); QCOMPARE(lm->lineKind, LineKind::Field);
QVERIFY(lm->nodeKind != NodeKind::Padding);
// Begin value edit // Begin value edit
bool ok = m_editor->beginInlineEdit(EditTarget::Value, kFirstDataLine); bool ok = m_editor->beginInlineEdit(EditTarget::Value, kFirstDataLine);
QVERIFY(ok); QVERIFY(ok);

View File

@@ -418,30 +418,6 @@ private slots:
QVERIFY(result.contains("wchar_t wname[32];")); QVERIFY(result.contains("wchar_t wname[32];"));
} }
// ── Padding node ──
void testPaddingNode() {
rcx::NodeTree tree;
rcx::Node root;
root.kind = rcx::NodeKind::Struct;
root.name = "PadTest";
root.structTypeName = "PadTest";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
rcx::Node pad;
pad.kind = rcx::NodeKind::Padding;
pad.name = "reserved";
pad.parentId = rootId;
pad.offset = 0;
pad.arrayLen = 16;
tree.addNode(pad);
QString result = rcx::renderCpp(tree, rootId);
QVERIFY(result.contains("uint8_t reserved[16];"));
}
// ── Full SDK export (multiple root structs) ── // ── Full SDK export (multiple root structs) ──
void testFullSdkExport() { void testFullSdkExport() {

View File

@@ -304,39 +304,6 @@ private slots:
QVERIFY(result.contains("float speed;")); QVERIFY(result.contains("float speed;"));
} }
void testGenerator_typeAliases_padding() {
// Padding gap and tail padding should use aliased uint8_t
NodeTree tree;
Node root;
root.kind = NodeKind::Struct;
root.name = "PadTest";
root.structTypeName = "PadTest";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node f1;
f1.kind = NodeKind::UInt32;
f1.name = "a";
f1.parentId = rootId;
f1.offset = 0;
tree.addNode(f1);
Node f2;
f2.kind = NodeKind::UInt32;
f2.name = "b";
f2.parentId = rootId;
f2.offset = 8; // gap of 4 bytes at offset 4
tree.addNode(f2);
QHash<NodeKind, QString> aliases;
aliases[NodeKind::Padding] = "BYTE";
QString result = renderCpp(tree, rootId, &aliases);
// Padding gap should use the alias
QVERIFY(result.contains("BYTE _pad"));
}
void testGenerator_typeAliases_array() { void testGenerator_typeAliases_array() {
// Array element type should use alias // Array element type should use alias
NodeTree tree; NodeTree tree;
@@ -547,134 +514,92 @@ private slots:
void testWorkspace_simpleTree() { void testWorkspace_simpleTree() {
auto tree = makeSimpleTree(); auto tree = makeSimpleTree();
QStandardItemModel model; QStandardItemModel model;
buildWorkspaceModel(&model, tree, "TestProject.rcx"); QVector<TabInfo> tabs = {{ &tree, "TestProject.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
// 1 top-level item (the project) // Single "Project" root
QCOMPARE(model.rowCount(), 1); QCOMPARE(model.rowCount(), 1);
QStandardItem* project = model.item(0); QStandardItem* project = model.item(0);
QCOMPARE(project->text(), QString("TestProject.rcx")); QCOMPARE(project->text(), QString("Project"));
// Project has 1 child: the Player struct // 1 type directly under Project: Player (no member fields)
QCOMPARE(project->rowCount(), 1); QCOMPARE(project->rowCount(), 1);
QStandardItem* player = project->child(0); QVERIFY(project->child(0)->text().contains("Player"));
QVERIFY(player->text().contains("Player")); QVERIFY(project->child(0)->text().contains("struct"));
QVERIFY(player->text().contains("struct")); QCOMPARE(project->child(0)->rowCount(), 0);
// Player struct has 2 children: health, speed
QCOMPARE(player->rowCount(), 2);
QVERIFY(player->child(0)->text().contains("health"));
QVERIFY(player->child(1)->text().contains("speed"));
} }
void testWorkspace_twoRootTree() { void testWorkspace_twoRootTree() {
auto tree = makeTwoRootTree(); auto tree = makeTwoRootTree();
QStandardItemModel model; QStandardItemModel model;
buildWorkspaceModel(&model, tree, "TwoRoot.rcx"); QVector<TabInfo> tabs = {{ &tree, "TwoRoot.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
QCOMPARE(model.rowCount(), 1); QCOMPARE(model.rowCount(), 1);
QStandardItem* project = model.item(0); QStandardItem* project = model.item(0);
// 2 root struct children: Alpha and Bravo // 2 types sorted alphabetically: Alpha, Bravo (no field children)
QCOMPARE(project->rowCount(), 2); QCOMPARE(project->rowCount(), 2);
QVERIFY(project->child(0)->text().contains("Alpha")); QVERIFY(project->child(0)->text().contains("Alpha"));
QVERIFY(project->child(1)->text().contains("Bravo")); QVERIFY(project->child(1)->text().contains("Bravo"));
QCOMPARE(project->child(0)->rowCount(), 0);
// Each has 1 field child QCOMPARE(project->child(1)->rowCount(), 0);
QCOMPARE(project->child(0)->rowCount(), 1);
QVERIFY(project->child(0)->child(0)->text().contains("flagsA"));
QCOMPARE(project->child(1)->rowCount(), 1);
QVERIFY(project->child(1)->child(0)->text().contains("flagsB"));
} }
void testWorkspace_richTree_rootCount() { void testWorkspace_richTree_rootCount() {
auto tree = makeRichTree(); auto tree = makeRichTree();
QStandardItemModel model; QStandardItemModel model;
buildWorkspaceModel(&model, tree, "Rich.rcx"); QVector<TabInfo> tabs = {{ &tree, "Rich.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
QStandardItem* project = model.item(0); QStandardItem* project = model.item(0);
QCOMPARE(project->rowCount(), 3); // Pet, Cat, Ball QCOMPARE(project->rowCount(), 3); // Ball, Cat, Pet (sorted)
} }
void testWorkspace_richTree_petChildren() { void testWorkspace_richTree_sorted() {
auto tree = makeRichTree(); auto tree = makeRichTree();
QStandardItemModel model; QStandardItemModel model;
buildWorkspaceModel(&model, tree, "Rich.rcx"); QVector<TabInfo> tabs = {{ &tree, "Rich.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
QStandardItem* pet = model.item(0)->child(0); QStandardItem* project = model.item(0);
QVERIFY(pet->text().contains("Pet")); // Sorted alphabetically: Ball, Cat, Pet
// Pet has 2 non-hex children: name (UTF8), owner (Pointer64) QVERIFY(project->child(0)->text().contains("Ball"));
QCOMPARE(pet->rowCount(), 2); QVERIFY(project->child(1)->text().contains("Cat"));
QVERIFY(pet->child(0)->text().contains("name")); QVERIFY(project->child(2)->text().contains("Pet"));
QVERIFY(pet->child(1)->text().contains("owner")); // No member fields under type nodes
} QCOMPARE(project->child(0)->rowCount(), 0);
QCOMPARE(project->child(1)->rowCount(), 0);
void testWorkspace_richTree_catNesting() { QCOMPARE(project->child(2)->rowCount(), 0);
auto tree = makeRichTree();
QStandardItemModel model;
buildWorkspaceModel(&model, tree, "Rich.rcx");
QStandardItem* cat = model.item(0)->child(1);
QVERIFY(cat->text().contains("Cat"));
// Find the nested "Pet" struct child (base)
QStandardItem* base = nullptr;
for (int i = 0; i < cat->rowCount(); i++) {
if (cat->child(i)->text().contains("Pet") &&
cat->child(i)->text().contains("struct")) {
base = cat->child(i);
break;
}
}
QVERIFY2(base != nullptr, "Cat should have a nested Pet struct child");
// base has structId set
QVERIFY(base->data(Qt::UserRole + 1).isValid());
// base should have its own children (name + owner)
QCOMPARE(base->rowCount(), 2);
}
void testWorkspace_richTree_ballChildren() {
auto tree = makeRichTree();
QStandardItemModel model;
buildWorkspaceModel(&model, tree, "Rich.rcx");
QStandardItem* ball = model.item(0)->child(2);
QVERIFY(ball->text().contains("Ball"));
// Ball has 3 non-hex children: speed, position, color
QCOMPARE(ball->rowCount(), 3);
QVERIFY(ball->child(0)->text().contains("speed"));
QVERIFY(ball->child(1)->text().contains("position"));
QVERIFY(ball->child(2)->text().contains("color"));
} }
void testWorkspace_emptyTree() { void testWorkspace_emptyTree() {
NodeTree tree; NodeTree tree;
QStandardItemModel model; QStandardItemModel model;
buildWorkspaceModel(&model, tree, "Empty.rcx"); QVector<TabInfo> tabs = {{ &tree, "Empty.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
// Still has the "Project" root, just no children
QCOMPARE(model.rowCount(), 1); QCOMPARE(model.rowCount(), 1);
QCOMPARE(model.item(0)->text(), QString("Project"));
QCOMPARE(model.item(0)->rowCount(), 0); QCOMPARE(model.item(0)->rowCount(), 0);
} }
void testWorkspace_structIdRole() { void testWorkspace_structIdRole() {
auto tree = makeSimpleTree(); auto tree = makeSimpleTree();
QStandardItemModel model; QStandardItemModel model;
buildWorkspaceModel(&model, tree, "Test.rcx"); QVector<TabInfo> tabs = {{ &tree, "Test.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
QStandardItem* project = model.item(0); QStandardItem* project = model.item(0);
// Project item should NOT have structId // Project root has kGroupSentinel
QVERIFY(!project->data(Qt::UserRole + 1).isValid()); QCOMPARE(project->data(Qt::UserRole + 1).toULongLong(), kGroupSentinel);
// Player struct should have structId // Player type item should have structId
QStandardItem* player = project->child(0); QStandardItem* player = project->child(0);
QVERIFY(player->data(Qt::UserRole + 1).isValid()); QVERIFY(player->data(Qt::UserRole + 1).isValid());
QVERIFY(player->data(Qt::UserRole + 1).toULongLong() > 0); QVERIFY(player->data(Qt::UserRole + 1).toULongLong() > 0);
QVERIFY(player->data(Qt::UserRole + 1).toULongLong() != kGroupSentinel);
// health field should NOT have structId
QStandardItem* health = player->child(0);
QVERIFY(!health->data(Qt::UserRole + 1).isValid());
} }
// ═══════════════════════════════════════════════════ // ═══════════════════════════════════════════════════

View File

@@ -92,9 +92,16 @@ private slots:
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, QString("Reclass Dark")); QCOMPARE(all[0].name, QString("Reclass Dark"));
QCOMPARE(all[1].name, QString("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() {

View File

@@ -8,6 +8,8 @@
#include <QLineEdit> #include <QLineEdit>
#include <QListView> #include <QListView>
#include <QStringListModel> #include <QStringListModel>
#include <QLabel>
#include <QFrame>
#include <Qsci/qsciscintilla.h> #include <Qsci/qsciscintilla.h>
#include "controller.h" #include "controller.h"
#include "typeselectorpopup.h" #include "typeselectorpopup.h"
@@ -198,6 +200,127 @@ private slots:
} }
} }
// ── Isolate first-show cost with different window flags ──
void benchmarkFirstShow() {
auto ms = [](qint64 ns) { return QString::number(ns / 1000000.0, 'f', 2); };
struct FlagTest {
const char* name;
Qt::WindowFlags flags;
};
FlagTest tests[] = {
{"Qt::Popup|Frameless", Qt::Popup | Qt::FramelessWindowHint},
{"Qt::Tool|Frameless", Qt::Tool | Qt::FramelessWindowHint},
{"Qt::ToolTip", Qt::ToolTip},
{"Qt::Window|Frameless", Qt::Window | Qt::FramelessWindowHint},
{"Qt::Popup|Frameless (2nd)", Qt::Popup | Qt::FramelessWindowHint},
};
for (const auto& test : tests) {
auto* f = new QFrame(nullptr, test.flags);
f->resize(300, 400);
QElapsedTimer t; t.start();
f->show();
qint64 t1 = t.nsecsElapsed(); t.restart();
QApplication::processEvents();
qint64 t2 = t.nsecsElapsed();
f->hide();
QApplication::processEvents();
t.restart();
f->show();
qint64 t3 = t.nsecsElapsed(); t.restart();
QApplication::processEvents();
qint64 t4 = t.nsecsElapsed();
f->hide();
QApplication::processEvents();
qDebug() << "";
qDebug().noquote() << QString("=== %1 ===").arg(test.name);
qDebug().noquote() << QString(" 1st: show=%1ms events=%2ms | 2nd: show=%3ms events=%4ms")
.arg(ms(t1)).arg(ms(t2)).arg(ms(t3)).arg(ms(t4));
delete f;
}
// TypeSelectorPopup: cold vs after warmUp
{
auto* popup = new TypeSelectorPopup();
TypeEntry dummy;
dummy.entryKind = TypeEntry::Primitive;
dummy.primitiveKind = NodeKind::Hex8;
dummy.displayName = "test";
popup->setTypes({dummy});
QElapsedTimer t; t.start();
popup->show();
qint64 t1 = t.nsecsElapsed(); t.restart();
QApplication::processEvents();
qint64 t2 = t.nsecsElapsed();
popup->hide();
QApplication::processEvents();
t.restart();
popup->show();
qint64 t3 = t.nsecsElapsed(); t.restart();
QApplication::processEvents();
qint64 t4 = t.nsecsElapsed();
popup->hide();
QApplication::processEvents();
qDebug() << "";
qDebug().noquote() << QString("=== TypeSelectorPopup (cold, Qt::Popup) ===");
qDebug().noquote() << QString(" 1st: show=%1ms events=%2ms | 2nd: show=%3ms events=%4ms")
.arg(ms(t1)).arg(ms(t2)).arg(ms(t3)).arg(ms(t4));
delete popup;
}
// Clean order test: dummy popup with children FIRST, then TypeSelectorPopup
qDebug() << "";
qDebug() << "=== CLEAN: dummy popup first, then TypeSelectorPopup ===";
{
auto* dummy = new QFrame(nullptr, Qt::Popup | Qt::FramelessWindowHint);
dummy->resize(300, 400);
auto* dLay = new QVBoxLayout(dummy);
dLay->addWidget(new QLabel("dummy"));
dLay->addWidget(new QLineEdit);
auto* dModel = new QStringListModel(dummy);
QStringList dItems; for (int i = 0; i < 10; i++) dItems << "x";
dModel->setStringList(dItems);
auto* dLv = new QListView; dLv->setModel(dModel);
dLay->addWidget(dLv);
QElapsedTimer t; t.start();
dummy->show();
qint64 t1 = t.nsecsElapsed(); t.restart();
QApplication::processEvents();
qint64 t2 = t.nsecsElapsed();
dummy->hide();
QApplication::processEvents();
qDebug().noquote() << QString(" Dummy popup: show=%1ms events=%2ms").arg(ms(t1)).arg(ms(t2));
delete dummy;
}
{
auto* popup = new TypeSelectorPopup();
TypeEntry e;
e.entryKind = TypeEntry::Primitive;
e.primitiveKind = NodeKind::Hex8;
e.displayName = "test";
popup->setTypes({e});
popup->resize(300, 400);
QElapsedTimer t; t.start();
popup->show();
qint64 t1 = t.nsecsElapsed(); t.restart();
QApplication::processEvents();
qint64 t2 = t.nsecsElapsed();
popup->hide();
QApplication::processEvents();
qDebug().noquote() << QString(" TypeSelectorPopup (after dummy): show=%1ms events=%2ms").arg(ms(t1)).arg(ms(t2));
delete popup;
}
}
// ── Popup data model ── // ── Popup data model ──
void testPopupListsRootStructs() { void testPopupListsRootStructs() {

View File

@@ -57,8 +57,8 @@ static void buildValidationTree(NodeTree& tree) {
field(46, NodeKind::Hex32, "field_h32"); field(46, NodeKind::Hex32, "field_h32");
field(50, NodeKind::Hex64, "field_h64"); field(50, NodeKind::Hex64, "field_h64");
field(58, NodeKind::Pointer64, "field_ptr"); field(58, NodeKind::Pointer64, "field_ptr");
field(66, NodeKind::Padding, "pad0"); field(66, NodeKind::Hex32, "pad0");
tree.nodes.last().arrayLen = 6; field(70, NodeKind::Hex16, "pad1");
fieldArr(72, NodeKind::UInt32, 4, "field_arr"); fieldArr(72, NodeKind::UInt32, 4, "field_arr");
} }
@@ -725,9 +725,9 @@ private slots:
QCOMPARE(m_doc->undoStack.count(), 0); QCOMPARE(m_doc->undoStack.count(), 0);
} }
// ── changeNodeKind size transitions: shrink inserts padding ── // ── changeNodeKind size transitions: shrink inserts hex nodes ──
void testChangeKindShrinkInsertsPadding() { void testChangeKindShrinkInsertsHexNodes() {
int idx = findNode(m_doc->tree, "field_u32"); int idx = findNode(m_doc->tree, "field_u32");
QVERIFY(idx >= 0); QVERIFY(idx >= 0);
QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::UInt32); // 4 bytes QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::UInt32); // 4 bytes
@@ -737,7 +737,7 @@ private slots:
QApplication::processEvents(); QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::UInt8); QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::UInt8);
// Should have inserted padding nodes (Hex16 + Hex8 = 3 bytes, or similar) // Should have inserted hex nodes (Hex16 + Hex8 = 3 bytes, or similar)
QVERIFY(m_doc->tree.nodes.size() > origCount); QVERIFY(m_doc->tree.nodes.size() > origCount);
// Undo restores everything // Undo restores everything
@@ -985,37 +985,6 @@ private slots:
QVERIFY(!m_editor->isEditing()); QVERIFY(!m_editor->isEditing());
} }
// ── Editor: padding value edit blocked, name/type still work ──
void testPaddingEditRestrictions() {
m_ctrl->refresh();
QApplication::processEvents();
ComposeResult result = m_doc->compose();
m_editor->applyDocument(result);
QApplication::processEvents();
// Find padding line
int padLine = -1;
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].nodeKind == NodeKind::Padding &&
result.meta[i].lineKind == LineKind::Field) {
padLine = i;
break;
}
}
QVERIFY(padLine >= 0);
// Value edit rejected
QVERIFY(!m_editor->beginInlineEdit(EditTarget::Value, padLine));
// Type edit accepted
bool ok = m_editor->beginInlineEdit(EditTarget::Type, padLine);
QVERIFY(ok);
m_editor->cancelInlineEdit();
QApplication::processEvents();
}
// ── Editor: struct header rejects value edit ── // ── Editor: struct header rejects value edit ──
void testStructHeaderRejectsValueEdit() { void testStructHeaderRejectsValueEdit() {

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"

View File

@@ -15,6 +15,9 @@
#include <windows.h> #include <windows.h>
#include <io.h> #include <io.h>
#include <fcntl.h> #include <fcntl.h>
#else
#include <unistd.h>
#include <sys/select.h>
#endif #endif
int main(int argc, char* argv[]) { int main(int argc, char* argv[]) {