Compare commits

...

25 Commits

Author SHA1 Message Date
IChooseYou
596f410b96 perf: compose 30% faster — move semantics, BFS offsets, zero-alloc hex formatting
- compose.cpp: emitLine takes LineMeta&& (move, not copy) at all 22 call sites
- compose.cpp: reserve meta/text buffers, BFS offset computation O(N) vs O(N*D)
- compose.cpp: pre-compute typeNameLens[], merge global width loops
- format.cpp: bytesToHex uses stack buffer + lookup table (zero heap allocs)
- format.cpp: hexVal single QString::asprintf instead of 2-string concat
- editor.cpp: guard hover updates during applyDocument (stale index safety)
- core.h: assertion on makeArrayElemSelId negative index
- format.cpp: assertion on extractBits overflow
- main.cpp: tree lines enabled by default
- bench_large_class: add 2000-field benchComposeLarge test

Benchmark: 500 fields 0.70→0.51ms (27%), 2000 fields 2.28→1.57ms (31%)
2026-03-08 07:28:26 -06:00
IChooseYou
f0fc85f60f fix: CI test failures from collapsed=true default
- compose.cpp: show static fields for root structs even when collapsed
- test_compose: set collapsed=false on nodes needing expanded rendering
- test_disasm: set collapsed=false on vtable pointer nodes
- test_static_fields: rewrite collapsed test to use non-root child struct
2026-03-07 11:58:08 -07:00
IChooseYou
70c7404556 fix: MSVC build support, modern theme, vergilius fnptr import
- CMake: detect MSVC↔MinGW Qt ABI mismatch at configure time (#10)
- CMake: add /utf-8 /MP for MSVC builds
- CMake: fix theme/example deployment for multi-config generators (MSVC)
- Auto-run windeployqt post-build so correct Qt DLLs are always deployed
- Add Modern theme (dark blue with cyan/purple/amber accents)
- Vergilius import: handle function pointer typedefs
2026-03-07 11:31:04 -07:00
IChooseYou
f27459c21b fix: default collapsed=true for child structs, dock border wraps panel, search bar borderless, title bar +2px 2026-03-07 11:17:35 -07:00
IChooseYou
a5abcbeea6 Merge pull request #9 from noita-player/feature/peb-teb-mcp
Add process.info MCP tool for PEB/TEB enumeration and peb/tebs API for providers to implement
2026-03-07 09:42:51 -07:00
IChooseYou
7071402319 fix: workspace panel — preserve expansion on clear, dock title counts, drop kind text, close.svg clear button 2026-03-07 08:37:15 -07:00
IChooseYou
0dc390ed86 fix: WinDbg plugin dynamic dbgeng loading, editor two-tone bg, UI polish
WinDbg plugin: load dbgeng.dll dynamically from Debugging Tools directory
instead of static linking (system dbgeng.dll lacks remote DebugConnect).
Copy tools dbghelp.dll next to exe so it loads before System32 version.
Add COM init on DbgEng thread, browse for tools dir, styled dialog.

Editor: derive darker background via theme.background.darker(115) for
visual depth between chrome and editor surfaces.

UI: global scrollbar styling, workspace accent bar 1px, pane tab font
from editor settings, workspace dock default width 128px.
2026-03-07 08:31:51 -07:00
IChooseYou
188c27c6e2 feat: workspace panel visual overhaul, perf optimizations, remove kernel base addresses
Workspace panel:
- Custom WorkspaceDelegate: struct names bright, metadata dimmed, child types in teal
- Search box: monospace font, search icon, bordered with focus highlight
- Selection: accent bar, all fonts synced to 10pt monospace
- Remove rebuildWorkspaceModel from visibilityChanged (fixes double-click refresh)
- Incremental sync (syncProjectExplorer) preserves tree expansion state

Performance:
- childrenOf() O(1) via cached parent→children hash map
- Debounced workspace rebuilds (50ms coalesce)
- Pre-reserve node vector in NodeTree::fromJson
- Benchmark suite (bench_project)

Data:
- Remove kernel baseAddress from Vergilius/WinSDK examples (default to 0x400000)
2026-03-07 06:47:16 -07:00
noita-player
81f1e4319f Add process.info MCP tool for PEB/TEB enumeration
Expose PEB address via provider interface and query it in the
ProcessMemory plugin using NtQueryInformationProcess. The new
process.info MCP tool returns the PEB VA and enumerates TEBs by
querying thread information via NtQuerySystemInformation and
NtQueryInformationThread for each thread in the target process.
2026-03-06 23:21:10 -08:00
IChooseYou
3ab6affa5e fix: vergilius fnptr import, remove tab pin, flatten workspace tree, middle-click close
- Fix vergilius_to_rcx.py to detect function pointer syntax (*Name)(params) and emit FuncPtr64
- Re-fetch 85 structs to recover proper field names (697/716 fixed)
- Remove pin button from dock tabs and all pin-related context menu items
- Fix newClass() creating duplicate tabs
- Set workspace tree font to match tab bar (size 10)
- Flatten workspace tree: remove redundant Project group node (VS Code Explorer style)
- Add middle-click to close dock widget tabs
- Allow type chooser to show cross-doc types for root nodes
2026-03-06 17:39:50 -07:00
IChooseYou
35b3cd9ac1 feat: enum editing UI, protect enums from struct ops, New Class opens two tabs
- New Class creates two Unnamed tabs, selects the first
- New Enum creates 5 placeholder members (Member0-4)
- Right-click enum member: Add Member Above/Below, Remove Member
- Right-click enum header: Add Member, Rename, Delete only
- Enum nodes fully protected from struct operations (no Add Child, Insert, Convert)
2026-03-06 11:00:06 -07:00
IChooseYou
e5938f7e82 fix: enable hover on dock tab bars via WA_Hover attribute 2026-03-06 09:45:23 -07:00
IChooseYou
03c49d19dd fix: type chooser always shows modifiers, tabs show class name, dock buttons restored on re-dock 2026-03-06 09:23:36 -07:00
IChooseYou
b7eebedf50 fix: remove grab_tabs test target (missing source file) 2026-03-06 08:23:09 -07:00
IChooseYou
9ff456a8d6 revert: remove theme xcopy to avoid clobbering custom themes 2026-03-06 08:22:40 -07:00
IChooseYou
580f285edd fix: also copy theme JSON files to output dir for MSVC builds 2026-03-06 08:22:02 -07:00
IChooseYou
d23a6c7656 fix: copy example .rcx files to output dir for MSVC builds 2026-03-06 08:20:33 -07:00
IChooseYou
25d8de95b7 fix: crash in dismissStartPage due to re-entrant close/rejected signal 2026-03-06 08:16:13 -07:00
Sen66
955db3813a fix: msvc build due to startpage.h 2026-03-06 16:10:54 +01:00
IChooseYou
f4f203e0f0 Merge remote-tracking branch 'origin/fix-msvc-build' 2026-03-06 08:07:57 -07:00
IChooseYou
1d3f1a672a fix: start page card order, icon consistency, and Continue placement 2026-03-06 08:07:27 -07:00
Sen66
da29206bdb fix: msvc build with latest dock header file 2026-03-06 16:03:54 +01:00
IChooseYou
4986893fca feat: VS2022-style start page popup with recent files and get started cards 2026-03-06 07:58:13 -07:00
IChooseYou
17a1fb032e chore: remove Demo.rcx, add WinSDK + windows-x86_64.h examples 2026-03-06 07:56:33 -07:00
IChooseYou
8d92957837 fix: move DockTabButtons to header for MSVC automoc compatibility
automoc doesn't generate main.moc on MSVC, breaking the build.
Move DockTabButtons (which needs Q_OBJECT) to its own header so
automoc handles it as moc_dock_tab_buttons.cpp instead.
2026-03-06 06:14:59 -07:00
40 changed files with 291824 additions and 4194 deletions

View File

@@ -22,6 +22,32 @@ find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS ${_QT_COMPONENTS})
set(QT Qt${QT_VERSION_MAJOR})
message(STATUS "Using ${QT}: ${${QT}_DIR}")
# ── ABI sanity check: prevent MSVC ↔ MinGW Qt mismatch ──
# Building with MSVC against MinGW Qt (or vice versa) compiles fine but
# crashes immediately at runtime (ABI mismatch in QString/QSettings internals).
if(MSVC AND "${${QT}_DIR}" MATCHES "mingw")
message(FATAL_ERROR
"Qt installation was built with MinGW but this project is being compiled with MSVC.\n"
" Qt found at: ${${QT}_DIR}\n"
"This will compile but crash at startup due to ABI mismatch.\n"
"Fix: install Qt for MSVC (e.g. msvc2019_64) and set CMAKE_PREFIX_PATH to it:\n"
" cmake -DCMAKE_PREFIX_PATH=C:/Qt/6.5.2/msvc2019_64 ..")
elseif(MINGW AND "${${QT}_DIR}" MATCHES "msvc")
message(FATAL_ERROR
"Qt installation was built with MSVC but this project is being compiled with MinGW.\n"
" Qt found at: ${${QT}_DIR}\n"
"This will compile but crash at startup due to ABI mismatch.\n"
"Fix: install Qt for MinGW and set CMAKE_PREFIX_PATH to it:\n"
" cmake -DCMAKE_PREFIX_PATH=C:/Qt/6.5.2/mingw_64 ..")
endif()
# ── MSVC compile flags ──
if(MSVC)
# /utf-8: treat source and execution character sets as UTF-8
# /MP: multi-processor compilation
add_compile_options(/utf-8 /MP)
endif()
# Qt5 on Windows needs WinExtras for HICON conversion
set(_QT_WINEXTRAS "")
if(QT_VERSION_MAJOR EQUAL 5 AND WIN32)
@@ -109,6 +135,8 @@ add_executable(Reclass
src/scannerpanel.h
src/scannerpanel.cpp
src/mainwindow.h
src/startpage.h
src/dock_tab_buttons.h
src/optionsdialog.h
src/optionsdialog.cpp
src/titlebar.h
@@ -149,6 +177,26 @@ target_link_libraries(Reclass PRIVATE
)
if(WIN32)
target_link_libraries(Reclass PRIVATE dbghelp dwmapi psapi raw_pdb)
# Copy Debugging Tools dbghelp.dll next to Reclass.exe so the Windows
# loader picks it up (app dir > System32). The system dbghelp.dll
# lacks StackWalk2 which the tools dbgeng.dll needs for remote debug.
set(_DBG_TOOLS_DIRS
"C:/Program Files (x86)/Windows Kits/10/Debuggers/x64"
"C:/Program Files/Windows Kits/10/Debuggers/x64")
foreach(_dir ${_DBG_TOOLS_DIRS})
if(EXISTS "${_dir}/dbghelp.dll")
foreach(_dll dbghelp.dll dbgcore.dll symsrv.dll)
if(EXISTS "${_dir}/${_dll}")
add_custom_command(TARGET Reclass POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${_dir}/${_dll}" $<TARGET_FILE_DIR:Reclass>
COMMENT "Copying ${_dll} from Debugging Tools")
endif()
endforeach()
break()
endif()
endforeach()
endif()
add_executable(ReclassMcpBridge tools/rcx-mcp-stdio.cpp)
@@ -162,12 +210,30 @@ if(APPLE)
)
endif()
# Copy built-in theme JSON files to build directory
# Copy built-in theme JSON files next to the executable.
# For single-config generators (Ninja/Make) the exe is in ${CMAKE_BINARY_DIR},
# for multi-config generators (MSVC/Xcode) it's in ${CMAKE_BINARY_DIR}/<config>.
# Using a post-build copy with $<TARGET_FILE_DIR:Reclass> handles both.
file(GLOB _theme_files "${CMAKE_SOURCE_DIR}/src/themes/defaults/*.json")
file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/themes")
# Single-config: configure_file for IDE convenience (available before first build)
if(NOT CMAKE_CONFIGURATION_TYPES)
file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/themes")
foreach(_tf ${_theme_files})
get_filename_component(_name ${_tf} NAME)
configure_file(${_tf} "${CMAKE_BINARY_DIR}/themes/${_name}" COPYONLY)
endforeach()
endif()
# Post-build: always copy to the actual exe directory (works for all generators)
add_custom_command(TARGET Reclass POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:Reclass>/themes"
COMMENT "Creating themes directory next to executable")
foreach(_tf ${_theme_files})
get_filename_component(_name ${_tf} NAME)
configure_file(${_tf} "${CMAKE_BINARY_DIR}/themes/${_name}" COPYONLY)
add_custom_command(TARGET Reclass POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${_tf}" "$<TARGET_FILE_DIR:Reclass>/themes/${_name}")
endforeach()
if(APPLE)
@@ -176,12 +242,25 @@ if(APPLE)
PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/themes")
endif()
# Copy example .rcx files to build directory
# Copy example .rcx files next to the executable (same logic as themes)
file(GLOB _example_files "${CMAKE_SOURCE_DIR}/src/examples/*.rcx")
file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/examples")
if(NOT CMAKE_CONFIGURATION_TYPES)
file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/examples")
foreach(_ef ${_example_files})
get_filename_component(_name ${_ef} NAME)
configure_file(${_ef} "${CMAKE_BINARY_DIR}/examples/${_name}" COPYONLY)
endforeach()
endif()
add_custom_command(TARGET Reclass POST_BUILD
COMMAND ${CMAKE_COMMAND} -E make_directory "$<TARGET_FILE_DIR:Reclass>/examples"
COMMENT "Creating examples directory next to executable")
foreach(_ef ${_example_files})
get_filename_component(_name ${_ef} NAME)
configure_file(${_ef} "${CMAKE_BINARY_DIR}/examples/${_name}" COPYONLY)
add_custom_command(TARGET Reclass POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${_ef}" "$<TARGET_FILE_DIR:Reclass>/examples/${_name}")
endforeach()
if(APPLE)
@@ -498,6 +577,14 @@ if(BUILD_TESTING)
endif()
add_test(NAME bench_large_class COMMAND bench_large_class)
add_executable(bench_project tests/bench_project.cpp)
target_include_directories(bench_project PRIVATE src)
target_link_libraries(bench_project PRIVATE ${QT}::Widgets ${QT}::Test)
if(WIN32)
target_link_libraries(bench_project PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
endif()
add_test(NAME bench_project COMMAND bench_project)
# 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)
if(TARGET ${QT}::windeployqt)

View File

@@ -1,7 +1,7 @@
# cmake/deploy.cmake - Dual-mode script for deploying Qt runtime DLLs
#
# Script mode: cmake -P deploy.cmake <target_exe> <windeployqt>
# Include mode: include(deploy) from CMakeLists.txt (creates "deploy" target)
# Include mode: include(deploy) from CMakeLists.txt (creates "deploy" target + post-build)
if(CMAKE_SCRIPT_MODE_FILE)
set(TARGET_EXE ${CMAKE_ARGV3})
@@ -17,7 +17,6 @@ if(CMAKE_SCRIPT_MODE_FILE)
execute_process(
COMMAND ${WINDEPLOYQT}
--pdb
--no-compiler-runtime
--no-translations
--no-opengl-sw
@@ -67,6 +66,7 @@ if(NOT TARGET ${QT}::windeployqt AND TARGET ${QT}::qmake)
endif()
if(TARGET ${QT}::windeployqt)
# Standalone "deploy" target (can still be invoked manually)
add_custom_target(deploy
COMMAND ${CMAKE_COMMAND} -P ${CMAKE_CURRENT_LIST_DIR}/deploy.cmake
$<TARGET_FILE:Reclass>
@@ -79,4 +79,13 @@ if(TARGET ${QT}::windeployqt)
set_target_properties(deploy PROPERTIES
ADDITIONAL_CLEAN_FILES $<TARGET_FILE_DIR:Reclass>/.qt_deployed
)
# Auto-deploy as post-build step so the correct Qt DLLs are always next
# to the exe. Without this, MSVC builds load whatever Qt DLLs happen to
# be in PATH (often MinGW ones), causing instant ABI-mismatch crashes.
add_custom_command(TARGET Reclass POST_BUILD
COMMAND ${CMAKE_COMMAND} -P ${CMAKE_CURRENT_LIST_DIR}/deploy.cmake
$<TARGET_FILE:Reclass>
$<TARGET_FILE:${QT}::windeployqt>
COMMENT "Auto-deploying Qt runtime DLLs...")
endif()

View File

@@ -72,7 +72,8 @@
<AdditionalDependencies>dwmapi.lib;dbghelp.lib;%(AdditionalDependencies)</AdditionalDependencies>
</Link>
<PostBuildEvent>
<Command>$(QtToolsPath)/windeployqt $(SolutionDir)$(Platform)\$(Configuration)\$(ProjectName).exe</Command>
<Command>$(QtToolsPath)/windeployqt $(SolutionDir)$(Platform)\$(Configuration)\$(ProjectName).exe
xcopy /Y /I "$(SolutionDir)..\src\examples\*.rcx" "$(SolutionDir)$(Platform)\$(Configuration)\examples\"</Command>
</PostBuildEvent>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
@@ -84,7 +85,8 @@
<PreprocessorDefinitions>NOMINMAX;%(PreprocessorDefinitions)</PreprocessorDefinitions>
</ClCompile>
<PostBuildEvent>
<Command>$(QtToolsPath)/windeployqt $(SolutionDir)$(Platform)\$(Configuration)\$(ProjectName).exe</Command>
<Command>$(QtToolsPath)/windeployqt $(SolutionDir)$(Platform)\$(Configuration)\$(ProjectName).exe
xcopy /Y /I "$(SolutionDir)..\src\examples\*.rcx" "$(SolutionDir)$(Platform)\$(Configuration)\examples\"</Command>
</PostBuildEvent>
</ItemDefinitionGroup>
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)' == 'Debug|x64'" Label="Configuration">
@@ -140,10 +142,12 @@
<ClInclude Include="..\src\addressparser.h" />
<ClInclude Include="..\src\core.h" />
<ClInclude Include="..\src\disasm.h" />
<QtMoc Include="..\src\dock_tab_buttons.h" />
<ClInclude Include="..\src\generator.h" />
<ClInclude Include="..\src\iplugin.h" />
<ClInclude Include="..\src\pluginmanager.h" />
<ClInclude Include="..\src\providerregistry.h" />
<QtMoc Include="..\src\startpage.h" />
<ClInclude Include="..\src\workspace_model.h" />
<ClInclude Include="..\src\imports\export_reclass_xml.h" />
<ClInclude Include="..\src\imports\import_pdb.h" />
@@ -163,7 +167,12 @@
<ClCompile Include="..\src\editor.cpp" />
<ClCompile Include="..\src\format.cpp" />
<ClCompile Include="..\src\generator.cpp" />
<ClCompile Include="..\src\main.cpp" />
<ClCompile Include="..\src\main.cpp">
<DynamicSource Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">input</DynamicSource>
<QtMocFileName Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">%(Filename).moc</QtMocFileName>
<DynamicSource Condition="'$(Configuration)|$(Platform)'=='Release|x64'">input</DynamicSource>
<QtMocFileName Condition="'$(Configuration)|$(Platform)'=='Release|x64'">%(Filename).moc</QtMocFileName>
</ClCompile>
<ClCompile Include="..\src\optionsdialog.cpp" />
<ClCompile Include="..\src\pluginmanager.cpp" />
<ClCompile Include="..\src\processpicker.cpp" />

View File

@@ -89,6 +89,12 @@
<QtMoc Include="..\src\themes\thememanager.h">
<Filter>Header Files\themes</Filter>
</QtMoc>
<QtMoc Include="..\src\dock_tab_buttons.h">
<Filter>Header Files</Filter>
</QtMoc>
<QtMoc Include="..\src\startpage.h">
<Filter>Header Files</Filter>
</QtMoc>
</ItemGroup>
<ItemGroup>
<ClInclude Include="..\src\addressparser.h">
@@ -165,9 +171,6 @@
<ClCompile Include="..\src\generator.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="..\src\main.cpp">
<Filter>Source Files</Filter>
</ClCompile>
<ClCompile Include="..\src\optionsdialog.cpp">
<Filter>Source Files</Filter>
</ClCompile>
@@ -219,5 +222,8 @@
<ClCompile Include="..\src\themes\thememanager.cpp">
<Filter>Source Files\themes</Filter>
</ClCompile>
<ClCompile Include="..\src\main.cpp">
<Filter>Source Files</Filter>
</ClCompile>
</ItemGroup>
</Project>
</Project>

View File

@@ -19,6 +19,60 @@
#include <tlhelp32.h>
#include <psapi.h>
#include <shellapi.h>
typedef struct _UNICODE_STRING { USHORT Length, MaximumLength; PWSTR Buffer; } UNICODE_STRING;
typedef struct _CLIENT_ID { HANDLE UniqueProcess; HANDLE UniqueThread; } CLIENT_ID;
typedef struct _SYSTEM_THREAD_INFORMATION {
LARGE_INTEGER KernelTime, UserTime, CreateTime;
ULONG WaitTime; PVOID StartAddress; CLIENT_ID ClientId;
LONG Priority, BasePriority; ULONG ContextSwitches, ThreadState, WaitReason;
} SYSTEM_THREAD_INFORMATION;
typedef struct _SYSTEM_PROCESS_INFORMATION {
ULONG NextEntryOffset; // 0x000
ULONG NumberOfThreads; // 0x004
LARGE_INTEGER WorkingSetPrivateSize; // 0x008
ULONG HardFaultCount; // 0x010
ULONG NumberOfThreadsHighWatermark; // 0x014
ULONGLONG CycleTime; // 0x018
LARGE_INTEGER CreateTime; // 0x020
LARGE_INTEGER UserTime; // 0x028
LARGE_INTEGER KernelTime; // 0x030
UNICODE_STRING ImageName; // 0x038
LONG BasePriority; // 0x048
HANDLE UniqueProcessId; // 0x050
PVOID InheritedFromUniqueProcessId; // 0x058
ULONG HandleCount; // 0x060
ULONG SessionId; // 0x064
ULONG_PTR UniqueProcessKey; // 0x068
SIZE_T PeakVirtualSize; // 0x070
SIZE_T VirtualSize; // 0x078
ULONG PageFaultCount; // 0x080
ULONG _pad0; // 0x084
SIZE_T PeakWorkingSetSize; // 0x088
SIZE_T WorkingSetSize; // 0x090
SIZE_T QuotaPeakPagedPoolUsage; // 0x098
SIZE_T QuotaPagedPoolUsage; // 0x0A0
SIZE_T QuotaPeakNonPagedPoolUsage; // 0x0A8
SIZE_T QuotaNonPagedPoolUsage; // 0x0B0
SIZE_T PagefileUsage; // 0x0B8
SIZE_T PeakPagefileUsage; // 0x0C0
SIZE_T PrivatePageCount; // 0x0C8
LARGE_INTEGER ReadOperationCount; // 0x0D0
LARGE_INTEGER WriteOperationCount; // 0x0D8
LARGE_INTEGER OtherOperationCount; // 0x0E0
LARGE_INTEGER ReadTransferCount; // 0x0E8
LARGE_INTEGER WriteTransferCount; // 0x0F0
LARGE_INTEGER OtherTransferCount; // 0x0F8
} SYSTEM_PROCESS_INFORMATION; // sizeof = 0x100
typedef struct alignas(8) _THREAD_BASIC_INFORMATION {
NTSTATUS ExitStatus; // 0x00
ULONG _pad; // 0x04
PVOID TebBaseAddress; // 0x08
CLIENT_ID ClientId; // 0x10
ULONG_PTR AffinityMask; // 0x20
LONG Priority; // 0x28
LONG BasePriority; // 0x2C
} THREAD_BASIC_INFORMATION;
#elif defined(__linux__)
#include <climits>
#include <sys/types.h>
@@ -61,6 +115,17 @@ ProcessMemoryProvider::ProcessMemoryProvider(uint32_t pid, const QString& proces
BOOL isWow64 = FALSE;
if (IsWow64Process(m_handle, &isWow64) && isWow64)
m_pointerSize = 4;
// Query PEB address via NtQueryInformationProcess
{
typedef NTSTATUS(NTAPI* NtQIP_t)(HANDLE, ULONG, PVOID, ULONG, PULONG);
static NtQIP_t pNtQIP = (NtQIP_t)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtQueryInformationProcess");
if (pNtQIP) {
struct { PVOID r1; PVOID PebBaseAddress; PVOID r2[2]; ULONG_PTR pid; PVOID r3; } pbi = {};
ULONG retLen = 0;
if (pNtQIP(m_handle, /*ProcessBasicInformation*/0, &pbi, sizeof(pbi), &retLen) >= 0 && pbi.PebBaseAddress)
m_peb = (uint64_t)(uintptr_t)pbi.PebBaseAddress;
}
}
cacheModules();
}
}
@@ -426,6 +491,58 @@ int ProcessMemoryProvider::size() const
#endif
}
QVector<rcx::Provider::ThreadInfo> ProcessMemoryProvider::tebs() const
{
#ifdef _WIN32
QVector<ThreadInfo> result;
if (!m_handle || !m_peb) return result;
typedef NTSTATUS(NTAPI* NtQSI_t)(ULONG, PVOID, ULONG, PULONG);
typedef NTSTATUS(NTAPI* NtQIT_t)(HANDLE, ULONG, PVOID, ULONG, PULONG);
static auto pNtQSI = (NtQSI_t)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtQuerySystemInformation");
static auto pNtQIT = (NtQIT_t)GetProcAddress(GetModuleHandleA("ntdll.dll"), "NtQueryInformationThread");
if (!pNtQSI || !pNtQIT) return result;
// Enumerate threads via SystemProcessInformation (class 5)
ULONG retLen = 0;
ULONG bufSize = 1 << 20;
QByteArray buf(bufSize, 0);
NTSTATUS qsiSt;
for (int attempt = 0; attempt < 8; ++attempt) {
qsiSt = pNtQSI(5, buf.data(), bufSize, &retLen);
if ((uint32_t)qsiSt != 0xC0000004u) break;
bufSize *= 2;
buf.resize(bufSize);
}
if (qsiSt < 0) return result;
// Walk process entries to find ours
auto* proc = (SYSTEM_PROCESS_INFORMATION*)buf.data();
for (;;) {
if ((uintptr_t)proc->UniqueProcessId == m_pid) {
auto* threads = (SYSTEM_THREAD_INFORMATION*)((char*)proc + sizeof(*proc));
for (ULONG i = 0; i < proc->NumberOfThreads; ++i) {
DWORD tid = (DWORD)(uintptr_t)threads[i].ClientId.UniqueThread;
HANDLE hThread = OpenThread(THREAD_QUERY_LIMITED_INFORMATION, FALSE, tid);
if (!hThread) continue;
THREAD_BASIC_INFORMATION tbi = {};
ULONG tbiLen = 0;
NTSTATUS qitSt = pNtQIT(hThread, 0, &tbi, sizeof(tbi), &tbiLen);
if (qitSt >= 0 && tbi.TebBaseAddress)
result.append({(uint64_t)(uintptr_t)tbi.TebBaseAddress, tid});
CloseHandle(hThread);
}
break;
}
if (!proc->NextEntryOffset) break;
proc = (SYSTEM_PROCESS_INFORMATION*)((char*)proc + proc->NextEntryOffset);
}
return result;
#else
return {};
#endif
}
// ──────────────────────────────────────────────────────────────────────────
// ProcessMemoryPlugin implementation
// ──────────────────────────────────────────────────────────────────────────

View File

@@ -41,6 +41,8 @@ public:
// Process-specific helpers
uint32_t pid() const { return m_pid; }
void refreshModules() { m_modules.clear(); cacheModules(); }
uint64_t peb() const override { return m_peb; }
QVector<ThreadInfo> tebs() const override;
private:
void cacheModules();
@@ -56,6 +58,7 @@ private:
bool m_writable;
uint64_t m_base;
int m_pointerSize = 8;
uint64_t m_peb = 0;
struct ModuleInfo {
QString name;

View File

@@ -20,7 +20,7 @@ set(PLUGIN_SOURCES
add_library(WinDbgMemoryPlugin SHARED ${PLUGIN_SOURCES})
# Link Qt + DbgEng
target_link_libraries(WinDbgMemoryPlugin PRIVATE ${QT}::Widgets dbgeng ole32)
target_link_libraries(WinDbgMemoryPlugin PRIVATE ${QT}::Widgets ole32)
# Include directories
target_include_directories(WinDbgMemoryPlugin PRIVATE

View File

@@ -12,12 +12,99 @@
#include <QDebug>
#include <QClipboard>
#include <QGuiApplication>
#include <QFileDialog>
#include <QFileInfo>
#include <QSettings>
#ifdef _WIN32
#include <windows.h>
#include <initguid.h>
#include <dbgeng.h>
#pragma comment(lib, "dbgeng.lib")
// dbgeng.dll is loaded dynamically — see loadDbgEngTools()
// The system dbgeng.dll (C:\Windows\System32) does not support remote
// connections (DebugConnect returns 0x8007053d). The full version lives
// in the Debugging Tools for Windows directory. We load it dynamically
// so the plugin works without requiring the debugger tools on PATH.
static const char* const kDbgToolsDirs[] = {
"C:\\Program Files (x86)\\Windows Kits\\10\\Debuggers\\x64",
"C:\\Program Files\\Windows Kits\\10\\Debuggers\\x64",
};
static const char* const kSettingsKey = "WinDbgPlugin/DbgToolsDir";
typedef HRESULT (STDAPICALLTYPE *PFN_DebugConnect)(PCSTR, REFIID, PVOID*);
typedef HRESULT (STDAPICALLTYPE *PFN_DebugCreate)(REFIID, PVOID*);
static QString s_loadedDir;
static HMODULE s_hDbgEng = nullptr;
static HMODULE tryLoadFrom(const char* dir) {
SetDllDirectoryA(dir);
// Pre-load dependencies so the tools versions are used instead of
// the older System32 copies (e.g. dbghelp.dll without StackWalk2).
char path[MAX_PATH];
for (auto dep : {"dbghelp.dll", "dbgcore.dll", "symsrv.dll"}) {
snprintf(path, sizeof(path), "%s\\%s", dir, dep);
LoadLibraryA(path); // OK if missing
}
snprintf(path, sizeof(path), "%s\\dbgeng.dll", dir);
HMODULE h = LoadLibraryA(path);
if (h) {
s_loadedDir = QString::fromLocal8Bit(dir);
qDebug() << "[WinDbg] Loaded dbgeng.dll from" << dir;
}
return h;
}
static HMODULE loadDbgEngTools() {
if (s_hDbgEng) return s_hDbgEng;
// 1. Try user-configured path from settings
QSettings settings;
QString userDir = settings.value(kSettingsKey).toString();
if (!userDir.isEmpty()) {
s_hDbgEng = tryLoadFrom(userDir.toLocal8Bit().constData());
if (s_hDbgEng) return s_hDbgEng;
}
// 2. Try well-known install paths
for (auto dir : kDbgToolsDirs) {
s_hDbgEng = tryLoadFrom(dir);
if (s_hDbgEng) return s_hDbgEng;
}
SetDllDirectoryA(nullptr);
return nullptr;
}
static bool dbgToolsFound() {
loadDbgEngTools();
return s_hDbgEng != nullptr;
}
static PFN_DebugConnect getDebugConnect() {
static PFN_DebugConnect pfn = nullptr;
static bool tried = false;
if (!tried) {
tried = true;
HMODULE h = loadDbgEngTools();
if (h) pfn = (PFN_DebugConnect)GetProcAddress(h, "DebugConnect");
if (!pfn) qWarning() << "[WinDbg] DebugConnect not available — Debugging Tools not found";
}
return pfn;
}
static PFN_DebugCreate getDebugCreate() {
static PFN_DebugCreate pfn = nullptr;
static bool tried = false;
if (!tried) {
tried = true;
HMODULE h = loadDbgEngTools();
if (h) pfn = (PFN_DebugCreate)GetProcAddress(h, "DebugCreate");
if (!pfn) qWarning() << "[WinDbg] DebugCreate not available — Debugging Tools not found";
}
return pfn;
}
#endif
// ──────────────────────────────────────────────────────────────────────────
@@ -65,6 +152,9 @@ WinDbgMemoryProvider::WinDbgMemoryProvider(const QString& target)
dispatchToOwner([this, &target]() {
HRESULT hr;
// COM must be initialized on this thread for DbgEng
CoInitializeEx(nullptr, COINIT_MULTITHREADED);
qDebug() << "[WinDbg] Opening target:" << target
<< "on DbgEng thread" << QThread::currentThread();
@@ -72,9 +162,11 @@ WinDbgMemoryProvider::WinDbgMemoryProvider(const QString& target)
|| target.startsWith("npipe:", Qt::CaseInsensitive))
{
// ── Remote: connect to existing WinDbg debug server ──
auto pfnConnect = getDebugConnect();
if (!pfnConnect) { qWarning() << "[WinDbg] Debugging Tools required for remote connections"; return; }
QByteArray connUtf8 = target.toUtf8();
qDebug() << "[WinDbg] DebugConnect:" << target;
hr = DebugConnect(connUtf8.constData(), IID_IDebugClient, (void**)&m_client);
hr = pfnConnect(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) {
@@ -86,7 +178,9 @@ WinDbgMemoryProvider::WinDbgMemoryProvider(const QString& target)
else
{
// ── Local: create debug client for pid/dump ──
hr = DebugCreate(IID_IDebugClient, (void**)&m_client);
auto pfnCreate = getDebugCreate();
if (!pfnCreate) { qWarning() << "[WinDbg] Debugging Tools required"; return; }
hr = pfnCreate(IID_IDebugClient, (void**)&m_client);
qDebug() << "[WinDbg] DebugCreate hr=" << Qt::hex << (unsigned long)hr
<< "client=" << (void*)m_client;
if (FAILED(hr) || !m_client) {
@@ -239,6 +333,7 @@ WinDbgMemoryProvider::~WinDbgMemoryProvider()
m_client->DetachProcesses();
}
cleanup();
CoUninitialize();
});
} else {
// Thread not running — clean up directly (best-effort)
@@ -503,7 +598,7 @@ std::unique_ptr<rcx::Provider> WinDbgMemoryPlugin::createProvider(const QString&
*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.")
"(e.g. .server tcp:port=5056) and the port/pipe is reachable.")
.arg(target);
else if (target.startsWith("pid:", Qt::CaseInsensitive))
*errorMsg = QString("Failed to attach to process.\n\n"
@@ -532,7 +627,7 @@ bool WinDbgMemoryPlugin::selectTarget(QWidget* parent, QString* target)
{
QDialog dlg(parent);
dlg.setWindowTitle("WinDbg Settings");
dlg.resize(460, 300);
dlg.resize(480, 360);
QPalette dlgPal = qApp->palette();
dlg.setPalette(dlgPal);
@@ -540,17 +635,27 @@ bool WinDbgMemoryPlugin::selectTarget(QWidget* parent, QString* target)
auto* layout = new QVBoxLayout(&dlg);
QColor editBg = dlgPal.window().color().darker(115);
QString editSS = QStringLiteral(
"QLineEdit { background: %1; color: %2; border: 1px solid %3;"
" border-radius: 3px; padding: 4px 6px; }")
.arg(editBg.name(),
dlgPal.color(QPalette::Text).name(),
dlgPal.color(QPalette::Mid).name());
layout->addWidget(new QLabel(
"Connect to a running WinDbg debug server.\n"
"In WinDbg, run: .server tcp:port=5055\n\n"
"In WinDbg, run: .server tcp:port=5056\n\n"
"Non-invasive debug and dump files only.\n"
"Execution control (bp, g, t, p) is not supported."));
"Execution control (bp, g, t, p) is not supported.\n"
"WinDbg Classic is recommended."));
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");
connEdit->setPlaceholderText("tcp:Port=5056,Server=127.0.0.1");
connEdit->setText("tcp:Port=5056,Server=127.0.0.1");
connEdit->setStyleSheet(editSS);
layout->addWidget(connEdit);
layout->addSpacing(4);
@@ -574,8 +679,72 @@ bool WinDbgMemoryPlugin::selectTarget(QWidget* parent, QString* target)
layout->addLayout(row);
};
addExample(".server tcp:port=5055");
addExample(".server tcp:port=5056");
addExample(".server npipe:pipe=reclass");
// ── Debugger Tools status ──
layout->addSpacing(8);
#ifdef _WIN32
bool found = dbgToolsFound();
auto* toolsRow = new QHBoxLayout;
auto* toolsLabel = new QLabel;
if (found) {
toolsLabel->setText(QStringLiteral("Debugging Tools: %1").arg(s_loadedDir));
QPalette tp = dlgPal;
tp.setColor(QPalette::WindowText, dlgPal.color(QPalette::Disabled, QPalette::WindowText));
toolsLabel->setPalette(tp);
} else {
toolsLabel->setText("Debugging Tools: not found");
QPalette tp = dlgPal;
tp.setColor(QPalette::WindowText, QColor(220, 120, 80));
toolsLabel->setPalette(tp);
}
toolsLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
toolsRow->addWidget(toolsLabel, 1);
auto* browseBtn = new QPushButton("Browse...");
browseBtn->setFixedWidth(70);
browseBtn->setToolTip("Locate Debugging Tools for Windows directory (contains dbgeng.dll)");
QObject::connect(browseBtn, &QPushButton::clicked, [&dlg, toolsLabel, &dlgPal]() {
QString dir = QFileDialog::getExistingDirectory(&dlg,
"Locate Debugging Tools for Windows",
"C:\\Program Files (x86)\\Windows Kits\\10\\Debuggers");
if (dir.isEmpty()) return;
QString dllPath = dir + "/dbgeng.dll";
if (!QFileInfo::exists(dllPath)) {
QMessageBox::warning(&dlg, "Not Found",
"dbgeng.dll was not found in that directory.\n"
"Select the folder containing dbgeng.dll\n"
"(e.g. Debuggers\\x64).");
return;
}
QSettings settings;
settings.setValue(kSettingsKey, dir);
// Force reload on next use
s_hDbgEng = nullptr;
s_loadedDir.clear();
if (dbgToolsFound()) {
toolsLabel->setText(QStringLiteral("Debugging Tools: %1").arg(s_loadedDir));
QPalette tp = dlgPal;
tp.setColor(QPalette::WindowText, dlgPal.color(QPalette::Disabled, QPalette::WindowText));
toolsLabel->setPalette(tp);
}
});
toolsRow->addWidget(browseBtn);
layout->addLayout(toolsRow);
if (!found) {
auto* note = new QLabel(
"The system dbgeng.dll does not support remote connections.\n"
"Install Debugging Tools for Windows or use Browse to locate them.");
QPalette np = dlgPal;
np.setColor(QPalette::WindowText, dlgPal.color(QPalette::Disabled, QPalette::WindowText));
note->setPalette(np);
note->setWordWrap(true);
layout->addWidget(note);
}
#endif
layout->addStretch();
auto* btnLayout = new QHBoxLayout;

View File

@@ -53,7 +53,7 @@ struct ComposeState {
siblingStack[d] = hasMoreSiblings;
}
void emitLine(const QString& lineText, LineMeta lm) {
void emitLine(const QString& lineText, LineMeta&& lm) {
if (currentLine > 0) text += '\n';
// 3-char fold indicator column: " - " expanded, " + " collapsed, " " other
// CommandRow has no fold prefix (flush left)
@@ -87,7 +87,7 @@ struct ComposeState {
text += lineText;
}
meta.append(lm);
meta.append(std::move(lm));
currentLine++;
}
};
@@ -208,7 +208,7 @@ void composeLeaf(ComposeState& state, const NodeTree& tree,
QString lineText = fmt::fmtNodeLine(node, prov, absAddr, depth, sub,
/*comment=*/{}, typeW, nameW, ptrTypeOverride,
state.compactColumns);
state.emitLine(lineText, lm);
state.emitLine(lineText, std::move(lm));
}
}
@@ -246,7 +246,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
lm.markerMask = (1u << M_CYCLE) | (1u << M_ERR);
lm.foldLevel = computeFoldLevel(depth, false);
state.emitLine(fmt::indent(depth) + QStringLiteral("/* CYCLE: ") +
node.name + QStringLiteral(" */"), lm);
node.name + QStringLiteral(" */"), std::move(lm));
return;
}
state.visiting.insert(node.id);
@@ -267,7 +267,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
lm.arrayElementIdx = arrayElementIdx;
uint64_t relOff = absAddr - arrayContainerAddr;
QString relOffHex = QString::number(relOff, 16).toUpper();
state.emitLine(fmt::indent(depth) + QStringLiteral("[%1] +0x%2").arg(arrayElementIdx).arg(relOffHex), lm);
state.emitLine(fmt::indent(depth) + QStringLiteral("[%1] +0x%2").arg(arrayElementIdx).arg(relOffHex), std::move(lm));
}
// Detect root header: first root-level struct — suppressed from display
@@ -325,7 +325,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
headerText.chop(1);
// Remove trailing separator spaces
while (headerText.endsWith(' ')) headerText.chop(1);
state.emitLine(headerText, lm);
state.emitLine(headerText, std::move(lm));
// Emit standalone brace line
LineMeta braceLm;
braceLm.nodeIdx = nodeIdx;
@@ -334,9 +334,9 @@ void composeParent(ComposeState& state, const NodeTree& tree,
braceLm.lineKind = LineKind::Header;
braceLm.foldLevel = computeFoldLevel(depth, true);
braceLm.markerMask = (1u << M_STRUCT_BG);
state.emitLine(fmt::indent(depth) + QStringLiteral("{"), braceLm);
state.emitLine(fmt::indent(depth) + QStringLiteral("{"), std::move(braceLm));
} else {
state.emitLine(headerText, lm);
state.emitLine(headerText, std::move(lm));
}
}
@@ -372,7 +372,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
lm.offsetText = fmt::fmtOffsetMargin(absAddr, true, state.offsetHexDigits);
lm.offsetAddr = absAddr;
lm.ptrBase = state.currentPtrBase;
state.emitLine(fmt::fmtEnumMember(m.first, m.second, childDepth, maxNameLen), lm);
state.emitLine(fmt::fmtEnumMember(m.first, m.second, childDepth, maxNameLen), std::move(lm));
}
// Footer
@@ -389,7 +389,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
lm.offsetText = fmt::fmtOffsetMargin(absAddr, false, state.offsetHexDigits);
lm.offsetAddr = absAddr;
lm.ptrBase = state.currentPtrBase;
state.emitLine(fmt::fmtStructFooter(node, depth, 0), lm);
state.emitLine(fmt::fmtStructFooter(node, depth, 0), std::move(lm));
}
state.visiting.remove(node.id);
@@ -423,7 +423,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
lm.offsetAddr = absAddr;
lm.ptrBase = state.currentPtrBase;
state.emitLine(fmt::fmtBitfieldMember(m.name, m.bitWidth, bitVal,
childDepth, maxNameLen), lm);
childDepth, maxNameLen), std::move(lm));
}
// Footer
@@ -441,7 +441,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
lm.offsetText = fmt::fmtOffsetMargin(absAddr + sz, false, state.offsetHexDigits);
lm.offsetAddr = absAddr + sz;
lm.ptrBase = state.currentPtrBase;
state.emitLine(fmt::fmtStructFooter(node, depth, sz), lm);
state.emitLine(fmt::fmtStructFooter(node, depth, sz), std::move(lm));
}
state.visiting.remove(node.id);
@@ -501,7 +501,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
state.emitLine(fmt::fmtNodeLine(elem, prov, elemAddr, childDepth, 0,
{}, eTW, eNW, elemTypeStr,
state.compactColumns), lm);
state.compactColumns), std::move(lm));
}
}
@@ -559,7 +559,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
lm.effectiveTypeW = overflow ? rawType.size() : typeW;
lm.effectiveNameW = nameW;
state.emitLine(fmt::fmtStructHeader(child, childDepth,
/*collapsed=*/true, typeW, nameW, state.compactColumns), lm);
/*collapsed=*/true, typeW, nameW, state.compactColumns), std::move(lm));
continue;
}
composeNode(state, tree, prov, childIdx, childDepth,
@@ -587,7 +587,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
}
// ── Static fields: render after regular children, before footer ──
if (!staticIdxs.isEmpty() && !node.collapsed) {
if (!staticIdxs.isEmpty() && (!node.collapsed || isRootHeader)) {
// Build identifier resolver for static field expressions
auto makeResolver = [&](uint64_t parentAbsAddr) {
AddressParserCallbacks cbs;
@@ -700,7 +700,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
lm.ptrBase = state.currentPtrBase;
lm.effectiveTypeW = typeName.size() + 7; // "static " prefix
lm.effectiveNameW = sf.name.size();
state.emitLine(headerLine, lm);
state.emitLine(headerLine, std::move(lm));
// ── Body + children (only when expanded) ──
if (!isCollapsed) {
@@ -747,7 +747,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
blm.offsetText = QString(state.offsetHexDigits, QChar(' '));
blm.offsetAddr = staticAddr;
blm.ptrBase = state.currentPtrBase;
state.emitLine(bodyLine, blm);
state.emitLine(bodyLine, std::move(blm));
}
// If struct/array, compose children at evaluated address
@@ -780,7 +780,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
flm.offsetAddr = staticAddr;
}
flm.ptrBase = state.currentPtrBase;
state.emitLine(fmt::indent(childDepth) + QStringLiteral("};"), flm);
state.emitLine(fmt::indent(childDepth) + QStringLiteral("};"), std::move(flm));
}
}
}
@@ -802,7 +802,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
lm.offsetText = fmt::fmtOffsetMargin(absAddr + sz, false, state.offsetHexDigits);
lm.offsetAddr = absAddr + sz;
lm.ptrBase = state.currentPtrBase;
state.emitLine(fmt::fmtStructFooter(node, depth, sz), lm);
state.emitLine(fmt::fmtStructFooter(node, depth, sz), std::move(lm));
}
state.visiting.remove(node.id);
@@ -865,7 +865,7 @@ void composeNode(ComposeState& state, const NodeTree& tree,
if (state.braceWrap && !effectiveCollapsed && ptrText.endsWith(QChar('{'))) {
ptrText.chop(1);
while (ptrText.endsWith(' ')) ptrText.chop(1);
state.emitLine(ptrText, lm);
state.emitLine(ptrText, std::move(lm));
LineMeta braceLm;
braceLm.nodeIdx = nodeIdx;
braceLm.nodeId = node.id;
@@ -873,9 +873,9 @@ void composeNode(ComposeState& state, const NodeTree& tree,
braceLm.lineKind = LineKind::Header;
braceLm.foldLevel = computeFoldLevel(depth, true);
braceLm.markerMask = lm.markerMask;
state.emitLine(fmt::indent(depth) + QStringLiteral("{"), braceLm);
state.emitLine(fmt::indent(depth) + QStringLiteral("{"), std::move(braceLm));
} else {
state.emitLine(ptrText, lm);
state.emitLine(ptrText, std::move(lm));
}
}
}
@@ -955,7 +955,7 @@ void composeNode(ComposeState& state, const NodeTree& tree,
lm.offsetText.clear();
lm.foldLevel = computeFoldLevel(depth, false);
lm.markerMask = 0;
state.emitLine(fmt::indent(depth) + QStringLiteral("}"), lm);
state.emitLine(fmt::indent(depth) + QStringLiteral("}"), std::move(lm));
}
}
return;
@@ -988,10 +988,32 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
});
}
// Precompute absolute offsets (baseAddress + structure-relative offset)
// Pre-allocate output buffers (estimate ~3 lines per node, ~80 chars per line)
state.meta.reserve(tree.nodes.size() * 3);
state.text.reserve(tree.nodes.size() * 80);
// Precompute absolute offsets via BFS (O(N) — avoids per-node parent-chain walk)
state.absOffsets.resize(tree.nodes.size());
state.absOffsets.fill(0);
for (int i = 0; i < tree.nodes.size(); i++)
state.absOffsets[i] = tree.baseAddress + tree.computeOffset(i);
if (tree.nodes[i].parentId == 0)
state.absOffsets[i] = tree.nodes[i].offset;
{
QVector<int> bfsQueue;
for (int i : state.childMap.value(0))
bfsQueue.append(i);
int front = 0;
while (front < bfsQueue.size()) {
int idx = bfsQueue[front++];
int pi = tree.indexOfId(tree.nodes[idx].parentId);
state.absOffsets[idx] = (pi >= 0 ? state.absOffsets[pi] : 0)
+ tree.nodes[idx].offset;
for (int ci : state.childMap.value(tree.nodes[idx].id))
bfsQueue.append(ci);
}
}
for (auto& v : state.absOffsets)
v += tree.baseAddress;
// Compute hex digit tier from max absolute address
{
@@ -1020,23 +1042,21 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
return fmt::typeNameRaw(n.kind);
};
// Compute effective type column width from longest type name
// Include struct/array headers which use "struct TypeName" or "type[count]" format
// Pre-compute type name lengths (avoids re-creating temp QStrings in width loops)
QVector<int> typeNameLens(tree.nodes.size());
for (int i = 0; i < tree.nodes.size(); i++)
typeNameLens[i] = nodeTypeName(tree.nodes[i]).size();
// Compute effective column widths from longest type/name in a single pass
const int typeCap = state.compactColumns ? kCompactTypeW : kMaxTypeW;
int maxTypeLen = kMinTypeW;
for (const Node& node : tree.nodes) {
maxTypeLen = qMax(maxTypeLen, (int)nodeTypeName(node).size());
int maxNameLen = kMinNameW;
for (int i = 0; i < tree.nodes.size(); i++) {
maxTypeLen = qMax(maxTypeLen, typeNameLens[i]);
if (!isHexPreview(tree.nodes[i].kind))
maxNameLen = qMax(maxNameLen, (int)tree.nodes[i].name.size());
}
state.typeW = qBound(kMinTypeW, maxTypeLen, typeCap);
// Compute effective name column width from longest name
// Include struct/array names - they now use columnar layout too
int maxNameLen = kMinNameW;
for (const Node& node : tree.nodes) {
// Skip hex (they show ASCII preview, not name column)
if (isHexPreview(node.kind)) continue;
maxNameLen = qMax(maxNameLen, (int)node.name.size());
}
state.nameW = qBound(kMinNameW, maxNameLen, kMaxNameW);
// Pre-compute per-scope widths (each container gets widths based on direct children only)
@@ -1053,7 +1073,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
// Skip struct children — pointer headers shouldn't inflate sibling widths
if (child.kind == NodeKind::Struct)
continue;
scopeMaxType = qMax(scopeMaxType, (int)nodeTypeName(child).size());
scopeMaxType = qMax(scopeMaxType, typeNameLens[childIdx]);
// Name width (skip hex, but include containers)
if (!isHexPreview(child.kind)) {
@@ -1079,7 +1099,6 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
}
// Compute scope widths for root level (parentId == 0)
// Include struct/array headers - they now use columnar layout too
{
int rootMaxType = kMinTypeW;
int rootMaxName = kMinNameW;
@@ -1088,7 +1107,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
// Skip struct children — pointer headers shouldn't inflate sibling widths
if (child.kind == NodeKind::Struct)
continue;
rootMaxType = qMax(rootMaxType, (int)nodeTypeName(child).size());
rootMaxType = qMax(rootMaxType, typeNameLens[childIdx]);
// Name width (skip hex, include containers)
if (!isHexPreview(child.kind)) {
@@ -1115,7 +1134,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
lm.markerMask = 0;
lm.effectiveTypeW = state.typeW;
lm.effectiveNameW = state.nameW;
state.emitLine(cmdRowText, lm);
state.emitLine(cmdRowText, std::move(lm));
}
// Brace wrapping: emit standalone "{" after CommandRow
@@ -1127,7 +1146,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
braceLm.lineKind = LineKind::Header;
braceLm.foldLevel = SC_FOLDLEVELBASE;
braceLm.markerMask = 0;
state.emitLine(QStringLiteral("{"), braceLm);
state.emitLine(QStringLiteral("{"), std::move(braceLm));
}
const QVector<int>& roots = childIndices(state, 0);

View File

@@ -231,17 +231,7 @@ void RcxController::connectEditor(RcxEditor* editor) {
TypePopupMode mode = TypePopupMode::FieldType;
if (target == EditTarget::ArrayElementType)
mode = TypePopupMode::ArrayElement;
else if (target == EditTarget::PointerTarget) {
// Primitive pointers (ptrDepth>0) should open FieldType with
// the base type selected and *//** preselected — not PointerTarget.
bool isPrimPtr = false;
if (nodeIdx >= 0 && nodeIdx < m_doc->tree.nodes.size()) {
const auto& n = m_doc->tree.nodes[nodeIdx];
isPrimPtr = n.ptrDepth > 0 && n.refId == 0;
}
mode = isPrimPtr ? TypePopupMode::FieldType
: TypePopupMode::PointerTarget;
}
// PointerTarget is handled as FieldType — modifiers * / ** will be pre-selected
showTypePopup(editor, mode, nodeIdx, globalPos);
});
@@ -1787,7 +1777,41 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
&& !node.bitfieldMembers.isEmpty()
&& subLine >= 0 && subLine < node.bitfieldMembers.size();
bool isEnumNode = node.resolvedClassKeyword() == QStringLiteral("enum");
if (isEnumMember || isBitfieldMember) {
if (isEnumMember) {
menu.addAction(icon("diff-added.svg"), "Add Member Above", [this, nodeId, subLine]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni < 0) return;
auto members = m_doc->tree.nodes[ni].enumMembers;
int64_t val = (subLine > 0) ? members[subLine - 1].second + 1 : 0;
auto oldMembers = members;
members.insert(subLine, {QStringLiteral("NewMember"), val});
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeEnumMembers{nodeId, oldMembers, members}));
});
menu.addAction(icon("diff-added.svg"), "Add Member Below", [this, nodeId, subLine]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni < 0) return;
auto members = m_doc->tree.nodes[ni].enumMembers;
int64_t val = members[subLine].second + 1;
auto oldMembers = members;
members.insert(subLine + 1, {QStringLiteral("NewMember"), val});
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeEnumMembers{nodeId, oldMembers, members}));
});
menu.addAction(icon("trash.svg"), "Remove Member", [this, nodeId, subLine]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni < 0) return;
auto members = m_doc->tree.nodes[ni].enumMembers;
auto oldMembers = members;
members.remove(subLine);
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeEnumMembers{nodeId, oldMembers, members}));
});
menu.addSeparator();
}
if (isBitfieldMember) {
const auto& bm = node.bitfieldMembers[subLine];
if (bm.bitWidth == 1) {
@@ -1802,6 +1826,28 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
menu.addSeparator();
}
// Fall through to always-available actions
} else if (isEnumNode) {
// Enum header line — enum-specific actions only (no struct ops)
menu.addAction(icon("diff-added.svg"), "Add Member", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni < 0) return;
auto members = m_doc->tree.nodes[ni].enumMembers;
int64_t nextVal = members.isEmpty() ? 0 : members.last().second + 1;
auto oldMembers = members;
members.append({QStringLiteral("NewMember"), nextVal});
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeEnumMembers{nodeId, oldMembers, members}));
});
menu.addAction(icon("edit.svg"), "&Rename...", [this, editor, line]() {
editor->beginInlineEdit(EditTarget::Name, line);
});
menu.addSeparator();
menu.addAction(icon("trash.svg"), "&Delete", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) removeNode(ni);
});
menu.addSeparator();
// Fall through to always-available actions
} else {
// ── Quick-convert suggestions (top-level for fast access) ──
@@ -2775,7 +2821,7 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
}
// Add types from other open documents
if (mode != TypePopupMode::Root && m_projectDocs) {
if (m_projectDocs) {
QSet<QString> localNames;
for (const auto& e : entries)
if (e.entryKind == TypeEntry::Composite)

View File

@@ -199,7 +199,7 @@ struct Node {
QString offsetExpr; // C/C++ expression → absolute address (static fields only)
int arrayLen = 1; // Array: element count
int strLen = 64;
bool collapsed = false;
bool collapsed = true;
uint64_t refId = 0; // Pointer32/64: id of Struct to expand at *ptr
NodeKind elementKind = NodeKind::UInt8; // Array: element type; Pointer with ptrDepth>0: target type
int ptrDepth = 0; // Pointer: 0=struct/void ptr, 1=primitive*, 2=primitive**
@@ -285,7 +285,7 @@ struct Node {
n.offsetExpr = o["offsetExpr"].toString();
n.arrayLen = qBound(1, o["arrayLen"].toInt(1), 1000000);
n.strLen = qBound(1, o["strLen"].toInt(64), 1000000);
n.collapsed = o["collapsed"].toBool(false);
n.collapsed = o["collapsed"].toBool(true);
n.refId = o["refId"].toString("0").toULongLong();
n.elementKind = kindFromString(o["elementKind"].toString("UInt8"));
n.ptrDepth = qBound(0, o["ptrDepth"].toInt(0), 2);
@@ -333,6 +333,7 @@ struct NodeTree {
int pointerSize = 8; // 4 for 32-bit targets, 8 for 64-bit
uint64_t m_nextId = 1;
mutable QHash<uint64_t, int> m_idCache;
mutable QHash<uint64_t, QVector<int>> m_childCache;
int addNode(const Node& n) {
Node copy = n;
@@ -342,13 +343,15 @@ struct NodeTree {
nodes.append(copy);
if (!m_idCache.isEmpty())
m_idCache[copy.id] = idx;
if (!m_childCache.isEmpty())
m_childCache[copy.parentId].append(idx);
return idx;
}
// Reserve a unique ID atomically (for use before pushing undo commands)
uint64_t reserveId() { return m_nextId++; }
void invalidateIdCache() const { m_idCache.clear(); }
void invalidateIdCache() const { m_idCache.clear(); m_childCache.clear(); }
int indexOfId(uint64_t id) const {
if (m_idCache.isEmpty() && !nodes.isEmpty()) {
@@ -359,11 +362,11 @@ struct NodeTree {
}
QVector<int> childrenOf(uint64_t parentId) const {
QVector<int> result;
for (int i = 0; i < nodes.size(); i++) {
if (nodes[i].parentId == parentId) result.append(i);
if (m_childCache.isEmpty() && !nodes.isEmpty()) {
for (int i = 0; i < nodes.size(); i++)
m_childCache[nodes[i].parentId].append(i);
}
return result;
return m_childCache.value(parentId);
}
// Collect node + all descendants (iterative, cycle-safe)
@@ -483,6 +486,7 @@ struct NodeTree {
t.pointerSize = o["pointerSize"].toInt(8);
t.m_nextId = o["nextId"].toString("1").toULongLong();
QJsonArray arr = o["nodes"].toArray();
t.nodes.reserve(arr.size());
for (const auto& v : arr) {
Node n = Node::fromJson(v.toObject());
t.nodes.append(n);
@@ -571,6 +575,7 @@ static constexpr uint64_t kArrayElemMask = 0x3FFF000000000000ULL; // 14 bits
// Encode an array element selection ID: nodeId | kArrayElemBit | (elemIdx << 48)
inline uint64_t makeArrayElemSelId(uint64_t nodeId, int elemIdx) {
Q_ASSERT(elemIdx >= 0);
return nodeId | kArrayElemBit | ((uint64_t)(elemIdx & 0x3FFF) << kArrayElemShift);
}
inline int arrayElemIdxFromSelId(uint64_t selId) {

36
src/dock_tab_buttons.h Normal file
View File

@@ -0,0 +1,36 @@
#pragma once
#include <QWidget>
#include <QToolButton>
#include <QHBoxLayout>
#include <QIcon>
// Dock tab button widget (close button)
// Placed on the right side of each dock tab via QTabBar::setTabButton.
class DockTabButtons : public QWidget {
Q_OBJECT
public:
QToolButton* closeBtn;
explicit DockTabButtons(QWidget* parent = nullptr) : QWidget(parent) {
auto* hl = new QHBoxLayout(this);
hl->setContentsMargins(0, 0, 0, 0);
hl->setSpacing(0);
closeBtn = new QToolButton(this);
closeBtn->setAutoRaise(true);
closeBtn->setCursor(Qt::PointingHandCursor);
closeBtn->setFixedSize(16, 16);
closeBtn->setToolTip("Close tab");
closeBtn->setIcon(QIcon(":/vsicons/close.svg"));
closeBtn->setIconSize(QSize(12, 12));
hl->addWidget(closeBtn);
}
void applyTheme(const QColor& hover) {
QString style = QStringLiteral(
"QToolButton { border: none; padding: 1px; border-radius: 0px; }"
"QToolButton:hover { background: %1; }").arg(hover.name());
closeBtn->setStyleSheet(style);
}
};

View File

@@ -562,9 +562,11 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
if (chosen == actRel && !m_relativeOffsets) {
m_relativeOffsets = true;
reformatMargins();
emit relativeOffsetsChanged(true);
} else if (chosen == actAbs && m_relativeOffsets) {
m_relativeOffsets = false;
reformatMargins();
emit relativeOffsetsChanged(false);
}
return;
}
@@ -837,8 +839,11 @@ void RcxEditor::allocateMarginStyles() {
}
void RcxEditor::applyTheme(const Theme& theme) {
// Editor uses a slightly darker background than chrome for visual depth
const QColor editorBg = theme.background.darker(115);
// Paper and text
m_sci->setPaper(theme.background);
m_sci->setPaper(editorBg);
m_sci->setColor(theme.text);
m_sci->setCaretForegroundColor(theme.text);
@@ -882,25 +887,25 @@ void RcxEditor::applyTheme(const Theme& theme) {
m_lexer->setColor(theme.text, QsciLexerCPP::Operator);
m_lexer->setColor(theme.syntaxType, QsciLexerCPP::GlobalClass);
for (int i = 0; i <= 127; i++)
m_lexer->setPaper(theme.background, i);
m_lexer->setPaper(editorBg, i);
// Margins
m_sci->setMarginsBackgroundColor(theme.background);
m_sci->setMarginsBackgroundColor(editorBg);
m_sci->setMarginsForegroundColor(theme.textFaint);
m_sci->setFoldMarginColors(theme.background, theme.background);
m_sci->setFoldMarginColors(editorBg, editorBg);
// Markers
m_sci->setMarkerBackgroundColor(theme.markerPtr, M_PTR0);
m_sci->setMarkerForegroundColor(theme.markerPtr, M_PTR0);
m_sci->setMarkerBackgroundColor(theme.background, M_CYCLE);
m_sci->setMarkerForegroundColor(theme.background, M_CYCLE);
m_sci->setMarkerBackgroundColor(editorBg, M_CYCLE);
m_sci->setMarkerForegroundColor(editorBg, M_CYCLE);
m_sci->setMarkerBackgroundColor(theme.markerError, M_ERR);
m_sci->setMarkerForegroundColor(theme.text, M_ERR);
m_sci->setMarkerBackgroundColor(theme.background, M_STRUCT_BG);
m_sci->setMarkerBackgroundColor(editorBg, M_STRUCT_BG);
m_sci->setMarkerForegroundColor(theme.text, M_STRUCT_BG);
m_sci->setMarkerBackgroundColor(theme.hover, M_HOVER);
m_sci->setMarkerBackgroundColor(theme.selected, M_SELECTED);
m_sci->setMarkerBackgroundColor(theme.background, M_CMD_ROW);
m_sci->setMarkerBackgroundColor(editorBg, M_CMD_ROW);
m_sci->setMarkerBackgroundColor(theme.indHoverSpan, M_ACCENT);
// Margin extended styles
@@ -911,7 +916,7 @@ void RcxEditor::applyTheme(const Theme& theme) {
m_sci->SendScintilla(QsciScintillaBase::SCI_STYLESETFORE,
abs, theme.textFaint);
m_sci->SendScintilla(QsciScintillaBase::SCI_STYLESETBACK,
abs, theme.background);
abs, editorBg);
}
}
@@ -955,8 +960,14 @@ void RcxEditor::applyDocument(const ComposeResult& result) {
}
// Dynamically resize margin to fit the current hex digit tier
QString marginSizer = QString(" %1 ").arg(QString(m_layout.offsetHexDigits, '0'));
m_sci->setMarginWidth(0, marginSizer);
// RVA mode uses half width since relative offsets are much shorter
{
int marginDigits = m_relativeOffsets
? qMax(m_layout.offsetHexDigits / 2, 4)
: m_layout.offsetHexDigits;
QString marginSizer = QString(" %1 ").arg(QString(marginDigits, '0'));
m_sci->setMarginWidth(0, marginSizer);
}
m_sci->setReadOnly(false);
m_sci->setText(result.text);
@@ -1063,6 +1074,11 @@ void RcxEditor::reformatMargins() {
uint64_t base = m_layout.baseAddress;
int hexDigits = m_layout.offsetHexDigits;
// Resize margin: RVA offsets are much shorter than full addresses
int marginDigits = m_relativeOffsets ? qMax(hexDigits / 2, 4) : hexDigits;
QString marginSizer = QString(" %1 ").arg(QString(marginDigits, '0'));
m_sci->setMarginWidth(0, marginSizer);
// ── Pass 1: margin text (global offset only) ──
m_sci->clearMarginText(-1);
for (int i = 0; i < m_meta.size(); i++) {
@@ -2192,6 +2208,7 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
#endif
m_relativeOffsets = !m_relativeOffsets;
reformatMargins();
emit relativeOffsetsChanged(m_relativeOffsets);
return true;
}
}
@@ -2271,7 +2288,8 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
m_hoverInside = m_sci->viewport()->rect().contains(m_lastHoverPos);
}
// Resolve hovered nodeId on move/wheel (non-edit mode only)
if (!m_editState.active &&
// Guard: skip during applyDocument — m_nodeLineIndex may be stale
if (!m_editState.active && !m_applyingDocument &&
(event->type() == QEvent::MouseMove || event->type() == QEvent::Wheel)) {
auto h = hitTest(m_lastHoverPos);
uint64_t newHoverId = (m_hoverInside && h.line >= 0) ? h.nodeId : 0;
@@ -3599,8 +3617,13 @@ void RcxEditor::setEditorFont(const QString& fontName) {
// Re-apply margin styles and width with new font metrics
allocateMarginStyles();
applyTheme(ThemeManager::instance().current());
QString marginSizer = QString(" %1 ").arg(QString(m_layout.offsetHexDigits, '0'));
m_sci->setMarginWidth(0, marginSizer);
{
int marginDigits = m_relativeOffsets
? qMax(m_layout.offsetHexDigits / 2, 4)
: m_layout.offsetHexDigits;
QString marginSizer = QString(" %1 ").arg(QString(marginDigits, '0'));
m_sci->setMarginWidth(0, marginSizer);
}
}
void RcxEditor::setGlobalFontName(const QString& fontName) {

View File

@@ -84,6 +84,7 @@ signals:
void typeSelectorRequested();
void typePickerRequested(EditTarget target, int nodeIdx, QPoint globalPos);
void insertAboveRequested(int nodeIdx, NodeKind kind);
void relativeOffsetsChanged(bool relative);
protected:
bool eventFilter(QObject* obj, QEvent* event) override;

View File

@@ -1,143 +0,0 @@
{
"baseAddress": "0",
"nextId": "20",
"nodes": [
{
"id": "1",
"kind": "Struct",
"name": "player",
"structTypeName": "PlayerEntity",
"classKeyword": "class",
"parentId": "0",
"offset": 0,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
},
{
"id": "2",
"kind": "Pointer64",
"name": "__vptr",
"parentId": "1",
"offset": 0,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
},
{
"id": "3",
"kind": "Int32",
"name": "health",
"parentId": "1",
"offset": 8,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
},
{
"id": "4",
"kind": "Int32",
"name": "armor",
"parentId": "1",
"offset": 12,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
},
{
"id": "5",
"kind": "Float",
"name": "pos_x",
"parentId": "1",
"offset": 16,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
},
{
"id": "6",
"kind": "Float",
"name": "pos_y",
"parentId": "1",
"offset": 20,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
},
{
"id": "7",
"kind": "Float",
"name": "pos_z",
"parentId": "1",
"offset": 24,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
},
{
"id": "8",
"kind": "Hex32",
"name": "pad_1C",
"parentId": "1",
"offset": 28,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
},
{
"id": "9",
"kind": "Pointer64",
"name": "name",
"parentId": "1",
"offset": 32,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64,
"ptrDepth": 1
},
{
"id": "10",
"kind": "UInt64",
"name": "flags",
"parentId": "1",
"offset": 40,
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
},
{
"id": "11",
"kind": "Hex64",
"name": "static_field",
"parentId": "1",
"offset": 0,
"isStatic": true,
"offsetExpr": "base + pos_x",
"collapsed": false,
"refId": "0",
"elementKind": "UInt8",
"arrayLen": 1,
"strLen": 64
}
]
}

File diff suppressed because it is too large Load Diff

244419
src/examples/WinSDK.rcx Normal file

File diff suppressed because it is too large Load Diff

42817
src/examples/windows-x86_64.h Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -73,7 +73,7 @@ QString pointerTypeName(NodeKind kind, const QString& targetName) {
// ── Value formatting ──
static QString hexVal(uint64_t v) {
return QStringLiteral("0x") + QString::number(v, 16);
return QString::asprintf("0x%llx", (unsigned long long)v);
}
static QString rawHex(uint64_t v, int digits) {
@@ -228,15 +228,18 @@ static QString bytesToAscii(const QByteArray& b, int slot) {
return out;
}
static const char kHexDigits[] = "0123456789ABCDEF";
static QString bytesToHex(const QByteArray& b, int slot) {
QString out;
out.reserve(slot * 3);
QChar buf[64]; // max slot=8 → 8*3-1=23 chars; 64 is plenty
int pos = 0;
for (int i = 0; i < slot; ++i) {
uint8_t c = (i < b.size()) ? (uint8_t)b[i] : 0;
out += QString::asprintf("%02X", (unsigned)c);
if (i + 1 < slot) out += ' ';
buf[pos++] = QLatin1Char(kHexDigits[c >> 4]);
buf[pos++] = QLatin1Char(kHexDigits[c & 0xF]);
if (i + 1 < slot) buf[pos++] = QLatin1Char(' ');
}
return out;
return QString(buf, pos);
}
static QString fmtAsciiAndBytes(const Provider& prov, uint64_t addr,
@@ -715,6 +718,7 @@ uint64_t extractBits(const Provider& prov, uint64_t addr,
case NodeKind::Hex32: container = prov.readU32(addr); break;
default: container = prov.readU64(addr); break;
}
Q_ASSERT(bitOffset + bitWidth <= 64);
if (bitWidth >= 64) return container >> bitOffset;
return (container >> bitOffset) & ((1ULL << bitWidth) - 1);
}

View File

@@ -332,7 +332,10 @@ struct Parser {
QVector<ParsedStruct> structs;
QSet<QString> forwardDecls;
QHash<QString, QString> typedefs; // alias -> real type
QSet<QString> pointerTypedefs; // aliases that are pointer-to-struct
QHash<QString, QVector<int>> arrayTypedefs; // aliases that are array types (alias -> dimensions)
QHash<QString, int> sizeAsserts; // struct name -> declared size
QHash<QString, int> structAlignments; // struct name -> ALIGN(N) value
explicit Parser(const QVector<Token>& t, const QVector<LineOffset>& lo)
: tokens(t), lineOffsets(lo) {}
@@ -375,12 +378,57 @@ struct Parser {
}
}
// Skip ALIGN( N ) macro if present (Vergilius-style headers)
// Returns the alignment value, or 0 if no ALIGN macro.
int skipAlignMacro() {
if (checkIdent("ALIGN") || checkIdent("__declspec")) {
advance();
int alignVal = 0;
if (match(TokKind::LParen)) {
// Try to read the alignment number
if (peek().kind == TokKind::Number) {
alignVal = peek().text.toInt();
}
int depth = 1;
while (depth > 0 && peek().kind != TokKind::Eof) {
if (peek().kind == TokKind::LParen) depth++;
else if (peek().kind == TokKind::RParen) depth--;
advance();
}
}
return alignVal;
}
return 0;
}
// Check if next tokens after keyword are ALIGN(...) then Ident/LBrace
bool peekPastAlign(int offset, TokKind expected) const {
int i = cur + offset;
if (i < tokens.size() && tokens[i].kind == TokKind::Ident &&
(tokens[i].text == QStringLiteral("ALIGN") ||
tokens[i].text == QStringLiteral("__declspec"))) {
i++; // skip ALIGN
if (i < tokens.size() && tokens[i].kind == TokKind::LParen) {
int depth = 1; i++;
while (i < tokens.size() && depth > 0) {
if (tokens[i].kind == TokKind::LParen) depth++;
else if (tokens[i].kind == TokKind::RParen) depth--;
i++;
}
}
return i < tokens.size() && tokens[i].kind == expected;
}
return false;
}
// ── Top-level parse ──
void parse() {
while (peek().kind != TokKind::Eof) {
if (checkIdent("struct") || checkIdent("class")) {
parseStructOrForward();
} else if (checkIdent("union")) {
parseTopLevelUnion();
} else if (checkIdent("static_assert")) {
parseStaticAssert();
} else if (checkIdent("typedef")) {
@@ -400,6 +448,9 @@ struct Parser {
void parseStructOrForward() {
QString keyword = advance().text; // "struct" or "class"
// Skip ALIGN( N ) between keyword and name
int alignVal = skipAlignMacro();
// Anonymous struct: struct { ... }
if (check(TokKind::LBrace)) {
// Skip anonymous struct at top level
@@ -411,6 +462,9 @@ struct Parser {
if (!check(TokKind::Ident)) { skipToSemiOrBrace(); return; }
QString name = advance().text;
if (alignVal > 0)
structAlignments[name] = alignVal;
// Check for inheritance: struct Foo : public Bar {
// Just skip the inheritance clause
if (check(TokKind::Colon)) {
@@ -446,14 +500,18 @@ struct Parser {
while (peek().kind != TokKind::RBrace && peek().kind != TokKind::Eof) {
// Nested struct definition
if (checkIdent("struct") || checkIdent("class")) {
if (peek(1).kind == TokKind::Ident && peek(2).kind == TokKind::LBrace) {
// Check: struct [ALIGN(N)] Name {
if ((peek(1).kind == TokKind::Ident && peek(2).kind == TokKind::LBrace) ||
peekPastAlign(1, TokKind::Ident)) {
// Nested named struct: parse as a top-level struct, then treat as embedded field
parseStructOrForward();
continue;
}
if (peek(1).kind == TokKind::LBrace) {
// Check: struct [ALIGN(N)] {
if (peek(1).kind == TokKind::LBrace || peekPastAlign(1, TokKind::LBrace)) {
// Anonymous nested struct { ... } fieldName;
advance(); // skip "struct"
skipAlignMacro();
advance(); // skip "{"
// Skip body
int depth = 1;
@@ -499,9 +557,54 @@ struct Parser {
}
}
// Top-level named union definition: union [ALIGN(N)] Name { ... };
// Parsed as a struct with classKeyword "union" and all members as fields
void parseTopLevelUnion() {
advance(); // skip "union"
int alignVal = skipAlignMacro();
// Forward declaration: union Name;
if (check(TokKind::Ident) && peek(1).kind == TokKind::Semi) {
QString name = advance().text;
advance(); // skip ;
forwardDecls.insert(name);
return;
}
// Anonymous union at top level (skip)
if (check(TokKind::LBrace)) {
skipToSemiOrBrace();
if (check(TokKind::RBrace)) { advance(); match(TokKind::Semi); }
return;
}
if (!check(TokKind::Ident)) { skipToSemiOrBrace(); return; }
QString name = advance().text;
if (alignVal > 0)
structAlignments[name] = alignVal;
if (!match(TokKind::LBrace)) { skipToSemiOrBrace(); return; }
ParsedStruct ps;
ps.name = name;
ps.keyword = QStringLiteral("union");
// Parse body — same as struct body but members overlap at offset 0
parseStructBody(ps);
if (!match(TokKind::RBrace)) { skipToSemiOrBrace(); return; }
match(TokKind::Semi);
structs.append(ps);
}
void parseUnion(ParsedStruct& ps) {
advance(); // skip "union"
// Skip ALIGN( N ) between union keyword and name/brace
skipAlignMacro();
// Optional union tag name (before {)
if (check(TokKind::Ident) && peek(1).kind == TokKind::LBrace) {
advance(); // skip union tag name
@@ -525,9 +628,11 @@ struct Parser {
continue;
}
// Handle anonymous struct inside union: struct { ... };
if ((checkIdent("struct") || checkIdent("class")) && peek(1).kind == TokKind::LBrace) {
// Handle anonymous struct inside union: struct [ALIGN(N)] { ... };
if ((checkIdent("struct") || checkIdent("class")) &&
(peek(1).kind == TokKind::LBrace || peekPastAlign(1, TokKind::LBrace))) {
advance(); // skip "struct"
skipAlignMacro();
advance(); // skip "{"
int depth = 1;
while (peek().kind != TokKind::Eof && depth > 0) {
@@ -541,9 +646,10 @@ struct Parser {
continue;
}
// Handle nested named struct definition inside union
// Handle nested named struct definition inside union: struct [ALIGN(N)] Name {
if ((checkIdent("struct") || checkIdent("class")) &&
peek(1).kind == TokKind::Ident && peek(2).kind == TokKind::LBrace) {
((peek(1).kind == TokKind::Ident && peek(2).kind == TokKind::LBrace) ||
peekPastAlign(1, TokKind::Ident))) {
parseStructOrForward();
continue;
}
@@ -584,13 +690,26 @@ struct Parser {
QString typeName = parseTypeName();
if (typeName.isEmpty()) { cur = startPos; return false; }
// Resolve typedef
while (typedefs.contains(typeName))
typeName = typedefs[typeName];
// Resolve typedef — track pointer and array typedefs in the chain
bool typedefPointer = false;
QVector<int> typedefArrayDims;
{
QString resolved = typeName;
QSet<QString> seen;
while (typedefs.contains(resolved) && !seen.contains(resolved)) {
if (pointerTypedefs.contains(resolved))
typedefPointer = true;
if (typedefArrayDims.isEmpty() && arrayTypedefs.contains(resolved))
typedefArrayDims = arrayTypedefs[resolved];
seen.insert(resolved);
resolved = typedefs[resolved];
}
typeName = resolved;
}
// Pointer stars
bool isPointer = false;
int ptrDepth = 0;
bool isPointer = typedefPointer;
int ptrDepth = typedefPointer ? 1 : 0;
while (match(TokKind::Star)) {
isPointer = true;
ptrDepth++;
@@ -628,6 +747,18 @@ struct Parser {
match(TokKind::RBracket);
}
// Apply array dimensions from typedef (e.g. typedef ULONG GDI_HANDLE_BUFFER[60])
if (!typedefArrayDims.isEmpty()) {
if (field.arraySizes.isEmpty())
field.arraySizes = typedefArrayDims;
else {
// Combine: typedef dims come first, field dims appended
QVector<int> combined = typedefArrayDims;
combined.append(field.arraySizes);
field.arraySizes = combined;
}
}
// Bitfield: Type name : width
if (check(TokKind::Colon)) {
advance();
@@ -750,28 +881,83 @@ struct Parser {
parseStructOrForward();
return;
}
// typedef struct ExistingName AliasName;
// typedef struct ExistingName * AliasName;
advance(); // skip struct/class
if (check(TokKind::Ident)) {
QString existingName = advance().text;
// Pointer stars
while (match(TokKind::Star)) {}
bool hasPtr = false;
while (match(TokKind::Star)) { hasPtr = true; }
// Skip const/volatile after pointer
while (checkIdent("const") || checkIdent("volatile")) advance();
if (check(TokKind::Ident)) {
QString aliasName = advance().text;
typedefs[aliasName] = existingName;
if (aliasName != existingName) { // skip self-referencing typedefs
typedefs[aliasName] = existingName;
if (hasPtr) pointerTypedefs.insert(aliasName);
}
}
}
match(TokKind::Semi);
return;
}
// typedef BaseType AliasName;
// typedef BaseType [*] AliasName [N];
// Skip leading const/volatile qualifiers: typedef const Type* Alias;
while (checkIdent("const") || checkIdent("volatile")) advance();
QString baseType = parseTypeName();
if (baseType.isEmpty()) { skipToSemiOrBrace(); return; }
while (match(TokKind::Star)) {} // pointer typedefs
bool hasPtr = false;
while (match(TokKind::Star)) { hasPtr = true; }
// Skip const/volatile after pointer
while (checkIdent("const") || checkIdent("volatile")) advance();
while (match(TokKind::Star)) { hasPtr = true; }
// Function pointer typedef: typedef RetType ( *Name )( args... );
if (check(TokKind::LParen)) {
int save = cur;
advance(); // skip (
bool isFnPtr = false;
QString fnName;
if (match(TokKind::Star) && check(TokKind::Ident)) {
fnName = advance().text;
if (match(TokKind::RParen) && check(TokKind::LParen)) {
isFnPtr = true;
}
}
if (isFnPtr) {
// Skip the argument list and register as pointer type
skipToSemiOrBrace();
pointerTypedefs.insert(fnName);
typedefs[fnName] = QStringLiteral("void");
} else {
cur = save;
skipToSemiOrBrace();
}
return;
}
if (check(TokKind::Ident)) {
QString alias = advance().text;
typedefs[alias] = baseType;
// Array dimensions: typedef Type Name[N][M];
QVector<int> dims;
while (check(TokKind::LBracket)) {
advance();
if (check(TokKind::Number)) {
bool ok;
QString numText = peek().text;
int val = numText.startsWith(QStringLiteral("0x"), Qt::CaseInsensitive)
? numText.mid(2).toInt(&ok, 16) : numText.toInt(&ok);
if (ok) dims.append(val);
advance();
}
match(TokKind::RBracket);
}
if (alias != baseType) { // skip self-referencing typedefs
typedefs[alias] = baseType;
if (hasPtr) pointerTypedefs.insert(alias);
if (!dims.isEmpty()) arrayTypedefs[alias] = dims;
}
}
match(TokKind::Semi);
}
@@ -945,8 +1131,62 @@ struct BuildContext {
bool useCommentOffsets;
QSet<QString> enumNames; // enum type names (emit as UInt32 + refId)
int ptrSize = 8; // target pointer size (4 or 8)
const QHash<QString, int>& sizeAsserts; // declared struct sizes from static_assert
const QHash<QString, int>& structAlignments; // struct name -> ALIGN(N) value
};
// Forward declaration
static int fieldNaturalAlignment(const ParsedField& field, const BuildContext& ctx);
// Compute natural alignment for a union from its members (max member alignment)
static int unionNaturalAlignment(const ParsedField& field, const BuildContext& ctx) {
int maxAlign = 1;
for (const auto& member : field.unionMembers) {
int a = fieldNaturalAlignment(member, ctx);
if (a > maxAlign) maxAlign = a;
}
return maxAlign;
}
// Return natural alignment for a parsed field (used when computing offsets without comments)
static int fieldNaturalAlignment(const ParsedField& field, const BuildContext& ctx) {
if (field.isPointer) return ctx.ptrSize;
if (field.isUnion) return unionNaturalAlignment(field, ctx);
if (field.bitfieldWidth >= 0) {
// Bitfield alignment is determined by its storage type
auto it = ctx.typeTable.find(field.typeName);
if (it != ctx.typeTable.end()) return alignmentFor(it->kind);
return 4; // default bitfield alignment
}
auto it = ctx.typeTable.find(field.typeName);
if (it != ctx.typeTable.end()) return alignmentFor(it->kind);
// Unknown type (struct reference) — align to pointer size
return ctx.ptrSize;
}
static inline int alignUp(int offset, int align) {
return (offset + align - 1) & ~(align - 1);
}
// Look up the byte size of a struct type (from already-built tree or static_assert declarations)
static int structTypeSize(const QString& typeName, const BuildContext& ctx) {
auto classIt = ctx.classIds.find(typeName);
if (classIt != ctx.classIds.end()) {
int span = ctx.tree.structSpan(classIt.value());
if (span > 0) {
// Pad to struct's declared alignment (ALIGN(N))
auto alignIt = ctx.structAlignments.find(typeName);
if (alignIt != ctx.structAlignments.end() && *alignIt > 1)
span = alignUp(span, *alignIt);
return span;
}
}
auto sizeIt = ctx.sizeAsserts.find(typeName);
if (sizeIt != ctx.sizeAsserts.end())
return sizeIt.value();
return 0;
}
static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
const QVector<ParsedField>& fields) {
int computedOffset = 0;
@@ -959,8 +1199,11 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
int groupOffset;
if (ctx.useCommentOffsets && field.commentOffset >= 0)
groupOffset = field.commentOffset - baseOffset;
else
else {
int bfAlign = fieldNaturalAlignment(field, ctx);
computedOffset = alignUp(computedOffset, bfAlign);
groupOffset = computedOffset;
}
int startIdx = fi;
int totalBits = 0;
while (fi < fields.size() && fields[fi].bitfieldWidth >= 0) {
@@ -982,8 +1225,11 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
int unionOffset;
if (ctx.useCommentOffsets && field.commentOffset >= 0)
unionOffset = field.commentOffset - baseOffset;
else
else {
int uAlign = fieldNaturalAlignment(field, ctx);
computedOffset = alignUp(computedOffset, uAlign);
unionOffset = computedOffset;
}
Node unionNode;
unionNode.kind = NodeKind::Struct;
@@ -1013,8 +1259,11 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
int fieldOffset;
if (ctx.useCommentOffsets && field.commentOffset >= 0)
fieldOffset = field.commentOffset - baseOffset;
else
else {
int fAlign = fieldNaturalAlignment(field, ctx);
computedOffset = alignUp(computedOffset, fAlign);
fieldOffset = computedOffset;
}
// Resolve type
auto typeIt = ctx.typeTable.find(field.typeName);
@@ -1022,8 +1271,27 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
// Pointer field
if (field.isPointer) {
NodeKind ptrKind = (ctx.ptrSize >= 8) ? NodeKind::Pointer64 : NodeKind::Pointer32;
// Array of pointers: PVOID arr[N]
if (!field.arraySizes.isEmpty()) {
int totalElements = 1;
for (int dim : field.arraySizes) totalElements *= (dim > 0 ? dim : 1);
Node n;
n.kind = NodeKind::Array;
n.name = field.name;
n.parentId = parentId;
n.offset = fieldOffset;
n.arrayLen = totalElements;
n.elementKind = ptrKind;
ctx.tree.addNode(n);
computedOffset = fieldOffset + totalElements * ctx.ptrSize;
continue;
}
Node n;
n.kind = (ctx.ptrSize >= 8) ? NodeKind::Pointer64 : NodeKind::Pointer32;
n.kind = ptrKind;
n.name = field.name;
n.parentId = parentId;
n.offset = fieldOffset;
@@ -1098,7 +1366,7 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
if (firstDim <= 0) firstDim = 1;
if (baseKind == NodeKind::Int8 && field.arraySizes.size() == 1 &&
field.typeName == QStringLiteral("char")) {
field.typeName == QStringLiteral("char") && firstDim <= 128) {
Node n;
n.kind = NodeKind::UTF8;
n.name = field.name;
@@ -1111,7 +1379,8 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
}
if (baseKind == NodeKind::UInt16 && field.arraySizes.size() == 1 &&
(field.typeName == QStringLiteral("wchar_t") || field.typeName == QStringLiteral("WCHAR"))) {
(field.typeName == QStringLiteral("wchar_t") || field.typeName == QStringLiteral("WCHAR")) &&
firstDim <= 128) {
Node n;
n.kind = NodeKind::UTF16;
n.name = field.name;
@@ -1165,6 +1434,8 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
// Struct-type field
if (isStructType) {
int elemSize = structTypeSize(field.typeName, ctx);
if (!field.arraySizes.isEmpty()) {
int totalElements = 1;
for (int dim : field.arraySizes) totalElements *= (dim > 0 ? dim : 1);
@@ -1182,6 +1453,8 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
int nodeIdx = ctx.tree.addNode(n);
uint64_t nodeId = ctx.tree.nodes[nodeIdx].id;
ctx.pendingRefs.append({nodeId, field.typeName});
if (elemSize > 0)
computedOffset = fieldOffset + totalElements * elemSize;
continue;
}
@@ -1196,6 +1469,8 @@ static void buildFields(BuildContext& ctx, uint64_t parentId, int baseOffset,
int nodeIdx = ctx.tree.addNode(n);
uint64_t nodeId = ctx.tree.nodes[nodeIdx].id;
ctx.pendingRefs.append({nodeId, field.typeName});
if (elemSize > 0)
computedOffset = fieldOffset + elemSize;
continue;
}
@@ -1271,7 +1546,7 @@ NodeTree importFromSource(const QString& sourceCode, QString* errorMsg, int poin
enumNames.insert(ps.name);
}
BuildContext ctx{tree, typeTable, classIds, pendingRefs, useCommentOffsets, enumNames, pointerSize};
BuildContext ctx{tree, typeTable, classIds, pendingRefs, useCommentOffsets, enumNames, pointerSize, parser.sizeAsserts, parser.structAlignments};
// Build nodes for each struct/enum
for (const auto& ps : parser.structs) {

View File

@@ -480,64 +480,7 @@ public:
}
};
// ── Dock tab button widget (pin + close) ──
// Placed on the right side of each dock tab via QTabBar::setTabButton.
class DockTabButtons : public QWidget {
Q_OBJECT
public:
QToolButton* pinBtn;
QToolButton* closeBtn;
bool pinned = false;
explicit DockTabButtons(QWidget* parent = nullptr) : QWidget(parent) {
auto* hl = new QHBoxLayout(this);
hl->setContentsMargins(0, 0, 0, 0);
hl->setSpacing(0);
pinBtn = new QToolButton(this);
pinBtn->setAutoRaise(true);
pinBtn->setCursor(Qt::PointingHandCursor);
pinBtn->setFixedSize(16, 16);
pinBtn->setToolTip("Pin tab");
updatePinIcon();
hl->addWidget(pinBtn);
closeBtn = new QToolButton(this);
closeBtn->setAutoRaise(true);
closeBtn->setCursor(Qt::PointingHandCursor);
closeBtn->setFixedSize(16, 16);
closeBtn->setToolTip("Close tab");
closeBtn->setIcon(QIcon(":/vsicons/close.svg"));
closeBtn->setIconSize(QSize(12, 12));
hl->addWidget(closeBtn);
connect(pinBtn, &QToolButton::clicked, this, [this]() {
pinned = !pinned;
updatePinIcon();
emit pinToggled(pinned);
});
}
void applyTheme(const QColor& hover) {
QString style = QStringLiteral(
"QToolButton { border: none; padding: 1px; border-radius: 0px; }"
"QToolButton:hover { background: %1; }").arg(hover.name());
pinBtn->setStyleSheet(style);
closeBtn->setStyleSheet(style);
}
void setPinned(bool p) { pinned = p; updatePinIcon(); emit pinToggled(pinned); }
signals:
void pinToggled(bool pinned);
private:
void updatePinIcon() {
pinBtn->setIcon(QIcon(pinned ? ":/vsicons/pinned.svg" : ":/vsicons/pin.svg"));
pinBtn->setIconSize(QSize(12, 12));
pinBtn->setToolTip(pinned ? "Unpin tab" : "Pin tab");
}
};
#include "dock_tab_buttons.h"
static void applyGlobalTheme(const rcx::Theme& theme) {
QPalette pal;
@@ -566,7 +509,19 @@ static void applyGlobalTheme(const rcx::Theme& theme) {
qApp->setPalette(pal);
qApp->setStyleSheet(QString());
// Global scrollbar styling — track matches control bg, handle is solid
qApp->setStyleSheet(QStringLiteral(
"QScrollBar:vertical { background: palette(window); width: 12px; margin: 0; border: none; }"
"QScrollBar::handle:vertical { background: %1; min-height: 20px; border: none; }"
"QScrollBar::handle:vertical:hover { background: %2; }"
"QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }"
"QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: none; }"
"QScrollBar:horizontal { background: palette(window); height: 12px; margin: 0; border: none; }"
"QScrollBar::handle:horizontal { background: %1; min-width: 20px; border: none; }"
"QScrollBar::handle:horizontal:hover { background: %2; }"
"QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { width: 0; }"
"QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { background: none; }")
.arg(theme.textFaint.name(), theme.textDim.name()));
}
class BorderOverlay : public QWidget {
@@ -631,6 +586,7 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
overlay->raise();
overlay->show();
// Central placeholder — will be replaced by start page after construction
m_centralPlaceholder = new QWidget(this);
m_centralPlaceholder->setFixedSize(0, 0);
m_centralPlaceholder->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored);
@@ -669,6 +625,9 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
connect(&ThemeManager::instance(), &ThemeManager::themeChanged,
this, &MainWindow::applyTheme);
// Apply theme once at startup (the signal only fires on change, not initial load)
applyTheme(ThemeManager::instance().current());
// Load plugins
m_pluginManager.LoadPlugins();
@@ -859,17 +818,17 @@ void MainWindow::createMenus() {
auto* actTreeLines = view->addAction("&Tree Lines");
actTreeLines->setCheckable(true);
actTreeLines->setChecked(settings.value("treeLines", false).toBool());
actTreeLines->setChecked(settings.value("treeLines", true).toBool());
connect(actTreeLines, &QAction::triggered, this, [this](bool checked) {
QSettings("Reclass", "Reclass").setValue("treeLines", checked);
for (auto& tab : m_tabs)
tab.ctrl->setTreeLines(checked);
});
auto* actRelOfs = view->addAction("R&elative Offsets");
actRelOfs->setCheckable(true);
actRelOfs->setChecked(settings.value("relativeOffsets", true).toBool());
connect(actRelOfs, &QAction::triggered, this, [this](bool checked) {
m_actRelOfs = view->addAction("R&elative Offsets");
m_actRelOfs->setCheckable(true);
m_actRelOfs->setChecked(settings.value("relativeOffsets", true).toBool());
connect(m_actRelOfs, &QAction::triggered, this, [this](bool checked) {
QSettings("Reclass", "Reclass").setValue("relativeOffsets", checked);
for (auto& tab : m_tabs)
for (auto& pane : tab.panes)
@@ -1271,22 +1230,36 @@ MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) {
// Style to match the top dock tab bar, with accent line on selected tab
{
const auto& t = ThemeManager::instance().current();
QSettings s("Reclass", "Reclass");
QString editorFont = s.value("font", "JetBrains Mono").toString();
pane.tabWidget->setStyleSheet(QStringLiteral(
"QTabBar { border: none; }"
"QTabBar::tab {"
" background: %1; color: %2; padding: 0px 16px; border: none; border-radius: 0px; height: 24px;"
" font-family: '%7'; font-size: 10pt;"
"}"
"QTabBar::tab:selected { color: %3; background: %4;"
" border-top: 3px solid %6; padding-top: -3px; }"
"QTabBar::tab:hover { color: %3; background: %5; }")
.arg(t.background.name(), t.textMuted.name(), t.text.name(),
t.backgroundAlt.name(), t.hover.name(), t.indHoverSpan.name()));
t.backgroundAlt.name(), t.hover.name(), t.indHoverSpan.name(),
editorFont));
}
// Create editor via controller (parent = tabWidget for ownership)
pane.editor = tab.ctrl->addSplitEditor(pane.tabWidget);
pane.editor->setRelativeOffsets(
QSettings("Reclass", "Reclass").value("relativeOffsets", true).toBool());
// Sync View menu checkbox when editor toggles offset mode (double-click / context menu)
connect(pane.editor, &RcxEditor::relativeOffsetsChanged, this, [this](bool rel) {
QSettings("Reclass", "Reclass").setValue("relativeOffsets", rel);
if (m_actRelOfs) m_actRelOfs->setChecked(rel);
// Propagate to all other editors so they stay in sync
for (auto& tab : m_tabs)
for (auto& p : tab.panes)
if (p.editor && p.editor != sender())
p.editor->setRelativeOffsets(rel);
});
pane.tabWidget->addTab(pane.editor, "Reclass"); // index 0
// Create per-pane rendered C++ view with find bar
@@ -1494,13 +1467,13 @@ QDockWidget* MainWindow::createTab(RcxDocument* doc) {
splitter->setHandleWidth(1);
auto* ctrl = new RcxController(doc, splitter);
QString title = doc->filePath.isEmpty()
? rootName(doc->tree) : QFileInfo(doc->filePath).fileName();
QString title = rootName(doc->tree);
auto* dock = new QDockWidget(title, this);
dock->setObjectName(QStringLiteral("DocDock_%1").arg(quintptr(dock), 0, 16));
dock->setFeatures(QDockWidget::DockWidgetClosable |
QDockWidget::DockWidgetMovable |
QDockWidget::DockWidgetFloatable);
dock->setAttribute(Qt::WA_DeleteOnClose);
// Two title bar widgets: a hidden one (docked) and a draggable one (floating)
auto* emptyTitleBar = new QWidget(dock);
emptyTitleBar->setFixedHeight(0);
@@ -1585,7 +1558,7 @@ QDockWidget* MainWindow::createTab(RcxDocument* doc) {
dockGrip->hide();
// Swap title bar when floating/docking, show/hide border + grip
connect(dock, &QDockWidget::topLevelChanged, this, [dock, emptyTitleBar, floatTitleBar, dockBorder, dockGrip](bool floating) {
connect(dock, &QDockWidget::topLevelChanged, this, [this, dock, emptyTitleBar, floatTitleBar, dockBorder, dockGrip](bool floating) {
dock->setTitleBarWidget(floating ? floatTitleBar : emptyTitleBar);
if (floating) {
dockBorder->setGeometry(0, 0, dock->width(), dock->height());
@@ -1597,6 +1570,8 @@ QDockWidget* MainWindow::createTab(RcxDocument* doc) {
} else {
dockBorder->hide();
dockGrip->hide();
// Re-docking creates a new tab bar — reinstall pin/close buttons
QTimer::singleShot(0, this, [this]() { setupDockTabBars(); });
}
});
dock->installEventFilter(new DockBorderFilter(dockBorder, dockGrip, dock));
@@ -1622,7 +1597,7 @@ QDockWidget* MainWindow::createTab(RcxDocument* doc) {
// Apply global compact columns setting to new tab
ctrl->setCompactColumns(QSettings("Reclass", "Reclass").value("compactColumns", true).toBool());
ctrl->setTreeLines(QSettings("Reclass", "Reclass").value("treeLines", false).toBool());
ctrl->setTreeLines(QSettings("Reclass", "Reclass").value("treeLines", true).toBool());
ctrl->setBraceWrap(QSettings("Reclass", "Reclass").value("braceWrap", false).toBool());
// Give every controller the shared document list for cross-tab type visibility
@@ -1634,7 +1609,6 @@ QDockWidget* MainWindow::createTab(RcxDocument* doc) {
if (visible) {
m_activeDocDock = dock;
updateWindowTitle();
rebuildWorkspaceModel();
// Sync view toggle buttons to this tab's active pane
auto it = m_tabs.find(dock);
if (it != m_tabs.end()) {
@@ -1666,6 +1640,8 @@ QDockWidget* MainWindow::createTab(RcxDocument* doc) {
m_activeDocDock = m_docDocks.isEmpty() ? nullptr : m_docDocks.last();
rebuildAllDocs();
rebuildWorkspaceModel();
if (m_tabs.isEmpty())
project_new();
});
connect(ctrl, &RcxController::nodeSelected,
@@ -1720,8 +1696,7 @@ QDockWidget* MainWindow::createTab(RcxDocument* doc) {
auto it2 = m_tabs.find(dockGuard);
if (it2 != m_tabs.end()) {
updateAllRenderedPanes(*it2);
if (it2->doc->filePath.isEmpty())
dockGuard->setWindowTitle(rootName(it2->doc->tree, it2->ctrl->viewRootId()));
dockGuard->setWindowTitle(rootName(it2->doc->tree, it2->ctrl->viewRootId()));
}
rebuildWorkspaceModel();
updateWindowTitle();
@@ -1737,8 +1712,7 @@ QDockWidget* MainWindow::createTab(RcxDocument* doc) {
auto it2 = m_tabs.find(dockGuard);
if (it2 != m_tabs.end()) {
updateAllRenderedPanes(*it2);
if (it2->doc->filePath.isEmpty())
dockGuard->setWindowTitle(rootName(it2->doc->tree, it2->ctrl->viewRootId()));
dockGuard->setWindowTitle(rootName(it2->doc->tree, it2->ctrl->viewRootId()));
}
updateWindowTitle();
rebuildWorkspaceModel();
@@ -1787,6 +1761,7 @@ void MainWindow::setupDockTabBars() {
// No stylesheet — painting handled by MenuBarStyle
tabBar->setStyleSheet(QString());
tabBar->setAttribute(Qt::WA_Hover, true);
tabBar->setElideMode(Qt::ElideNone);
tabBar->setExpanding(false);
// Set editor font so tab width sizing matches our label painting
@@ -1827,8 +1802,9 @@ void MainWindow::setupDockTabBars() {
tabBar->setTabButton(i, QTabBar::RightSide, btns);
}
// Context menu (install only once)
// Middle-click close + context menu (install only once)
if (tabBar->contextMenuPolicy() == Qt::CustomContextMenu) continue;
tabBar->installEventFilter(this);
tabBar->setContextMenuPolicy(Qt::CustomContextMenu);
connect(tabBar, &QTabBar::customContextMenuRequested,
this, [this, tabBar](const QPoint& pos) {
@@ -1843,9 +1819,6 @@ void MainWindow::setupDockTabBars() {
if (!target) return;
auto tabIt = m_tabs.find(target);
auto* btns = qobject_cast<DockTabButtons*>(
tabBar->tabButton(idx, QTabBar::RightSide));
bool isPinned = btns && btns->pinned;
QMenu menu;
@@ -1869,28 +1842,6 @@ void MainWindow::setupDockTabBars() {
});
}
// Close All But Pinned (only if any tab is pinned)
bool anyPinned = false;
for (int i = 0; i < tabBar->count(); ++i) {
auto* b = qobject_cast<DockTabButtons*>(
tabBar->tabButton(i, QTabBar::RightSide));
if (b && b->pinned) { anyPinned = true; break; }
}
if (anyPinned) {
menu.addAction("Close All But Pinned", [this, tabBar]() {
QVector<QDockWidget*> toClose;
for (int i = 0; i < tabBar->count(); ++i) {
auto* b = qobject_cast<DockTabButtons*>(
tabBar->tabButton(i, QTabBar::RightSide));
if (b && b->pinned) continue;
QString title = tabBar->tabText(i);
for (auto* d : m_docDocks)
if (d->windowTitle() == title) { toClose.append(d); break; }
}
for (auto* d : toClose) d->close();
});
}
menu.addSeparator();
// Copy Full Path / Open Containing Folder (only if saved)
@@ -1912,14 +1863,6 @@ void MainWindow::setupDockTabBars() {
menu.addSeparator();
// Pin / Unpin
if (btns) {
QIcon pinIcon = makeIcon(isPinned ? ":/vsicons/pinned.svg"
: ":/vsicons/pin.svg");
menu.addAction(pinIcon, isPinned ? "Unpin Tab" : "Pin Tab",
[btns, isPinned]() { btns->setPinned(!isPinned); });
}
menu.addSeparator();
// New Document Groups (only if >1 tab)
@@ -1971,8 +1914,47 @@ void MainWindow::setupDockTabBars() {
}
}
bool MainWindow::eventFilter(QObject* obj, QEvent* event) {
if (event->type() == QEvent::MouseButtonPress) {
auto* me = static_cast<QMouseEvent*>(event);
if (me->button() == Qt::MiddleButton) {
if (auto* tabBar = qobject_cast<QTabBar*>(obj)) {
int idx = tabBar->tabAt(me->pos());
if (idx >= 0) {
QString title = tabBar->tabText(idx);
for (auto* d : m_docDocks) {
if (d->windowTitle() == title) { d->close(); break; }
}
return true;
}
}
}
}
return QMainWindow::eventFilter(obj, event);
}
// Build a minimal empty struct for new documents
static void buildEmptyStruct(NodeTree& tree, const QString& classKeyword = QString()) {
// ── Enum: bare node with empty enumMembers, no hex children ──
if (classKeyword == QStringLiteral("enum")) {
Node root;
root.kind = NodeKind::Struct;
root.name = "Unnamed";
root.structTypeName = "Unnamed";
root.classKeyword = classKeyword;
root.parentId = 0;
root.offset = 0;
root.enumMembers = {
{QStringLiteral("Member0"), 0},
{QStringLiteral("Member1"), 1},
{QStringLiteral("Member2"), 2},
{QStringLiteral("Member3"), 3},
{QStringLiteral("Member4"), 4},
};
tree.addNode(root);
return;
}
Node root;
root.kind = NodeKind::Struct;
root.name = "instance";
@@ -2345,19 +2327,25 @@ void MainWindow::applyTheme(const Theme& theme) {
// Dock separator is 1px via PM_DockWidgetSeparatorExtent in MenuBarStyle
// Custom title bar
if (m_titleBar)
m_titleBar->applyTheme(theme);
// Start page
if (m_startPage)
m_startPage->applyTheme(theme);
// Update border overlay color
updateBorderColor(isActiveWindow() ? theme.borderFocused : theme.border);
// Style doc dock tab bars and remove dock borders
// Style doc dock tab bars and remove dock borders.
// QWidget default colors are required because having ANY stylesheet on QMainWindow
// switches children from palette-based to CSS-based rendering.
setStyleSheet(QStringLiteral(
"QMainWindow::separator { width: 1px; height: 1px; background: transparent; }"
"QDockWidget { border: none; }"
"QDockWidget > QWidget { border: none; }")
.arg(theme.border.name()));
"QDockWidget > QWidget { border: none; }"));
// Custom title bar — applied AFTER setStyleSheet() because the MainWindow
// stylesheet re-resolves descendant palettes and would reset the QMenuBar palette.
if (m_titleBar)
m_titleBar->applyTheme(theme);
for (auto* tabBar : findChildren<QTabBar*>()) {
// Only style tab bars owned directly by this QMainWindow (dock tabs),
@@ -2365,6 +2353,7 @@ void MainWindow::applyTheme(const Theme& theme) {
if (tabBar->parent() == this) {
// No stylesheet — painting handled by MenuBarStyle (CE_TabBarTabShape/Label)
tabBar->setStyleSheet(QString());
tabBar->setAttribute(Qt::WA_Hover, true);
tabBar->setElideMode(Qt::ElideNone);
tabBar->setExpanding(false);
// Set editor font so tab width sizing matches our label painting
@@ -2393,16 +2382,19 @@ void MainWindow::applyTheme(const Theme& theme) {
// Restyle per-pane view tab bars (Reclass / C++)
{
QString editorFont = QSettings("Reclass", "Reclass").value("font", "JetBrains Mono").toString();
QString paneTabStyle = QStringLiteral(
"QTabBar { border: none; }"
"QTabBar::tab {"
" background: %1; color: %2; padding: 0px 16px; border: none; border-radius: 0px; height: 24px;"
" font-family: '%7'; font-size: 10pt;"
"}"
"QTabBar::tab:selected { color: %3; background: %4;"
" border-top: 3px solid %6; padding-top: -3px; }"
"QTabBar::tab:hover { color: %3; background: %5; }")
.arg(theme.background.name(), theme.textMuted.name(), theme.text.name(),
theme.backgroundAlt.name(), theme.hover.name(), theme.indHoverSpan.name());
theme.backgroundAlt.name(), theme.hover.name(), theme.indHoverSpan.name(),
editorFont);
for (auto it = m_tabs.begin(); it != m_tabs.end(); ++it) {
for (auto& pane : it->panes) {
if (pane.tabWidget)
@@ -2428,19 +2420,30 @@ void MainWindow::applyTheme(const Theme& theme) {
if (auto* w = findChild<QWidget*>("resizeGrip"))
static_cast<ResizeGrip*>(w)->setGripColor(theme.textFaint);
// Workspace tree: colors from theme (selection + text)
// Workspace tree: delegate colors, palette, stylesheet
if (m_workspaceDelegate)
m_workspaceDelegate->setThemeColors(theme);
if (m_workspaceTree) {
QPalette tp = m_workspaceTree->palette();
tp.setColor(QPalette::Text, theme.textDim);
tp.setColor(QPalette::Highlight, theme.hover);
tp.setColor(QPalette::Highlight, theme.selected);
tp.setColor(QPalette::HighlightedText, theme.text);
m_workspaceTree->setPalette(tp);
m_workspaceTree->setStyleSheet(QStringLiteral(
"QTreeView { background: %1; border: none; }"
"QTreeView::branch:has-children:closed { image: url(:/chevron-right.svg); }"
"QTreeView::branch:has-children:open { image: url(:/chevron-down.svg); }")
.arg(theme.background.name()));
m_workspaceTree->viewport()->update();
}
if (m_workspaceSearch) {
m_workspaceSearch->setStyleSheet(QStringLiteral(
"QLineEdit { background: %1; color: %2; border: none;"
" border-bottom: 1px solid %3; padding: 4px 6px; }")
.arg(theme.background.name(), theme.textDim.name(), theme.border.name()));
" padding: 4px 8px; }"
"QLineEdit QToolButton { padding: 0px 4px; }"
"QLineEdit QToolButton:hover { background: %3; }")
.arg(theme.background.name(), theme.textDim.name(),
theme.hover.name()));
}
// Dock titlebar: restyle via palette + close button
@@ -2461,6 +2464,9 @@ void MainWindow::applyTheme(const Theme& theme) {
.arg(theme.textDim.name(), theme.indHoverSpan.name()));
if (m_dockGrip)
m_dockGrip->setGripColor(theme.textFaint);
if (m_workspaceDock)
m_workspaceDock->setStyleSheet(QStringLiteral(
"QDockWidget { border: 1px solid %1; }").arg(theme.border.name()));
// Scanner dock
if (m_scannerPanel)
@@ -2528,10 +2534,11 @@ void MainWindow::applyTheme(const Theme& theme) {
lexer->setColor(theme.text, QsciLexerCPP::Identifier);
lexer->setColor(theme.syntaxPreproc, QsciLexerCPP::PreProcessor);
lexer->setColor(theme.text, QsciLexerCPP::Operator);
const QColor editorBg = theme.background.darker(115);
for (int i = 0; i <= 127; i++)
lexer->setPaper(theme.background, i);
lexer->setPaper(editorBg, i);
}
sci->setPaper(theme.background);
sci->setPaper(theme.background.darker(115));
sci->setColor(theme.text);
sci->setCaretForegroundColor(theme.text);
sci->setCaretLineBackgroundColor(theme.hover);
@@ -2630,12 +2637,17 @@ void MainWindow::setEditorFont(const QString& fontName) {
}
}
}
// Sync workspace tree font
if (m_workspaceTree)
m_workspaceTree->setFont(f);
// Sync dock titlebar font
if (m_dockTitleLabel)
m_dockTitleLabel->setFont(f);
// Sync workspace tree, title, and search font (10pt monospace)
{
QFont wf(fontName, 10);
wf.setFixedPitch(true);
if (m_workspaceTree)
m_workspaceTree->setFont(wf);
if (m_dockTitleLabel)
m_dockTitleLabel->setFont(wf);
if (m_workspaceSearch)
m_workspaceSearch->setFont(wf);
}
// Sync scanner panel font
if (m_scannerPanel)
m_scannerPanel->setEditorFont(f);
@@ -2656,6 +2668,9 @@ void MainWindow::setEditorFont(const QString& fontName) {
tabBar->update();
}
}
// Pane tab bars (Reclass / C++) — re-apply stylesheet with new font
// (stylesheet overrides setFont, so font must be in the CSS)
applyTheme(ThemeManager::instance().current());
}
}
@@ -2687,9 +2702,7 @@ void MainWindow::updateWindowTitle() {
auto* activeDock = m_activeDocDock;
if (activeDock && m_tabs.contains(activeDock)) {
auto& tab = m_tabs[activeDock];
QString name = tab.doc->filePath.isEmpty()
? rootName(tab.doc->tree, tab.ctrl->viewRootId())
: QFileInfo(tab.doc->filePath).fileName();
QString name = rootName(tab.doc->tree, tab.ctrl->viewRootId());
if (tab.doc->modified) name += " *";
title = name + " - Reclass";
} else {
@@ -2743,15 +2756,16 @@ void MainWindow::setupRenderedSci(QsciScintilla* sci) {
lexer->setColor(theme.text, QsciLexerCPP::Identifier);
lexer->setColor(theme.syntaxPreproc, QsciLexerCPP::PreProcessor);
lexer->setColor(theme.text, QsciLexerCPP::Operator);
const QColor editorBg = theme.background.darker(115);
for (int i = 0; i <= 127; i++) {
lexer->setPaper(theme.background, i);
lexer->setPaper(editorBg, i);
lexer->setFont(f, i);
}
sci->setLexer(lexer);
sci->setBraceMatching(QsciScintilla::NoBraceMatch);
// Colors applied AFTER setLexer() — the lexer resets these on attach
sci->setPaper(theme.background);
sci->setPaper(editorBg);
sci->setColor(theme.text);
sci->setCaretForegroundColor(theme.text);
sci->setCaretLineVisible(true);
@@ -3007,7 +3021,7 @@ void MainWindow::importFromSource() {
rebuildWorkspaceModel();
if (!m_docDocks.isEmpty()) {
splitDockWidget(m_workspaceDock, m_docDocks.first(), Qt::Horizontal);
resizeDocks({m_workspaceDock}, {220}, Qt::Horizontal);
resizeDocks({m_workspaceDock}, {128}, Qt::Horizontal);
}
m_workspaceDock->show();
setAppStatus(QStringLiteral("Imported %1 classes from source").arg(classCount));
@@ -3061,7 +3075,7 @@ void MainWindow::importPdb() {
rebuildWorkspaceModel();
if (!m_docDocks.isEmpty()) {
splitDockWidget(m_workspaceDock, m_docDocks.first(), Qt::Horizontal);
resizeDocks({m_workspaceDock}, {220}, Qt::Horizontal);
resizeDocks({m_workspaceDock}, {128}, Qt::Horizontal);
}
m_workspaceDock->show();
setAppStatus(QStringLiteral("Imported %1 classes from %2")
@@ -3222,10 +3236,7 @@ QDockWidget* MainWindow::project_open(const QString& path) {
if (filePath.isEmpty()) {
filePath = QFileDialog::getOpenFileName(this,
"Open Definition", {},
"All Supported (*.rcx *.json *.reclass *.MemeCls *.xml)"
";;Reclass (*.rcx)"
";;JSON (*.json)"
";;ReClass XML (*.reclass *.MemeCls *.xml)"
"Reclass (*.rcx)"
";;All (*)");
if (filePath.isEmpty()) return nullptr;
}
@@ -3236,8 +3247,7 @@ QDockWidget* MainWindow::project_open(const QString& path) {
QFile probe(filePath);
if (probe.open(QIODevice::ReadOnly)) {
QByteArray head = probe.read(64);
isXml = head.trimmed().startsWith("<?xml") || head.trimmed().startsWith("<ReClass")
|| head.trimmed().startsWith("<MemeCls");
isXml = head.trimmed().startsWith("<?xml") || head.trimmed().startsWith("<ReClass");
}
}
@@ -3256,7 +3266,7 @@ QDockWidget* MainWindow::project_open(const QString& path) {
rebuildWorkspaceModel();
if (!m_docDocks.isEmpty()) {
splitDockWidget(m_workspaceDock, m_docDocks.first(), Qt::Horizontal);
resizeDocks({m_workspaceDock}, {220}, Qt::Horizontal);
resizeDocks({m_workspaceDock}, {128}, Qt::Horizontal);
}
m_workspaceDock->show();
int classCount = 0;
@@ -3282,7 +3292,7 @@ QDockWidget* MainWindow::project_open(const QString& path) {
rebuildWorkspaceModel();
if (!m_docDocks.isEmpty()) {
splitDockWidget(m_workspaceDock, m_docDocks.first(), Qt::Horizontal);
resizeDocks({m_workspaceDock}, {220}, Qt::Horizontal);
resizeDocks({m_workspaceDock}, {128}, Qt::Horizontal);
}
m_workspaceDock->show();
addRecentFile(filePath);
@@ -3312,16 +3322,13 @@ void MainWindow::project_close(QDockWidget* dock) {
if (!dock) dock = m_activeDocDock;
if (!dock) return;
dock->close();
rebuildWorkspaceModel();
}
void MainWindow::closeAllDocDocks() {
// Take a copy since closing modifies m_docDocks via destroyed signal
auto docks = m_docDocks;
for (auto* dock : docks) {
dock->setAttribute(Qt::WA_DeleteOnClose);
for (auto* dock : docks)
dock->close();
}
}
@@ -3338,7 +3345,7 @@ void MainWindow::createWorkspaceDock() {
const auto& t = ThemeManager::instance().current();
auto* titleBar = new QWidget(m_workspaceDock);
titleBar->setFixedHeight(24);
titleBar->setFixedHeight(26);
titleBar->setAutoFillBackground(true);
{
QPalette tbPal = titleBar->palette();
@@ -3357,6 +3364,10 @@ void MainWindow::createWorkspaceDock() {
QPalette lp = m_dockTitleLabel->palette();
lp.setColor(QPalette::WindowText, t.textDim);
m_dockTitleLabel->setPalette(lp);
QSettings s("Reclass", "Reclass");
QFont f(s.value("font", "JetBrains Mono").toString(), 10);
f.setFixedPitch(true);
m_dockTitleLabel->setFont(f);
}
layout->addWidget(m_dockTitleLabel);
@@ -3376,6 +3387,13 @@ void MainWindow::createWorkspaceDock() {
m_workspaceDock->setTitleBarWidget(titleBar);
}
// Outer border around entire dock (header + search + tree)
{
const auto& t = ThemeManager::instance().current();
m_workspaceDock->setStyleSheet(QStringLiteral(
"QDockWidget { border: 1px solid %1; }").arg(t.border.name()));
}
// Container widget: search box + tree view
auto* dockContainer = new QWidget(m_workspaceDock);
auto* dockLayout = new QVBoxLayout(dockContainer);
@@ -3384,13 +3402,52 @@ void MainWindow::createWorkspaceDock() {
m_workspaceSearch = new QLineEdit(dockContainer);
m_workspaceSearch->setPlaceholderText(QStringLiteral("Search..."));
m_workspaceSearch->setClearButtonEnabled(true);
// Clear button uses our close.svg icon instead of Qt's default circle-X
{
QSettings s("Reclass", "Reclass");
QFont f(s.value("font", "JetBrains Mono").toString(), 10);
f.setFixedPitch(true);
m_workspaceSearch->setFont(f);
}
{
auto* searchAction = m_workspaceSearch->addAction(
QIcon(QStringLiteral(":/vsicons/search.svg")),
QLineEdit::LeadingPosition);
// Find the QToolButton created for the action and shrink its icon
for (auto* btn : m_workspaceSearch->findChildren<QToolButton*>()) {
if (btn->defaultAction() == searchAction) {
btn->setIconSize(QSize(14, 14));
break;
}
}
}
{
auto* clearAction = m_workspaceSearch->addAction(
QIcon(QStringLiteral(":/vsicons/close.svg")),
QLineEdit::TrailingPosition);
clearAction->setVisible(false);
connect(clearAction, &QAction::triggered,
m_workspaceSearch, &QLineEdit::clear);
connect(m_workspaceSearch, &QLineEdit::textChanged,
clearAction, [clearAction](const QString& text) {
clearAction->setVisible(!text.isEmpty());
});
for (auto* btn : m_workspaceSearch->findChildren<QToolButton*>()) {
if (btn->defaultAction() == clearAction) {
btn->setIconSize(QSize(14, 14));
break;
}
}
}
{
const auto& t = ThemeManager::instance().current();
m_workspaceSearch->setStyleSheet(QStringLiteral(
"QLineEdit { background: %1; color: %2; border: none;"
" border-bottom: 1px solid %3; padding: 4px 6px; }")
.arg(t.background.name(), t.textDim.name(), t.border.name()));
" padding: 4px 8px; }"
"QLineEdit QToolButton { padding: 0px 4px; }"
"QLineEdit QToolButton:hover { background: %3; }")
.arg(t.background.name(), t.textDim.name(),
t.hover.name()));
}
dockLayout->addWidget(m_workspaceSearch);
@@ -3408,23 +3465,37 @@ void MainWindow::createWorkspaceDock() {
m_workspaceTree->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_workspaceTree->setExpandsOnDoubleClick(false);
m_workspaceTree->setMouseTracking(true);
{
QSettings s("Reclass", "Reclass");
QFont f(s.value("font", "JetBrains Mono").toString(), 10);
f.setFixedPitch(true);
m_workspaceTree->setFont(f);
}
connect(m_workspaceSearch, &QLineEdit::textChanged, this, [this](const QString& text) {
m_workspaceProxy->setFilterFixedString(text);
if (!text.isEmpty())
m_workspaceTree->expandAll();
else
m_workspaceTree->expandToDepth(0);
});
// Override palette: selection + hover use theme colors (not default blue)
// Custom delegate for rich text rendering (name bright, metadata dim)
{
const auto& t = ThemeManager::instance().current();
m_workspaceDelegate = new rcx::WorkspaceDelegate(m_workspaceTree);
m_workspaceDelegate->setThemeColors(t);
m_workspaceTree->setItemDelegate(m_workspaceDelegate);
QPalette tp = m_workspaceTree->palette();
tp.setColor(QPalette::Text, t.textDim);
tp.setColor(QPalette::Highlight, t.hover);
tp.setColor(QPalette::Highlight, t.selected);
tp.setColor(QPalette::HighlightedText, t.text);
m_workspaceTree->setPalette(tp);
m_workspaceTree->setStyleSheet(QStringLiteral(
"QTreeView { background: %1; border: none; }"
"QTreeView::branch:has-children:closed { image: url(:/chevron-right.svg); }"
"QTreeView::branch:has-children:open { image: url(:/chevron-down.svg); }")
.arg(t.background.name()));
}
dockLayout->addWidget(m_workspaceTree);
@@ -3432,13 +3503,9 @@ void MainWindow::createWorkspaceDock() {
m_workspaceTree->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_workspaceTree, &QWidget::customContextMenuRequested, this, [this](const QPoint& pos) {
QModelIndex index = m_workspaceTree->indexAt(pos);
if (!index.isValid()) return;
auto structIdVar = index.data(Qt::UserRole + 1);
uint64_t structId = structIdVar.isValid() ? structIdVar.toULongLong() : 0;
// Right-click on "Project" group → New Class / New Struct / New Enum
if (structId == rcx::kGroupSentinel) {
// Right-click on empty area → New Class / New Struct / New Enum
if (!index.isValid()) {
QMenu menu;
auto* actClass = menu.addAction("New Class");
auto* actStruct = menu.addAction("New Struct");
@@ -3450,6 +3517,8 @@ void MainWindow::createWorkspaceDock() {
return;
}
auto structIdVar = index.data(Qt::UserRole + 1);
uint64_t structId = structIdVar.isValid() ? structIdVar.toULongLong() : 0;
if (structId == 0) return;
auto subVar = index.data(Qt::UserRole);
@@ -3554,12 +3623,6 @@ void MainWindow::createWorkspaceDock() {
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);
if (!subVar.isValid()) return;
auto* ownerDock = static_cast<QDockWidget*>(subVar.value<void*>());
@@ -3759,19 +3822,47 @@ void MainWindow::rebuildAllDocs() {
}
void MainWindow::rebuildWorkspaceModel() {
// Debounce: coalesce rapid calls into a single rebuild
if (!m_workspaceRebuildTimer) {
m_workspaceRebuildTimer = new QTimer(this);
m_workspaceRebuildTimer->setSingleShot(true);
m_workspaceRebuildTimer->setInterval(50);
connect(m_workspaceRebuildTimer, &QTimer::timeout,
this, &MainWindow::rebuildWorkspaceModelNow);
}
m_workspaceRebuildTimer->start();
}
void MainWindow::rebuildWorkspaceModelNow() {
QVector<rcx::TabInfo> tabs;
QSet<RcxDocument*> seenDocs;
for (auto it = m_tabs.begin(); it != m_tabs.end(); ++it) {
TabState& tab = it.value();
if (seenDocs.contains(tab.doc)) continue; // skip duplicate doc views
if (seenDocs.contains(tab.doc)) continue;
seenDocs.insert(tab.doc);
QString name = tab.doc->filePath.isEmpty()
? rootName(tab.doc->tree, tab.ctrl->viewRootId())
: QFileInfo(tab.doc->filePath).fileName();
QString name = rootName(tab.doc->tree, tab.ctrl->viewRootId());
tabs.append({ &tab.doc->tree, name, static_cast<void*>(it.key()) });
}
rcx::buildProjectExplorer(m_workspaceModel, tabs);
m_workspaceTree->expandToDepth(0);
rcx::syncProjectExplorer(m_workspaceModel, tabs);
if (m_dockTitleLabel) {
int structs = 0, enums = 0;
for (int i = 0; i < m_workspaceModel->rowCount(); ++i) {
if (m_workspaceModel->item(i)->data(Qt::UserRole + 2).toBool())
++enums;
else
++structs;
}
QString title = QStringLiteral("Project");
if (structs || enums) {
title += QStringLiteral(" \u2014 %1 struct%2")
.arg(structs).arg(structs != 1 ? "s" : "");
if (enums)
title += QStringLiteral(" \u00b7 %1 enum%2")
.arg(enums).arg(enums != 1 ? "s" : "");
}
m_dockTitleLabel->setText(title);
}
}
void MainWindow::addRecentFile(const QString& path) {
@@ -4007,6 +4098,67 @@ void MainWindow::updateBorderColor(const QColor& color) {
m_borderOverlay->update();
}
void MainWindow::showStartPage() {
if (m_startPage) return;
m_startPage = new StartPageWidget(this);
m_startPage->applyTheme(ThemeManager::instance().current());
// Size the popup to ~90% of the main window
QSize sz(qBound(900, int(width() * 0.9), width() - 20),
qBound(560, int(height() * 0.85), height() - 20));
m_startPage->setFixedSize(sz);
// Wire start page signals — each closes the dialog then performs action
connect(m_startPage, &StartPageWidget::openProject, this, [this]() {
dismissStartPage();
openFile();
if (m_tabs.isEmpty()) showStartPage();
});
connect(m_startPage, &StartPageWidget::newClass, this, [this]() {
dismissStartPage();
newClass();
});
connect(m_startPage, &StartPageWidget::importSource, this, [this]() {
dismissStartPage();
importFromSource();
if (m_tabs.isEmpty()) showStartPage();
});
connect(m_startPage, &StartPageWidget::importXml, this, [this]() {
dismissStartPage();
importReclassXml();
if (m_tabs.isEmpty()) showStartPage();
});
connect(m_startPage, &StartPageWidget::importPdb, this, [this]() {
dismissStartPage();
importPdb();
if (m_tabs.isEmpty()) showStartPage();
});
connect(m_startPage, &StartPageWidget::continueClicked, this, [this]() {
dismissStartPage();
selfTest();
});
connect(m_startPage, &StartPageWidget::fileSelected, this, [this](const QString& path) {
dismissStartPage();
project_open(path);
});
connect(m_startPage, &QDialog::rejected, this, [this]() {
dismissStartPage();
});
// Center over main window and show as application-modal
m_startPage->move(geometry().center() - m_startPage->rect().center());
m_startPage->open();
}
void MainWindow::dismissStartPage() {
if (!m_startPage) return;
auto* sp = m_startPage;
m_startPage = nullptr; // null first — close() may re-enter via rejected signal
sp->close();
sp->deleteLater();
}
} // namespace rcx
// ── Entry point ──
@@ -4043,11 +4195,9 @@ int main(int argc, char* argv[]) {
window.show();
// Auto-open demo project from saved .rcx file
QMetaObject::invokeMethod(&window, "selfTest");
// Show VS2022-style start page instead of jumping straight to demo
QMetaObject::invokeMethod(&window, "showStartPage", Qt::QueuedConnection);
return app.exec();
}
// DockTabButtons has Q_OBJECT in main.cpp — need the moc include
#include "main.moc"

View File

@@ -3,6 +3,7 @@
#include "titlebar.h"
#include "pluginmanager.h"
#include "scannerpanel.h"
#include "startpage.h"
#include <QMainWindow>
#include <QLabel>
#include <QSplitter>
@@ -23,6 +24,7 @@ namespace rcx {
class McpBridge;
class ShimmerLabel;
class DockGripWidget;
class WorkspaceDelegate;
class MainWindow : public QMainWindow {
Q_OBJECT
@@ -90,6 +92,7 @@ private:
PluginManager m_pluginManager;
McpBridge* m_mcp = nullptr;
QAction* m_mcpAction = nullptr;
QAction* m_actRelOfs = nullptr;
QMenu* m_sourceMenu = nullptr;
QMenu* m_recentFilesMenu = nullptr;
@@ -154,11 +157,14 @@ private:
QStandardItemModel* m_workspaceModel = nullptr;
QSortFilterProxyModel* m_workspaceProxy = nullptr;
QLineEdit* m_workspaceSearch = nullptr;
WorkspaceDelegate* m_workspaceDelegate = nullptr;
QLabel* m_dockTitleLabel = nullptr;
QToolButton* m_dockCloseBtn = nullptr;
DockGripWidget* m_dockGrip = nullptr;
void createWorkspaceDock();
void rebuildWorkspaceModel();
void rebuildWorkspaceModel(); // debounced — safe to call frequently
void rebuildWorkspaceModelNow(); // immediate rebuild
QTimer* m_workspaceRebuildTimer = nullptr;
void updateBorderColor(const QColor& color);
// Scanner dock
@@ -169,9 +175,15 @@ private:
DockGripWidget* m_scanDockGrip = nullptr;
void createScannerDock();
// Start page
StartPageWidget* m_startPage = nullptr;
Q_INVOKABLE void showStartPage();
void dismissStartPage();
protected:
void changeEvent(QEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
bool eventFilter(QObject* obj, QEvent* event) override;
};
} // namespace rcx

View File

@@ -447,6 +447,22 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
}}
});
// process.info
tools.append(QJsonObject{
{"name", "process.info"},
{"description", "Returns PEB address and enumerates all Thread Environment Blocks (TEBs) for the attached process. "
"TEBs are discovered via NtQuerySystemInformation and NtQueryInformationThread. "
"Each TEB entry includes: address, threadId. "
"Requires a live process provider with PEB support."},
{"inputSchema", QJsonObject{
{"type", "object"},
{"properties", QJsonObject{
{"tabIndex", QJsonObject{{"type", "integer"},
{"description", "MDI tab index (0-based). Omit for active tab."}}}
}}
}}
});
return okReply(id, QJsonObject{{"tools", tools}});
}
@@ -472,6 +488,7 @@ QJsonObject McpBridge::handleToolsCall(const QJsonValue& id, const QJsonObject&
else if (toolName == "ui.action") result = toolUiAction(args);
else if (toolName == "tree.search") result = toolTreeSearch(args);
else if (toolName == "node.history") result = toolNodeHistory(args);
else if (toolName == "process.info") result = toolProcessInfo(args);
else return errReply(id, -32601, "Unknown tool: " + toolName);
m_mainWindow->clearMcpStatus();
@@ -1327,6 +1344,39 @@ QJsonObject McpBridge::toolNodeHistory(const QJsonObject& args) {
QJsonDocument(result).toJson(QJsonDocument::Compact)));
}
// ════════════════════════════════════════════════════════════════════
// TOOL: process.info — PEB address + TEB enumeration
// ════════════════════════════════════════════════════════════════════
QJsonObject McpBridge::toolProcessInfo(const QJsonObject& args) {
auto* tab = resolveTab(args);
if (!tab) return makeTextResult("No active tab", true);
auto* prov = tab->doc->provider.get();
if (!prov) return makeTextResult("No data source attached", true);
if (!prov->isLive()) return makeTextResult("Not a live provider", true);
uint64_t pebAddr = prov->peb();
if (!pebAddr) return makeTextResult("PEB not available for this provider", true);
QJsonObject out;
out["peb"] = "0x" + QString::number(pebAddr, 16).toUpper();
auto tebList = prov->tebs();
QJsonArray tebArr;
for (const auto& t : tebList) {
tebArr.append(QJsonObject{
{"address", "0x" + QString::number(t.tebAddress, 16).toUpper()},
{"threadId", (qint64)t.threadId}
});
}
out["tebs"] = tebArr;
out["tebCount"] = tebArr.size();
return makeTextResult(QString::fromUtf8(QJsonDocument(out).toJson(QJsonDocument::Indented)));
}
// ════════════════════════════════════════════════════════════════════
// Notifications (call from MainWindow/Controller hooks)
// ════════════════════════════════════════════════════════════════════

View File

@@ -60,6 +60,7 @@ private:
QJsonObject toolUiAction(const QJsonObject& args);
QJsonObject toolTreeSearch(const QJsonObject& args);
QJsonObject toolNodeHistory(const QJsonObject& args);
QJsonObject toolProcessInfo(const QJsonObject& args);
// Helpers
QJsonObject makeTextResult(const QString& text, bool isError = false);

View File

@@ -73,6 +73,13 @@ public:
// Default: returns empty (scan engine falls back to [0, size())).
virtual QVector<MemoryRegion> enumerateRegions() const { return {}; }
// Process Environment Block address (x64 PEB VA in target process).
// Only meaningful for live process providers. Returns 0 if unavailable.
virtual uint64_t peb() const { return 0; }
struct ThreadInfo { uint64_t tebAddress; uint32_t threadId; };
virtual QVector<ThreadInfo> tebs() const { return {}; }
// --- Derived convenience (non-virtual, never override) ---
bool isValid() const { return size() > 0; }

View File

@@ -49,6 +49,7 @@
<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="chevron-right.svg">vsicons/chevron-right.svg</file>
<file alias="folder.svg">vsicons/folder.svg</file>
<file alias="symbol-enum.svg">vsicons/symbol-enum.svg</file>
<file alias="symbol-class.svg">vsicons/symbol-class.svg</file>
@@ -64,5 +65,6 @@
<file alias="pinned.svg">vsicons/pinned.svg</file>
<file alias="close-all.svg">vsicons/close-all.svg</file>
<file alias="split-vertical.svg">vsicons/split-vertical.svg</file>
<file alias="book.svg">vsicons/book.svg</file>
</qresource>
</RCC>

360
src/startpage.h Normal file
View File

@@ -0,0 +1,360 @@
#pragma once
#include "themes/thememanager.h"
#include <QDialog>
#include <QLineEdit>
#include <QPainter>
#include <QMouseEvent>
#include <QWheelEvent>
#include <QFileInfo>
#include <QDir>
#include <QSettings>
#include <QCoreApplication>
#include <QPainterPath>
namespace rcx {
// Single-widget start page: everything painted in paintEvent.
// Zero CSS, zero Fusion conflicts, zero child-widget styling issues.
class StartPageWidget : public QDialog {
Q_OBJECT
public:
explicit StartPageWidget(QWidget* parent = nullptr) : QDialog(parent) {
setWindowFlags(Qt::FramelessWindowHint | Qt::Dialog);
setMouseTracking(true);
setAttribute(Qt::WA_OpaquePaintEvent);
m_search = new QLineEdit(this);
m_search->setPlaceholderText("Search recent...");
m_search->setFixedHeight(30);
m_search->setMaximumWidth(330);
m_search->addAction(QIcon(":/vsicons/search.svg"), QLineEdit::TrailingPosition);
connect(m_search, &QLineEdit::textChanged, this, [this]{ buildGroups(); update(); });
loadEntries();
buildGroups();
applyTheme(ThemeManager::instance().current());
}
void applyTheme(const Theme& t) {
m_t = t;
m_search->setStyleSheet(
"QLineEdit { background: " + t.background.name() + "; color: " + t.text.name()
+ "; border: 1px solid " + t.border.name()
+ "; padding: 2px 8px; font-size: 13px; }"
"QLineEdit:focus { border: 1px solid " + t.borderFocused.name() + "; }");
update();
}
signals:
void openProject();
void newClass();
void importSource();
void importXml();
void importPdb();
void continueClicked();
void fileSelected(const QString& path);
protected:
void paintEvent(QPaintEvent*) override {
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
const int LX = 48, TM = 36, RM = 32, GAP = 40, RW = 340;
const int rpX = width() - RW - RM;
const int lW = qMax(100, rpX - GAP - LX);
p.fillRect(rect(), m_t.background);
// ── Title ──
int y = TM;
QFont titleF = font(); titleF.setPixelSize(30); titleF.setWeight(QFont::Light);
p.setFont(titleF); p.setPen(m_t.text);
QFontMetrics titleFm(titleF);
p.drawText(LX, y + titleFm.ascent(), "Reclass");
y += titleFm.height() + 24;
// ── Headings (left + right at same y) ──
QFont headF = font(); headF.setPixelSize(20); headF.setWeight(QFont::DemiBold);
p.setFont(headF); QFontMetrics headFm(headF);
p.drawText(LX, y + headFm.ascent(), "Open recent");
int ry = y;
p.drawText(rpX, ry + headFm.ascent(), "Get started");
ry += headFm.height() + 14;
y += headFm.height() + 14;
// ── Search bar (only child widget) ──
m_search->setGeometry(LX, y, qMin(330, lW), 30);
y += 46;
m_listTop = y;
// ── Right panel ──
drawCards(p, rpX, ry, RW);
// ── File list ──
drawFileList(p, LX, lW);
// ── Border ──
p.setPen(QPen(m_t.border, 1));
p.setBrush(Qt::NoBrush);
p.drawRect(rect().adjusted(0, 0, -1, -1));
}
void mouseMoveEvent(QMouseEvent* e) override {
auto [z, i] = hitTest(e->pos());
if (z != m_hz || i != m_hi) {
m_hz = z; m_hi = i;
setCursor(z != HZ_None ? Qt::PointingHandCursor : Qt::ArrowCursor);
update();
}
}
void mousePressEvent(QMouseEvent* e) override {
if (e->button() != Qt::LeftButton) return;
auto [z, i] = hitTest(e->pos());
if (z == HZ_Entry) emit fileSelected(m_filtered[i].path);
if (z == HZ_Group) { m_groups[i].expanded = !m_groups[i].expanded; update(); }
if (z == HZ_Card && i == 0) emit newClass();
if (z == HZ_Card && i == 1) emit openProject();
if (z == HZ_Card && i == 2) emit importSource();
if (z == HZ_Card && i == 3) emit importXml();
if (z == HZ_Card && i == 4) emit importPdb();
if (z == HZ_Continue) emit continueClicked();
}
void wheelEvent(QWheelEvent* e) override {
m_scrollY = qBound(0, m_scrollY - e->angleDelta().y() / 2, m_maxScroll);
update();
}
void resizeEvent(QResizeEvent* e) override { QWidget::resizeEvent(e); update(); }
void leaveEvent(QEvent*) override { m_hz = HZ_None; m_hi = -1; setCursor(Qt::ArrowCursor); update(); }
void keyPressEvent(QKeyEvent* e) override { if (e->key() == Qt::Key_Escape) reject(); }
private:
enum HZ { HZ_None, HZ_Entry, HZ_Group, HZ_Card, HZ_Continue };
struct Hit { HZ zone; int idx; };
struct Entry {
QString path, fileName, dirPath;
QDateTime lastModified;
bool isExample;
};
struct Group {
QString name;
bool expanded = true;
QVector<int> entries;
};
Theme m_t;
QLineEdit* m_search;
QVector<Entry> m_all, m_filtered;
QVector<Group> m_groups;
int m_scrollY = 0, m_maxScroll = 0, m_listTop = 0, m_contentH = 0;
HZ m_hz = HZ_None;
int m_hi = -1;
// Hit rects populated during paint
QVector<QPair<int, QRectF>> m_grpRects, m_entRects;
QRectF m_cardR[5], m_contR;
void drawIcon(QPainter& p, const QString& path, int x, int y, int sz) {
QIcon(path).paint(&p, x, y, sz, sz);
}
// ── Data loading ──
void loadEntries() {
m_all.clear();
QSettings s("Reclass", "Reclass");
for (const auto& path : s.value("recentFiles").toStringList()) {
QFileInfo fi(path);
if (!fi.exists()) continue;
m_all.append({fi.absoluteFilePath(), fi.fileName(), fi.absolutePath(),
fi.lastModified(), false});
}
#ifdef __APPLE__
QDir exDir(QDir::cleanPath(QCoreApplication::applicationDirPath() + "/../Resources/examples"));
#else
QDir exDir(QCoreApplication::applicationDirPath() + "/examples");
#endif
for (const auto& fn : exDir.entryList({"*.rcx"}, QDir::Files, QDir::Name))
m_all.append({exDir.absoluteFilePath(fn), fn, exDir.absolutePath(),
QFileInfo(exDir.filePath(fn)).lastModified(), true});
}
void buildGroups() {
QString f = m_search->text().trimmed().toLower();
m_filtered.clear();
for (const auto& e : m_all)
if (f.isEmpty() || e.fileName.toLower().contains(f) || e.dirPath.toLower().contains(f))
m_filtered.append(e);
QDate today = QDate::currentDate();
QVector<int> bk[6];
for (int i = 0; i < m_filtered.size(); i++) {
auto& e = m_filtered[i];
if (e.isExample) { bk[5].append(i); continue; }
int d = e.lastModified.date().daysTo(today);
if (d == 0) bk[0].append(i);
else if (d == 1) bk[1].append(i);
else if (d < 7) bk[2].append(i);
else if (e.lastModified.date().month() == today.month()
&& e.lastModified.date().year() == today.year()) bk[3].append(i);
else bk[4].append(i);
}
static const char* names[] = {"Today","Yesterday","This week","This month","Older","Examples"};
m_groups.clear();
for (int i = 0; i < 6; i++)
if (!bk[i].isEmpty()) m_groups.append({names[i], true, bk[i]});
m_scrollY = 0;
}
// ── Drawing ──
void drawCards(QPainter& p, int x, int y, int w) {
struct C { const char* icon; const char* title; const char* desc; };
static const C cards[] = {
{":/vsicons/symbol-structure.svg", "New Class", "Start a new binary class definition"},
{":/vsicons/folder-opened.svg", "Open project", "Open an existing .rcx project"},
{":/vsicons/file-binary.svg", "Import from Source", "Import C/C++ header or source file"},
{":/vsicons/code.svg", "Import ReClass XML", "Import from ReClass .xml format"},
{":/vsicons/debug.svg", "Import PDB", "Import types from a .pdb symbol file"}
};
const int N = 5, CH = 84, R = 6, panelH = N * CH;
// Rounded panel background
QPainterPath clip;
clip.addRoundedRect(QRectF(x, y, w, panelH), R, R);
p.save();
p.setClipPath(clip);
p.fillRect(x, y, w, panelH, m_t.background);
for (int i = 0; i < N; i++) {
int cy = y + i * CH;
QRectF cr(x, cy, w, CH);
m_cardR[i] = cr;
bool hov = (m_hz == HZ_Card && m_hi == i);
if (hov) {
p.fillRect(cr, m_t.hover);
p.fillRect(QRectF(x, cy, 3, CH), m_t.indHoverSpan);
}
// Icon (32px, centered vertically)
int iconSz = 32;
drawIcon(p, cards[i].icon, x + 24, cy + (CH - iconSz) / 2, iconSz);
// Title + description block, centered vertically
int tx = x + 24 + iconSz + 16;
QFont tf = font(); tf.setPixelSize(15);
QFont df = font(); df.setPixelSize(12);
QFontMetrics tfm(tf), dfm(df);
int blockH = tfm.height() + 5 + dfm.height();
int by = cy + (CH - blockH) / 2;
p.setFont(tf); p.setPen(m_t.text);
p.drawText(tx, by + tfm.ascent(), cards[i].title);
p.setFont(df); p.setPen(m_t.textDim);
p.drawText(tx, by + tfm.height() + 5 + dfm.ascent(), cards[i].desc);
}
p.restore();
// "Continue →" centered under the panel
int cy = y + panelH + 8;
QFont lf = font(); lf.setPixelSize(13);
if (m_hz == HZ_Continue) lf.setUnderline(true);
p.setFont(lf); p.setPen(m_t.indHoverSpan);
QFontMetrics lfm(lf);
QString ct = QStringLiteral("Continue \u2192");
int cw = lfm.horizontalAdvance(ct);
m_contR = QRectF(x + (w - cw) / 2, cy, cw, lfm.height());
p.drawText(int(m_contR.x()), cy + lfm.ascent(), ct);
}
void drawFileList(QPainter& p, int x, int w) {
int listH = height() - 24 - m_listTop;
p.save();
p.setClipRect(x, m_listTop, w, listH);
int fy = m_listTop - m_scrollY;
m_grpRects.clear();
m_entRects.clear();
for (int gi = 0; gi < m_groups.size(); gi++) {
auto& g = m_groups[gi];
if (gi > 0) fy += 15;
// Group header
m_grpRects.append({gi, QRectF(x, fy, w, 28)});
p.setPen(Qt::NoPen); p.setBrush(m_t.text);
int triX = x + 8, triY = fy + 11;
QPolygonF tri;
if (g.expanded) tri << QPointF(triX,triY) << QPointF(triX+6,triY) << QPointF(triX+3,triY+6);
else tri << QPointF(triX,triY) << QPointF(triX+6,triY+3) << QPointF(triX,triY+6);
p.drawPolygon(tri);
QFont gf = font(); gf.setPixelSize(13);
p.setFont(gf); p.setPen(m_t.text);
p.drawText(triX + 14, fy + 14 + QFontMetrics(gf).ascent() / 2 - 1, g.name);
fy += 28;
if (!g.expanded) continue;
for (int ei : g.entries) {
auto& e = m_filtered[ei];
QRectF er(x, fy, w, 52);
m_entRects.append({ei, er});
if (m_hz == HZ_Entry && m_hi == ei) p.fillRect(er, m_t.hover);
drawIcon(p, e.isExample ? ":/vsicons/book.svg" : ":/vsicons/symbol-structure.svg",
x + 24, fy + 17, 18);
int tx = x + 52, avail = w - 64;
QFont nf = font(); nf.setPixelSize(14);
p.setFont(nf); p.setPen(m_t.text);
QFontMetrics nm(nf);
int ny = fy + 8;
p.drawText(tx, ny + nm.ascent(),
nm.elidedText(e.fileName, Qt::ElideMiddle, avail * 0.65));
if (!e.isExample) {
p.setPen(m_t.textDim);
QString dt = e.lastModified.toString("M/d/yyyy h:mm AP");
p.drawText(x + w - 12 - nm.horizontalAdvance(dt), ny + nm.ascent(), dt);
}
QFont pf = font(); pf.setPixelSize(12);
p.setFont(pf); p.setPen(m_t.textDim);
QFontMetrics pm(pf);
p.drawText(tx, ny + nm.height() + 4 + pm.ascent(),
pm.elidedText(e.dirPath, Qt::ElideMiddle, avail));
fy += 52;
}
}
m_contentH = fy + m_scrollY - m_listTop;
m_maxScroll = qMax(0, m_contentH - listH);
p.restore();
}
// ── Hit testing ──
Hit hitTest(QPoint pos) const {
for (int i = 0; i < 5; i++)
if (m_cardR[i].contains(pos)) return {HZ_Card, i};
if (m_contR.contains(pos)) return {HZ_Continue, 0};
if (pos.y() >= m_listTop && pos.y() < height() - 24) {
for (const auto& [gi, r] : m_grpRects)
if (r.contains(pos)) return {HZ_Group, gi};
for (const auto& [ei, r] : m_entRects)
if (r.contains(pos)) return {HZ_Entry, ei};
}
return {HZ_None, -1};
}
};
} // namespace rcx

View File

@@ -0,0 +1,32 @@
{
"name": "Modern",
"background": "#0e1117",
"backgroundAlt": "#12151c",
"surface": "#181d27",
"border": "#1e2533",
"borderFocused": "#4fc3f7",
"button": "#1e2433",
"text": "#a8bbd0",
"textDim": "#7a8fa8",
"textMuted": "#566278",
"textFaint": "#3d4d6a",
"hover": "#1e2433",
"selected": "#232a3a",
"selection": "#1a4a5e",
"syntaxKeyword": "#9d8cff",
"syntaxNumber": "#f0c060",
"syntaxString": "#26c6b3",
"syntaxComment": "#566278",
"syntaxPreproc": "#f472b6",
"syntaxType": "#4fc3f7",
"indHoverSpan": "#f0c060",
"indCmdPill": "#12151c",
"indDataChanged": "#6bda8a",
"indHeatCold": "#f0c060",
"indHeatWarm": "#e8946a",
"indHeatHot": "#ff6b6b",
"indHintGreen": "#2a5e3a",
"markerPtr": "#ff6b6b",
"markerCycle": "#f0c060",
"markerError": "#3a1a1a"
}

View File

@@ -1,5 +1,6 @@
#include "titlebar.h"
#include "themes/thememanager.h"
#include <QMenu>
#include <QMouseEvent>
#include <QPainter>
#include <QStyle>
@@ -76,15 +77,35 @@ void TitleBarWidget::applyTheme(const Theme& theme) {
QStringLiteral("QLabel { color: %1; font-size: 12px; font-weight: bold; }")
.arg(theme.text.name()));
// Menu bar palette — hover/bg handled by MenuBarStyle QProxyStyle.
// Set Window + Button to background so Fusion never paints a foreign color.
// Menu bar palette — all roles used by MenuBarStyle, so live theme
// switches don't rely on app-palette inheritance (which can stall
// once setPalette has been called on a widget).
{
QPalette mbPal = m_menuBar->palette();
mbPal.setColor(QPalette::Window, theme.background);
mbPal.setColor(QPalette::Button, theme.background);
mbPal.setColor(QPalette::ButtonText, theme.text);
mbPal.setColor(QPalette::Text, theme.text);
mbPal.setColor(QPalette::Highlight, theme.selected);
mbPal.setColor(QPalette::Link, theme.indHoverSpan);
mbPal.setColor(QPalette::AlternateBase, theme.surface);
mbPal.setColor(QPalette::Dark, theme.border);
mbPal.setColor(QPalette::Mid, theme.hover);
m_menuBar->setPalette(mbPal);
m_menuBar->setAutoFillBackground(false);
// Propagate to existing QMenu children so dropdown popups update too
for (auto* menu : m_menuBar->findChildren<QMenu*>()) {
QPalette mp = menu->palette();
mp.setColor(QPalette::Window, theme.background);
mp.setColor(QPalette::WindowText, theme.text);
mp.setColor(QPalette::Text, theme.text);
mp.setColor(QPalette::Highlight, theme.selected);
mp.setColor(QPalette::Link, theme.indHoverSpan);
mp.setColor(QPalette::AlternateBase, theme.surface);
mp.setColor(QPalette::Dark, theme.border);
menu->setPalette(mp);
}
}
// Chrome buttons

View File

@@ -415,9 +415,9 @@ TypeSelectorPopup::TypeSelectorPopup(QWidget* parent)
return btn;
};
m_chipPrim = makeChip(QStringLiteral("P"));
m_chipTypes = makeChip(QStringLiteral("T"));
m_chipEnums = makeChip(QStringLiteral("E"));
m_chipPrim = makeChip(QStringLiteral("Built-in"));
m_chipTypes = makeChip(QStringLiteral("Types"));
m_chipEnums = makeChip(QStringLiteral("Enum"));
m_chipPrim->setAccessibleName(QStringLiteral("Show primitives"));
m_chipTypes->setAccessibleName(QStringLiteral("Show composites"));
m_chipEnums->setAccessibleName(QStringLiteral("Show enums"));
@@ -1080,9 +1080,9 @@ void TypeSelectorPopup::applyFilter(const QString& text) {
auto updateChipLabel = [](QToolButton* btn, const QString& abbrev, int count) {
btn->setText(QStringLiteral("%1 (%2)").arg(abbrev).arg(count));
};
if (m_chipPrim) updateChipLabel(m_chipPrim, QStringLiteral("P"), primCount);
if (m_chipTypes) updateChipLabel(m_chipTypes, QStringLiteral("T"), typeCount);
if (m_chipEnums) updateChipLabel(m_chipEnums, QStringLiteral("E"), enumCount);
if (m_chipPrim) updateChipLabel(m_chipPrim, QStringLiteral("Built-in"), primCount);
if (m_chipTypes) updateChipLabel(m_chipTypes, QStringLiteral("Types"), typeCount);
if (m_chipEnums) updateChipLabel(m_chipEnums, QStringLiteral("Enum"), enumCount);
if (m_statusLabel)
m_statusLabel->setText(QStringLiteral("%1 results").arg(resultCount));

View File

@@ -1,8 +1,12 @@
#pragma once
#include "core.h"
#include "themes/theme.h"
#include <QIcon>
#include <QStandardItemModel>
#include <QStandardItem>
#include <QStyledItemDelegate>
#include <QPainter>
#include <QApplication>
#include <algorithm>
namespace rcx {
@@ -13,22 +17,85 @@ struct TabInfo {
void* subPtr; // QDockWidget* as void*
};
// Sentinel value stored in UserRole+1 to mark the Project group node.
static constexpr uint64_t kGroupSentinel = ~uint64_t(0);
// Helper: is a Hex padding node
inline bool isHexPad(NodeKind k) {
return k == NodeKind::Hex8 || k == NodeKind::Hex16
|| k == NodeKind::Hex32 || k == NodeKind::Hex64;
}
// Build child rows for a struct item.
inline void buildStructChildren(QStandardItem* item,
const NodeTree* tree, uint64_t structId,
void* subPtr) {
item->removeRows(0, item->rowCount());
QVector<int> members = tree->childrenOf(structId);
std::sort(members.begin(), members.end(), [&](int a, int b) {
return tree->nodes[a].offset < tree->nodes[b].offset;
});
auto memberTypeName = [](const Node& m) -> QString {
if (m.kind == NodeKind::Struct) {
return m.structTypeName.isEmpty() ? m.resolvedClassKeyword()
: m.structTypeName;
}
return QString::fromLatin1(kindToString(m.kind));
};
for (int mi : members) {
const Node& m = tree->nodes[mi];
if (isHexPad(m.kind)) continue;
QString childDisplay = QStringLiteral("%1 %2")
.arg(memberTypeName(m), m.name);
auto* childItem = new QStandardItem(childDisplay);
childItem->setData(QVariant::fromValue(subPtr), Qt::UserRole);
childItem->setData(QVariant::fromValue(m.id), Qt::UserRole + 1);
item->appendRow(childItem);
}
}
// Helper to build display string for a type entry.
inline QString typeDisplayString(const Node* node, const NodeTree* tree) {
auto nameOf = [](const Node* n) {
return n->structTypeName.isEmpty() ? n->name : n->structTypeName;
};
if (node->resolvedClassKeyword() == QStringLiteral("enum")) {
return QStringLiteral("%1 \u2014 %2")
.arg(nameOf(node),
QString::number(node->enumMembers.size()));
}
QVector<int> members = tree->childrenOf(node->id);
int vc = 0;
for (int mi : members)
if (!isHexPad(tree->nodes[mi].kind)) ++vc;
return QStringLiteral("%1 \u2014 %2")
.arg(nameOf(node), QString::number(vc));
}
// Build a new item for a type entry.
inline QStandardItem* makeTypeItem(const Node* node, const NodeTree* tree,
void* subPtr) {
bool isEnum = node->resolvedClassKeyword() == QStringLiteral("enum");
auto* item = new QStandardItem(
QIcon(isEnum ? ":/vsicons/symbol-enum.svg"
: ":/vsicons/symbol-structure.svg"),
typeDisplayString(node, tree));
item->setData(QVariant::fromValue(subPtr), Qt::UserRole);
item->setData(QVariant::fromValue(node->id), Qt::UserRole + 1);
item->setData(isEnum, Qt::UserRole + 2);
if (!isEnum)
buildStructChildren(item, tree, node->id, subPtr);
return item;
}
// Full rebuild — used by benchmarks and first build.
inline void buildProjectExplorer(QStandardItemModel* model,
const QVector<TabInfo>& tabs) {
model->clear();
model->setHorizontalHeaderLabels({QStringLiteral("Name")});
// Single "Project" root with folder icon
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);
// Collect all top-level structs/enums across all tabs
struct Entry { const Node* node; void* subPtr; const NodeTree* tree; };
QVector<Entry> types, enums;
for (const auto& tab : tabs) {
@@ -43,92 +110,190 @@ inline void buildProjectExplorer(QStandardItemModel* model,
}
}
auto nameOf = [](const Node* n) {
return n->structTypeName.isEmpty() ? n->name : n->structTypeName;
};
// Helper: is a Hex padding node
auto isHexPad = [](NodeKind k) {
return k == NodeKind::Hex8 || k == NodeKind::Hex16
|| k == NodeKind::Hex32 || k == NodeKind::Hex64;
};
// Helper: type display string for a member node
auto memberTypeName = [](const Node& m) -> QString {
if (m.kind == NodeKind::Struct) {
QString stn = m.structTypeName.isEmpty() ? m.resolvedClassKeyword()
: m.structTypeName;
return stn;
}
return QString::fromLatin1(kindToString(m.kind));
};
// Sort structs by visible children count descending (most fields first)
auto countVisible = [&](const Entry& e) {
int n = 0;
for (int idx : e.tree->childrenOf(e.node->id))
if (!isHexPad(e.tree->nodes[idx].kind)) ++n;
return n;
};
auto cmpChildren = [&](const Entry& a, const Entry& b) {
int ca = countVisible(a);
int cb = countVisible(b);
if (ca != cb) return ca > cb;
return nameOf(a.node).compare(nameOf(b.node), Qt::CaseInsensitive) < 0;
};
std::sort(types.begin(), types.end(), cmpChildren);
auto cmpName = [&](const Entry& a, const Entry& b) {
return nameOf(a.node).compare(nameOf(b.node), Qt::CaseInsensitive) < 0;
};
std::sort(enums.begin(), enums.end(), cmpName);
for (const auto& e : types) {
QVector<int> members = e.tree->childrenOf(e.node->id);
// Count non-hex members for display
int visibleCount = 0;
for (int mi : members)
if (!isHexPad(e.tree->nodes[mi].kind)) ++visibleCount;
QString display = QStringLiteral("%1 (%2) \u2014 %3")
.arg(nameOf(e.node), e.node->resolvedClassKeyword(),
QString::number(visibleCount));
auto* item = new QStandardItem(
QIcon(":/vsicons/symbol-structure.svg"), display);
item->setData(QVariant::fromValue(e.subPtr), Qt::UserRole);
item->setData(QVariant::fromValue(e.node->id), Qt::UserRole + 1);
// Add child rows sorted by offset (skip Hex padding)
std::sort(members.begin(), members.end(), [&](int a, int b) {
return e.tree->nodes[a].offset < e.tree->nodes[b].offset;
});
for (int mi : members) {
const Node& m = e.tree->nodes[mi];
if (isHexPad(m.kind)) continue;
QString childDisplay = QStringLiteral("%1 %2")
.arg(memberTypeName(m), m.name);
auto* childItem = new QStandardItem(childDisplay);
childItem->setData(QVariant::fromValue(e.subPtr), Qt::UserRole);
childItem->setData(QVariant::fromValue(m.id), Qt::UserRole + 1);
item->appendRow(childItem);
}
projectItem->appendRow(item);
}
for (const auto& e : enums) {
int count = e.node->enumMembers.size();
QString display = QStringLiteral("%1 (%2) \u2014 %3")
.arg(nameOf(e.node), e.node->resolvedClassKeyword(),
QString::number(count));
auto* item = new QStandardItem(
QIcon(":/vsicons/symbol-enum.svg"), display);
item->setData(QVariant::fromValue(e.subPtr), Qt::UserRole);
item->setData(QVariant::fromValue(e.node->id), Qt::UserRole + 1);
projectItem->appendRow(item);
}
model->appendRow(projectItem);
for (const auto& e : types)
model->appendRow(makeTypeItem(e.node, e.tree, e.subPtr));
for (const auto& e : enums)
model->appendRow(makeTypeItem(e.node, e.tree, e.subPtr));
}
// Incremental sync — preserves tree expansion/scroll state.
inline void syncProjectExplorer(QStandardItemModel* model,
const QVector<TabInfo>& tabs) {
// First call — full build
if (model->rowCount() == 0 && !tabs.isEmpty()) {
buildProjectExplorer(model, tabs);
return;
}
// Collect desired entries
struct Entry { uint64_t id; const Node* node; void* subPtr; const NodeTree* tree; bool isEnum; };
QVector<Entry> desired;
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;
bool ie = n.resolvedClassKeyword() == QStringLiteral("enum");
desired.append({n.id, &n, tab.subPtr, tab.tree, ie});
}
}
QHash<uint64_t, int> desiredMap;
desiredMap.reserve(desired.size());
for (int i = 0; i < desired.size(); ++i)
desiredMap[desired[i].id] = i;
// Remove stale items (backwards)
for (int i = model->rowCount() - 1; i >= 0; --i) {
uint64_t id = model->item(i)->data(Qt::UserRole + 1).toULongLong();
if (!desiredMap.contains(id))
model->removeRow(i);
}
// Update existing items
QSet<uint64_t> existing;
for (int i = 0; i < model->rowCount(); ++i) {
auto* item = model->item(i);
uint64_t id = item->data(Qt::UserRole + 1).toULongLong();
existing.insert(id);
auto dit = desiredMap.find(id);
if (dit == desiredMap.end()) continue;
const Entry& e = desired[*dit];
QString display = typeDisplayString(e.node, e.tree);
if (item->text() != display)
item->setText(display);
item->setData(QVariant::fromValue(e.subPtr), Qt::UserRole);
// Refresh children only when count changed (avoids destroying expansion state)
if (!e.isEnum) {
QVector<int> members = e.tree->childrenOf(id);
int visCount = 0;
for (int mi : members)
if (!isHexPad(e.tree->nodes[mi].kind)) ++visCount;
if (item->rowCount() != visCount)
buildStructChildren(item, e.tree, id, e.subPtr);
}
}
// Add new items
for (const auto& e : desired) {
if (existing.contains(e.id)) continue;
model->appendRow(makeTypeItem(e.node, e.tree, e.subPtr));
}
if (model->horizontalHeaderItem(0) == nullptr)
model->setHorizontalHeaderLabels({QStringLiteral("Name")});
}
// ── Custom delegate for rich workspace tree rendering ──
class WorkspaceDelegate : public QStyledItemDelegate {
public:
using QStyledItemDelegate::QStyledItemDelegate;
void setThemeColors(const Theme& t) {
m_text = t.text;
m_textDim = t.textDim;
m_textMuted = t.textMuted;
m_syntaxType = t.syntaxType;
m_hover = t.hover;
m_selected = t.selected;
m_accent = t.borderFocused; // left accent bar
m_bg = t.background;
}
QSize sizeHint(const QStyleOptionViewItem& option,
const QModelIndex& index) const override {
QSize s = QStyledItemDelegate::sizeHint(option, index);
int pad = index.parent().isValid() ? 6 : 10;
s.setHeight(option.fontMetrics.height() + pad);
return s;
}
void paint(QPainter* painter, const QStyleOptionViewItem& option,
const QModelIndex& index) const override {
painter->save();
QStyleOptionViewItem opt = option;
initStyleOption(&opt, index);
opt.text.clear();
opt.icon = QIcon(); // we draw icon manually
QApplication::style()->drawControl(QStyle::CE_ItemViewItem, &opt, painter);
// Custom background for selection/hover
if (opt.state & QStyle::State_Selected) {
painter->fillRect(opt.rect, m_selected);
// Left accent bar
painter->fillRect(QRect(opt.rect.x(), opt.rect.y(), 1, opt.rect.height()), m_accent);
} else if (opt.state & QStyle::State_MouseOver) {
painter->fillRect(opt.rect, m_hover);
}
bool isChild = index.parent().isValid();
QString fullText = index.data(Qt::DisplayRole).toString();
QRect textRect = opt.rect.adjusted(4, 0, -4, 0);
// Draw icon for top-level items
if (!isChild) {
QVariant iconVar = index.data(Qt::DecorationRole);
if (iconVar.isValid()) {
QIcon icon = iconVar.value<QIcon>();
int iconSz = opt.fontMetrics.height();
int iconY = textRect.y() + (textRect.height() - iconSz) / 2;
icon.paint(painter, textRect.x(), iconY, iconSz, iconSz);
textRect.setLeft(textRect.left() + iconSz + 4);
}
}
painter->setFont(opt.font);
if (!isChild) {
// Top-level: "StructName — 3"
int dashPos = fullText.indexOf(QChar(0x2014));
if (dashPos > 1) {
QString name = fullText.left(dashPos - 1);
QString meta = fullText.mid(dashPos - 1);
painter->setPen(m_text);
painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, name);
int nameW = opt.fontMetrics.horizontalAdvance(name);
QRect metaRect = textRect;
metaRect.setLeft(textRect.left() + nameW);
painter->setPen(m_textMuted);
painter->drawText(metaRect, Qt::AlignLeft | Qt::AlignVCenter, meta);
} else {
painter->setPen(m_text);
painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, fullText);
}
} else {
// Child: "TypeName fieldName"
int spacePos = fullText.indexOf(' ');
if (spacePos > 0) {
QString typeName = fullText.left(spacePos);
QString fieldName = fullText.mid(spacePos);
painter->setPen(m_syntaxType);
painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, typeName);
int typeW = opt.fontMetrics.horizontalAdvance(typeName);
QRect fieldRect = textRect;
fieldRect.setLeft(textRect.left() + typeW);
painter->setPen(m_textDim);
painter->drawText(fieldRect, Qt::AlignLeft | Qt::AlignVCenter, fieldName);
} else {
painter->setPen(m_textDim);
painter->drawText(textRect, Qt::AlignLeft | Qt::AlignVCenter, fullText);
}
}
painter->restore();
}
private:
QColor m_text, m_textDim, m_textMuted, m_syntaxType;
QColor m_hover, m_selected, m_accent, m_bg;
};
} // namespace rcx

View File

@@ -65,6 +65,7 @@ private:
private slots:
void initTestCase();
void benchCompose();
void benchComposeLarge();
void benchApplyDocument();
void benchHoverHighlight();
void benchSelectionOverlay();
@@ -112,6 +113,36 @@ void BenchLargeClass::benchCompose()
QVERIFY(elapsed > 0);
}
void BenchLargeClass::benchComposeLarge()
{
// Build a 2000-field tree to stress-test compose at scale
NodeTree bigTree = buildLargeTree(2000);
QByteArray buf(0x40000, '\0');
for (int i = 0; i < buf.size(); ++i) buf[i] = (char)(i & 0xFF);
BufferProvider bigProv(buf, QStringLiteral("bench_large"));
// Warmup
{ ComposeResult w = rcx::compose(bigTree, bigProv); Q_UNUSED(w); }
const int ITERS = 50;
QElapsedTimer timer;
timer.start();
for (int i = 0; i < ITERS; ++i) {
ComposeResult r = rcx::compose(bigTree, bigProv);
Q_UNUSED(r);
}
qint64 elapsed = timer.elapsed();
qDebug() << "";
qDebug() << "=== Compose Benchmark (2000 fields) ===";
qDebug() << " Tree:" << bigTree.nodes.size() << "nodes";
qDebug() << " Iterations:" << ITERS;
qDebug() << " Total:" << elapsed << "ms";
qDebug() << " Per-compose:" << (double)elapsed / ITERS << "ms";
QVERIFY(elapsed > 0);
}
void BenchLargeClass::benchApplyDocument()
{
RcxEditor editor;

282
tests/bench_project.cpp Normal file
View File

@@ -0,0 +1,282 @@
/*
* bench_project — benchmark project lifecycle operations:
* - New class creation
* - Loading large .rcx files (WinSDK, Vergilius)
* - Workspace model building
* - Workspace search filtering
* - JSON parsing vs model building breakdown
*/
#include <QtTest/QtTest>
#include <QElapsedTimer>
#include <QJsonDocument>
#include <QStandardItemModel>
#include <QSortFilterProxyModel>
#include "core.h"
#include "controller.h"
#include "workspace_model.h"
using namespace rcx;
class BenchProject : public QObject {
Q_OBJECT
private slots:
void benchNewClass();
void benchLoadVergilius();
void benchLoadWinSDK();
void benchJsonParse();
void benchNodeTreeFromJson();
void benchBuildWorkspaceModel();
void benchWorkspaceSearch();
};
static QString findExample(const QString& name) {
// Try relative to executable, then common build layout
QStringList candidates = {
QCoreApplication::applicationDirPath() + "/examples/" + name,
QCoreApplication::applicationDirPath() + "/../src/examples/" + name,
QStringLiteral("src/examples/") + name,
QStringLiteral("../src/examples/") + name,
};
for (const auto& c : candidates)
if (QFileInfo::exists(c)) return c;
return {};
}
// ── New class (just the core operations, no UI) ──
void BenchProject::benchNewClass()
{
const int ITERS = 1000;
QElapsedTimer timer;
timer.start();
for (int i = 0; i < ITERS; ++i) {
NodeTree tree;
tree.baseAddress = 0x00400000;
Node root;
root.kind = NodeKind::Struct;
root.name = QStringLiteral("NewClass");
root.structTypeName = QStringLiteral("NewClass");
root.classKeyword = QStringLiteral("class");
tree.addNode(root);
// Add 8 hex64 padding fields (what buildEmptyStruct does)
uint64_t rootId = tree.nodes[0].id;
for (int j = 0; j < 8; ++j) {
Node pad;
pad.kind = NodeKind::Hex64;
pad.name = QString();
pad.parentId = rootId;
pad.offset = j * 8;
tree.addNode(pad);
}
}
qint64 elapsed = timer.elapsed();
qDebug() << "";
qDebug() << "=== New Class (core tree build) ===";
qDebug() << " Iterations:" << ITERS;
qDebug() << " Total:" << elapsed << "ms";
qDebug() << " Per-new:" << (double)elapsed / ITERS << "ms";
}
// ── Load .rcx files ──
static bool loadRcx(const QString& path, NodeTree& tree) {
QFile f(path);
if (!f.open(QIODevice::ReadOnly)) return false;
QJsonDocument jdoc = QJsonDocument::fromJson(f.readAll());
tree = NodeTree::fromJson(jdoc.object());
return !tree.nodes.isEmpty();
}
void BenchProject::benchLoadVergilius()
{
QString path = findExample("Vergilius_25H2.rcx");
if (path.isEmpty()) { QSKIP("Vergilius_25H2.rcx not found"); return; }
const int ITERS = 5;
QElapsedTimer timer;
timer.start();
for (int i = 0; i < ITERS; ++i) {
NodeTree tree;
QVERIFY(loadRcx(path, tree));
if (i == 0)
qDebug() << " Nodes:" << tree.nodes.size();
}
qint64 elapsed = timer.elapsed();
qDebug() << "";
qDebug() << "=== Load Vergilius_25H2.rcx ===";
qDebug() << " File:" << QFileInfo(path).size() / 1024 << "KB";
qDebug() << " Iterations:" << ITERS;
qDebug() << " Total:" << elapsed << "ms";
qDebug() << " Per-load:" << (double)elapsed / ITERS << "ms";
}
void BenchProject::benchLoadWinSDK()
{
QString path = findExample("WinSDK.rcx");
if (path.isEmpty()) { QSKIP("WinSDK.rcx not found"); return; }
const int ITERS = 5;
QElapsedTimer timer;
timer.start();
for (int i = 0; i < ITERS; ++i) {
NodeTree tree;
QVERIFY(loadRcx(path, tree));
if (i == 0)
qDebug() << " Nodes:" << tree.nodes.size();
}
qint64 elapsed = timer.elapsed();
qDebug() << "";
qDebug() << "=== Load WinSDK.rcx ===";
qDebug() << " File:" << QFileInfo(path).size() / 1024 << "KB";
qDebug() << " Iterations:" << ITERS;
qDebug() << " Total:" << elapsed << "ms";
qDebug() << " Per-load:" << (double)elapsed / ITERS << "ms";
}
// ── Breakdown: JSON parse vs NodeTree build ──
void BenchProject::benchJsonParse()
{
QString path = findExample("Vergilius_25H2.rcx");
if (path.isEmpty()) path = findExample("WinSDK.rcx");
if (path.isEmpty()) { QSKIP("No large .rcx found"); return; }
QFile f(path);
QVERIFY(f.open(QIODevice::ReadOnly));
QByteArray data = f.readAll();
f.close();
const int ITERS = 5;
// Phase 1: raw JSON parse
QElapsedTimer timer;
timer.start();
QJsonDocument jdoc;
for (int i = 0; i < ITERS; ++i)
jdoc = QJsonDocument::fromJson(data);
qint64 jsonMs = timer.elapsed();
// Phase 2: NodeTree::fromJson
QJsonObject root = jdoc.object();
timer.start();
NodeTree tree;
for (int i = 0; i < ITERS; ++i)
tree = NodeTree::fromJson(root);
qint64 treeMs = timer.elapsed();
qDebug() << "";
qDebug() << "=== JSON Parse Breakdown ===" << QFileInfo(path).fileName();
qDebug() << " File:" << data.size() / 1024 << "KB," << tree.nodes.size() << "nodes";
qDebug() << " JSON parse:" << (double)jsonMs / ITERS << "ms/iter";
qDebug() << " NodeTree build:" << (double)treeMs / ITERS << "ms/iter";
qDebug() << " Total per-load:" << (double)(jsonMs + treeMs) / ITERS << "ms";
}
void BenchProject::benchNodeTreeFromJson()
{
// Already covered by benchJsonParse breakdown
QVERIFY(true);
}
// ── Workspace model building ──
void BenchProject::benchBuildWorkspaceModel()
{
// Load both large examples if available
QVector<NodeTree> trees;
for (const auto& name : {QStringLiteral("Vergilius_25H2.rcx"), QStringLiteral("WinSDK.rcx")}) {
QString path = findExample(name);
if (path.isEmpty()) continue;
NodeTree t;
if (loadRcx(path, t)) trees.append(std::move(t));
}
if (trees.isEmpty()) { QSKIP("No .rcx examples found"); return; }
// Build TabInfo array
QVector<TabInfo> tabs;
for (const auto& t : trees)
tabs.append({ &t, QStringLiteral("test"), nullptr });
QStandardItemModel model;
const int ITERS = 20;
QElapsedTimer timer;
timer.start();
for (int i = 0; i < ITERS; ++i)
buildProjectExplorer(&model, tabs);
qint64 elapsed = timer.elapsed();
// Count items
int topLevel = model.rowCount();
int totalChildren = 0;
for (int i = 0; i < topLevel; ++i)
totalChildren += model.item(i)->rowCount();
int totalNodes = 0;
for (const auto& t : trees) totalNodes += t.nodes.size();
fprintf(stderr, "\n=== Build Workspace Model ===\n");
fprintf(stderr, " Trees: %d total nodes: %d\n", (int)trees.size(), totalNodes);
fprintf(stderr, " Top-level items: %d child items: %d\n", topLevel, totalChildren);
fprintf(stderr, " Iterations: %d\n", ITERS);
fprintf(stderr, " Total: %lld ms\n", (long long)elapsed);
fprintf(stderr, " Per-build: %.1f ms\n", (double)elapsed / ITERS);
}
// ── Workspace search filtering ──
void BenchProject::benchWorkspaceSearch()
{
QVector<NodeTree> trees;
for (const auto& name : {QStringLiteral("Vergilius_25H2.rcx"), QStringLiteral("WinSDK.rcx")}) {
QString path = findExample(name);
if (path.isEmpty()) continue;
NodeTree t;
if (loadRcx(path, t)) trees.append(std::move(t));
}
if (trees.isEmpty()) { QSKIP("No .rcx examples found"); return; }
QVector<TabInfo> tabs;
for (const auto& t : trees)
tabs.append({ &t, QStringLiteral("test"), nullptr });
QStandardItemModel model;
buildProjectExplorer(&model, tabs);
QSortFilterProxyModel proxy;
proxy.setSourceModel(&model);
proxy.setFilterCaseSensitivity(Qt::CaseInsensitive);
proxy.setRecursiveFilteringEnabled(true);
const QStringList queries = {
"EPROCESS", "KTHREAD", "LIST_ENTRY", "HAL", "DMA",
"xyz_no_match", "a", "Dispatch"
};
const int ITERS = 50;
QElapsedTimer timer;
timer.start();
for (int i = 0; i < ITERS; ++i) {
for (const auto& q : queries)
proxy.setFilterFixedString(q);
proxy.setFilterFixedString(QString()); // clear
}
qint64 elapsed = timer.elapsed();
int totalOps = ITERS * (queries.size() + 1);
fprintf(stderr, "\n=== Workspace Search Filter ===\n");
fprintf(stderr, " Model rows: %d queries: %d\n", model.rowCount(), (int)queries.size());
fprintf(stderr, " Iterations: %d total filter ops: %d\n", ITERS, totalOps);
fprintf(stderr, " Total: %lld ms\n", (long long)elapsed);
fprintf(stderr, " Per-filter: %.2f ms\n", (double)elapsed / totalOps);
}
QTEST_MAIN(BenchProject)
#include "bench_project.moc"

View File

@@ -234,6 +234,7 @@ private slots:
child.name = "Child";
child.parentId = rootId;
child.offset = 0;
child.collapsed = false;
int ci = tree.addNode(child);
uint64_t childId = tree.nodes[ci].id;
@@ -281,6 +282,7 @@ private slots:
inner.name = "Inner";
inner.parentId = rootId;
inner.offset = 4;
inner.collapsed = false;
int ii = tree.addNode(inner);
uint64_t innerId = tree.nodes[ii].id;
@@ -354,6 +356,7 @@ private slots:
tmpl.name = "VTable";
tmpl.parentId = 0;
tmpl.offset = 200; // far away so standalone rendering uses offset 200
tmpl.collapsed = false;
int ti = tree.addNode(tmpl);
uint64_t tmplId = tree.nodes[ti].id;
@@ -378,6 +381,7 @@ private slots:
ptr.parentId = mainId;
ptr.offset = 4;
ptr.refId = tmplId;
ptr.collapsed = false;
tree.addNode(ptr);
// Provider: pointer at offset 4 points to address 100
@@ -434,6 +438,7 @@ private slots:
tmpl.name = "Target";
tmpl.parentId = 0;
tmpl.offset = 200;
tmpl.collapsed = false;
int ti = tree.addNode(tmpl);
uint64_t tmplId = tree.nodes[ti].id;
@@ -450,6 +455,7 @@ private slots:
ptr.parentId = mainId;
ptr.offset = 0;
ptr.refId = tmplId;
ptr.collapsed = false;
tree.addNode(ptr);
// All zeros = null pointer
@@ -493,6 +499,7 @@ private slots:
tmpl.name = "Target";
tmpl.parentId = 0;
tmpl.offset = 200;
tmpl.collapsed = false; // standalone rendering shows children
int ti = tree.addNode(tmpl);
uint64_t tmplId = tree.nodes[ti].id;
@@ -509,7 +516,7 @@ private slots:
ptr.parentId = mainId;
ptr.offset = 0;
ptr.refId = tmplId;
ptr.collapsed = true; // collapsed
ptr.collapsed = true; // collapsed — this is the test condition
tree.addNode(ptr);
// Non-null pointer
@@ -548,6 +555,7 @@ private slots:
tmpl.name = "Recursive";
tmpl.parentId = 0;
tmpl.offset = 200;
tmpl.collapsed = false;
int ti = tree.addNode(tmpl);
uint64_t tmplId = tree.nodes[ti].id;
@@ -565,6 +573,7 @@ private slots:
backPtr.parentId = tmplId;
backPtr.offset = 4;
backPtr.refId = tmplId; // points back to same struct
backPtr.collapsed = false;
tree.addNode(backPtr);
// Pointer in Main → Recursive
@@ -574,6 +583,7 @@ private slots:
ptr.parentId = mainId;
ptr.offset = 0;
ptr.refId = tmplId;
ptr.collapsed = false;
tree.addNode(ptr);
// Provider: main ptr at offset 0 points to 100
@@ -696,6 +706,7 @@ private slots:
arr.offset = 0;
arr.elementKind = NodeKind::Int32;
arr.arrayLen = 10;
arr.collapsed = false;
tree.addNode(arr);
NullProvider prov;
@@ -847,6 +858,7 @@ private slots:
arr.offset = 0;
arr.elementKind = NodeKind::Int32;
arr.arrayLen = 2;
arr.collapsed = false;
int ai = tree.addNode(arr);
uint64_t arrId = tree.nodes[ai].id;
@@ -856,6 +868,7 @@ private slots:
elem0.name = "Item";
elem0.parentId = arrId;
elem0.offset = 0;
elem0.collapsed = false;
int e0i = tree.addNode(elem0);
uint64_t elem0Id = tree.nodes[e0i].id;
@@ -871,6 +884,7 @@ private slots:
elem1.name = "Item";
elem1.parentId = arrId;
elem1.offset = 4;
elem1.collapsed = false;
int e1i = tree.addNode(elem1);
uint64_t elem1Id = tree.nodes[e1i].id;
@@ -1035,6 +1049,7 @@ private slots:
arr.offset = 0;
arr.elementKind = NodeKind::UInt32;
arr.arrayLen = 4;
arr.collapsed = false;
tree.addNode(arr);
// Buffer with known values: 0x11, 0x22, 0x33, 0x44
@@ -1140,6 +1155,7 @@ private slots:
arr.offset = 0;
arr.elementKind = NodeKind::Struct;
arr.arrayLen = 1;
arr.collapsed = false;
int ai = tree.addNode(arr);
uint64_t arrId = tree.nodes[ai].id;
@@ -1149,6 +1165,7 @@ private slots:
elem.name = "Item";
elem.parentId = arrId;
elem.offset = 0;
elem.collapsed = false;
int ei = tree.addNode(elem);
uint64_t elemId = tree.nodes[ei].id;
@@ -1481,6 +1498,7 @@ private slots:
structC.structTypeName = "InnerData";
structC.parentId = 0;
structC.offset = 300;
structC.collapsed = false;
int ci = tree.addNode(structC);
uint64_t structCId = tree.nodes[ci].id;
@@ -1498,6 +1516,7 @@ private slots:
structB.structTypeName = "Wrapper";
structB.parentId = 0;
structB.offset = 200;
structB.collapsed = false;
int bi = tree.addNode(structB);
uint64_t structBId = tree.nodes[bi].id;
@@ -1514,6 +1533,7 @@ private slots:
bptr.parentId = structBId;
bptr.offset = 4;
bptr.refId = structCId; // points to InnerData
bptr.collapsed = false;
tree.addNode(bptr);
// Root's pointer to StructB
@@ -1523,6 +1543,7 @@ private slots:
rptr.parentId = rootId;
rptr.offset = 0;
rptr.refId = structBId;
rptr.collapsed = false;
tree.addNode(rptr);
// Provider: rptr at 0 → addr 100, bptr at 100+4=104 → addr 150
@@ -1591,6 +1612,7 @@ private slots:
structB.name = "StructB";
structB.parentId = 0;
structB.offset = 200;
structB.collapsed = false;
int bi = tree.addNode(structB);
uint64_t structBId = tree.nodes[bi].id;
@@ -1608,6 +1630,7 @@ private slots:
ptrToB.parentId = mainId;
ptrToB.offset = 4;
ptrToB.refId = structBId;
ptrToB.collapsed = false;
tree.addNode(ptrToB);
// StructB → Main pointer (creates cycle!)
@@ -1617,6 +1640,7 @@ private slots:
ptrToMain.parentId = structBId;
ptrToMain.offset = 4;
ptrToMain.refId = mainId;
ptrToMain.collapsed = false;
tree.addNode(ptrToMain);
// Provider: Main.to_b at offset 4 → addr 100
@@ -2009,6 +2033,7 @@ private slots:
u.name = "u1";
u.parentId = rootId;
u.offset = 0;
u.collapsed = false;
int ui = tree.addNode(u);
uint64_t uId = tree.nodes[ui].id;
@@ -2655,6 +2680,7 @@ private slots:
inner.kind = NodeKind::Struct;
inner.name = "NewClass";
inner.parentId = 0;
inner.collapsed = false;
inner.offset = 200;
int ii = tree.addNode(inner);
uint64_t innerId = tree.nodes[ii].id;
@@ -2688,6 +2714,7 @@ private slots:
ptr.parentId = rootId;
ptr.offset = 8;
ptr.refId = innerId;
ptr.collapsed = false;
tree.addNode(ptr);
// Last child: hex64 at depth 1

View File

@@ -214,6 +214,7 @@ private slots:
vptr.parentId = rootId;
vptr.offset = 0;
vptr.refId = vtId;
vptr.collapsed = false;
tree.addNode(vptr);
// Compose the tree
@@ -408,6 +409,7 @@ private slots:
Node vptr; vptr.kind = NodeKind::Pointer64; vptr.name = "__vptr";
vptr.parentId = rootId; vptr.offset = 0; vptr.refId = vtId;
vptr.collapsed = false;
tree.addNode(vptr);
// Compose with the snapshot (like production: compose uses snapshot)

View File

@@ -498,9 +498,9 @@ void TestImportSource::computedOffsets() {
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 4);
QCOMPARE(tree.nodes[kids[0]].offset, 0); // uint8_t at 0
QCOMPARE(tree.nodes[kids[1]].offset, 1); // uint16_t at 1
QCOMPARE(tree.nodes[kids[2]].offset, 3); // uint32_t at 3
QCOMPARE(tree.nodes[kids[3]].offset, 7); // uint64_t at 7
QCOMPARE(tree.nodes[kids[1]].offset, 2); // uint16_t at 2 (aligned)
QCOMPARE(tree.nodes[kids[2]].offset, 4); // uint32_t at 4 (aligned)
QCOMPARE(tree.nodes[kids[3]].offset, 8); // uint64_t at 8 (aligned)
}
void TestImportSource::mixedOffsetsAutoDetect() {
@@ -805,7 +805,7 @@ void TestImportSource::bitfieldSkipped() {
QCOMPARE(tree.nodes[kids[1]].bitfieldMembers[1].bitWidth, (uint8_t)12);
QCOMPARE(tree.nodes[kids[1]].bitfieldMembers[1].bitOffset, (uint8_t)4);
QCOMPARE(tree.nodes[kids[2]].name, QStringLiteral("after"));
QCOMPARE(tree.nodes[kids[2]].offset, 6);
QCOMPARE(tree.nodes[kids[2]].offset, 8); // aligned to uint32_t boundary
}
void TestImportSource::bitfieldWithOffsetsEmitsHex() {

View File

@@ -517,16 +517,11 @@ private slots:
QVector<TabInfo> tabs = {{ &tree, "TestProject.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
// Single "Project" root
// Flat model: Player at root (has 2 non-hex members → lazy placeholder)
QCOMPARE(model.rowCount(), 1);
QStandardItem* project = model.item(0);
QCOMPARE(project->text(), QString("Project"));
// 1 type directly under Project: Player (no member fields)
QCOMPARE(project->rowCount(), 1);
QVERIFY(project->child(0)->text().contains("Player"));
QVERIFY(project->child(0)->text().contains("struct"));
QCOMPARE(project->child(0)->rowCount(), 0);
QVERIFY(model.item(0)->text().contains("Player"));
QVERIFY(model.item(0)->text().contains("struct"));
QVERIFY(model.item(0)->rowCount() > 0); // children populated directly
}
void testWorkspace_twoRootTree() {
@@ -535,15 +530,10 @@ private slots:
QVector<TabInfo> tabs = {{ &tree, "TwoRoot.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
QCOMPARE(model.rowCount(), 1);
QStandardItem* project = model.item(0);
// 2 types sorted alphabetically: Alpha, Bravo (no field children)
QCOMPARE(project->rowCount(), 2);
QVERIFY(project->child(0)->text().contains("Alpha"));
QVERIFY(project->child(1)->text().contains("Bravo"));
QCOMPARE(project->child(0)->rowCount(), 0);
QCOMPARE(project->child(1)->rowCount(), 0);
// Flat model: 2 types at root
QCOMPARE(model.rowCount(), 2);
QVERIFY(model.item(0)->text().contains("Alpha"));
QVERIFY(model.item(1)->text().contains("Bravo"));
}
void testWorkspace_richTree_rootCount() {
@@ -552,25 +542,19 @@ private slots:
QVector<TabInfo> tabs = {{ &tree, "Rich.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
QStandardItem* project = model.item(0);
QCOMPARE(project->rowCount(), 3); // Ball, Cat, Pet (sorted)
QCOMPARE(model.rowCount(), 3); // Ball, Cat, Pet
}
void testWorkspace_richTree_sorted() {
void testWorkspace_richTree_insertionOrder() {
auto tree = makeRichTree();
QStandardItemModel model;
QVector<TabInfo> tabs = {{ &tree, "Rich.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
QStandardItem* project = model.item(0);
// Sorted alphabetically: Ball, Cat, Pet
QVERIFY(project->child(0)->text().contains("Ball"));
QVERIFY(project->child(1)->text().contains("Cat"));
QVERIFY(project->child(2)->text().contains("Pet"));
// No member fields under type nodes
QCOMPARE(project->child(0)->rowCount(), 0);
QCOMPARE(project->child(1)->rowCount(), 0);
QCOMPARE(project->child(2)->rowCount(), 0);
// Types at root in insertion order: Pet, Cat, Ball
QVERIFY(model.item(0)->text().contains("Pet"));
QVERIFY(model.item(1)->text().contains("Cat"));
QVERIFY(model.item(2)->text().contains("Ball"));
}
void testWorkspace_emptyTree() {
@@ -579,10 +563,8 @@ private slots:
QVector<TabInfo> tabs = {{ &tree, "Empty.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
// Still has the "Project" root, just no children
QCOMPARE(model.rowCount(), 1);
QCOMPARE(model.item(0)->text(), QString("Project"));
QCOMPARE(model.item(0)->rowCount(), 0);
// Flat model: no types means no rows
QCOMPARE(model.rowCount(), 0);
}
void testWorkspace_structIdRole() {
@@ -591,15 +573,11 @@ private slots:
QVector<TabInfo> tabs = {{ &tree, "Test.rcx", nullptr }};
buildProjectExplorer(&model, tabs);
QStandardItem* project = model.item(0);
// Project root has kGroupSentinel
QCOMPARE(project->data(Qt::UserRole + 1).toULongLong(), kGroupSentinel);
// Player type item should have structId
QStandardItem* player = project->child(0);
// Flat model: first item is the Player type with its structId
QVERIFY(model.rowCount() > 0);
QStandardItem* player = model.item(0);
QVERIFY(player->data(Qt::UserRole + 1).isValid());
QVERIFY(player->data(Qt::UserRole + 1).toULongLong() > 0);
QVERIFY(player->data(Qt::UserRole + 1).toULongLong() != kGroupSentinel);
}
// ═══════════════════════════════════════════════════

View File

@@ -360,17 +360,45 @@ private slots:
}
void testComposeNoStaticFieldsWhenCollapsed() {
TestTree t;
t.addField("x", NodeKind::Float, 0);
t.addStaticField("h", "base");
// Collapse the root struct
t.tree.nodes[0].collapsed = true;
// Use a non-root struct to test collapsed behavior
// (root structs are always expanded via isRootHeader)
NodeTree tree;
Node root;
root.kind = NodeKind::Struct;
root.name = "Root";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node child;
child.kind = NodeKind::Struct;
child.name = "Child";
child.parentId = rootId;
child.offset = 0;
child.collapsed = true; // collapsed child struct
int ci = tree.addNode(child);
uint64_t childId = tree.nodes[ci].id;
Node f;
f.kind = NodeKind::Float;
f.name = "x";
f.parentId = childId;
f.offset = 0;
tree.addNode(f);
Node sf;
sf.kind = NodeKind::Hex64;
sf.name = "h";
sf.parentId = childId;
sf.offset = 0;
sf.isStatic = true;
sf.offsetExpr = QStringLiteral("base");
tree.addNode(sf);
NullProvider prov;
ComposeResult result = compose(t.tree, prov);
ComposeResult result = compose(tree, prov);
// When collapsed, no static field lines should appear
QStringList lines = result.text.split('\n');
for (const auto& lm : result.meta)
QVERIFY2(!lm.isStaticLine,
"Static field line should not appear when struct is collapsed");

View File

@@ -24,7 +24,7 @@
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;
static const int DBG_PORT = 5056;
class TestWinDbgProvider : public QObject {
Q_OBJECT
@@ -132,7 +132,7 @@ private slots:
void initTestCase()
{
m_connString = QString("tcp:Port=%1,Server=localhost").arg(DBG_PORT);
m_connString = QString("tcp:Port=%1,Server=127.0.0.1").arg(DBG_PORT);
// If a debug server is already listening (e.g. WinDbg with .server),
// skip launching our own cdb.exe.
@@ -207,7 +207,7 @@ private slots:
void plugin_canHandle_tcp()
{
WinDbgMemoryPlugin plugin;
QVERIFY(plugin.canHandle("tcp:Port=5055,Server=localhost"));
QVERIFY(plugin.canHandle("tcp:Port=5056,Server=localhost"));
QVERIFY(plugin.canHandle("TCP:Port=1234,Server=10.0.0.1"));
}
@@ -608,7 +608,7 @@ private slots:
// ── Kernel/dump session tests ──
// Set WINDBG_KERNEL_CONN to a target string:
// "dump:F:/path/to/file.dmp" — open dump directly
// "tcp:Port=5055,Server=localhost" — connect to debug server
// "tcp:Port=5056,Server=localhost" — connect to debug server
// Set WINDBG_KERNEL_ADDR to a readable hex address (e.g. kernel base).
static QString kernelTarget()

View File

@@ -341,6 +341,19 @@ def parse_field_line(line, offset, parent_id, ids, struct_registry):
line = re.sub(r'\bvolatile\b', '', line).strip()
line = re.sub(r'\s+', ' ', line)
# Check for function pointer: RETURN_TYPE (*NAME)(PARAMS)
fnptr_m = re.search(r'\(\*\s*(\w+)\s*\)', line)
if fnptr_m:
field_name = fnptr_m.group(1)
node_id = ids.alloc()
return {
'id': str(node_id),
'kind': 'FuncPtr64',
'name': field_name,
'offset': offset,
'parentId': str(parent_id),
}
# Check for struct/union keyword prefix
keyword = None
m = re.match(r'^(struct|union|enum)\s+(.+)', line)