Compare commits

..

44 Commits

Author SHA1 Message Date
IChooseYou
1c3b4af045 feat: fix heatmap false-heat on offset shift, hover flicker, type chooser cleanup
- Clear value history when node offsets change (insert/delete/resize/
  manual offset edit) so stale values from old addresses don't show
  false heat coloring
- Invalidate in-flight async reads (bump refreshGen) when tree layout
  changes, preventing stale snapshot data from re-introducing heat
- Fix command bar hover cursor flicker: remove premature
  applyHoverCursor() from applyDocument() — runs correctly via
  applySelectionOverlays() after text is finalized
- Fix hover indicator survival: reorder refresh() so text-modifying
  passes (updateCommandRow) run before overlay passes
- Guard synthetic Leave events during setText() to preserve hover state
- Remove primitives from type chooser when pointer modifier (* / **)
  is active; remove primitives entirely in Root command bar mode
- Add test_editor and test_controller test coverage for heat clearing,
  hover survival, and mixed hex/non-hex type scenarios
2026-02-17 11:41:46 -07:00
IChooseYou
5ae9ca0979 feat: value history heatmap, write-fail guard, crash handler hardening
- Value history ring buffer (10 slots) tracks per-node change frequency
- Three-level heatmap: cold (blue), warm (amber), hot (red) via theme
- Heat persists indefinitely (no fade) — shows analysis history
- Calltip on hover shows previous values list
- Old themes auto-derive heat colors from existing palette
- Write failures no longer apply optimistic visual updates
- Crash handler: re-entrancy guard, context dump before risky APIs
2026-02-16 16:44:46 -07:00
Sen66
e064646c02 Added Reclass.NET plugin compatibility layer 2026-02-17 00:18:30 +01:00
IChooseYou
c6c56ffaee feat: default offset margin to relative (+0x) mode 2026-02-16 14:27:41 -07:00
IChooseYou
aba8e5cac9 feat: add Export ReClass XML and remove local-path tests
Adds Export ReClass XML menu item that writes NodeTree to ReClass .NET
compatible XML format with full round-trip fidelity. Removes test cases
that referenced local machine file paths.
2026-02-16 14:16:19 -07:00
IChooseYou
3a5d03fae0 feat: add Import from Source parser for C/C++ struct definitions
Adds a new "Import from Source..." menu item that opens a QScintilla
editor dialog where users can paste C/C++ struct definitions. The parser
tokenizes and parses the source using recursive descent, supporting
stdint.h types, Windows types (BYTE/DWORD/PVOID/etc), multi-word C
types, pointers, arrays, Vec2/3/4/Mat4x4 detection, unions (first
member), padding fields, typedefs, forward declarations, static_assert
size checks, and auto-detection of comment offset mode vs computed
offsets. Also removes the flaky test_editor cursor shape tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 14:08:12 -07:00
IChooseYou
df79da54e3 ci: remove windows-qt5 job entirely 2026-02-16 12:35:21 -07:00
IChooseYou
e3ff4dfe71 ci: snapshot releases with date tags and platform-specific names
- Tag: snapshot-DD-MM-YYYY instead of latest
- Assets: Reclass-win64-qt6.zip, Reclass-linux64-qt6.AppImage, Reclass-win64-qt5.zip
- Qt5 job now produces release artifacts
- Jobs serialized: windows → linux → windows-qt5
2026-02-16 11:34:14 -07:00
IChooseYou
735e4ea9f7 fix: exclude Qt5-incompatible tests from windows-qt5 CI job 2026-02-16 10:51:35 -07:00
IChooseYou
d937d2f42e fix: Qt5 compat - fix ambiguous QByteRef comparison in test_windbg_provider 2026-02-16 10:37:49 -07:00
IChooseYou
3685530287 fix: Qt5 compat - use toInt() instead of toInteger() for QJsonValue 2026-02-16 10:23:55 -07:00
IChooseYou
9e90f66ca0 fix: Qt5 compat - use pos() instead of position() for QMouseEvent 2026-02-16 09:12:17 -07:00
IChooseYou
f53fa84a15 fix: Qt5 compat - fix addAction wrapper, qHash for NodeKind, add windows-qt5 CI 2026-02-16 09:06:10 -07:00
IChooseYou
13e28e8791 Merge remote-tracking branch 'origin/qt5-compat' 2026-02-16 09:04:35 -07:00
IChooseYou
079b3121ce Revert "add Qt5 compatibility wrapper for addAction and linux-qt5 CI job"
This reverts commit 5e40349768.
2026-02-16 09:04:28 -07:00
IChooseYou
5e40349768 add Qt5 compatibility wrapper for addAction and linux-qt5 CI job 2026-02-16 08:58:09 -07:00
Sen66
8dd6110ec6 Try to fix Qt5 compat + no Qt6 deprec warning 2026-02-16 16:27:28 +01:00
IChooseYou
eb27fc7988 rename Close to Unload Project in File menu, remove icon 2026-02-15 14:39:14 -07:00
IChooseYou
85994d68b9 remove Node menu from menubar, actions available via editor right-click 2026-02-15 14:36:22 -07:00
IChooseYou
55dc5d5875 CI: serialize linux after windows to avoid release tag race condition 2026-02-15 14:29:01 -07:00
IChooseYou
3a92336132 fix: only package plugin DLLs/SOs, not build artifacts 2026-02-15 14:18:09 -07:00
IChooseYou
f9b33f2ba7 fix: options dialog test segfault from dangling ref to themes() temporary 2026-02-15 13:53:59 -07:00
IChooseYou
f2dab07870 fix: build ProcessMemory plugin on Linux, include Plugins in AppImage 2026-02-15 13:45:36 -07:00
IChooseYou
9d22a5ed69 fix: Options dialog - remove CSS overrides, fix title case, add show icon checkbox, add Generator page 2026-02-15 13:41:13 -07:00
IChooseYou
193ab81ecf docs: remove stale status notes from README
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 13:12:36 -07:00
IChooseYou
aa0840b332 CI: consolidate win64+linux64 into single latest release, no pre-release
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 13:10:22 -07:00
IChooseYou
f3631f17ff fix: skip mv when AppImage already has correct name 2026-02-15 12:59:32 -07:00
IChooseYou
42e9bde7ba fix: find qmake via PATH/fallback for linuxdeploy Qt plugin 2026-02-15 12:52:25 -07:00
IChooseYou
07fedf0ae8 fix: derive QMAKE path from Qt6_DIR for linuxdeploy plugin 2026-02-15 12:44:42 -07:00
IChooseYou
2e02a01495 feat: project tree delete, close tab, Linux AppImage bundling
- Right-click delete on classes in Project Tree dock
- File > Close (Ctrl+W) to unload active project tab
- File > Open now replaces current project instead of merging
- Linux CI builds AppImage via linuxdeploy + Qt plugin so users
  don't need Qt installed (fixes libQt6Core.so.6 not found)
- Pin ubuntu-22.04 for broader glibc compatibility
2026-02-15 12:37:56 -07:00
computron
71bc51cbab fix: guard Windows-only setDarkTitleBar and fix deprecated addAction arg order 2026-02-15 11:28:39 -07:00
computron
60a97ab81b fix: add missing Unix headers for Linux build 2026-02-15 11:20:28 -07:00
IChooseYou
bb00e75019 CI: win64 + linux64 builds, guard Windows-only targets for cross-platform 2026-02-15 11:16:09 -07:00
IChooseYou
c038c59e34 CI: add write permission for releases 2026-02-15 11:01:31 -07:00
IChooseYou
862f76b984 CI: auto-upload build zip to latest release 2026-02-15 10:24:46 -07:00
sysadmin
818285a76e CI: skip editor/windbg/com tests that need display or debug tools 2026-02-15 09:49:34 -07:00
sysadmin
ef5e2ebdb9 CI: fix Qt6 install - remove invalid module, pin aqtversion, use 6.8.1 LTS 2026-02-15 09:39:52 -07:00
sysadmin
75fedd2222 CI: switch to Qt6 2026-02-15 09:34:48 -07:00
sysadmin
389745e501 Add Windows CI build 2026-02-15 09:30:49 -07:00
sysadmin
1473a58742 IChooseYou 2026-02-15 09:23:17 -07:00
untitled
4192a4dad3 Hide project tree by default, remove 1px menu border, darken hover/selected theme colors 2026-02-15 08:29:59 -07:00
IChooseYou
4c6bb9564f Fix 7 verified bugs: ref invalidation, bounds check, double refresh, dangling pointer, undo bypass, overflow, hash collision
- BUG-1 (HIGH): Replace dangling QVector reference with local copies in applyTypePopupResult
- BUG-2 (MEDIUM): Add missing upper-bound check in EditTarget::Name handler
- BUG-5 (LOW): Remove redundant unconditional refresh() at end of applyTypePopupResult
- BUG-6 (LOW): Use QPointer for m_cachedPopup to auto-null on parent destruction
- BUG-7 (LOW): Rewrite materializeRefChildren to use undo macro (cmd::Insert + cmd::Collapse)
- BUG-8 (LOW): Guard against integer overflow in byteSize() and clamp arrayLen/strLen in fromJson
- BUG-9 (LOW): Use QPair<uint64_t,uint64_t> key in collectPointerRanges visited set
2026-02-15 08:16:52 -07:00
Sen66
0ef9841f90 Added options dialog 2026-02-15 03:24:12 +01:00
IChooseYOu
0a8244dad4 Single-click type chooser, popup warmup fix, rename ProcessMemory plugin
- Type chooser popup now opens on single click (no need to pre-select node)
- Fix ~170ms first-open delay by pre-initializing Qt popup subsystem at startup
- Rename ProcessMemoryWindows -> ProcessMemory (already supports Linux)
2026-02-14 16:08:44 -07:00
62 changed files with 7743 additions and 1176 deletions

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

@@ -0,0 +1,191 @@
name: Build
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: write
jobs:
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Qt6
uses: jurplel/install-qt-action@v4
with:
version: '6.8.1'
arch: 'win64_msvc2022_64'
cache: true
aqtversion: '==3.1.21'
- uses: ilammy/msvc-dev-cmd@v1
with:
arch: x64
- name: Configure
run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
- name: Build
run: cmake --build build
- name: Test
run: ctest --test-dir build --output-on-failure --exclude-regex "test_editor|test_controller|test_windbg_provider|test_com_security"
- name: Upload artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: Reclass-win64-qt6
path: |
build/Reclass.exe
build/ReclassMcpBridge.exe
build/Plugins/*.dll
build/*.dll
build/platforms/
build/styles/
build/imageformats/
build/iconengines/
build/themes/
build/examples/
build/screenshot.png
- name: Get date tag
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
id: date
shell: bash
run: echo "tag=$(date +'%d-%m-%Y')" >> "$GITHUB_OUTPUT"
- name: Package release zip
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
shell: bash
run: |
mkdir -p release
cp build/Reclass.exe release/
cp build/ReclassMcpBridge.exe release/
cp build/*.dll release/ 2>/dev/null || true
cp -r build/platforms release/ 2>/dev/null || true
cp -r build/styles release/ 2>/dev/null || true
cp -r build/imageformats release/ 2>/dev/null || true
cp -r build/iconengines release/ 2>/dev/null || true
mkdir -p release/Plugins
cp build/Plugins/*.dll release/Plugins/ 2>/dev/null || true
cp -r build/themes release/ 2>/dev/null || true
cp -r build/examples release/ 2>/dev/null || true
cp build/screenshot.png release/ 2>/dev/null || true
cd release && 7z a ../Reclass-win64-qt6.zip *
- name: Upload release asset
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: softprops/action-gh-release@v2
with:
tag_name: snapshot-${{ steps.date.outputs.tag }}
name: Snapshot ${{ steps.date.outputs.tag }}
body: |
Automated snapshot from main branch.
Commit: ${{ github.sha }}
prerelease: false
files: Reclass-win64-qt6.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
linux:
needs: windows
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install Qt6
uses: jurplel/install-qt-action@v4
with:
version: '6.8.1'
cache: true
aqtversion: '==3.1.21'
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y ninja-build libgl1-mesa-dev libfuse2 libxcb-cursor0
- name: Configure
run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release
- name: Build
run: cmake --build build
- name: Test
run: xvfb-run ctest --test-dir build --output-on-failure --exclude-regex "test_editor|test_controller"
env:
QT_QPA_PLATFORM: offscreen
- name: Create AppImage
run: |
# Download linuxdeploy and Qt plugin
wget -q https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
wget -q https://github.com/linuxdeploy/linuxdeploy-plugin-qt/releases/download/continuous/linuxdeploy-plugin-qt-x86_64.AppImage
chmod +x linuxdeploy-x86_64.AppImage linuxdeploy-plugin-qt-x86_64.AppImage
# Build AppDir structure
mkdir -p AppDir/usr/bin AppDir/usr/share/icons/hicolor/256x256/apps
cp build/Reclass AppDir/usr/bin/
cp build/ReclassMcpBridge AppDir/usr/bin/
cp -r build/themes AppDir/usr/bin/ 2>/dev/null || true
cp -r build/examples AppDir/usr/bin/ 2>/dev/null || true
mkdir -p AppDir/usr/bin/Plugins
cp build/Plugins/*.so AppDir/usr/bin/Plugins/ 2>/dev/null || true
cp src/icons/class.png AppDir/usr/share/icons/hicolor/256x256/apps/reclass.png
# Create AppImage with Qt libs bundled
# install-qt-action adds Qt bin to PATH; find qmake there
QMAKE_BIN=$(which qmake 2>/dev/null || which qmake6 2>/dev/null || find "$RUNNER_WORKSPACE" -name qmake -path "*/bin/*" | head -1)
echo "Found qmake at: $QMAKE_BIN"
export QMAKE="$QMAKE_BIN"
QT_ROOT=$(dirname "$(dirname "$QMAKE_BIN")")
export LD_LIBRARY_PATH="$QT_ROOT/lib:$LD_LIBRARY_PATH"
export EXTRA_QT_PLUGINS="svg;iconengines"
./linuxdeploy-x86_64.AppImage --appdir AppDir \
--desktop-file deploy/Reclass.desktop \
--icon-file AppDir/usr/share/icons/hicolor/256x256/apps/reclass.png \
--plugin qt \
--output appimage
# Rename to final name
ls Reclass-*.AppImage
mv Reclass-*.AppImage Reclass-linux64-qt6.AppImage
- name: Upload artifact
uses: actions/upload-artifact@v4
if: always()
with:
name: Reclass-linux64-qt6
path: Reclass-linux64-qt6.AppImage
- name: Get date tag
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
id: date
shell: bash
run: echo "tag=$(date +'%d-%m-%Y')" >> "$GITHUB_OUTPUT"
- name: Upload release asset
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: softprops/action-gh-release@v2
with:
tag_name: snapshot-${{ steps.date.outputs.tag }}
name: Snapshot ${{ steps.date.outputs.tag }}
body: |
Automated snapshot from main branch.
Commit: ${{ github.sha }}
prerelease: false
files: Reclass-linux64-qt6.AppImage
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -59,7 +59,15 @@ add_executable(Reclass
src/themes/thememanager.cpp
src/themes/themeeditor.h
src/themes/themeeditor.cpp
src/import_reclass_xml.h
src/import_reclass_xml.cpp
src/import_source.h
src/import_source.cpp
src/export_reclass_xml.h
src/export_reclass_xml.cpp
src/mainwindow.h
src/optionsdialog.h
src/optionsdialog.cpp
src/titlebar.h
src/titlebar.cpp
src/mcp/mcp_bridge.h
@@ -93,14 +101,24 @@ foreach(_tf ${_theme_files})
configure_file(${_tf} "${CMAKE_BINARY_DIR}/themes/${_name}" COPYONLY)
endforeach()
# Copy example .rcx files to build directory
file(GLOB _example_files "${CMAKE_SOURCE_DIR}/src/examples/*.rcx")
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()
include(deploy)
add_custom_target(screenshot ALL
COMMAND Reclass --screenshot ${CMAKE_BINARY_DIR}/screenshot.png
DEPENDS Reclass deploy
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
COMMENT "Capturing UI screenshot with class open..."
)
if(TARGET deploy)
add_custom_target(screenshot ALL
COMMAND Reclass --screenshot ${CMAKE_BINARY_DIR}/screenshot.png
DEPENDS Reclass deploy
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
COMMENT "Capturing UI screenshot with class open..."
)
endif()
set(_combine_script "${CMAKE_BINARY_DIR}/combine_sources.cmake")
file(WRITE ${_combine_script} "
@@ -149,13 +167,6 @@ if(BUILD_TESTING)
target_link_libraries(test_compose PRIVATE ${QT}::Core ${QT}::Test)
add_test(NAME test_compose COMMAND test_compose)
add_executable(test_editor tests/test_editor.cpp src/editor.cpp src/compose.cpp src/format.cpp src/providerregistry.cpp
src/themes/theme.cpp src/themes/thememanager.cpp)
target_include_directories(test_editor PRIVATE src)
target_link_libraries(test_editor PRIVATE
${QT}::Widgets ${QT}::PrintSupport ${QT}::Test
QScintilla::QScintilla)
add_test(NAME test_editor COMMAND test_editor)
add_executable(test_provider tests/test_provider.cpp)
target_include_directories(test_provider PRIVATE src)
@@ -215,6 +226,16 @@ if(BUILD_TESTING)
endif()
add_test(NAME test_context_menu COMMAND test_context_menu)
add_executable(test_editor tests/test_editor.cpp
src/editor.cpp src/compose.cpp src/format.cpp
src/providerregistry.cpp
src/themes/theme.cpp src/themes/thememanager.cpp)
target_include_directories(test_editor PRIVATE src)
target_link_libraries(test_editor PRIVATE
${QT}::Widgets ${QT}::PrintSupport ${QT}::Test
QScintilla::QScintilla)
add_test(NAME test_editor COMMAND test_editor)
add_executable(test_rendered_view tests/test_rendered_view.cpp
src/generator.cpp src/compose.cpp src/format.cpp)
target_include_directories(test_rendered_view PRIVATE src)
@@ -257,15 +278,38 @@ if(BUILD_TESTING)
target_link_libraries(test_theme PRIVATE ${QT}::Widgets ${QT}::Test)
add_test(NAME test_theme COMMAND test_theme)
add_executable(test_windbg_provider tests/test_windbg_provider.cpp
plugins/WinDbgMemory/WinDbgMemoryPlugin.cpp)
target_include_directories(test_windbg_provider PRIVATE src plugins/WinDbgMemory)
target_link_libraries(test_windbg_provider PRIVATE
${QT}::Widgets ${QT}::Concurrent ${QT}::Test)
add_executable(test_options_dialog tests/test_options_dialog.cpp
src/optionsdialog.cpp src/themes/theme.cpp src/themes/thememanager.cpp)
target_include_directories(test_options_dialog PRIVATE src)
target_link_libraries(test_options_dialog PRIVATE ${QT}::Widgets ${QT}::Test)
add_test(NAME test_options_dialog COMMAND test_options_dialog)
add_executable(test_import_xml tests/test_import_xml.cpp
src/import_reclass_xml.cpp src/format.cpp src/compose.cpp)
target_include_directories(test_import_xml PRIVATE src)
target_link_libraries(test_import_xml PRIVATE ${QT}::Core ${QT}::Test)
add_test(NAME test_import_xml COMMAND test_import_xml)
add_executable(test_import_source tests/test_import_source.cpp
src/import_source.cpp src/format.cpp src/compose.cpp)
target_include_directories(test_import_source PRIVATE src)
target_link_libraries(test_import_source PRIVATE ${QT}::Core ${QT}::Test)
add_test(NAME test_import_source COMMAND test_import_source)
add_executable(test_export_xml tests/test_export_xml.cpp
src/export_reclass_xml.cpp src/import_reclass_xml.cpp src/format.cpp src/compose.cpp)
target_include_directories(test_export_xml PRIVATE src)
target_link_libraries(test_export_xml PRIVATE ${QT}::Core ${QT}::Test)
add_test(NAME test_export_xml COMMAND test_export_xml)
if(WIN32)
target_link_libraries(test_windbg_provider PRIVATE dbgeng ole32)
add_executable(test_windbg_provider tests/test_windbg_provider.cpp
plugins/WinDbgMemory/WinDbgMemoryPlugin.cpp)
target_include_directories(test_windbg_provider PRIVATE src plugins/WinDbgMemory)
target_link_libraries(test_windbg_provider PRIVATE
${QT}::Widgets ${QT}::Concurrent ${QT}::Test dbgeng ole32)
add_test(NAME test_windbg_provider COMMAND test_windbg_provider)
endif()
add_test(NAME test_windbg_provider COMMAND test_windbg_provider)
# Standalone test: proves whether CoInitializeSecurity is needed for DebugConnect
# Requires a running WinDbg debug server on port 5055
@@ -288,5 +332,8 @@ if(BUILD_TESTING)
)
endif()
endif()
add_subdirectory(plugins/ProcessMemoryWindows)
add_subdirectory(plugins/WinDbgMemory)
add_subdirectory(plugins/ProcessMemory)
if(WIN32)
add_subdirectory(plugins/WinDbgMemory)
add_subdirectory(plugins/RcNetPluginCompatLayer)
endif()

View File

@@ -15,10 +15,6 @@ This tool helps you inspect raw bytes and interpret them as types (structs, arra
}
}
```
- Plugin system is partially implemented. Some UI bugs exist.
- Vector/Matrix improvements have been made but are not entirely complete.
- Every edit goes through a full undo/redo system.
## Build
1. Prerequisites

8
deploy/Reclass.desktop Normal file
View File

@@ -0,0 +1,8 @@
[Desktop Entry]
Type=Application
Name=Reclass
Comment=Memory structure reverse engineering tool
Exec=Reclass
Icon=reclass
Categories=Development;Debugger;
Terminal=false

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,93 @@
cmake_minimum_required(VERSION 3.20)
project(RcNetCompatPlugin LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Qt is found by the parent project; QT variable (Qt5 or Qt6) is inherited
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
# Plugin sources
set(PLUGIN_SOURCES
RcNetCompatPlugin.h
RcNetCompatPlugin.cpp
RcNetCompatProvider.h
RcNetCompatProvider.cpp
ReClassNET_Plugin.hpp
${CMAKE_CURRENT_SOURCE_DIR}/../../src/processpicker.h
${CMAKE_CURRENT_SOURCE_DIR}/../../src/processpicker.cpp
${CMAKE_CURRENT_SOURCE_DIR}/../../src/processpicker.ui
)
# -- Optional .NET bridge -------------------------------------------------
# When the .NET SDK is available, build the C# bridge assembly and enable
# CLR hosting support in the C++ plugin.
find_program(DOTNET_EXE dotnet)
if(DOTNET_EXE)
# Check that 'dotnet build' actually works for net472
execute_process(
COMMAND ${DOTNET_EXE} --list-sdks
OUTPUT_VARIABLE _dotnet_sdks
ERROR_QUIET
OUTPUT_STRIP_TRAILING_WHITESPACE
)
if(_dotnet_sdks)
set(HAS_CLR_BRIDGE ON)
message(STATUS "RcNetCompat: .NET SDK found -- building managed bridge")
endif()
endif()
if(HAS_CLR_BRIDGE)
list(APPEND PLUGIN_SOURCES
ClrHost.h
ClrHost.cpp
)
# Build the C# bridge assembly
set(_bridge_src "${CMAKE_CURRENT_SOURCE_DIR}/bridge")
set(_bridge_out "${CMAKE_BINARY_DIR}/Plugins/RcNetBridge.dll")
add_custom_command(
OUTPUT "${_bridge_out}"
COMMAND ${DOTNET_EXE} build
"${_bridge_src}/RcNetBridge.csproj"
-c Release
-o "${CMAKE_BINARY_DIR}/Plugins"
--nologo -v quiet
DEPENDS
"${_bridge_src}/RcNetBridge.cs"
"${_bridge_src}/RcNetBridge.csproj"
COMMENT "Building RcNetBridge.dll (.NET bridge)..."
)
add_custom_target(RcNetBridge ALL DEPENDS "${_bridge_out}")
else()
message(STATUS "RcNetCompat: .NET SDK not found -- managed plugin support disabled")
endif()
# Create shared library (DLL)
add_library(RcNetCompatPlugin SHARED ${PLUGIN_SOURCES})
if(HAS_CLR_BRIDGE)
target_compile_definitions(RcNetCompatPlugin PRIVATE HAS_CLR_BRIDGE=1)
add_dependencies(RcNetCompatPlugin RcNetBridge)
# CLR hosting uses COM (ole32)
target_link_libraries(RcNetCompatPlugin PRIVATE ole32)
endif()
# Link Qt
target_link_libraries(RcNetCompatPlugin PRIVATE ${QT}::Widgets ${_QT_WINEXTRAS})
# Include directories
target_include_directories(RcNetCompatPlugin PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/../../src
)
# Output to Plugins folder
set_target_properties(RcNetCompatPlugin PROPERTIES
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Plugins"
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/Plugins"
)

View File

@@ -0,0 +1,162 @@
#include "ClrHost.h"
#include <cwchar>
// -- GUIDs ----------------------------------------------------------------
using FnCLRCreateInstance = HRESULT(STDAPICALLTYPE*)(REFCLSID, REFIID, LPVOID*);
// {9280188D-0E8E-4867-B30C-7FA83884E8DE}
static const GUID sCLSID_CLRMetaHost =
{0x9280188d, 0x0e8e, 0x4867, {0xb3, 0x0c, 0x7f, 0xa8, 0x38, 0x84, 0xe8, 0xde}};
// {D332DB9E-B9B3-4125-8207-A14884F53216}
static const GUID sIID_ICLRMetaHost =
{0xD332DB9E, 0xB9B3, 0x4125, {0x82, 0x07, 0xA1, 0x48, 0x84, 0xF5, 0x32, 0x16}};
// {BD39D1D2-BA2F-486A-89B0-B4B0CB466891}
static const GUID sIID_ICLRRuntimeInfo =
{0xBD39D1D2, 0xBA2F, 0x486a, {0x89, 0xB0, 0xB4, 0xB0, 0xCB, 0x46, 0x68, 0x91}};
// {90F1A06E-7712-4762-86B5-7A5EBA6BDB02}
static const GUID sCLSID_CLRRuntimeHost =
{0x90F1A06E, 0x7712, 0x4762, {0x86, 0xB5, 0x7A, 0x5E, 0xBA, 0x6B, 0xDB, 0x02}};
// {90F1A06C-7712-4762-86B5-7A5EBA6BDB02}
static const GUID sIID_ICLRRuntimeHost =
{0x90F1A06C, 0x7712, 0x4762, {0x86, 0xB5, 0x7A, 0x5E, 0xBA, 0x6B, 0xDB, 0x02}};
// -- ClrHost implementation -----------------------------------------------
ClrHost::ClrHost()
{
startClr();
}
ClrHost::~ClrHost()
{
if (m_runtimeHost) m_runtimeHost->Release();
if (m_runtimeInfo) m_runtimeInfo->Release();
if (m_metaHost) m_metaHost->Release();
if (m_mscoree) FreeLibrary(m_mscoree);
}
bool ClrHost::startClr()
{
m_mscoree = LoadLibraryW(L"mscoree.dll");
if (!m_mscoree)
return false;
auto fnCreate = reinterpret_cast<FnCLRCreateInstance>(
GetProcAddress(m_mscoree, "CLRCreateInstance"));
if (!fnCreate)
return false;
HRESULT hr = fnCreate(sCLSID_CLRMetaHost, sIID_ICLRMetaHost,
reinterpret_cast<LPVOID*>(&m_metaHost));
if (FAILED(hr) || !m_metaHost)
return false;
hr = m_metaHost->GetRuntime(L"v4.0.30319", sIID_ICLRRuntimeInfo,
reinterpret_cast<LPVOID*>(&m_runtimeInfo));
if (FAILED(hr) || !m_runtimeInfo)
return false;
hr = m_runtimeInfo->GetInterface(sCLSID_CLRRuntimeHost, sIID_ICLRRuntimeHost,
(LPVOID*)&m_runtimeHost);
if (FAILED(hr) || !m_runtimeHost)
return false;
hr = m_runtimeHost->Start();
if (FAILED(hr))
return false;
m_clrStarted = true;
return true;
}
bool ClrHost::loadManagedPlugin(const QString& bridgeDllPath,
const QString& pluginPath,
RcNetFunctions* outFunctions,
QString* errorMsg)
{
if (!m_runtimeHost || !m_clrStarted) {
if (errorMsg)
*errorMsg = QStringLiteral(
".NET Framework 4.x is not available on this machine.\n"
"Install the .NET Framework 4.7.2+ runtime to load managed plugins.");
return false;
}
// Zero the function table -- the bridge will fill it
memset(outFunctions, 0, sizeof(RcNetFunctions));
// Build the argument string: "<hex_address_of_function_table>|<plugin_path>"
// Use %ls (not %s) for wide strings -- MinGW follows POSIX conventions.
wchar_t arg[2048];
swprintf(arg, sizeof(arg) / sizeof(wchar_t),
L"%llx|%ls",
reinterpret_cast<unsigned long long>(outFunctions),
reinterpret_cast<const wchar_t*>(pluginPath.utf16()));
DWORD retVal = 0;
HRESULT hr = m_runtimeHost->ExecuteInDefaultAppDomain(
reinterpret_cast<LPCWSTR>(bridgeDllPath.utf16()),
L"RcNetBridge.Bridge",
L"Initialize",
arg,
&retVal
);
if (FAILED(hr)) {
if (errorMsg)
*errorMsg = QStringLiteral(
"Failed to execute .NET bridge (HRESULT 0x%1).\n"
"Bridge: %2\n"
"Plugin: %3")
.arg(static_cast<uint>(hr), 8, 16, QChar('0'))
.arg(bridgeDllPath)
.arg(pluginPath);
return false;
}
if (retVal != 0) {
if (errorMsg) {
switch (retVal) {
case 1:
*errorMsg = QStringLiteral("Bridge: invalid argument format.");
break;
case 2:
*errorMsg = QStringLiteral(
"No ICoreProcessFunctions implementation found in the .NET plugin.\n"
"The DLL may not be a ReClass.NET plugin.");
break;
case 3:
*errorMsg = QStringLiteral(
"Failed to load the .NET plugin assembly.\n"
"Check that all its dependencies are available.");
break;
default:
*errorMsg = QStringLiteral("Bridge returned error code %1.").arg(retVal);
break;
}
}
return false;
}
// Verify the bridge wrote at least the minimum required function pointers
if (!outFunctions->ReadRemoteMemory ||
!outFunctions->OpenRemoteProcess ||
!outFunctions->EnumerateProcesses ||
!outFunctions->CloseRemoteProcess) {
if (errorMsg)
*errorMsg = QStringLiteral(
"The .NET bridge loaded but did not provide the required functions "
"(ReadRemoteMemory, OpenRemoteProcess, CloseRemoteProcess, EnumerateProcesses).");
return false;
}
return true;
}

View File

@@ -0,0 +1,99 @@
#pragma once
// In-process CLR hosting for loading .NET ReClass.NET plugins.
// Dynamically loads mscoree.dll and uses ICLRMetaHost -> ICLRRuntimeInfo ->
// ICLRRuntimeHost::ExecuteInDefaultAppDomain to call into the C# bridge.
#include "ReClassNET_Plugin.hpp"
#include <QString>
#include <windows.h>
#include <objbase.h>
// -- Minimal COM interface definitions for CLR hosting --------------------
// Defined here to avoid depending on Windows SDK metahost.h / mscoree.h
// which may not be present in all MinGW distributions.
// Only methods we actually call have real signatures; the rest are stubs
// that preserve correct vtable offsets.
#undef INTERFACE
#define INTERFACE ICLRMetaHost
DECLARE_INTERFACE_(ICLRMetaHost, IUnknown)
{
// IUnknown
STDMETHOD(QueryInterface)(REFIID riid, void** ppv) PURE;
STDMETHOD_(ULONG, AddRef)() PURE;
STDMETHOD_(ULONG, Release)() PURE;
// ICLRMetaHost
STDMETHOD(GetRuntime)(LPCWSTR pwzVersion, REFIID riid, LPVOID* ppRuntime) PURE;
STDMETHOD(GetVersionFromFile)(LPCWSTR, LPWSTR, DWORD*) PURE;
STDMETHOD(EnumerateInstalledRuntimes)(void**) PURE;
STDMETHOD(EnumerateLoadedRuntimes)(HANDLE, void**) PURE;
STDMETHOD(RequestRuntimeLoadedNotification)(void*) PURE;
STDMETHOD(QueryLegacyV2RuntimeBinding)(REFIID, LPVOID*) PURE;
STDMETHOD_(void, ExitProcess)(INT32) PURE;
};
#undef INTERFACE
#define INTERFACE ICLRRuntimeInfo
DECLARE_INTERFACE_(ICLRRuntimeInfo, IUnknown)
{
// IUnknown
STDMETHOD(QueryInterface)(REFIID riid, void** ppv) PURE;
STDMETHOD_(ULONG, AddRef)() PURE;
STDMETHOD_(ULONG, Release)() PURE;
// ICLRRuntimeInfo
STDMETHOD(GetVersionString)(LPWSTR, DWORD*) PURE;
STDMETHOD(GetRuntimeDirectory)(LPWSTR, DWORD*) PURE;
STDMETHOD(IsLoaded)(HANDLE, BOOL*) PURE;
STDMETHOD(LoadErrorString)(UINT, LPWSTR, DWORD*, LONG) PURE;
STDMETHOD(LoadLibrary)(LPCWSTR, HMODULE*) PURE;
STDMETHOD(GetProcAddress)(LPCSTR, LPVOID*) PURE;
STDMETHOD(GetInterface)(REFCLSID rclsid, REFIID riid, LPVOID* ppUnk) PURE;
};
#undef INTERFACE
#define INTERFACE ICLRRuntimeHost
DECLARE_INTERFACE_(ICLRRuntimeHost, IUnknown)
{
// IUnknown
STDMETHOD(QueryInterface)(REFIID riid, void** ppv) PURE;
STDMETHOD_(ULONG, AddRef)() PURE;
STDMETHOD_(ULONG, Release)() PURE;
// ICLRRuntimeHost
STDMETHOD(Start)() PURE;
STDMETHOD(Stop)() PURE;
STDMETHOD(SetHostControl)(void*) PURE;
STDMETHOD(GetCLRControl)(void**) PURE;
STDMETHOD(UnloadAppDomain)(DWORD, BOOL) PURE;
STDMETHOD(ExecuteInAppDomain)(DWORD, void*, void*) PURE;
STDMETHOD(GetCurrentAppDomainId)(DWORD*) PURE;
STDMETHOD(ExecuteApplication)(LPCWSTR, DWORD, LPCWSTR*, DWORD, LPCWSTR*, int*) PURE;
STDMETHOD(ExecuteInDefaultAppDomain)(LPCWSTR, LPCWSTR, LPCWSTR, LPCWSTR, DWORD*) PURE;
};
#undef INTERFACE
// -- CLR Host wrapper -----------------------------------------------------
class ClrHost
{
public:
ClrHost();
~ClrHost();
// True if the .NET Framework CLR (v4.0) is available on this machine.
bool isAvailable() const { return m_runtimeHost != nullptr && m_clrStarted; }
// Load a managed ReClass.NET plugin via the C# bridge.
bool loadManagedPlugin(const QString& bridgeDllPath,
const QString& pluginPath,
RcNetFunctions* outFunctions,
QString* errorMsg = nullptr);
private:
bool startClr();
HMODULE m_mscoree = nullptr;
ICLRMetaHost* m_metaHost = nullptr;
ICLRRuntimeInfo* m_runtimeInfo = nullptr;
ICLRRuntimeHost* m_runtimeHost = nullptr;
bool m_clrStarted = false;
};

View File

@@ -0,0 +1,333 @@
#include "RcNetCompatPlugin.h"
#include "RcNetCompatProvider.h"
#include "../../src/processpicker.h"
#include <QApplication>
#include <QCoreApplication>
#include <QDir>
#include <QFileDialog>
#include <QFileInfo>
#include <QMessageBox>
#include <QStyle>
#include <windows.h>
// -- Helpers --------------------------------------------------------------
QIcon RcNetCompatPlugin::Icon() const
{
return qApp->style()->standardIcon(QStyle::SP_TrashIcon);
}
// --.NET assembly detection ----------------------------------------------
static bool isDotNetAssembly(const QString& path)
{
// A .NET assembly has a non-zero CLR header directory entry in the PE
// optional header. We check this by loading the PE without running
// DllMain and inspecting the IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR.
HMODULE hMod = GetModuleHandleW(reinterpret_cast<LPCWSTR>(path.utf16()));
if (!hMod)
hMod = LoadLibraryExW(reinterpret_cast<LPCWSTR>(path.utf16()),
nullptr, DONT_RESOLVE_DLL_REFERENCES);
if (!hMod) return false;
auto* dos = reinterpret_cast<const IMAGE_DOS_HEADER*>(hMod);
if (dos->e_magic != IMAGE_DOS_SIGNATURE) return false;
auto* nt = reinterpret_cast<const IMAGE_NT_HEADERS*>(
reinterpret_cast<const char*>(hMod) + dos->e_lfanew);
if (nt->Signature != IMAGE_NT_SIGNATURE) return false;
constexpr DWORD kClrIndex = IMAGE_DIRECTORY_ENTRY_COM_DESCRIPTOR; // 14
DWORD rva = 0, dirSize = 0;
if (nt->OptionalHeader.Magic == IMAGE_NT_OPTIONAL_HDR64_MAGIC) {
auto* opt = reinterpret_cast<const IMAGE_OPTIONAL_HEADER64*>(&nt->OptionalHeader);
if (opt->NumberOfRvaAndSizes > kClrIndex) {
rva = opt->DataDirectory[kClrIndex].VirtualAddress;
dirSize = opt->DataDirectory[kClrIndex].Size;
}
} else {
auto* opt = reinterpret_cast<const IMAGE_OPTIONAL_HEADER32*>(&nt->OptionalHeader);
if (opt->NumberOfRvaAndSizes > kClrIndex) {
rva = opt->DataDirectory[kClrIndex].VirtualAddress;
dirSize = opt->DataDirectory[kClrIndex].Size;
}
}
return rva != 0 && dirSize != 0;
}
// --Unified loader (dispatches native vs managed) ------------------------
bool RcNetCompatPlugin::loadPlugin(const QString& path, QString* errorMsg)
{
if (m_dllPath == path && (m_lib || m_isManaged))
return true; // Already loaded
if (isDotNetAssembly(path)) {
#ifdef HAS_CLR_BRIDGE
return loadManagedDll(path, errorMsg);
#else
if (errorMsg)
*errorMsg = QStringLiteral(
"This is a .NET assembly.\n\n"
"This build does not include .NET bridge support.\n"
"Rebuild with the .NET SDK installed to enable managed plugin loading.");
return false;
#endif
}
return loadNativeDll(path, errorMsg);
}
// --Native DLL loading ---------------------------------------------------
bool RcNetCompatPlugin::loadNativeDll(const QString& path, QString* errorMsg)
{
unloadNativeDll();
m_lib = std::make_unique<QLibrary>(path);
if (!m_lib->load()) {
if (errorMsg)
*errorMsg = QStringLiteral("Failed to load DLL: %1").arg(m_lib->errorString());
m_lib.reset();
return false;
}
// Resolve all function pointers
m_fns.EnumerateProcesses =
reinterpret_cast<FnEnumerateProcesses>(m_lib->resolve("EnumerateProcesses"));
m_fns.OpenRemoteProcess =
reinterpret_cast<FnOpenRemoteProcess>(m_lib->resolve("OpenRemoteProcess"));
m_fns.IsProcessValid =
reinterpret_cast<FnIsProcessValid>(m_lib->resolve("IsProcessValid"));
m_fns.CloseRemoteProcess =
reinterpret_cast<FnCloseRemoteProcess>(m_lib->resolve("CloseRemoteProcess"));
m_fns.ReadRemoteMemory =
reinterpret_cast<FnReadRemoteMemory>(m_lib->resolve("ReadRemoteMemory"));
m_fns.WriteRemoteMemory =
reinterpret_cast<FnWriteRemoteMemory>(m_lib->resolve("WriteRemoteMemory"));
m_fns.EnumerateRemoteSectionsAndModules =
reinterpret_cast<FnEnumerateRemoteSectionsAndModules>(
m_lib->resolve("EnumerateRemoteSectionsAndModules"));
m_fns.ControlRemoteProcess =
reinterpret_cast<FnControlRemoteProcess>(m_lib->resolve("ControlRemoteProcess"));
// At minimum we need read + open + close
if (!m_fns.ReadRemoteMemory || !m_fns.OpenRemoteProcess || !m_fns.CloseRemoteProcess || !m_fns.EnumerateProcesses) {
if (errorMsg)
*errorMsg = QStringLiteral(
"DLL is missing required exports (ReadRemoteMemory, OpenRemoteProcess, "
"CloseRemoteProcess, EnumerateProcesses). Is this a ReClass.NET native plugin?");
m_lib->unload();
m_lib.reset();
m_fns = {};
return false;
}
m_dllPath = path;
m_isManaged = false;
return true;
}
void RcNetCompatPlugin::unloadNativeDll()
{
if (m_lib) {
m_lib->unload();
m_lib.reset();
}
m_fns = {};
m_dllPath.clear();
m_isManaged = false;
}
// --Managed (.NET) DLL loading via CLR bridge ----------------------------
#ifdef HAS_CLR_BRIDGE
bool RcNetCompatPlugin::loadManagedDll(const QString& path, QString* errorMsg)
{
unloadNativeDll();
// Lazily create the CLR host (one per plugin lifetime)
if (!m_clrHost)
m_clrHost = std::make_unique<ClrHost>();
if (!m_clrHost->isAvailable()) {
if (errorMsg)
*errorMsg = QStringLiteral(
".NET Framework 4.x is not available on this machine.\n"
"Install the .NET Framework 4.7.2+ runtime to load managed plugins.");
return false;
}
// Locate RcNetBridge.dll next to our own plugin DLL
// Use native separators -- the CLR expects Windows-style backslash paths.
QString bridgePath = QDir::toNativeSeparators(
QCoreApplication::applicationDirPath()
+ QStringLiteral("/Plugins/RcNetBridge.dll"));
if (!QFileInfo::exists(bridgePath)) {
if (errorMsg)
*errorMsg = QStringLiteral(
"RcNetBridge.dll not found in the Plugins folder.\n"
"Expected at: %1").arg(bridgePath);
return false;
}
m_fns = {};
QString nativePath = QDir::toNativeSeparators(path);
if (!m_clrHost->loadManagedPlugin(bridgePath, nativePath, &m_fns, errorMsg))
return false;
m_dllPath = path;
m_isManaged = true;
return true;
}
#endif // HAS_CLR_BRIDGE
// --IProviderPlugin ------------------------------------------------------
bool RcNetCompatPlugin::canHandle(const QString& target) const
{
// Target format: "dllpath|pid:name"
return target.contains('|');
}
std::unique_ptr<rcx::Provider> RcNetCompatPlugin::createProvider(
const QString& target, QString* errorMsg)
{
// Parse "dllpath|pid:name"
int sep = target.indexOf('|');
if (sep < 0) {
if (errorMsg) *errorMsg = QStringLiteral("Invalid target format");
return nullptr;
}
QString dllPath = target.left(sep);
QString pidPart = target.mid(sep + 1);
// Load (or reuse) the plugin DLL
if (!loadPlugin(dllPath, errorMsg))
return nullptr;
// Parse pid:name
QStringList parts = pidPart.split(':');
bool ok = false;
uint32_t pid = parts[0].toUInt(&ok);
if (!ok || pid == 0) {
if (errorMsg) *errorMsg = QStringLiteral("Invalid PID: %1").arg(parts[0]);
return nullptr;
}
QString procName = parts.size() > 1 ? parts[1] : QStringLiteral("PID %1").arg(pid);
auto provider = std::make_unique<RcNetCompatProvider>(m_fns, pid, procName);
if (!provider->isValid()) {
if (errorMsg)
*errorMsg = QStringLiteral(
"Failed to open process %1 (PID: %2) via ReClass.NET plugin.\n"
"Ensure the process is running and the plugin supports it.")
.arg(procName).arg(pid);
return nullptr;
}
return provider;
}
uint64_t RcNetCompatPlugin::getInitialBaseAddress(const QString& target) const
{
Q_UNUSED(target);
// The provider sets its own base from module enumeration.
return 0;
}
bool RcNetCompatPlugin::selectTarget(QWidget* parent, QString* target)
{
// Step 1: Pick a ReClass.NET plugin DLL (native or .NET)
QString dllPath = QFileDialog::getOpenFileName(
parent,
QStringLiteral("Select ReClass.NET Plugin"),
QString(),
QStringLiteral("DLL Files (*.dll)"));
if (dllPath.isEmpty())
return false;
// Step 2: Load and validate the DLL
QString loadErr;
if (!loadPlugin(dllPath, &loadErr)) {
QMessageBox::warning(parent,
QStringLiteral("ReClass.NET Compat Layer"),
loadErr);
return false;
}
// Step 3: Enumerate processes and show picker
QVector<PluginProcessInfo> pluginProcesses = enumerateProcesses();
QList<ProcessInfo> processes;
for (const auto& p : pluginProcesses) {
ProcessInfo info;
info.pid = p.pid;
info.name = p.name;
info.path = p.path;
info.icon = p.icon;
processes.append(info);
}
ProcessPicker picker(processes, parent);
if (picker.exec() != QDialog::Accepted)
return false;
uint32_t pid = picker.selectedProcessId();
QString name = picker.selectedProcessName();
// Step 4: Format target as "dllpath|pid:name"
*target = QStringLiteral("%1|%2:%3").arg(dllPath).arg(pid).arg(name);
return true;
}
// --Process enumeration --------------------------------------------------
namespace {
struct ProcessCollector {
QVector<PluginProcessInfo>* dest = nullptr;
};
thread_local ProcessCollector g_processCollector;
void RC_CALLCONV processCallback(EnumerateProcessData* data)
{
if (!data || !g_processCollector.dest) return;
PluginProcessInfo info;
info.pid = static_cast<uint32_t>(data->Id);
info.name = QString::fromUtf16(data->Name);
info.path = QString::fromUtf16(data->Path);
g_processCollector.dest->append(info);
}
} // anonymous namespace
QVector<PluginProcessInfo> RcNetCompatPlugin::enumerateProcesses()
{
QVector<PluginProcessInfo> result;
if (!m_fns.EnumerateProcesses)
return result;
g_processCollector.dest = &result;
m_fns.EnumerateProcesses(processCallback);
g_processCollector.dest = nullptr;
return result;
}
// --Plugin factory -------------------------------------------------------
extern "C" RCX_PLUGIN_EXPORT IPlugin* CreatePlugin()
{
return new RcNetCompatPlugin();
}

View File

@@ -0,0 +1,61 @@
#pragma once
#include "../../src/iplugin.h"
#include "ReClassNET_Plugin.hpp"
#include <QLibrary>
#include <memory>
#ifdef HAS_CLR_BRIDGE
#include "ClrHost.h"
#endif
/**
* ReclassX plugin that loads ReClass.NET plugin DLLs
* and exposes them as ReclassX providers.
*
* Supports both native DLLs (C exports) and, when built with
* HAS_CLR_BRIDGE, managed .NET assemblies via in-process CLR hosting.
*
* Target string format: "dllpath|pid:processname"
*/
class RcNetCompatPlugin : public IProviderPlugin
{
public:
// Plugin metadata
std::string Name() const override { return "ReClass.NET Compat Layer"; }
std::string Version() const override { return "1.0.0"; }
std::string Author() const override { return "Reclass"; }
std::string Description() const override {
return "Loads ReClass.NET native and .NET plugin DLLs as Reclass data sources";
}
k_ELoadType LoadType() const override { return k_ELoadTypeAuto; }
QIcon Icon() const override;
// IProviderPlugin interface
bool canHandle(const QString& target) const override;
std::unique_ptr<rcx::Provider> createProvider(const QString& target, QString* errorMsg) override;
uint64_t getInitialBaseAddress(const QString& target) const override;
bool selectTarget(QWidget* parent, QString* target) override;
// Override process enumeration -- we enumerate via the loaded DLL
bool providesProcessList() const override { return true; }
QVector<PluginProcessInfo> enumerateProcesses() override;
private:
bool loadPlugin(const QString& path, QString* errorMsg = nullptr);
bool loadNativeDll(const QString& path, QString* errorMsg = nullptr);
void unloadNativeDll();
#ifdef HAS_CLR_BRIDGE
bool loadManagedDll(const QString& path, QString* errorMsg = nullptr);
std::unique_ptr<ClrHost> m_clrHost;
#endif
std::unique_ptr<QLibrary> m_lib;
RcNetFunctions m_fns;
QString m_dllPath;
bool m_isManaged = false;
};
// Plugin export
extern "C" RCX_PLUGIN_EXPORT IPlugin* CreatePlugin();

View File

@@ -0,0 +1,125 @@
#include "RcNetCompatProvider.h"
#include <QFileInfo>
#include <cstring>
// -- Construction / destruction -------------------------------------------
RcNetCompatProvider::RcNetCompatProvider(const RcNetFunctions& fns,
uint32_t pid,
const QString& processName)
: m_fns(fns)
, m_pid(pid)
, m_processName(processName)
{
if (m_fns.OpenRemoteProcess)
m_handle = m_fns.OpenRemoteProcess(static_cast<RC_Size>(pid),
ProcessAccess::Full);
if (m_handle)
cacheModules();
}
RcNetCompatProvider::~RcNetCompatProvider()
{
if (m_handle && m_fns.CloseRemoteProcess)
m_fns.CloseRemoteProcess(m_handle);
}
// -- Required overrides ---------------------------------------------------
bool RcNetCompatProvider::read(uint64_t addr, void* buf, int len) const
{
if (!m_handle || !m_fns.ReadRemoteMemory || len <= 0)
return false;
uint64_t absAddr = m_base + addr;
return m_fns.ReadRemoteMemory(m_handle,
reinterpret_cast<RC_Pointer>(absAddr),
static_cast<RC_Pointer>(buf),
0, len);
}
int RcNetCompatProvider::size() const
{
if (!m_handle) return 0;
if (m_fns.IsProcessValid && !m_fns.IsProcessValid(m_handle)) return 0;
return 0x10000;
}
// -- Optional overrides ---------------------------------------------------
bool RcNetCompatProvider::write(uint64_t addr, const void* buf, int len)
{
if (!m_handle || !m_fns.WriteRemoteMemory || len <= 0)
return false;
uint64_t absAddr = m_base + addr;
return m_fns.WriteRemoteMemory(m_handle,
reinterpret_cast<RC_Pointer>(absAddr),
const_cast<RC_Pointer>(static_cast<const void*>(buf)),
0, len);
}
QString RcNetCompatProvider::getSymbol(uint64_t addr) const
{
for (const auto& mod : m_modules)
{
if (addr >= mod.base && addr < mod.base + mod.size)
{
uint64_t offset = addr - mod.base;
return QStringLiteral("%1+0x%2")
.arg(mod.name)
.arg(offset, 0, 16, QChar('0'));
}
}
return {};
}
// -- Module enumeration ---------------------------------------------------
namespace {
// Thread-local collector for the module enumeration callback.
// ReClass.NET callbacks are synchronous, so this is safe.
struct ModuleCollector {
QVector<RcNetCompatProvider::ModuleInfo>* dest = nullptr;
};
thread_local ModuleCollector g_moduleCollector;
void RC_CALLCONV moduleCallback(EnumerateRemoteModuleData* data)
{
if (!data || !g_moduleCollector.dest) return;
QString path = QString::fromUtf16(data->Path);
QFileInfo fi(path);
RcNetCompatProvider::ModuleInfo info;
info.name = fi.fileName();
info.base = reinterpret_cast<uint64_t>(data->BaseAddress);
info.size = static_cast<uint64_t>(data->Size);
g_moduleCollector.dest->append(info);
}
// We still need a section callback even though we don't use it.
void RC_CALLCONV sectionCallback(EnumerateRemoteSectionData*)
{
// Intentionally empty -- we only need module data.
}
} // anonymous namespace
void RcNetCompatProvider::cacheModules()
{
if (!m_fns.EnumerateRemoteSectionsAndModules || !m_handle)
return;
m_modules.clear();
g_moduleCollector.dest = &m_modules;
m_fns.EnumerateRemoteSectionsAndModules(m_handle, sectionCallback, moduleCallback);
g_moduleCollector.dest = nullptr;
// Set base to first module if we got any
if (!m_modules.isEmpty() && m_base == 0)
m_base = m_modules.first().base;
}

View File

@@ -0,0 +1,48 @@
#pragma once
#include "../../src/providers/provider.h"
#include "ReClassNET_Plugin.hpp"
#include <QString>
#include <QVector>
/**
* Provider that bridges ReClass.NET native plugin DLL calls
* to the ReclassX Provider interface.
*/
class RcNetCompatProvider : public rcx::Provider
{
public:
RcNetCompatProvider(const RcNetFunctions& fns, uint32_t pid,
const QString& processName);
~RcNetCompatProvider() override;
// Required overrides
bool read(uint64_t addr, void* buf, int len) const override;
int size() const override;
// Optional overrides
bool write(uint64_t addr, const void* buf, int len) override;
bool isWritable() const override { return m_fns.WriteRemoteMemory != nullptr; }
QString name() const override { return m_processName; }
QString kind() const override { return QStringLiteral("RcNet"); }
bool isLive() const override { return true; }
uint64_t base() const override { return m_base; }
void setBase(uint64_t b) override { m_base = b; }
QString getSymbol(uint64_t addr) const override;
struct ModuleInfo {
QString name;
uint64_t base;
uint64_t size;
};
private:
void cacheModules();
RcNetFunctions m_fns;
RC_Pointer m_handle = nullptr;
uint32_t m_pid;
QString m_processName;
uint64_t m_base = 0;
QVector<ModuleInfo> m_modules;
};

View File

@@ -0,0 +1,140 @@
#pragma once
// Subset of ReClass.NET native plugin types needed for the compatibility layer.
// Based on the ReClass.NET NativeCore plugin interface.
// Only types required by the 8 supported exports are included (no debug types).
#include <cstdint>
#ifdef _WIN32
#define RC_CALLCONV __stdcall
#else
#define RC_CALLCONV
#endif
// -- Basic types ----------------------------------------------------------
using RC_Pointer = void*;
using RC_Size = uint64_t;
using RC_UnicodeChar = char16_t;
// -- Enums ----------------------------------------------------------------
enum class ProcessAccess
{
Read = 0,
Write = 1,
Full = 2
};
enum class SectionProtection
{
NoAccess = 0,
Read = 1,
Write = 2,
Execute = 4,
Guard = 8
};
enum class SectionType
{
Unknown = 0,
Private = 1,
Mapped = 2,
Image = 3
};
enum class SectionCategory
{
Unknown = 0,
CODE = 1,
DATA = 2,
HEAP = 3
};
enum class ControlRemoteProcessAction
{
Suspend = 0,
Resume = 1,
Terminate = 2
};
// -- Callback data structures ---------------------------------------------
#pragma pack(push, 1)
struct EnumerateProcessData
{
RC_Size Id;
RC_UnicodeChar Name[260];
RC_UnicodeChar Path[260];
};
struct EnumerateRemoteSectionData
{
RC_Pointer BaseAddress;
RC_Size Size;
SectionType Type;
SectionCategory Category;
SectionProtection Protection;
RC_UnicodeChar Name[16];
RC_UnicodeChar ModulePath[260];
};
struct EnumerateRemoteModuleData
{
RC_Pointer BaseAddress;
RC_Size Size;
RC_UnicodeChar Path[260];
};
#pragma pack(pop)
// -- Callback typedefs ----------------------------------------------------
using EnumerateProcessCallback = void(RC_CALLCONV*)(EnumerateProcessData* data);
using EnumerateRemoteSectionsCallback = void(RC_CALLCONV*)(EnumerateRemoteSectionData* data);
using EnumerateRemoteModulesCallback = void(RC_CALLCONV*)(EnumerateRemoteModuleData* data);
// -- Function pointer typedefs for resolved exports -----------------------
using FnEnumerateProcesses = void(RC_CALLCONV*)(EnumerateProcessCallback callback);
using FnOpenRemoteProcess = RC_Pointer(RC_CALLCONV*)(RC_Size id, ProcessAccess desiredAccess);
using FnIsProcessValid = bool(RC_CALLCONV*)(RC_Pointer handle);
using FnCloseRemoteProcess = void(RC_CALLCONV*)(RC_Pointer handle);
using FnReadRemoteMemory = bool(RC_CALLCONV*)(RC_Pointer handle,
RC_Pointer address,
RC_Pointer buffer,
int offset,
int size);
using FnWriteRemoteMemory = bool(RC_CALLCONV*)(RC_Pointer handle,
RC_Pointer address,
RC_Pointer buffer,
int offset,
int size);
using FnEnumerateRemoteSectionsAndModules =
void(RC_CALLCONV*)(RC_Pointer handle,
EnumerateRemoteSectionsCallback sectionCallback,
EnumerateRemoteModulesCallback moduleCallback);
using FnControlRemoteProcess = void(RC_CALLCONV*)(RC_Pointer handle,
ControlRemoteProcessAction action);
// -- Resolved function table ----------------------------------------------
struct RcNetFunctions
{
FnEnumerateProcesses EnumerateProcesses = nullptr;
FnOpenRemoteProcess OpenRemoteProcess = nullptr;
FnIsProcessValid IsProcessValid = nullptr;
FnCloseRemoteProcess CloseRemoteProcess = nullptr;
FnReadRemoteMemory ReadRemoteMemory = nullptr;
FnWriteRemoteMemory WriteRemoteMemory = nullptr;
FnEnumerateRemoteSectionsAndModules EnumerateRemoteSectionsAndModules = nullptr;
FnControlRemoteProcess ControlRemoteProcess = nullptr;
};

View File

@@ -0,0 +1,677 @@
// RcNetBridge -- in-process C# bridge for loading .NET ReClass.NET plugins.
//
// Called from C++ via ICLRRuntimeHost::ExecuteInDefaultAppDomain().
// The single entry point is Bridge.Initialize(string arg) where arg is:
// "<hex_address_of_RcNetFunctions>|<plugin_dll_path>"
//
// The bridge:
// 1. Registers an AssemblyResolve handler that provides THIS assembly
// when a plugin asks for "ReClassNET", so the stub types below satisfy
// the plugin's type references.
// 2. Loads the plugin assembly and finds an ICoreProcessFunctions
// implementation.
// 3. Creates [UnmanagedFunctionPointer] delegates wrapping each method.
// 4. Writes the native-callable function pointers into the RcNetFunctions
// struct at the address provided by C++.
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
// ===========================================================================
// ReClass.NET stub types
// These mirror the subset of types from the ReClass.NET assembly that
// memory-reading plugins reference. When the CLR resolves "ReClassNET"
// via our AssemblyResolve handler, it gets THIS assembly, and these types
// satisfy the plugin's type references.
//
// Types are placed in the exact namespaces used by the real ReClass.NET
// assembly so that plugins compiled against it resolve correctly.
// ===========================================================================
// --------------------------------------------------------------------------
// ReClassNET.Memory -- section enums (referenced by EnumerateRemoteSectionData)
// --------------------------------------------------------------------------
namespace ReClassNET.Memory
{
public enum SectionProtection
{
NoAccess = 0,
Read = 1,
Write = 2,
Execute = 4,
Guard = 8
}
public enum SectionType
{
Unknown = 0,
Private = 1,
Mapped = 2,
Image = 3
}
public enum SectionCategory
{
Unknown = 0,
CODE = 1,
DATA = 2,
HEAP = 3
}
}
// --------------------------------------------------------------------------
// ReClassNET.Debugger -- debugger types (used by ICoreProcessFunctions)
// --------------------------------------------------------------------------
namespace ReClassNET.Debugger
{
public enum DebugContinueStatus
{
Handled = 0,
NotHandled = 1
}
public enum HardwareBreakpointRegister
{
InvalidRegister = 0,
Dr0 = 1,
Dr1 = 2,
Dr2 = 3,
Dr3 = 4
}
public enum HardwareBreakpointTrigger
{
Execute = 0,
Access = 1,
Write = 2
}
public enum HardwareBreakpointSize
{
Size1 = 1,
Size2 = 2,
Size4 = 4,
Size8 = 8
}
public struct ExceptionDebugInfo
{
public IntPtr ExceptionCode;
public IntPtr ExceptionFlags;
public IntPtr ExceptionAddress;
public HardwareBreakpointRegister CausedBy;
public RegisterInfo Registers;
public struct RegisterInfo
{
public IntPtr Rax, Rbx, Rcx, Rdx;
public IntPtr Rdi, Rsi, Rsp, Rbp, Rip;
public IntPtr R8, R9, R10, R11, R12, R13, R14, R15;
}
}
public struct DebugEvent
{
public DebugContinueStatus ContinueStatus;
public IntPtr ProcessId;
public IntPtr ThreadId;
public ExceptionDebugInfo ExceptionInfo;
}
}
// --------------------------------------------------------------------------
// ReClassNET.Core -- interface, enums, delegates, and data structs
// --------------------------------------------------------------------------
namespace ReClassNET.Core
{
public enum ProcessAccess
{
Read = 0,
Write = 1,
Full = 2
}
public enum ControlRemoteProcessAction
{
Suspend = 0,
Resume = 1,
Terminate = 2
}
public struct EnumerateProcessData
{
public IntPtr Id;
public string Name;
public string Path;
}
public struct EnumerateRemoteSectionData
{
public IntPtr BaseAddress;
public IntPtr Size;
public ReClassNET.Memory.SectionType Type;
public ReClassNET.Memory.SectionCategory Category;
public ReClassNET.Memory.SectionProtection Protection;
public string Name;
public string ModulePath;
}
public struct EnumerateRemoteModuleData
{
public IntPtr BaseAddress;
public IntPtr Size;
public string Path;
}
public delegate void EnumerateProcessCallback(ref EnumerateProcessData data);
public delegate void EnumerateRemoteSectionCallback(ref EnumerateRemoteSectionData data);
public delegate void EnumerateRemoteModuleCallback(ref EnumerateRemoteModuleData data);
public interface ICoreProcessFunctions
{
void EnumerateProcesses(EnumerateProcessCallback callbackProcess);
IntPtr OpenRemoteProcess(IntPtr pid, ProcessAccess desiredAccess);
bool IsProcessValid(IntPtr process);
void CloseRemoteProcess(IntPtr process);
bool ReadRemoteMemory(IntPtr process, IntPtr address, ref byte[] buffer, int offset, int size);
bool WriteRemoteMemory(IntPtr process, IntPtr address, ref byte[] buffer, int offset, int size);
void EnumerateRemoteSectionsAndModules(
IntPtr process,
EnumerateRemoteSectionCallback callbackSection,
EnumerateRemoteModuleCallback callbackModule);
void ControlRemoteProcess(IntPtr process, ControlRemoteProcessAction action);
// Debugger methods -- stubs required for interface compatibility
bool AttachDebuggerToProcess(IntPtr id);
void DetachDebuggerFromProcess(IntPtr id);
bool AwaitDebugEvent(ref ReClassNET.Debugger.DebugEvent evt, int timeoutInMilliseconds);
void HandleDebugEvent(ref ReClassNET.Debugger.DebugEvent evt);
bool SetHardwareBreakpoint(IntPtr id, IntPtr address,
ReClassNET.Debugger.HardwareBreakpointRegister register,
ReClassNET.Debugger.HardwareBreakpointTrigger trigger,
ReClassNET.Debugger.HardwareBreakpointSize size,
bool set);
}
}
// --------------------------------------------------------------------------
// ReClassNET.Memory -- RemoteProcess stub
// --------------------------------------------------------------------------
namespace ReClassNET.Memory
{
public class RemoteProcess { }
}
// --------------------------------------------------------------------------
// ReClassNET.Logger -- ILogger stub
// --------------------------------------------------------------------------
namespace ReClassNET.Logger
{
public interface ILogger { }
}
// --------------------------------------------------------------------------
// Stub types for IPluginHost properties
// --------------------------------------------------------------------------
namespace ReClassNET.Forms
{
public class MainForm { }
}
namespace ReClassNET
{
public class Settings { }
}
// --------------------------------------------------------------------------
// ReClassNET.Plugins
// --------------------------------------------------------------------------
namespace ReClassNET.Plugins
{
public abstract class Plugin : IDisposable
{
public virtual bool Initialize(IPluginHost host) { return true; }
public virtual void Terminate() { }
public virtual void Dispose() { }
}
public interface IPluginHost
{
ReClassNET.Forms.MainForm MainWindow { get; }
System.Resources.ResourceManager Resources { get; }
ReClassNET.Memory.RemoteProcess Process { get; }
ReClassNET.Logger.ILogger Logger { get; }
ReClassNET.Settings Settings { get; }
}
}
// ===========================================================================
// Bridge
// ===========================================================================
namespace RcNetBridge
{
internal class StubPluginHost : ReClassNET.Plugins.IPluginHost
{
public ReClassNET.Forms.MainForm MainWindow => null;
public System.Resources.ResourceManager Resources => null;
public ReClassNET.Memory.RemoteProcess Process => null;
public ReClassNET.Logger.ILogger Logger => null;
public ReClassNET.Settings Settings => null;
}
public class Bridge
{
// -- Persistent state (static so it survives after Initialize returns) --
private static ReClassNET.Core.ICoreProcessFunctions s_functions;
private static readonly List<Delegate> s_pinned = new List<Delegate>();
// -- Entry point called from C++ --------------------------------------
/// <summary>
/// Called by ICLRRuntimeHost::ExecuteInDefaultAppDomain.
/// arg = "&lt;hex_address_of_RcNetFunctions&gt;|&lt;plugin_dll_path&gt;"
/// Returns 0 on success, non-zero error code on failure.
/// </summary>
public static int Initialize(string arg)
{
try
{
int sep = arg.IndexOf('|');
if (sep < 0) return 1; // bad arg
long ptrValue = long.Parse(arg.Substring(0, sep), NumberStyles.HexNumber);
IntPtr funcTablePtr = new IntPtr(ptrValue);
string pluginPath = arg.Substring(sep + 1);
// Set up assembly resolution
string pluginDir = Path.GetDirectoryName(pluginPath) ?? ".";
string parentDir = Path.GetDirectoryName(pluginDir);
AppDomain.CurrentDomain.AssemblyResolve += (sender, resolveArgs) =>
{
string asmName = new AssemblyName(resolveArgs.Name).Name;
// Provide our own assembly as the "ReClass.NET" stub
if (string.Equals(asmName, "ReClass.NET", StringComparison.OrdinalIgnoreCase))
return typeof(Bridge).Assembly;
// Search plugin directory and parent for other dependencies
string dllName = asmName + ".dll";
foreach (string dir in new[] { pluginDir, parentDir })
{
if (dir == null) continue;
string path = Path.Combine(dir, dllName);
if (File.Exists(path))
return Assembly.LoadFrom(path);
}
return null;
};
// Load plugin and find ICoreProcessFunctions
if (!LoadPlugin(pluginPath))
return 2; // no implementation found
// Write function pointers
WriteFunctionPointers(funcTablePtr);
return 0;
}
catch (Exception ex) when (ex is ReflectionTypeLoadException || ex is FileNotFoundException)
{
return 3;
}
catch
{
return 4;
}
}
// -- Plugin loading ---------------------------------------------------
private static bool LoadPlugin(string pluginPath)
{
Assembly asm = Assembly.LoadFrom(pluginPath);
// Find a concrete type that implements ICoreProcessFunctions.
// ReClass.NET plugins typically extend Plugin and directly
// implement ICoreProcessFunctions on the same class.
foreach (Type type in asm.GetExportedTypes())
{
if (type.IsAbstract || type.IsInterface) continue;
Type iface = type.GetInterfaces().FirstOrDefault(i =>
i.FullName == "ReClassNET.Core.ICoreProcessFunctions");
if (iface == null) continue;
object instance = Activator.CreateInstance(type);
// Try calling Initialize() but don't fail if it throws --
// plugins use it for UI integration with the host app,
// which we can't fully provide. The process functions
// (ReadRemoteMemory, etc.) work without it.
try
{
MethodInfo init = type.GetMethod("Initialize",
BindingFlags.Public | BindingFlags.Instance,
null, new[] { typeof(ReClassNET.Plugins.IPluginHost) }, null);
if (init != null)
init.Invoke(instance, new object[] { new StubPluginHost() });
}
catch { }
s_functions = (ReClassNET.Core.ICoreProcessFunctions)instance;
return true;
}
return false;
}
// -- Native-callable delegate types -----------------------------------
// These match the C++ RcNetFunctions struct field order exactly.
// On x64 Windows all calling conventions collapse to the Microsoft
// x64 ABI, so StdCall is used for documentation / x86 correctness.
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
delegate void DelEnumProcesses(IntPtr callback);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
delegate IntPtr DelOpenRemoteProcess(ulong id, int access);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
[return: MarshalAs(UnmanagedType.I1)]
delegate bool DelIsProcessValid(IntPtr handle);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
delegate void DelCloseRemoteProcess(IntPtr handle);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
[return: MarshalAs(UnmanagedType.I1)]
delegate bool DelReadRemoteMemory(IntPtr handle, IntPtr address,
IntPtr buffer, int offset, int size);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
[return: MarshalAs(UnmanagedType.I1)]
delegate bool DelWriteRemoteMemory(IntPtr handle, IntPtr address,
IntPtr buffer, int offset, int size);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
delegate void DelEnumSectionsAndModules(IntPtr handle,
IntPtr sectionCallback, IntPtr moduleCallback);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
delegate void DelControlRemoteProcess(IntPtr handle, int action);
// Callback delegate types -- these point into C++ and are called by us.
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
delegate void NativeProcessCallback(IntPtr data);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
delegate void NativeSectionCallback(IntPtr data);
[UnmanagedFunctionPointer(CallingConvention.StdCall)]
delegate void NativeModuleCallback(IntPtr data);
// -- Write function pointers to the C++ struct ------------------------
private static void WriteFunctionPointers(IntPtr funcTable)
{
// RcNetFunctions layout: 8 consecutive function pointers.
int i = 0;
WriteSlot(funcTable, i++, Pin<DelEnumProcesses>(EnumProcessesImpl));
WriteSlot(funcTable, i++, Pin<DelOpenRemoteProcess>(OpenProcessImpl));
WriteSlot(funcTable, i++, Pin<DelIsProcessValid>(IsProcessValidImpl));
WriteSlot(funcTable, i++, Pin<DelCloseRemoteProcess>(CloseProcessImpl));
WriteSlot(funcTable, i++, Pin<DelReadRemoteMemory>(ReadMemoryImpl));
WriteSlot(funcTable, i++, Pin<DelWriteRemoteMemory>(WriteMemoryImpl));
WriteSlot(funcTable, i++, Pin<DelEnumSectionsAndModules>(EnumSectionsModulesImpl));
WriteSlot(funcTable, i++, Pin<DelControlRemoteProcess>(ControlProcessImpl));
}
private static IntPtr Pin<T>(T del) where T : class
{
Delegate d = del as Delegate;
s_pinned.Add(d); // prevent GC
return Marshal.GetFunctionPointerForDelegate(d);
}
private static void WriteSlot(IntPtr table, int index, IntPtr value)
{
Marshal.WriteIntPtr(table, index * IntPtr.Size, value);
}
// -- Implementation methods -------------------------------------------
// -- EnumerateProcesses --
// C++ passes a native callback; we call the plugin, convert each
// managed EnumerateProcessData to the packed native layout, and
// forward to the native callback.
private static void EnumProcessesImpl(IntPtr nativeCallbackPtr)
{
try
{
if (s_functions == null || nativeCallbackPtr == IntPtr.Zero) return;
NativeProcessCallback nativeCb =
Marshal.GetDelegateForFunctionPointer<NativeProcessCallback>(nativeCallbackPtr);
// Native layout (pack=1): uint64 Id + char16[260] Name + char16[260] Path
const int kStructSize = 8 + 520 + 520; // 1048 bytes
s_functions.EnumerateProcesses(
(ref ReClassNET.Core.EnumerateProcessData data) =>
{
IntPtr mem = Marshal.AllocHGlobal(kStructSize);
try
{
// Zero-fill
byte[] zeros = new byte[kStructSize];
Marshal.Copy(zeros, 0, mem, kStructSize);
// Id (8 bytes at offset 0)
Marshal.WriteInt64(mem, 0, data.Id.ToInt64());
// Name (char16[260] at offset 8)
if (data.Name != null)
{
char[] chars = data.Name.ToCharArray();
int count = Math.Min(chars.Length, 259);
Marshal.Copy(chars, 0, new IntPtr(mem.ToInt64() + 8), count);
}
// Path (char16[260] at offset 528)
if (data.Path != null)
{
char[] chars = data.Path.ToCharArray();
int count = Math.Min(chars.Length, 259);
Marshal.Copy(chars, 0, new IntPtr(mem.ToInt64() + 528), count);
}
nativeCb(mem);
}
finally
{
Marshal.FreeHGlobal(mem);
}
});
}
catch { /* swallow -- don't crash the host process */ }
}
// -- OpenRemoteProcess --
private static IntPtr OpenProcessImpl(ulong id, int access)
{
try
{
if (s_functions == null) return IntPtr.Zero;
return s_functions.OpenRemoteProcess(
new IntPtr((long)id),
(ReClassNET.Core.ProcessAccess)access);
}
catch { return IntPtr.Zero; }
}
// -- IsProcessValid --
private static bool IsProcessValidImpl(IntPtr handle)
{
try
{
if (s_functions == null) return false;
return s_functions.IsProcessValid(handle);
}
catch { return false; }
}
// -- CloseRemoteProcess --
private static void CloseProcessImpl(IntPtr handle)
{
try { s_functions?.CloseRemoteProcess(handle); }
catch { }
}
// -- ReadRemoteMemory --
// C++ provides a native buffer pointer. We read into a managed array
// via the plugin's interface, then copy to the native buffer.
private static bool ReadMemoryImpl(IntPtr handle, IntPtr address,
IntPtr buffer, int offset, int size)
{
try
{
if (s_functions == null || size <= 0) return false;
byte[] managed = new byte[size];
bool ok = s_functions.ReadRemoteMemory(
handle, address, ref managed, 0, size);
if (ok)
Marshal.Copy(managed, 0, new IntPtr(buffer.ToInt64() + offset), size);
return ok;
}
catch { return false; }
}
// -- WriteRemoteMemory --
private static bool WriteMemoryImpl(IntPtr handle, IntPtr address,
IntPtr buffer, int offset, int size)
{
try
{
if (s_functions == null || size <= 0) return false;
byte[] managed = new byte[size];
Marshal.Copy(new IntPtr(buffer.ToInt64() + offset), managed, 0, size);
return s_functions.WriteRemoteMemory(
handle, address, ref managed, 0, size);
}
catch { return false; }
}
// -- EnumerateRemoteSectionsAndModules --
private static void EnumSectionsModulesImpl(IntPtr handle,
IntPtr sectionCallbackPtr, IntPtr moduleCallbackPtr)
{
try
{
if (s_functions == null) return;
// Section callback -- forward to native
// Native layout (pack=1): RC_Pointer Base(8) + RC_Size Size(8) +
// SectionType(4) + SectionCategory(4) + SectionProtection(4) +
// char16 Name[16](32) + char16 ModulePath[260](520) = 580 bytes
NativeSectionCallback nativeSectionCb = (sectionCallbackPtr != IntPtr.Zero)
? Marshal.GetDelegateForFunctionPointer<NativeSectionCallback>(sectionCallbackPtr)
: null;
// Module callback -- forward to native
// Native layout (pack=1): RC_Pointer Base(8) + RC_Size Size(8) +
// char16 Path[260](520) = 536 bytes
NativeModuleCallback nativeModuleCb = (moduleCallbackPtr != IntPtr.Zero)
? Marshal.GetDelegateForFunctionPointer<NativeModuleCallback>(moduleCallbackPtr)
: null;
s_functions.EnumerateRemoteSectionsAndModules(handle,
// Section callback
(ref ReClassNET.Core.EnumerateRemoteSectionData sdata) =>
{
if (nativeSectionCb == null) return;
const int kSize = 8 + 8 + 4 + 4 + 4 + 32 + 520; // 580
IntPtr mem = Marshal.AllocHGlobal(kSize);
try
{
byte[] z = new byte[kSize];
Marshal.Copy(z, 0, mem, kSize);
Marshal.WriteInt64(mem, 0, sdata.BaseAddress.ToInt64());
Marshal.WriteInt64(mem, 8, sdata.Size.ToInt64());
Marshal.WriteInt32(mem, 16, (int)sdata.Type);
Marshal.WriteInt32(mem, 20, (int)sdata.Category);
Marshal.WriteInt32(mem, 24, (int)sdata.Protection);
if (sdata.Name != null)
{
char[] c = sdata.Name.ToCharArray();
Marshal.Copy(c, 0, new IntPtr(mem.ToInt64() + 28),
Math.Min(c.Length, 15));
}
if (sdata.ModulePath != null)
{
char[] c = sdata.ModulePath.ToCharArray();
Marshal.Copy(c, 0, new IntPtr(mem.ToInt64() + 60),
Math.Min(c.Length, 259));
}
nativeSectionCb(mem);
}
finally { Marshal.FreeHGlobal(mem); }
},
// Module callback
(ref ReClassNET.Core.EnumerateRemoteModuleData mdata) =>
{
if (nativeModuleCb == null) return;
const int kSize = 8 + 8 + 520; // 536
IntPtr mem = Marshal.AllocHGlobal(kSize);
try
{
byte[] z = new byte[kSize];
Marshal.Copy(z, 0, mem, kSize);
Marshal.WriteInt64(mem, 0, mdata.BaseAddress.ToInt64());
Marshal.WriteInt64(mem, 8, mdata.Size.ToInt64());
if (mdata.Path != null)
{
char[] c = mdata.Path.ToCharArray();
Marshal.Copy(c, 0, new IntPtr(mem.ToInt64() + 16),
Math.Min(c.Length, 259));
}
nativeModuleCb(mem);
}
finally { Marshal.FreeHGlobal(mem); }
});
}
catch { }
}
// -- ControlRemoteProcess --
private static void ControlProcessImpl(IntPtr handle, int action)
{
try
{
s_functions?.ControlRemoteProcess(handle,
(ReClassNET.Core.ControlRemoteProcessAction)action);
}
catch { }
}
}
}

View File

@@ -0,0 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<OutputType>Library</OutputType>
<AssemblyName>RcNetBridge</AssemblyName>
<RootNamespace>RcNetBridge</RootNamespace>
<AllowUnsafeBlocks>false</AllowUnsafeBlocks>
<LangVersion>7.3</LangVersion>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
</Project>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 63 KiB

View File

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

View File

@@ -178,6 +178,14 @@ RcxEditor* RcxController::addSplitEditor(QWidget* parent) {
editor->applyDocument(m_lastResult);
}
updateCommandRow();
// Eagerly pre-warm the type popup so first click isn't slow (~350ms cold start).
if (!m_cachedPopup) {
QTimer::singleShot(0, this, [this, editor]() {
if (!m_cachedPopup && !m_editors.isEmpty())
ensurePopup(editor);
});
}
return editor;
}
@@ -226,8 +234,9 @@ void RcxController::connectEditor(RcxEditor* editor) {
switch (target) {
case EditTarget::Name: {
if (text.isEmpty()) break;
if (nodeIdx >= m_doc->tree.nodes.size()) break;
const Node& node = m_doc->tree.nodes[nodeIdx];
// ASCII edit on Hex/Padding nodes
// ASCII edit on Hex nodes
if (isHexPreview(node.kind)) {
setNodeValue(nodeIdx, subLine, text, /*isAscii=*/true);
} else {
@@ -427,7 +436,10 @@ void RcxController::connectEditor(RcxEditor* editor) {
m_doc->undoStack.clear();
m_doc->provider = std::move(provider);
m_doc->dataPath.clear();
m_doc->tree.baseAddress = newBase;
if (m_doc->tree.baseAddress == 0)
m_doc->tree.baseAddress = newBase;
else
m_doc->provider->setBase(m_doc->tree.baseAddress);
resetSnapshot();
emit m_doc->documentChanged();
@@ -602,7 +614,7 @@ void RcxController::refresh() {
if (isHexPreview(node.kind)) {
// Per-byte tracking for hex preview nodes
int lineOff = (node.kind == NodeKind::Padding) ? lm.subLine * 8 : 0;
int lineOff = 0;
int byteCount = lm.lineByteCount;
for (int b = 0; b < byteCount; b++) {
if (m_changedOffsets.contains(offset + lineOff + b)) {
@@ -624,6 +636,39 @@ void RcxController::refresh() {
}
}
// Update value history and compute heat levels
// Only run when a live provider is attached (not for static file/buffer sources)
{
const Provider* prov = nullptr;
if (m_snapshotProv && m_snapshotProv->isLive())
prov = m_snapshotProv.get();
else if (m_doc->provider && m_doc->provider->isValid() && m_doc->provider->isLive())
prov = m_doc->provider.get();
if (prov) {
for (auto& lm : m_lastResult.meta) {
if (lm.nodeIdx < 0 || lm.nodeIdx >= m_doc->tree.nodes.size()) continue;
if (isSyntheticLine(lm) || lm.isContinuation) continue;
if (lm.lineKind != LineKind::Field) continue;
const Node& node = m_doc->tree.nodes[lm.nodeIdx];
// Skip containers — they don't have scalar values
if (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) continue;
int64_t nodeOff = m_doc->tree.computeOffset(lm.nodeIdx);
uint64_t addr = static_cast<uint64_t>(nodeOff); // provider-relative
int sz = node.byteSize();
if (sz <= 0 || !prov->isReadable(addr, sz)) continue;
QString val = fmt::readValue(node, *prov, addr, lm.subLine);
if (!val.isEmpty()) {
m_valueHistory[lm.nodeId].record(val);
lm.heatLevel = m_valueHistory[lm.nodeId].heatLevel();
}
}
}
}
// Prune stale selections (nodes removed by undo/redo/delete)
QSet<uint64_t> valid;
for (uint64_t id : m_selIds) {
@@ -647,13 +692,16 @@ void RcxController::refresh() {
for (auto* editor : m_editors) {
editor->setCustomTypeNames(customTypes);
editor->setValueHistoryRef(&m_valueHistory);
ViewState vs = editor->saveViewState();
editor->applyDocument(m_lastResult);
editor->restoreViewState(vs);
}
applySelectionOverlays();
// Text-modifying passes first (command row replaces line 0 text),
// then overlays last so hover indicators survive the refresh.
pushSavedSourcesToEditors();
updateCommandRow();
applySelectionOverlays();
}
void RcxController::changeNodeKind(int nodeIdx, NodeKind newKind) {
@@ -799,43 +847,74 @@ void RcxController::materializeRefChildren(int nodeIdx) {
if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return;
auto& tree = m_doc->tree;
// Snapshot values before addNode invalidates references
const uint64_t parentId = tree.nodes[nodeIdx].id;
const uint64_t refId = tree.nodes[nodeIdx].refId;
// Snapshot values before any mutation invalidates references
const uint64_t parentId = tree.nodes[nodeIdx].id;
const uint64_t refId = tree.nodes[nodeIdx].refId;
const NodeKind parentKind = tree.nodes[nodeIdx].kind;
const QString parentName = tree.nodes[nodeIdx].name;
if (refId == 0) return;
if (!tree.childrenOf(parentId).isEmpty()) return; // already materialized
// Clone all children of the referenced struct as real children of this struct
// Collect children to clone (copy by value to avoid reference invalidation)
QVector<int> refChildren = tree.childrenOf(refId);
if (refChildren.isEmpty()) return;
QVector<Node> clones;
clones.reserve(refChildren.size());
for (int ci : refChildren) {
Node copy = tree.nodes[ci];
copy.id = 0; // auto-assign new ID
Node copy = tree.nodes[ci]; // copy by value before any mutation
copy.id = tree.reserveId();
copy.parentId = parentId;
copy.collapsed = true; // start collapsed
tree.addNode(copy);
copy.collapsed = true;
clones.append(copy);
}
// Wrap all mutations in an undo macro
bool wasSuppressed = m_suppressRefresh;
m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QStringLiteral("Materialize ref children"));
for (const Node& clone : clones) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::Insert{clone, {}}));
}
tree.invalidateIdCache();
// Auto-expand the self-referential child (the one that was the cycle)
// so the user gets expand in a single click
QVector<int> newChildren = tree.childrenOf(parentId);
for (int ci : newChildren) {
auto& c = tree.nodes[ci];
if (c.kind == parentKind && c.name == parentName && c.refId == refId) {
c.collapsed = false;
for (const Node& clone : clones) {
if (clone.kind == parentKind && clone.name == parentName && clone.refId == refId) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::Collapse{clone.id, true, false}));
break;
}
}
refresh();
m_doc->undoStack.endMacro();
m_suppressRefresh = wasSuppressed;
if (!m_suppressRefresh) refresh();
}
void RcxController::applyCommand(const Command& command, bool isUndo) {
auto& tree = m_doc->tree;
// Clear value history for nodes whose effective offset changed.
// When offsets shift (insert/delete/resize), old recorded values came from
// a different memory address, so keeping them would show false heat.
// Also invalidates any in-flight async read so that stale snapshot data
// from before the offset change doesn't re-introduce false heat.
auto clearHistoryForAdjs = [&](const QVector<cmd::OffsetAdj>& adjs) {
if (adjs.isEmpty()) return;
m_refreshGen++; // discard in-flight async read (stale layout)
for (const auto& adj : adjs) {
// Clear the adjusted node itself
m_valueHistory.remove(adj.nodeId);
// Clear all descendants (their effective address also shifted)
for (int ci : tree.subtreeIndices(adj.nodeId))
m_valueHistory.remove(tree.nodes[ci].id);
}
};
std::visit([&](auto&& c) {
using T = std::decay_t<decltype(c)>;
if constexpr (std::is_same_v<T, cmd::ChangeKind>) {
@@ -847,6 +926,12 @@ void RcxController::applyCommand(const Command& command, bool isUndo) {
if (ai >= 0)
tree.nodes[ai].offset = isUndo ? adj.oldOffset : adj.newOffset;
}
// The changed node's value format changed; clear its history.
// If offAdjs is empty (same-size change), still bump gen to
// discard in-flight reads that would record the old format.
if (c.offAdjs.isEmpty()) m_refreshGen++;
m_valueHistory.remove(c.nodeId);
clearHistoryForAdjs(c.offAdjs);
} else if constexpr (std::is_same_v<T, cmd::Rename>) {
int idx = tree.indexOfId(c.nodeId);
if (idx >= 0)
@@ -875,6 +960,7 @@ void RcxController::applyCommand(const Command& command, bool isUndo) {
if (ai >= 0) tree.nodes[ai].offset = adj.newOffset;
}
}
clearHistoryForAdjs(c.offAdjs);
} else if constexpr (std::is_same_v<T, cmd::Remove>) {
if (isUndo) {
// Restore nodes first
@@ -891,13 +977,17 @@ void RcxController::applyCommand(const Command& command, bool isUndo) {
int ai = tree.indexOfId(adj.nodeId);
if (ai >= 0) tree.nodes[ai].offset = adj.newOffset;
}
// Remove nodes
// Remove nodes and their value history
QVector<int> indices = tree.subtreeIndices(c.nodeId);
std::sort(indices.begin(), indices.end(), std::greater<int>());
for (int idx : indices)
for (int idx : indices) {
m_valueHistory.remove(tree.nodes[idx].id);
tree.nodes.remove(idx);
}
tree.invalidateIdCache();
}
// Siblings shifted — their old values are from wrong addresses
clearHistoryForAdjs(c.offAdjs);
} else if constexpr (std::is_same_v<T, cmd::ChangeBase>) {
tree.baseAddress = isUndo ? c.oldBase : c.newBase;
qDebug() << "[ChangeBase] tree.baseAddress =" << Qt::hex << tree.baseAddress
@@ -909,11 +999,14 @@ void RcxController::applyCommand(const Command& command, bool isUndo) {
resetSnapshot();
} else if constexpr (std::is_same_v<T, cmd::WriteBytes>) {
const QByteArray& bytes = isUndo ? c.oldBytes : c.newBytes;
if (!m_doc->provider->writeBytes(c.addr, bytes))
// Write through snapshot (patches pages only on success) or provider directly.
// If write fails, the snapshot is NOT patched, so the next compose shows the
// real unchanged value — no optimistic visual leak.
bool ok = m_snapshotProv
? m_snapshotProv->write(c.addr, bytes.constData(), bytes.size())
: m_doc->provider->writeBytes(c.addr, bytes);
if (!ok)
qWarning() << "WriteBytes failed at address" << QString::number(c.addr, 16);
// Patch snapshot so compose sees the new value immediately
if (m_snapshotProv)
m_snapshotProv->patchPages(c.addr, bytes.constData(), bytes.size());
} else if constexpr (std::is_same_v<T, cmd::ChangeArrayMeta>) {
int idx = tree.indexOfId(c.nodeId);
if (idx >= 0) {
@@ -941,6 +1034,11 @@ void RcxController::applyCommand(const Command& command, bool isUndo) {
int idx = tree.indexOfId(c.nodeId);
if (idx >= 0)
tree.nodes[idx].offset = isUndo ? c.oldOffset : c.newOffset;
// Node and its descendants read from a different address now
m_refreshGen++; // discard in-flight async read (stale layout)
m_valueHistory.remove(c.nodeId);
for (int ci : tree.subtreeIndices(c.nodeId))
m_valueHistory.remove(tree.nodes[ci].id);
}
}, command);
@@ -996,8 +1094,21 @@ void RcxController::setNodeValue(int nodeIdx, int subLine, const QString& text,
// Validate write range before pushing command
if (!m_doc->provider->isReadable(addr, writeSize)) return;
// Read old bytes before writing (for undo)
QByteArray oldBytes = m_doc->provider->readBytes(addr, writeSize);
// Test the write first — don't push a command that will silently fail.
// This prevents optimistic visual updates for read-only providers.
bool writeOk = m_snapshotProv
? m_snapshotProv->write(addr, newBytes.constData(), newBytes.size())
: m_doc->provider->writeBytes(addr, newBytes);
if (!writeOk) {
qWarning() << "Write failed at address" << QString::number(addr, 16);
refresh(); // refresh to show the real unchanged value
return;
}
// Write succeeded — push undo command (redo will write again, which is harmless)
m_doc->undoStack.push(new RcxCommand(this,
cmd::WriteBytes{addr, oldBytes, newBytes}));
}
@@ -1093,23 +1204,23 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
// Quick-convert suggestions for Hex nodes
bool addedQuickConvert = false;
if (node.kind == NodeKind::Hex64) {
menu.addAction(icon("symbol-numeric.svg"), "Change to uint64_t", [this, nodeId]() {
menu.addAction("Change to uint64_t", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) changeNodeKind(ni, NodeKind::UInt64);
});
menu.addAction(icon("symbol-numeric.svg"), "Change to uint32_t", [this, nodeId]() {
menu.addAction("Change to uint32_t", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) changeNodeKind(ni, NodeKind::UInt32);
});
addedQuickConvert = true;
} else if (node.kind == NodeKind::Hex32) {
menu.addAction(icon("symbol-numeric.svg"), "Change to uint32_t", [this, nodeId]() {
menu.addAction("Change to uint32_t", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) changeNodeKind(ni, NodeKind::UInt32);
});
addedQuickConvert = true;
} else if (node.kind == NodeKind::Hex16) {
menu.addAction(icon("symbol-numeric.svg"), "Change to int16_t", [this, nodeId]() {
menu.addAction("Change to int16_t", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) changeNodeKind(ni, NodeKind::Int16);
});
@@ -1119,7 +1230,6 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
menu.addSeparator();
bool isEditable = node.kind != NodeKind::Struct && node.kind != NodeKind::Array
&& node.kind != NodeKind::Padding
&& m_doc->provider->isWritable();
if (isEditable) {
menu.addAction(icon("edit.svg"), "Edit &Value\tEnter", [editor, line]() {
@@ -1135,6 +1245,51 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
editor->beginInlineEdit(EditTarget::Type, line);
});
// Convert to Hex nodes (decompose non-hex types into Hex64/32/16/8)
if (!isHexNode(node.kind) && node.kind != NodeKind::Struct && node.kind != NodeKind::Array) {
menu.addAction("Convert to &Hex", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni < 0) return;
const Node& n = m_doc->tree.nodes[ni];
int totalSize = n.byteSize();
if (totalSize <= 0) return;
uint64_t parentId = n.parentId;
int baseOffset = n.offset;
bool wasSuppressed = m_suppressRefresh;
m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QStringLiteral("Convert to Hex"));
// Remove the original node
QVector<Node> subtree;
subtree.append(n);
m_doc->undoStack.push(new RcxCommand(this,
cmd::Remove{nodeId, subtree, {}}));
// Insert hex nodes to fill the space (largest first)
int padOffset = baseOffset;
int gap = totalSize;
while (gap > 0) {
NodeKind padKind;
int padSize;
if (gap >= 8) { padKind = NodeKind::Hex64; padSize = 8; }
else if (gap >= 4) { padKind = NodeKind::Hex32; padSize = 4; }
else if (gap >= 2) { padKind = NodeKind::Hex16; padSize = 2; }
else { padKind = NodeKind::Hex8; padSize = 1; }
insertNode(parentId, padOffset, padKind,
QString("pad_%1").arg(padOffset, 2, 16, QChar('0')));
padOffset += padSize;
gap -= padSize;
}
m_doc->undoStack.endMacro();
m_suppressRefresh = wasSuppressed;
if (!m_suppressRefresh) refresh();
});
}
menu.addSeparator();
if (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) {
@@ -1362,8 +1517,8 @@ void RcxController::handleNodeClick(RcxEditor* source, int line,
}
}
applySelectionOverlays();
updateCommandRow();
applySelectionOverlays();
if (m_selIds.size() == 1) {
uint64_t sid = *m_selIds.begin();
@@ -1376,8 +1531,8 @@ void RcxController::handleNodeClick(RcxEditor* source, int line,
void RcxController::clearSelection() {
m_selIds.clear();
m_anchorLine = -1;
applySelectionOverlays();
updateCommandRow();
applySelectionOverlays();
}
void RcxController::applySelectionOverlays() {
@@ -1396,17 +1551,17 @@ void RcxController::performRealignment(uint64_t structId, int targetAlign) {
return tree.nodes[a].offset < tree.nodes[b].offset;
});
// Separate into real nodes (non-Padding) and padding nodes
// Separate into real nodes (non-hex) and hex filler nodes
struct NodeInfo { uint64_t id; int offset; int size; };
QVector<NodeInfo> realNodes;
QVector<uint64_t> padIds;
QVector<uint64_t> hexIds;
for (int ci : kids) {
const Node& child = tree.nodes[ci];
int sz = (child.kind == NodeKind::Struct || child.kind == NodeKind::Array)
? tree.structSpan(child.id) : child.byteSize();
if (child.kind == NodeKind::Padding)
padIds.append(child.id);
if (isHexNode(child.kind))
hexIds.append(child.id);
else
realNodes.append({child.id, child.offset, sz});
}
@@ -1439,7 +1594,7 @@ void RcxController::performRealignment(uint64_t structId, int targetAlign) {
}
// Check if anything actually changes
if (offChanges.isEmpty() && padIds.isEmpty() && padsNeeded.isEmpty())
if (offChanges.isEmpty() && hexIds.isEmpty() && padsNeeded.isEmpty())
return;
// Apply as undoable macro
@@ -1447,14 +1602,14 @@ void RcxController::performRealignment(uint64_t structId, int targetAlign) {
m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QStringLiteral("Realign to %1").arg(targetAlign));
// 1. Remove all existing Padding nodes (no offset adjustments — we recompute)
for (uint64_t pid : padIds) {
int idx = tree.indexOfId(pid);
// 1. Remove all existing hex filler nodes (no offset adjustments — we recompute)
for (uint64_t hid : hexIds) {
int idx = tree.indexOfId(hid);
if (idx < 0) continue;
QVector<Node> subtree;
subtree.append(tree.nodes[idx]);
m_doc->undoStack.push(new RcxCommand(this,
cmd::Remove{pid, subtree, {}}));
cmd::Remove{hid, subtree, {}}));
}
// 2. Reposition real nodes
@@ -1463,15 +1618,28 @@ void RcxController::performRealignment(uint64_t structId, int targetAlign) {
cmd::ChangeOffset{oc.id, oc.oldOff, oc.newOff}));
}
// 3. Insert new padding in gaps
// 3. Insert hex nodes to fill gaps (largest first for alignment)
for (const auto& pi : padsNeeded) {
Node pad;
pad.kind = NodeKind::Padding;
pad.parentId = structId;
pad.offset = pi.offset;
pad.arrayLen = pi.size;
pad.id = tree.reserveId();
m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{pad}));
int padOffset = pi.offset;
int gap = pi.size;
while (gap > 0) {
NodeKind padKind;
int padSize;
if (gap >= 8) { padKind = NodeKind::Hex64; padSize = 8; }
else if (gap >= 4) { padKind = NodeKind::Hex32; padSize = 4; }
else if (gap >= 2) { padKind = NodeKind::Hex16; padSize = 2; }
else { padKind = NodeKind::Hex8; padSize = 1; }
Node pad;
pad.kind = padKind;
pad.parentId = structId;
pad.offset = padOffset;
pad.name = QString("pad_%1").arg(padOffset, 2, 16, QChar('0'));
pad.id = tree.reserveId();
m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{pad}));
padOffset += padSize;
gap -= padSize;
}
}
m_doc->undoStack.endMacro();
@@ -1575,7 +1743,6 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
auto addPrimitives = [&](bool enabled, bool excludeStructArrayPad) {
for (const auto& m : kKindMeta) {
if (m.kind == NodeKind::Padding) continue;
if (excludeStructArrayPad &&
(m.kind == NodeKind::Struct || m.kind == NodeKind::Array))
continue;
@@ -1606,7 +1773,7 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
switch (mode) {
case TypePopupMode::Root:
addPrimitives(/*enabled=*/false, /*excludeStructArrayPad=*/false);
// No primitives in Root mode only project types are valid roots
addComposites([&](const Node&, const TypeEntry& e) {
return e.structId == m_viewRootId;
});
@@ -1710,6 +1877,10 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
});
connect(popup, &TypeSelectorPopup::createNewTypeRequested,
this, [this, mode, nodeIdx]() {
bool wasSuppressed = m_suppressRefresh;
m_suppressRefresh = true;
m_doc->undoStack.beginMacro(QStringLiteral("Create new type"));
Node n;
n.kind = NodeKind::Struct;
n.name = QString();
@@ -1717,6 +1888,16 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
n.offset = 0;
n.id = m_doc->tree.reserveId();
m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{n}));
// Populate with default hex nodes (8 x Hex64 = 64 bytes)
for (int i = 0; i < 8; i++) {
insertNode(n.id, i * 8, NodeKind::Hex64,
QString("field_%1").arg(i * 8, 2, 16, QChar('0')));
}
m_doc->undoStack.endMacro();
m_suppressRefresh = wasSuppressed;
TypeEntry newEntry;
newEntry.entryKind = TypeEntry::Composite;
newEntry.structId = n.id;
@@ -1735,14 +1916,22 @@ void RcxController::applyTypePopupResult(TypePopupMode mode, int nodeIdx,
}
if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return;
const Node& node = m_doc->tree.nodes[nodeIdx];
// BUG-1 fix: Copy needed fields to locals before any mutation.
// changeNodeKind() can trigger insertNode() → addNode() → nodes.append(),
// which may reallocate the QVector, invalidating any reference into it.
const uint64_t nodeId = m_doc->tree.nodes[nodeIdx].id;
const NodeKind nodeKind = m_doc->tree.nodes[nodeIdx].kind;
const NodeKind elemKind = m_doc->tree.nodes[nodeIdx].elementKind;
const uint64_t nodeRefId = m_doc->tree.nodes[nodeIdx].refId;
const int arrLen = m_doc->tree.nodes[nodeIdx].arrayLen;
// Parse the full text for modifiers (e.g. "int32_t[10]", "Ball*")
TypeSpec spec = parseTypeSpec(fullText);
if (mode == TypePopupMode::FieldType) {
if (entry.entryKind == TypeEntry::Primitive) {
if (entry.primitiveKind != node.kind)
if (entry.primitiveKind != nodeKind)
changeNodeKind(nodeIdx, entry.primitiveKind);
} else if (entry.entryKind == TypeEntry::Composite) {
bool wasSuppressed = m_suppressRefresh;
@@ -1751,34 +1940,34 @@ void RcxController::applyTypePopupResult(TypePopupMode mode, int nodeIdx,
if (spec.isPointer) {
// Pointer modifier: e.g. "Material*" → Pointer64 + refId
if (node.kind != NodeKind::Pointer64)
if (nodeKind != NodeKind::Pointer64)
changeNodeKind(nodeIdx, NodeKind::Pointer64);
int idx = m_doc->tree.indexOfId(node.id);
int idx = m_doc->tree.indexOfId(nodeId);
if (idx >= 0 && m_doc->tree.nodes[idx].refId != entry.structId)
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangePointerRef{node.id, m_doc->tree.nodes[idx].refId, entry.structId}));
cmd::ChangePointerRef{nodeId, m_doc->tree.nodes[idx].refId, entry.structId}));
} else if (spec.arrayCount > 0) {
// Array modifier: e.g. "Material[10]" → Array + Struct element
if (node.kind != NodeKind::Array)
if (nodeKind != NodeKind::Array)
changeNodeKind(nodeIdx, NodeKind::Array);
int idx = m_doc->tree.indexOfId(node.id);
int idx = m_doc->tree.indexOfId(nodeId);
if (idx >= 0) {
auto& n = m_doc->tree.nodes[idx];
if (n.elementKind != NodeKind::Struct || n.arrayLen != spec.arrayCount)
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeArrayMeta{node.id, n.elementKind, NodeKind::Struct,
cmd::ChangeArrayMeta{nodeId, n.elementKind, NodeKind::Struct,
n.arrayLen, spec.arrayCount}));
if (n.refId != entry.structId)
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangePointerRef{node.id, n.refId, entry.structId}));
cmd::ChangePointerRef{nodeId, n.refId, entry.structId}));
}
} else {
// Plain struct: e.g. "Material" → Struct + structTypeName + refId + collapsed
if (node.kind != NodeKind::Struct)
if (nodeKind != NodeKind::Struct)
changeNodeKind(nodeIdx, NodeKind::Struct);
int idx = m_doc->tree.indexOfId(node.id);
int idx = m_doc->tree.indexOfId(nodeId);
if (idx >= 0) {
int refIdx = m_doc->tree.indexOfId(entry.structId);
QString targetName;
@@ -1789,11 +1978,11 @@ void RcxController::applyTypePopupResult(TypePopupMode mode, int nodeIdx,
QString oldTypeName = m_doc->tree.nodes[idx].structTypeName;
if (oldTypeName != targetName)
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeStructTypeName{node.id, oldTypeName, targetName}));
cmd::ChangeStructTypeName{nodeId, oldTypeName, targetName}));
// Set refId so compose can expand the referenced struct's children
if (m_doc->tree.nodes[idx].refId != entry.structId)
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangePointerRef{node.id, m_doc->tree.nodes[idx].refId, entry.structId}));
cmd::ChangePointerRef{nodeId, m_doc->tree.nodes[idx].refId, entry.structId}));
// ChangePointerRef auto-sets collapsed=true when refId != 0
}
}
@@ -1804,33 +1993,32 @@ void RcxController::applyTypePopupResult(TypePopupMode mode, int nodeIdx,
}
} else if (mode == TypePopupMode::ArrayElement) {
if (entry.entryKind == TypeEntry::Primitive) {
if (entry.primitiveKind != node.elementKind) {
if (entry.primitiveKind != elemKind) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeArrayMeta{node.id,
node.elementKind, entry.primitiveKind,
node.arrayLen, node.arrayLen}));
cmd::ChangeArrayMeta{nodeId,
elemKind, entry.primitiveKind,
arrLen, arrLen}));
}
} else if (entry.entryKind == TypeEntry::Composite) {
if (node.elementKind != NodeKind::Struct || node.refId != entry.structId) {
if (elemKind != NodeKind::Struct || nodeRefId != entry.structId) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeArrayMeta{node.id,
node.elementKind, NodeKind::Struct,
node.arrayLen, node.arrayLen}));
if (node.refId != entry.structId) {
cmd::ChangeArrayMeta{nodeId,
elemKind, NodeKind::Struct,
arrLen, arrLen}));
if (nodeRefId != entry.structId) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangePointerRef{node.id, node.refId, entry.structId}));
cmd::ChangePointerRef{nodeId, nodeRefId, entry.structId}));
}
}
}
} else if (mode == TypePopupMode::PointerTarget) {
// "void" entry → refId 0; composite entry → real structId
uint64_t realRefId = (entry.entryKind == TypeEntry::Composite) ? entry.structId : 0;
if (realRefId != node.refId) {
if (realRefId != nodeRefId) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangePointerRef{node.id, node.refId, realRefId}));
cmd::ChangePointerRef{nodeId, nodeRefId, realRefId}));
}
}
refresh();
}
void RcxController::attachViaPlugin(const QString& providerIdentifier, const QString& target) {
@@ -1854,7 +2042,10 @@ void RcxController::attachViaPlugin(const QString& providerIdentifier, const QSt
m_doc->undoStack.clear();
m_doc->provider = std::move(provider);
m_doc->dataPath.clear();
m_doc->tree.baseAddress = newBase;
if (m_doc->tree.baseAddress == 0)
m_doc->tree.baseAddress = newBase;
else
m_doc->provider->setBase(m_doc->tree.baseAddress);
resetSnapshot();
emit m_doc->documentChanged();
refresh();
@@ -1897,9 +2088,15 @@ void RcxController::pushSavedSourcesToEditors() {
// ── Auto-refresh ──
void RcxController::setRefreshInterval(int ms) {
if (m_refreshTimer)
m_refreshTimer->setInterval(qMax(1, ms));
}
void RcxController::setupAutoRefresh() {
int ms = QSettings("Reclass", "Reclass").value("refreshMs", 660).toInt();
m_refreshTimer = new QTimer(this);
m_refreshTimer->setInterval(660);
m_refreshTimer->setInterval(qMax(1, ms));
connect(m_refreshTimer, &QTimer::timeout, this, &RcxController::onRefreshTick);
m_refreshTimer->start();
@@ -1913,11 +2110,11 @@ void RcxController::setupAutoRefresh() {
void RcxController::collectPointerRanges(
uint64_t structId, uint64_t memBase,
int depth, int maxDepth,
QSet<uint64_t>& visited,
QSet<QPair<uint64_t,uint64_t>>& visited,
QVector<QPair<uint64_t,int>>& ranges) const
{
if (depth >= maxDepth) return;
uint64_t key = memBase ^ (structId * 0x9E3779B97F4A7C15ULL);
QPair<uint64_t,uint64_t> key{structId, memBase};
if (visited.contains(key)) return;
visited.insert(key);
@@ -1974,11 +2171,11 @@ void RcxController::onRefreshTick() {
ranges.append({0, extent});
if (m_snapshotProv) {
QSet<uint64_t> visited;
QSet<QPair<uint64_t,uint64_t>> visited;
uint64_t rootId = m_viewRootId;
if (rootId == 0 && !m_doc->tree.nodes.isEmpty())
rootId = m_doc->tree.nodes[0].id;
collectPointerRanges(rootId, 0, 0, 4, visited, ranges);
collectPointerRanges(rootId, 0, 0, 99, visited, ranges);
}
m_readInFlight = true;
@@ -2094,6 +2291,7 @@ void RcxController::resetSnapshot() {
m_snapshotProv.reset();
m_prevPages.clear();
m_changedOffsets.clear();
m_valueHistory.clear();
}
void RcxController::handleMarginClick(RcxEditor* editor, int margin,

View File

@@ -7,6 +7,7 @@
#include <QUndoCommand>
#include <QTimer>
#include <QFutureWatcher>
#include <QPointer>
#include <memory>
namespace rcx {
@@ -112,6 +113,7 @@ public:
RcxDocument* document() const { return m_doc; }
void setEditorFont(const QString& fontName);
void setRefreshInterval(int ms);
// MCP bridge accessors
void setSuppressRefresh(bool v) { m_suppressRefresh = v; }
@@ -120,6 +122,9 @@ public:
int activeSourceIndex() const { return m_activeSourceIdx; }
void switchSource(int idx) { switchToSavedSource(idx); }
// Test accessor
const QHash<uint64_t, ValueHistory>& valueHistory() const { return m_valueHistory; }
signals:
void nodeSelected(int nodeIdx);
void selectionChanged(int count);
@@ -138,7 +143,7 @@ private:
int m_activeSourceIdx = -1;
// ── Cached type selector popup (avoids ~350ms cold-start on first show) ──
TypeSelectorPopup* m_cachedPopup = nullptr;
QPointer<TypeSelectorPopup> m_cachedPopup;
// ── Auto-refresh state ──
using PageMap = QHash<uint64_t, QByteArray>;
@@ -147,6 +152,7 @@ private:
std::unique_ptr<SnapshotProvider> m_snapshotProv;
PageMap m_prevPages;
QSet<int64_t> m_changedOffsets;
QHash<uint64_t, ValueHistory> m_valueHistory;
uint64_t m_refreshGen = 0;
uint64_t m_readGen = 0;
bool m_readInFlight = false;
@@ -169,7 +175,7 @@ private:
void resetSnapshot();
void collectPointerRanges(uint64_t structId, uint64_t memBase,
int depth, int maxDepth,
QSet<uint64_t>& visited,
QSet<QPair<uint64_t,uint64_t>>& visited,
QVector<QPair<uint64_t,int>>& ranges) const;
};

View File

@@ -8,6 +8,7 @@
#include <QHash>
#include <QSet>
#include <cstdint>
#include <array>
#include <memory>
#include <variant>
@@ -27,21 +28,20 @@ enum class NodeKind : uint8_t {
Pointer32, Pointer64,
Vec2, Vec3, Vec4, Mat4x4,
UTF8, UTF16,
Padding,
Struct, Array
};
} // namespace rcx (temporarily close for qHash)
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
inline uint qHash(rcx::NodeKind key, uint seed = 0) { return ::qHash(static_cast<uint8_t>(key), seed); }
inline uint qHash(rcx::NodeKind key, uint seed = 0) { return qHash(static_cast<int>(key), seed); }
#endif
namespace rcx { // reopen
// ── Kind flags (replaces repeated Hex/Padding switches) ──
// ── Kind flags (replaces repeated Hex switches) ──
enum KindFlags : uint32_t {
KF_None = 0,
KF_HexPreview = 1 << 0, // Hex8..Hex64 + Padding (ASCII+hex layout)
KF_HexPreview = 1 << 0, // Hex8..Hex64 (ASCII+hex layout)
KF_Container = 1 << 1, // Struct/Array
KF_String = 1 << 2, // UTF8/UTF16
KF_Vector = 1 << 3, // Vec2/3/4
@@ -84,7 +84,6 @@ inline constexpr KindMeta kKindMeta[] = {
{NodeKind::Mat4x4, "Mat4x4", "mat4x4", 64, 4, 4, KF_None},
{NodeKind::UTF8, "UTF8", "char[]", 1, 1, 1, KF_String},
{NodeKind::UTF16, "UTF16", "wchar_t[]", 2, 1, 2, KF_String},
{NodeKind::Padding, "Padding", "pad", 1, 1, 1, KF_HexPreview},
{NodeKind::Struct, "Struct", "struct", 0, 1, 1, KF_Container},
{NodeKind::Array, "Array", "array", 0, 1, 1, KF_Container},
};
@@ -155,7 +154,6 @@ inline QStringList allTypeNamesForUI(bool stripBrackets = false) {
enum Marker : int {
M_CONT = 0,
M_PAD = 1,
M_PTR0 = 2,
M_CYCLE = 3,
M_ERR = 4,
@@ -187,9 +185,12 @@ struct Node {
int byteSize() const {
switch (kind) {
case NodeKind::UTF8: return strLen;
case NodeKind::UTF16: return strLen * 2;
case NodeKind::Padding: return qMax(1, arrayLen);
case NodeKind::Array: return arrayLen * sizeForKind(elementKind);
case NodeKind::UTF16: return qMin(strLen, INT_MAX / 2) * 2;
case NodeKind::Array: {
int elemSz = sizeForKind(elementKind);
if (elemSz <= 0) return 0;
return qMin(arrayLen, INT_MAX / elemSz) * elemSz;
}
default: return sizeForKind(kind);
}
}
@@ -221,8 +222,8 @@ struct Node {
n.classKeyword = o["classKeyword"].toString();
n.parentId = o["parentId"].toString("0").toULongLong();
n.offset = o["offset"].toInt(0);
n.arrayLen = o["arrayLen"].toInt(1);
n.strLen = o["strLen"].toInt(64);
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.refId = o["refId"].toString("0").toULongLong();
n.elementKind = kindFromString(o["elementKind"].toString("UInt8"));
@@ -405,6 +406,49 @@ struct NodeTree {
};
// ── Value History (ring buffer for heatmap) ──
struct ValueHistory {
static constexpr int kCapacity = 10;
std::array<QString, kCapacity> values;
int count = 0; // total unique values recorded
int head = 0; // next write position in ring
void record(const QString& v) {
if (count > 0) {
int last = (head + kCapacity - 1) % kCapacity;
if (values[last] == v) return; // no change
}
values[head] = v;
head = (head + 1) % kCapacity;
if (count < INT_MAX) count++;
}
int uniqueCount() const { return qMin(count, kCapacity); }
// 0=static, 1=cold(2 unique), 2=warm(3-4), 3=hot(5+)
int heatLevel() const {
if (count <= 1) return 0;
if (count == 2) return 1;
if (count <= 4) return 2;
return 3;
}
QString last() const {
if (count == 0) return {};
return values[(head + kCapacity - 1) % kCapacity];
}
// Iterate from oldest to newest (up to uniqueCount entries)
template<typename Fn>
void forEach(Fn&& fn) const {
int n = uniqueCount();
int start = (head + kCapacity - n) % kCapacity;
for (int i = 0; i < n; i++)
fn(values[(start + i) % kCapacity]);
}
};
// ── LineMeta ──
enum class LineKind : uint8_t {
@@ -439,6 +483,7 @@ struct LineMeta {
uint64_t offsetAddr = 0; // Raw absolute address (for margin toggle)
uint32_t markerMask = 0;
bool dataChanged = false; // true if any byte in this node changed since last refresh
int heatLevel = 0; // 0=static, 1=cold, 2=warm, 3=hot (from ValueHistory)
QVector<int> changedByteIndices; // Hex preview: which byte indices (0-based) changed on this line
int lineByteCount = 0; // Hex preview: actual data byte count on this line
int effectiveTypeW = 14; // Per-line type column width used for rendering
@@ -535,7 +580,7 @@ inline ColumnSpan nameSpanFor(const LineMeta& lm, int typeW = kColType, int name
int ind = kFoldCol + lm.depth * 3;
int start = ind + typeW + kSepWidth;
// Hex/Padding: ASCII preview occupies the name column (padded to nameW)
// Hex: ASCII preview occupies the name column (padded to nameW)
if (isHexPreview(lm.nodeKind))
return {start, start + nameW, true};
@@ -547,9 +592,9 @@ inline ColumnSpan valueSpanFor(const LineMeta& lm, int /*lineLength*/, int typeW
lm.lineKind == LineKind::ArrayElementSeparator) return {};
int ind = kFoldCol + lm.depth * 3;
// Hex/Padding uses nameW for ASCII column (same as regular name column)
bool isHexPad = isHexPreview(lm.nodeKind);
int valWidth = isHexPad ? 23 : kColValue;
// Hex uses nameW for ASCII column (same as regular name column)
bool isHex = isHexPreview(lm.nodeKind);
int valWidth = isHex ? 23 : kColValue;
int prefixW = typeW + nameW + 2 * kSepWidth;
@@ -567,8 +612,8 @@ inline ColumnSpan commentSpanFor(const LineMeta& lm, int lineLength, int typeW =
if (lm.lineKind == LineKind::Header || lm.lineKind == LineKind::Footer) return {};
int ind = kFoldCol + lm.depth * 3;
bool isHexPad = isHexPreview(lm.nodeKind);
int valWidth = isHexPad ? 23 : kColValue;
bool isHex = isHexPreview(lm.nodeKind);
int valWidth = isHex ? 23 : kColValue;
int prefixW = typeW + nameW + 2 * kSepWidth;
int start;

View File

@@ -5,6 +5,7 @@
#include <Qsci/qsciscintillabase.h>
#include <Qsci/qscilexercpp.h>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QFont>
#include <QColor>
#include <QKeyEvent>
@@ -15,19 +16,153 @@
#include <QMenu>
#include <QApplication>
#include <QClipboard>
#include <QLabel>
#include <QToolButton>
#include <QScreen>
#include <functional>
#include "themes/thememanager.h"
namespace rcx {
// ── Value history popup (styled like TypeSelectorPopup) ──
class ValueHistoryPopup : public QFrame {
uint64_t m_nodeId = 0;
bool m_hasButtons = false;
QStringList m_values;
QVector<QLabel*> m_labels;
std::function<void(const QString&)> m_onSet;
public:
explicit ValueHistoryPopup(QWidget* parent)
: QFrame(parent, Qt::ToolTip | Qt::FramelessWindowHint)
{
setAttribute(Qt::WA_DeleteOnClose, false);
setAttribute(Qt::WA_ShowWithoutActivating, true);
setFrameShape(QFrame::NoFrame);
setAutoFillBackground(true);
}
uint64_t nodeId() const { return m_nodeId; }
void setOnSet(std::function<void(const QString&)> fn) { m_onSet = std::move(fn); }
void populate(uint64_t nodeId, const ValueHistory& hist, const QFont& font,
bool showButtons = false) {
QStringList vals;
hist.forEach([&](const QString& v) { vals.append(v); });
if (nodeId == m_nodeId && vals == m_values
&& showButtons == m_hasButtons && isVisible())
return;
// In-place label update when structure unchanged (avoids flicker)
if (nodeId == m_nodeId && vals.size() == m_values.size()
&& vals.size() == m_labels.size()
&& showButtons == m_hasButtons && isVisible()) {
for (int i = 0; i < vals.size(); i++)
m_labels[i]->setText(vals[i]);
m_values = vals;
return;
}
m_nodeId = nodeId;
m_values = vals;
m_hasButtons = showButtons;
m_labels.clear();
delete layout();
qDeleteAll(findChildren<QWidget*>(QString(), Qt::FindDirectChildrenOnly));
const auto& theme = ThemeManager::instance().current();
QPalette pal;
pal.setColor(QPalette::Window, theme.backgroundAlt);
pal.setColor(QPalette::WindowText, theme.text);
setPalette(pal);
auto* vbox = new QVBoxLayout(this);
vbox->setContentsMargins(8, 6, 8, 6);
vbox->setSpacing(2);
auto* title = new QLabel(QStringLiteral("Previous Values"));
QFont bold = font;
bold.setBold(true);
title->setFont(bold);
title->setStyleSheet(QStringLiteral("color: %1;").arg(theme.text.name()));
vbox->addWidget(title);
auto* sep = new QFrame;
sep->setFrameShape(QFrame::HLine);
sep->setFrameShadow(QFrame::Plain);
sep->setFixedHeight(1);
QPalette sp; sp.setColor(QPalette::WindowText, theme.border);
sep->setPalette(sp);
vbox->addWidget(sep);
for (const QString& v : vals) {
auto* row = new QHBoxLayout;
row->setContentsMargins(0, 1, 0, 1);
row->setSpacing(8);
auto* label = new QLabel(v);
label->setFont(font);
label->setStyleSheet(QStringLiteral("color: %1;").arg(theme.syntaxNumber.name()));
row->addWidget(label, 1);
m_labels.append(label);
if (showButtons) {
auto* setBtn = new QToolButton;
setBtn->setText(QStringLiteral("Set"));
setBtn->setAutoRaise(true);
setBtn->setCursor(Qt::PointingHandCursor);
setBtn->setFont(font);
setBtn->setStyleSheet(QStringLiteral(
"QToolButton { color: %1; border: none; padding: 1px 4px; }"
"QToolButton:hover { color: %2; background: %3; }")
.arg(theme.textDim.name(), theme.text.name(), theme.hover.name()));
QString val = v;
QObject::connect(setBtn, &QToolButton::clicked, [this, val]() {
if (m_onSet) m_onSet(val);
});
row->addWidget(setBtn);
}
vbox->addLayout(row);
}
adjustSize();
}
void showAt(const QPoint& globalPos) {
if (isVisible()) return;
QSize sz = sizeHint();
QRect screen = QApplication::screenAt(globalPos)
? QApplication::screenAt(globalPos)->availableGeometry()
: QRect(0, 0, 1920, 1080);
int x = qMin(globalPos.x(), screen.right() - sz.width());
int y = globalPos.y();
if (y + sz.height() > screen.bottom())
y = globalPos.y() - sz.height() - 4;
move(x, y);
show();
}
void dismiss() {
if (isVisible()) hide();
m_nodeId = 0;
m_values.clear();
m_labels.clear();
}
};
static constexpr int IND_EDITABLE = 8;
static constexpr int IND_HEX_DIM = 9;
static constexpr int IND_BASE_ADDR = 10; // Default text color override for command row address
static constexpr int IND_HOVER_SPAN = 11; // Blue text on hover (link-like)
static constexpr int IND_CMD_PILL = 12; // Rounded chip behind command row spans
static constexpr int IND_DATA_CHANGED = 13; // Amber text for changed data values
static constexpr int IND_HEAT_COLD = 13; // Heatmap level 1 (changed once)
static constexpr int IND_CLASS_NAME = 14; // Teal text for root class name
static constexpr int IND_HINT_GREEN = 15; // Green text for hint/comment text
static constexpr int IND_LOCAL_OFF = 16; // Dim text for inline local offset in relative mode
static constexpr int IND_HEAT_WARM = 17; // Heatmap level 2 (moderate changes)
static constexpr int IND_HEAT_HOT = 18; // Heatmap level 3 (frequent changes)
static QString g_fontName = "JetBrains Mono";
@@ -69,6 +204,27 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
m_sci->setContextMenuPolicy(Qt::CustomContextMenu);
connect(m_sci, &QWidget::customContextMenuRequested,
this, [this](const QPoint& pos) {
// Right-click on offset margin → show margin mode menu
int margin0Width = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_GETMARGINWIDTHN, 0UL, 0L);
if (pos.x() < margin0Width) {
QMenu menu;
auto* actRel = menu.addAction("Relative Offsets (+0x)");
auto* actAbs = menu.addAction("Absolute Addresses");
actRel->setCheckable(true);
actAbs->setCheckable(true);
actRel->setChecked(m_relativeOffsets);
actAbs->setChecked(!m_relativeOffsets);
QAction* chosen = menu.exec(m_sci->mapToGlobal(pos));
if (chosen == actRel && !m_relativeOffsets) {
m_relativeOffsets = true;
reformatMargins();
} else if (chosen == actAbs && m_relativeOffsets) {
m_relativeOffsets = false;
reformatMargins();
}
return;
}
int line = m_sci->lineAt(pos);
int nodeIdx = -1;
int subLine = 0;
@@ -141,7 +297,7 @@ void RcxEditor::setupScintilla() {
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_EDITABLE, 5 /*INDIC_HIDDEN*/);
// Hex/Padding node dim indicator — overrides text color
// Hex node dim indicator — overrides text color
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_HEX_DIM, 17 /*INDIC_TEXTFORE*/);
@@ -161,9 +317,13 @@ void RcxEditor::setupScintilla() {
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETUNDER,
IND_CMD_PILL, (long)1);
// Data-changed indicator
// Heatmap indicators (cold / warm / hot)
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_DATA_CHANGED, 17 /*INDIC_TEXTFORE*/);
IND_HEAT_COLD, 17 /*INDIC_TEXTFORE*/);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_HEAT_WARM, 17 /*INDIC_TEXTFORE*/);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_HEAT_HOT, 17 /*INDIC_TEXTFORE*/);
// Root class name — type color
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
@@ -241,9 +401,6 @@ void RcxEditor::setupMarkers() {
// M_CONT (0): continuation line (metadata only, no visual)
m_sci->markerDefine(QsciScintilla::Invisible, M_CONT);
// M_PAD (1): padding line (metadata only, no visual)
m_sci->markerDefine(QsciScintilla::Invisible, M_PAD);
// M_PTR0 (2): right triangle
m_sci->markerDefine(QsciScintilla::RightTriangle, M_PTR0);
@@ -303,8 +460,13 @@ void RcxEditor::applyTheme(const Theme& theme) {
IND_HOVER_SPAN, theme.indHoverSpan);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_CMD_PILL, theme.indCmdPill);
// Heatmap colors
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_DATA_CHANGED, theme.indDataChanged);
IND_HEAT_COLD, theme.indHeatCold);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_HEAT_WARM, theme.indHeatWarm);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_HEAT_HOT, theme.indHeatHot);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_CLASS_NAME, theme.syntaxType);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
@@ -366,6 +528,9 @@ void RcxEditor::applyDocument(const ComposeResult& result) {
if (m_editState.active)
endInlineEdit();
// Guard: suppress popup dismiss during setText() which fires synthetic Leave events
m_applyingDocument = true;
// Save hover state — setText() triggers viewport Leave events that would clear it
uint64_t savedHoverId = m_hoveredNodeId;
int savedHoverLine = m_hoveredLine;
@@ -404,7 +569,7 @@ void RcxEditor::applyDocument(const ComposeResult& result) {
applyMarkers(result.meta);
applyFoldLevels(result.meta);
applyHexDimming(result.meta);
applyDataChangedHighlight(result.meta);
applyHeatmapHighlight(result.meta);
applyCommandRowPills();
// Reset hint line - applySelectionOverlay will repaint indicators
@@ -414,6 +579,13 @@ void RcxEditor::applyDocument(const ComposeResult& result) {
m_hoveredNodeId = savedHoverId;
m_hoveredLine = savedHoverLine;
m_hoverInside = savedHoverInside;
m_applyingDocument = false;
// Re-apply hover markers (setText() clears all Scintilla markers).
// applyHoverCursor() is NOT called here — it evaluates hitTest() against
// composed text that updateCommandRow() will overwrite. The correct call
// happens via applySelectionOverlays() after all text is finalized.
applyHoverHighlight();
}
void RcxEditor::applyMarginText(const QVector<LineMeta>& meta) {
@@ -768,35 +940,52 @@ static QString getLineText(QsciScintilla* sci, int line) {
return text;
}
void RcxEditor::applyDataChangedHighlight(const QVector<LineMeta>& meta) {
for (int i = 0; i < meta.size(); i++) {
if (!meta[i].dataChanged) continue;
if (isSyntheticLine(meta[i])) continue;
void RcxEditor::applyHeatmapHighlight(const QVector<LineMeta>& meta) {
static constexpr int heatIndicators[] = { IND_HEAT_COLD, IND_HEAT_WARM, IND_HEAT_HOT };
for (int i = 0; i < meta.size(); i++) {
const LineMeta& lm = meta[i];
if (isSyntheticLine(lm)) continue;
int heat = lm.heatLevel;
int typeW = lm.effectiveTypeW;
int nameW = lm.effectiveNameW;
if (isHexPreview(lm.nodeKind) && !lm.changedByteIndices.isEmpty()) {
// Per-byte highlighting in ASCII + hex areas
if (heat <= 0) continue;
// Pick the right indicator for this heat level (1→cold, 2→warm, 3→hot)
int activeInd = heatIndicators[qBound(0, heat - 1, 2)];
// For hex preview nodes: per-byte heat coloring on changed bytes
if (isHexPreview(lm.nodeKind) && lm.dataChanged && !lm.changedByteIndices.isEmpty()) {
int ind = kFoldCol + lm.depth * 3;
int asciiStart = ind + typeW + kSepWidth;
// ASCII column is padded to nameW (aligned with value column)
int hexStart = asciiStart + nameW + kSepWidth;
for (int byteIdx : lm.changedByteIndices) {
// Highlight in ASCII area (1 char per byte)
fillIndicatorCols(IND_DATA_CHANGED, i, asciiStart + byteIdx, asciiStart + byteIdx + 1);
// Highlight in hex area (2 hex chars per byte at position byteIdx*3)
fillIndicatorCols(activeInd, i, asciiStart + byteIdx, asciiStart + byteIdx + 1);
int hexCol = hexStart + byteIdx * 3;
fillIndicatorCols(IND_DATA_CHANGED, i, hexCol, hexCol + 2);
fillIndicatorCols(activeInd, i, hexCol, hexCol + 2);
}
} else {
// Non-hex nodes: highlight entire value span
QString lineText = getLineText(m_sci, i);
ColumnSpan vs = valueSpan(lm, lineText.size(), typeW, nameW);
if (vs.valid)
fillIndicatorCols(IND_DATA_CHANGED, i, vs.start, vs.end);
// Clear the other two heat indicators on this line
for (int hi : heatIndicators) {
if (hi != activeInd)
clearIndicatorLine(hi, i);
}
continue;
}
// Non-hex nodes: apply heat-level indicator to value span
QString lineText = getLineText(m_sci, i);
ColumnSpan vs = valueSpan(lm, lineText.size(), typeW, nameW);
if (!vs.valid) continue;
fillIndicatorCols(activeInd, i, vs.start, vs.end);
// Clear the other two heat indicators on this span to avoid overlap
for (int hi : heatIndicators) {
if (hi != activeInd)
clearIndicatorLine(hi, i);
}
}
}
@@ -1038,9 +1227,6 @@ bool RcxEditor::resolvedSpanFor(int line, EditTarget t,
if (lm->nodeIdx < 0) return false;
// Padding: reject value editing (hex bytes are display-only)
if (t == EditTarget::Value && lm->nodeKind == NodeKind::Padding)
return false;
// Hex nodes: only Type is editable (ASCII preview + hex bytes are display-only)
if ((t == EditTarget::Name || t == EditTarget::Value) && isHexNode(lm->nodeKind))
return false;
@@ -1221,9 +1407,6 @@ static bool hitTestTarget(QsciScintilla* sci,
}
return false;
}
// Padding nodes: hex bytes are display-only, not editable
if (outTarget == EditTarget::Value && lm.nodeKind == NodeKind::Padding)
return false;
// Hex nodes: only Type is editable (ASCII preview + hex bytes are display-only)
if ((outTarget == EditTarget::Name || outTarget == EditTarget::Value) && isHexNode(lm.nodeKind))
return false;
@@ -1330,7 +1513,15 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
// Single-click on editable token of already-selected node → edit
int tLine, tCol; EditTarget t;
if (hitTestTarget(m_sci, m_meta, me->pos(), tLine, tCol, t)) {
if (alreadySelected && plain) {
// Type/ArrayElementType/PointerTarget open a dismissible popup
// (not inline text edit), so allow on first click without
// requiring the node to be pre-selected.
bool isPopupTarget = (t == EditTarget::Type
|| t == EditTarget::ArrayElementType
|| t == EditTarget::PointerTarget);
if ((alreadySelected || isPopupTarget) && plain) {
if (!alreadySelected)
emit nodeClicked(h.line, h.nodeId, me->modifiers());
m_pendingClickNodeId = 0;
return beginInlineEdit(t, tLine, tCol);
}
@@ -1402,7 +1593,11 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
auto* me = static_cast<QMouseEvent*>(event);
int margin0Width = (int)m_sci->SendScintilla(
QsciScintillaBase::SCI_GETMARGINWIDTHN, 0UL, 0L);
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
if ((int)me->position().x() < margin0Width) {
#else
if ((int)me->pos().x() < margin0Width) {
#endif
m_relativeOffsets = !m_relativeOffsets;
reformatMargins();
return true;
@@ -1451,6 +1646,10 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
}
// Track mouse position for cursor updates (both edit and non-edit mode)
if (obj == m_sci->viewport()) {
// Ignore synthetic Leave from setText() during document refresh
if (m_applyingDocument && event->type() == QEvent::Leave)
return true;
if (event->type() == QEvent::MouseMove) {
m_lastHoverPos = static_cast<QMouseEvent*>(event)->pos();
m_hoverInside = true;
@@ -1656,6 +1855,9 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
m_hoveredNodeId = 0;
m_hoveredLine = -1;
applyHoverHighlight();
// Dismiss hover popup so it gets recreated with Set buttons once edit starts
if (m_historyPopup)
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
// Clear editable-token color hints (de-emphasize non-active tokens)
clearIndicatorLine(IND_EDITABLE, m_hintLine);
m_hintLine = -1;
@@ -1673,9 +1875,6 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
(target == EditTarget::BaseAddress || target == EditTarget::Source
|| target == EditTarget::RootClassType || target == EditTarget::RootClassName)))
return false;
// Padding: reject value editing (display-only hex bytes)
if (target == EditTarget::Value && lm->nodeKind == NodeKind::Padding)
return false;
// Hex nodes: only Type is editable (ASCII preview + hex bytes are display-only)
if ((target == EditTarget::Name || target == EditTarget::Value) && isHexNode(lm->nodeKind))
return false;
@@ -1840,6 +2039,9 @@ bool RcxEditor::beginInlineEdit(EditTarget target, int line, int col) {
m_sci->viewport()->setCursor(Qt::ArrowCursor);
});
}
// Refresh hover cursor so value history popup appears with Set buttons immediately
if (target == EditTarget::Value)
QTimer::singleShot(0, this, &RcxEditor::applyHoverCursor);
return true;
}
@@ -2192,20 +2394,60 @@ void RcxEditor::applyHoverCursor() {
if (m_editState.active) {
if (m_sci->isListActive()) {
m_sci->viewport()->setCursor(Qt::ArrowCursor);
return;
}
auto h = hitTest(m_lastHoverPos);
if (h.line == m_editState.line &&
h.col >= m_editState.spanStart && h.col <= editEndCol()) {
m_sci->viewport()->setCursor(Qt::IBeamCursor);
} else {
m_sci->viewport()->setCursor(Qt::ArrowCursor);
auto h = hitTest(m_lastHoverPos);
if (h.line == m_editState.line &&
h.col >= m_editState.spanStart && h.col <= editEndCol()) {
m_sci->viewport()->setCursor(Qt::IBeamCursor);
} else {
m_sci->viewport()->setCursor(Qt::ArrowCursor);
}
}
// Value history popup — only during inline value editing on a heated node
{
bool showPopup = false;
if (m_valueHistory && m_editState.target == EditTarget::Value
&& m_editState.line >= 0 && m_editState.line < m_meta.size()) {
const LineMeta& lm = m_meta[m_editState.line];
if (lm.heatLevel > 0 && lm.nodeId != 0) {
auto it = m_valueHistory->find(lm.nodeId);
if (it != m_valueHistory->end() && it->uniqueCount() > 1) {
if (!m_historyPopup)
m_historyPopup = new ValueHistoryPopup(this);
auto* popup = static_cast<ValueHistoryPopup*>(m_historyPopup);
popup->setOnSet([this](const QString& val) {
if (!m_editState.active) return;
long endPos = posFromCol(m_sci, m_editState.line, editEndCol());
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSEL,
m_editState.posStart, endPos);
QByteArray utf8 = val.toUtf8();
m_sci->SendScintilla(QsciScintillaBase::SCI_REPLACESEL,
(uintptr_t)0, utf8.constData());
});
popup->populate(lm.nodeId, *it, editorFont(), true);
int px = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_POINTXFROMPOSITION,
(unsigned long)0, m_editState.posStart);
int py = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_POINTYFROMPOSITION,
(unsigned long)0, m_editState.posStart);
int lh = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT,
(unsigned long)m_editState.line);
QPoint anchor = m_sci->viewport()->mapToGlobal(QPoint(px, py + lh));
popup->showAt(anchor);
showPopup = true;
}
}
}
if (!showPopup && m_historyPopup && m_historyPopup->isVisible())
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
}
return;
}
// Mouse left viewport - set Arrow
// Mouse left viewport - set Arrow, dismiss history popup
// (but not during applyDocument — the Leave is synthetic from setText)
if (!m_hoverInside) {
if (m_historyPopup && !m_applyingDocument)
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
m_sci->viewport()->setCursor(Qt::ArrowCursor);
return;
}
@@ -2294,6 +2536,41 @@ void RcxEditor::applyHoverCursor() {
m_hoverSpanLines.append(h.line);
}
// Value history popup on hover (read-only, no buttons)
{
bool showPopup = false;
if (m_valueHistory && h.line >= 0 && h.line < m_meta.size()) {
const LineMeta& lm = m_meta[h.line];
if (lm.heatLevel > 0 && lm.nodeId != 0) {
auto it = m_valueHistory->find(lm.nodeId);
if (it != m_valueHistory->end() && it->uniqueCount() > 1) {
QString lineText = getLineText(m_sci, h.line);
ColumnSpan vs = valueSpan(lm, lineText.size(), lm.effectiveTypeW, lm.effectiveNameW);
if (vs.valid && h.col >= vs.start && h.col < vs.end) {
if (!m_historyPopup)
m_historyPopup = new ValueHistoryPopup(this);
auto* popup = static_cast<ValueHistoryPopup*>(m_historyPopup);
popup->populate(lm.nodeId, *it, editorFont(), false);
long linePos = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE,
(unsigned long)h.line);
long byteOff = lineText.left(vs.start).toUtf8().size();
int px = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_POINTXFROMPOSITION,
(unsigned long)0, linePos + byteOff);
int py = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_POINTYFROMPOSITION,
(unsigned long)0, linePos);
int lh = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT,
(unsigned long)h.line);
QPoint anchor = m_sci->viewport()->mapToGlobal(QPoint(px, py + lh));
popup->showAt(anchor);
showPopup = true;
}
}
}
}
if (!showPopup && m_historyPopup && m_historyPopup->isVisible())
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
}
// Determine cursor shape based on interaction type
Qt::CursorShape desired = Qt::ArrowCursor;

View File

@@ -54,6 +54,7 @@ public:
// Custom type names (struct types from the tree) shown in type picker + lexer GlobalClass coloring
QString textWithMargins() const;
void setCustomTypeNames(const QStringList& names);
void setValueHistoryRef(const QHash<uint64_t, ValueHistory>* ref) { m_valueHistory = ref; }
// Saved sources for quick-switch in source picker
void setSavedSources(const QVector<SavedSourceDisplay>& sources) { m_savedSourceDisplay = sources; }
@@ -78,7 +79,7 @@ private:
LayoutInfo m_layout; // cached from ComposeResult
// ── Toggle: absolute vs relative offset margin
bool m_relativeOffsets = false;
bool m_relativeOffsets = true;
int m_marginStyleBase = -1;
int m_hintLine = -1;
@@ -129,7 +130,12 @@ private:
// ── Saved sources for quick-switch ──
QVector<SavedSourceDisplay> m_savedSourceDisplay;
// ── Value history ref (owned by controller) ──
const QHash<uint64_t, ValueHistory>* m_valueHistory = nullptr;
QWidget* m_historyPopup = nullptr; // ValueHistoryPopup (file-local class in editor.cpp)
// ── Reentrancy guards ──
bool m_applyingDocument = false;
bool m_clampingSelection = false;
bool m_updatingComment = false;
@@ -145,7 +151,7 @@ private:
void applyMarkers(const QVector<LineMeta>& meta);
void applyFoldLevels(const QVector<LineMeta>& meta);
void applyHexDimming(const QVector<LineMeta>& meta);
void applyDataChangedHighlight(const QVector<LineMeta>& meta);
void applyHeatmapHighlight(const QVector<LineMeta>& meta);
void applyBaseAddressColoring(const QVector<LineMeta>& meta);
void applyCommandRowPills();

View File

@@ -1,344 +0,0 @@
{
"baseAddress": "400000",
"nextId": "29",
"nodes": [
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "1",
"kind": "Struct",
"name": "aBall",
"offset": 0,
"parentId": "0",
"refId": "0",
"strLen": 64,
"structTypeName": "ball"
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "2",
"kind": "Hex64",
"name": "field_00",
"offset": 0,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "3",
"kind": "Hex64",
"name": "field_08",
"offset": 8,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "4",
"kind": "Vec4",
"name": "position",
"offset": 16,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "5",
"kind": "Vec3",
"name": "velocity",
"offset": 32,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "6",
"kind": "Hex32",
"name": "field_2C",
"offset": 44,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "7",
"kind": "Float",
"name": "speed",
"offset": 48,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "8",
"kind": "UInt32",
"name": "color",
"offset": 52,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "9",
"kind": "Float",
"name": "radius",
"offset": 56,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "10",
"kind": "Hex32",
"name": "field_3C",
"offset": 60,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "11",
"kind": "Float",
"name": "mass",
"offset": 64,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "12",
"kind": "Hex64",
"name": "field_44",
"offset": 68,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "13",
"kind": "Bool",
"name": "bouncy",
"offset": 76,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "14",
"kind": "Hex8",
"name": "field_4D",
"offset": 77,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "15",
"kind": "Hex16",
"name": "field_4E",
"offset": 78,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "16",
"kind": "UInt32",
"name": "color",
"offset": 80,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "17",
"kind": "Hex32",
"name": "field_54",
"offset": 84,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "18",
"kind": "Hex64",
"name": "field_58",
"offset": 88,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "19",
"kind": "Hex64",
"name": "field_60",
"offset": 96,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "20",
"kind": "Struct",
"name": "aPhysics",
"offset": 0,
"parentId": "0",
"refId": "0",
"strLen": 64,
"structTypeName": "Physics"
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "21",
"kind": "Hex64",
"name": "field_00",
"offset": 0,
"parentId": "20",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "22",
"kind": "Hex64",
"name": "field_08",
"offset": 8,
"parentId": "20",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "23",
"kind": "Hex64",
"name": "field_10",
"offset": 16,
"parentId": "20",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "24",
"kind": "Hex64",
"name": "field_18",
"offset": 24,
"parentId": "20",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "25",
"kind": "Hex64",
"name": "field_20",
"offset": 32,
"parentId": "20",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": true,
"elementKind": "UInt8",
"id": "26",
"kind": "Pointer64",
"name": "physics",
"offset": 104,
"parentId": "1",
"refId": "20",
"strLen": 64
},
{
"arrayLen": 4,
"collapsed": false,
"elementKind": "Float",
"id": "27",
"kind": "Array",
"name": "scores",
"offset": 112,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 2,
"collapsed": false,
"elementKind": "Struct",
"id": "28",
"kind": "Array",
"name": "materials",
"offset": 128,
"parentId": "1",
"refId": "20",
"strLen": 64
}
]
}

204
src/export_reclass_xml.cpp Normal file
View File

@@ -0,0 +1,204 @@
#include "export_reclass_xml.h"
#include <QFile>
#include <QXmlStreamWriter>
#include <QHash>
#include <QVector>
#include <algorithm>
namespace rcx {
// Reverse type map: NodeKind -> ReClassEx V2016 XML Type integer
static int xmlTypeForKind(NodeKind kind) {
switch (kind) {
case NodeKind::Struct: return 1; // ClassInstance
case NodeKind::Hex32: return 4;
case NodeKind::Hex64: return 5;
case NodeKind::Hex16: return 6;
case NodeKind::Hex8: return 7;
case NodeKind::Pointer64: return 8; // ClassPointer
case NodeKind::Pointer32: return 8;
case NodeKind::Int64: return 9;
case NodeKind::Int32: return 10;
case NodeKind::Int16: return 11;
case NodeKind::Int8: return 12;
case NodeKind::Float: return 13;
case NodeKind::Double: return 14;
case NodeKind::UInt32: return 15;
case NodeKind::UInt16: return 16;
case NodeKind::UInt8: return 17;
case NodeKind::UInt64: return 32;
case NodeKind::UTF8: return 18;
case NodeKind::UTF16: return 19;
case NodeKind::Bool: return 17; // No native bool in ReClass, map to UInt8
case NodeKind::Vec2: return 22;
case NodeKind::Vec3: return 23;
case NodeKind::Vec4: return 24;
case NodeKind::Mat4x4: return 25;
case NodeKind::Array: return 27; // ClassInstanceArray
}
return 7; // fallback to Hex8
}
static int nodeSizeForExport(const Node& node) {
switch (node.kind) {
case NodeKind::UTF8: return node.strLen;
case NodeKind::UTF16: return node.strLen * 2;
case NodeKind::Array: {
int elemSz = sizeForKind(node.elementKind);
return node.arrayLen * (elemSz > 0 ? elemSz : 0);
}
default: return sizeForKind(node.kind);
}
}
// Resolve a struct type name from a node ID
static QString resolveStructName(const NodeTree& tree, uint64_t refId) {
int idx = tree.indexOfId(refId);
if (idx < 0) return {};
const Node& ref = tree.nodes[idx];
if (!ref.structTypeName.isEmpty()) return ref.structTypeName;
return ref.name;
}
bool exportReclassXml(const NodeTree& tree, const QString& filePath, QString* errorMsg) {
if (tree.nodes.isEmpty()) {
if (errorMsg) *errorMsg = QStringLiteral("No nodes to export");
return false;
}
QFile file(filePath);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
if (errorMsg) *errorMsg = QStringLiteral("Cannot open file for writing: ") + filePath;
return false;
}
// Build child map
QHash<uint64_t, QVector<int>> childMap;
for (int i = 0; i < tree.nodes.size(); i++)
childMap[tree.nodes[i].parentId].append(i);
QXmlStreamWriter xml(&file);
xml.setAutoFormatting(true);
xml.setAutoFormattingIndent(4);
xml.writeStartDocument();
xml.writeStartElement(QStringLiteral("ReClass"));
xml.writeComment(QStringLiteral("ReClassEx"));
// Get root structs
QVector<int> roots = childMap.value(0);
std::sort(roots.begin(), roots.end(), [&](int a, int b) {
return tree.nodes[a].offset < tree.nodes[b].offset;
});
int classCount = 0;
for (int ri : roots) {
const Node& root = tree.nodes[ri];
if (root.kind != NodeKind::Struct) continue;
xml.writeStartElement(QStringLiteral("Class"));
xml.writeAttribute(QStringLiteral("Name"), root.name.isEmpty() ? root.structTypeName : root.name);
xml.writeAttribute(QStringLiteral("Type"), QStringLiteral("28"));
xml.writeAttribute(QStringLiteral("Comment"), QString());
xml.writeAttribute(QStringLiteral("Offset"), QStringLiteral("0"));
xml.writeAttribute(QStringLiteral("strOffset"), QStringLiteral("0"));
xml.writeAttribute(QStringLiteral("Code"), QString());
// Get children sorted by offset
QVector<int> children = childMap.value(root.id);
std::sort(children.begin(), children.end(), [&](int a, int b) {
return tree.nodes[a].offset < tree.nodes[b].offset;
});
int i = 0;
while (i < children.size()) {
const Node& child = tree.nodes[children[i]];
// Collapse consecutive hex nodes into a single Custom node (Type=21)
if (isHexNode(child.kind)) {
int runStart = child.offset;
int runEnd = child.offset + child.byteSize();
int j = i + 1;
while (j < children.size()) {
const Node& next = tree.nodes[children[j]];
if (!isHexNode(next.kind)) break;
if (next.offset < runEnd) break; // overlap
runEnd = next.offset + next.byteSize();
j++;
}
int totalSize = runEnd - runStart;
xml.writeStartElement(QStringLiteral("Node"));
// Use first hex node's name if it's a single node, otherwise generate
QString hexName = (j - i == 1 && !child.name.isEmpty()) ? child.name : QString();
xml.writeAttribute(QStringLiteral("Name"), hexName);
xml.writeAttribute(QStringLiteral("Type"), QStringLiteral("21")); // Custom
xml.writeAttribute(QStringLiteral("Size"), QString::number(totalSize));
xml.writeAttribute(QStringLiteral("bHidden"), QStringLiteral("false"));
xml.writeAttribute(QStringLiteral("Comment"), QString());
xml.writeEndElement(); // Node
i = j;
continue;
}
xml.writeStartElement(QStringLiteral("Node"));
xml.writeAttribute(QStringLiteral("Name"), child.name);
xml.writeAttribute(QStringLiteral("Type"), QString::number(xmlTypeForKind(child.kind)));
xml.writeAttribute(QStringLiteral("Size"), QString::number(nodeSizeForExport(child)));
xml.writeAttribute(QStringLiteral("bHidden"), QStringLiteral("false"));
xml.writeAttribute(QStringLiteral("Comment"), QString());
// Pointer with target
if ((child.kind == NodeKind::Pointer64 || child.kind == NodeKind::Pointer32) && child.refId != 0) {
QString target = resolveStructName(tree, child.refId);
if (!target.isEmpty())
xml.writeAttribute(QStringLiteral("Pointer"), target);
}
// Embedded struct instance
if (child.kind == NodeKind::Struct) {
QString instName = child.structTypeName.isEmpty() ? child.name : child.structTypeName;
xml.writeAttribute(QStringLiteral("Instance"), instName);
}
// Array: Total attribute and child <Array> element
if (child.kind == NodeKind::Array) {
xml.writeAttribute(QStringLiteral("Total"), QString::number(child.arrayLen));
// Resolve element type name
QString elemName;
if (child.elementKind == NodeKind::Struct && !child.structTypeName.isEmpty()) {
elemName = child.structTypeName;
} else if (child.refId != 0) {
elemName = resolveStructName(tree, child.refId);
}
if (elemName.isEmpty())
elemName = kindToString(child.elementKind);
xml.writeStartElement(QStringLiteral("Array"));
xml.writeAttribute(QStringLiteral("Name"), elemName);
xml.writeAttribute(QStringLiteral("Total"), QString::number(child.arrayLen));
xml.writeEndElement(); // Array
}
xml.writeEndElement(); // Node
i++;
}
xml.writeEndElement(); // Class
classCount++;
}
xml.writeEndElement(); // ReClass
xml.writeEndDocument();
file.close();
if (classCount == 0) {
if (errorMsg) *errorMsg = QStringLiteral("No struct classes found to export");
return false;
}
return true;
}
} // namespace rcx

10
src/export_reclass_xml.h Normal file
View File

@@ -0,0 +1,10 @@
#pragma once
#include "core.h"
namespace rcx {
// Export a NodeTree to ReClass .NET / ReClassEx compatible XML format.
// Returns true on success; populates errorMsg on failure if non-null.
bool exportReclassXml(const NodeTree& tree, const QString& filePath, QString* errorMsg = nullptr);
} // namespace rcx

View File

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

View File

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

388
src/import_reclass_xml.cpp Normal file
View File

@@ -0,0 +1,388 @@
#include "import_reclass_xml.h"
#include <QFile>
#include <QXmlStreamReader>
#include <QHash>
#include <QVector>
#include <QDebug>
namespace rcx {
// ── Version-specific type maps ──
// Maps XML Type attribute (integer) → NodeKind.
// Entries with no rcx equivalent use Hex8 as fallback.
enum class XmlVersion { V2013, V2016 };
// 2016 / ReClassEx / MemeClsEx type map (35 entries, index = XML Type value)
static const struct { int xmlType; NodeKind kind; } kTypeMap2016[] = {
// 0: null (unused)
{ 1, NodeKind::Struct }, // ClassInstance
// 2,3: null
{ 4, NodeKind::Hex32 },
{ 5, NodeKind::Hex64 },
{ 6, NodeKind::Hex16 },
{ 7, NodeKind::Hex8 },
{ 8, NodeKind::Pointer64 }, // ClassPointer
{ 9, NodeKind::Int64 },
{ 10, NodeKind::Int32 },
{ 11, NodeKind::Int16 },
{ 12, NodeKind::Int8 },
{ 13, NodeKind::Float },
{ 14, NodeKind::Double },
{ 15, NodeKind::UInt32 },
{ 16, NodeKind::UInt16 },
{ 17, NodeKind::UInt8 },
{ 18, NodeKind::UTF8 }, // UTF8Text
{ 19, NodeKind::UTF16 }, // UTF16Text
{ 20, NodeKind::Pointer64 }, // FunctionPtr
{ 21, NodeKind::Hex8 }, // Custom (expanded by Size)
{ 22, NodeKind::Vec2 },
{ 23, NodeKind::Vec3 },
{ 24, NodeKind::Vec4 },
{ 25, NodeKind::Mat4x4 },
{ 26, NodeKind::Pointer64 }, // VTable
{ 27, NodeKind::Array }, // ClassInstanceArray
// 28: null (used for Class elements, not nodes)
{ 29, NodeKind::Pointer64 }, // UTF8TextPtr
{ 30, NodeKind::Pointer64 }, // UTF16TextPtr
// 31: BitField → UInt8 fallback
{ 31, NodeKind::UInt8 },
{ 32, NodeKind::UInt64 },
{ 33, NodeKind::Pointer64 }, // Function
};
// 2013 / ReClass 2011 type map (31 entries)
static const struct { int xmlType; NodeKind kind; } kTypeMap2013[] = {
{ 1, NodeKind::Struct }, // ClassInstance
{ 4, NodeKind::Hex32 },
{ 5, NodeKind::Hex16 },
{ 6, NodeKind::Hex8 },
{ 7, NodeKind::Pointer64 }, // ClassPointer
{ 8, NodeKind::Int32 },
{ 9, NodeKind::Int16 },
{ 10, NodeKind::Int8 },
{ 11, NodeKind::Float },
{ 12, NodeKind::UInt32 },
{ 13, NodeKind::UInt16 },
{ 14, NodeKind::UInt8 },
{ 15, NodeKind::UTF8 }, // UTF8Text
{ 16, NodeKind::Pointer64 }, // FunctionPtr
{ 17, NodeKind::Hex8 }, // Custom
{ 18, NodeKind::Vec2 },
{ 19, NodeKind::Vec3 },
{ 20, NodeKind::Vec4 },
{ 21, NodeKind::Mat4x4 },
{ 22, NodeKind::Pointer64 }, // VTable
{ 23, NodeKind::Array }, // ClassInstanceArray
{ 27, NodeKind::Int64 },
{ 28, NodeKind::Double },
{ 29, NodeKind::UTF16 }, // UTF16Text
{ 30, NodeKind::Array }, // ClassPointerArray
};
static NodeKind lookupKind(int xmlType, XmlVersion ver) {
if (ver == XmlVersion::V2016) {
for (const auto& e : kTypeMap2016)
if (e.xmlType == xmlType) return e.kind;
} else {
for (const auto& e : kTypeMap2013)
if (e.xmlType == xmlType) return e.kind;
}
return NodeKind::Hex8; // fallback
}
// Is this XML type a pointer-like type that uses the "Pointer" attribute?
static bool isPointerType(int xmlType, XmlVersion ver) {
if (ver == XmlVersion::V2016)
return xmlType == 8 || xmlType == 20 || xmlType == 26 || xmlType == 29 || xmlType == 30 || xmlType == 33;
else
return xmlType == 7 || xmlType == 16 || xmlType == 22;
}
// Is this XML type a ClassInstance (embedded struct)?
static bool isClassInstanceType(int xmlType, XmlVersion ver) {
if (ver == XmlVersion::V2016) return xmlType == 1;
else return xmlType == 1;
}
// Is this XML type a ClassInstanceArray?
static bool isClassInstanceArrayType(int xmlType, XmlVersion ver) {
if (ver == XmlVersion::V2016) return xmlType == 27;
else return xmlType == 23 || xmlType == 30;
}
// Is this XML type a text node?
static bool isTextType(int xmlType, XmlVersion ver) {
if (ver == XmlVersion::V2016) return xmlType == 18 || xmlType == 19;
else return xmlType == 15 || xmlType == 29;
}
// Is this XML type a UTF16 text node?
static bool isUtf16TextType(int xmlType, XmlVersion ver) {
if (ver == XmlVersion::V2016) return xmlType == 19;
else return xmlType == 29;
}
// Is this XML type a Custom node (expanded to hex)?
static bool isCustomType(int xmlType, XmlVersion ver) {
if (ver == XmlVersion::V2016) return xmlType == 21;
else return xmlType == 17;
}
// Deferred pointer resolution entry
struct PendingRef {
uint64_t nodeId;
QString className;
};
NodeTree importReclassXml(const QString& filePath, QString* errorMsg) {
qDebug() << "[ImportXML] Opening file:" << filePath;
QFile file(filePath);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
qDebug() << "[ImportXML] ERROR: Cannot open file";
if (errorMsg) *errorMsg = QStringLiteral("Cannot open file: ") + filePath;
return {};
}
qDebug() << "[ImportXML] File size:" << file.size() << "bytes";
QXmlStreamReader xml(&file);
XmlVersion version = XmlVersion::V2016; // default to 2016 (most common)
NodeTree tree;
tree.baseAddress = 0x00400000;
// Class name → struct node ID (for pointer resolution)
QHash<QString, uint64_t> classIds;
// Deferred pointer refs to resolve after all classes are parsed
QVector<PendingRef> pendingRefs;
// Detect version from first comment
bool versionDetected = false;
while (!xml.atEnd()) {
xml.readNext();
// Detect version from XML comments
if (!versionDetected && xml.isComment()) {
QString comment = xml.text().toString().trimmed();
if (comment.contains(QStringLiteral("ReClassEx"), Qt::CaseInsensitive) ||
comment.contains(QStringLiteral("MemeClsEx"), Qt::CaseInsensitive) ||
comment.contains(QStringLiteral("2016"), Qt::CaseInsensitive) ||
comment.contains(QStringLiteral("2015"), Qt::CaseInsensitive)) {
version = XmlVersion::V2016;
} else if (comment.contains(QStringLiteral("2013"), Qt::CaseInsensitive) ||
comment.contains(QStringLiteral("2011"), Qt::CaseInsensitive)) {
version = XmlVersion::V2013;
}
// else keep default V2016
versionDetected = true;
qDebug() << "[ImportXML] Detected version:" << (version == XmlVersion::V2016 ? "V2016" : "V2013");
}
if (!xml.isStartElement()) continue;
if (xml.name() == QStringLiteral("Class")) {
// Parse a class element into a root Struct node
QString className = xml.attributes().value(QStringLiteral("Name")).toString();
QString strOffset = xml.attributes().value(QStringLiteral("strOffset")).toString();
// Create root struct node (collapsed by default for large files)
Node structNode;
structNode.kind = NodeKind::Struct;
structNode.name = className;
structNode.structTypeName = className;
structNode.parentId = 0; // root level
structNode.offset = 0;
structNode.collapsed = true;
int structIdx = tree.addNode(structNode);
uint64_t structId = tree.nodes[structIdx].id;
classIds[className] = structId;
qDebug() << "[ImportXML] Class:" << className << "id:" << structId;
// Parse child Node elements
int childOffset = 0;
while (!xml.atEnd()) {
xml.readNext();
if (xml.isEndElement() && xml.name() == QStringLiteral("Class"))
break;
if (!xml.isStartElement() || xml.name() != QStringLiteral("Node"))
continue;
int xmlType = xml.attributes().value(QStringLiteral("Type")).toInt();
QString nodeName = xml.attributes().value(QStringLiteral("Name")).toString();
int nodeSize = xml.attributes().value(QStringLiteral("Size")).toInt();
QString ptrClass = xml.attributes().value(QStringLiteral("Pointer")).toString();
QString instClass = xml.attributes().value(QStringLiteral("Instance")).toString();
qDebug() << "[ImportXML] Node:" << nodeName << "type:" << xmlType
<< "size:" << nodeSize << "ptr:" << ptrClass << "inst:" << instClass;
// Handle Custom type: expand to appropriate hex nodes
if (isCustomType(xmlType, version) && nodeSize > 0) {
// Pick best-fit hex kind
NodeKind hexKind;
int hexSize;
if (nodeSize >= 8 && nodeSize % 8 == 0) {
hexKind = NodeKind::Hex64; hexSize = 8;
} else if (nodeSize >= 4 && nodeSize % 4 == 0) {
hexKind = NodeKind::Hex32; hexSize = 4;
} else if (nodeSize >= 2 && nodeSize % 2 == 0) {
hexKind = NodeKind::Hex16; hexSize = 2;
} else {
hexKind = NodeKind::Hex8; hexSize = 1;
}
int count = nodeSize / hexSize;
for (int i = 0; i < count; i++) {
Node n;
n.kind = hexKind;
n.name = (count == 1) ? nodeName : QString();
n.parentId = structId;
n.offset = childOffset;
tree.addNode(n);
childOffset += hexSize;
}
continue;
}
NodeKind kind = lookupKind(xmlType, version);
// Handle ClassInstanceArray: read child <Array> element
if (isClassInstanceArrayType(xmlType, version)) {
qDebug() << "[ImportXML] -> ClassInstanceArray";
int total = xml.attributes().value(QStringLiteral("Total")).toInt();
if (total <= 0)
total = xml.attributes().value(QStringLiteral("Count")).toInt();
if (total <= 0) total = 1;
// Read child <Array> element for class name
QString arrayClassName;
while (!xml.atEnd()) {
xml.readNext();
if (xml.isEndElement() && xml.name() == QStringLiteral("Node"))
break;
if (xml.isStartElement() && xml.name() == QStringLiteral("Array")) {
arrayClassName = xml.attributes().value(QStringLiteral("Name")).toString();
int arrayTotal = xml.attributes().value(QStringLiteral("Total")).toInt();
if (arrayTotal <= 0)
arrayTotal = xml.attributes().value(QStringLiteral("Count")).toInt();
if (arrayTotal > 0) total = arrayTotal;
}
}
// Create an Array node wrapping Struct elements
Node arrNode;
arrNode.kind = NodeKind::Array;
arrNode.name = nodeName;
arrNode.parentId = structId;
arrNode.offset = childOffset;
arrNode.arrayLen = total;
arrNode.elementKind = NodeKind::Struct;
if (!arrayClassName.isEmpty())
arrNode.structTypeName = arrayClassName;
int arrIdx = tree.addNode(arrNode);
uint64_t arrId = tree.nodes[arrIdx].id;
// Defer ref resolution if array references a class
if (!arrayClassName.isEmpty()) {
pendingRefs.append({arrId, arrayClassName});
}
childOffset += nodeSize > 0 ? nodeSize : 0;
continue;
}
Node n;
n.kind = kind;
n.name = nodeName;
n.parentId = structId;
n.offset = childOffset;
// Handle text nodes
if (isTextType(xmlType, version)) {
if (isUtf16TextType(xmlType, version))
n.strLen = qMax(1, nodeSize / 2);
else
n.strLen = qMax(1, nodeSize);
}
// Handle pointer types
if (isPointerType(xmlType, version) && !ptrClass.isEmpty()) {
qDebug() << "[ImportXML] -> Pointer to class:" << ptrClass;
n.collapsed = true; // Start collapsed to avoid recursive expansion freeze
int nodeIdx = tree.addNode(n);
uint64_t nodeId = tree.nodes[nodeIdx].id;
pendingRefs.append({nodeId, ptrClass});
childOffset += nodeSize > 0 ? nodeSize : sizeForKind(kind);
continue;
}
// Handle embedded class instance
if (isClassInstanceType(xmlType, version)) {
QString resolvedClass = instClass.isEmpty() ? ptrClass : instClass;
qDebug() << "[ImportXML] -> ClassInstance:" << resolvedClass;
n.collapsed = true; // Start collapsed to avoid recursive expansion freeze
n.structTypeName = resolvedClass;
if (!n.structTypeName.isEmpty()) {
int nodeIdx = tree.addNode(n);
uint64_t nodeId = tree.nodes[nodeIdx].id;
pendingRefs.append({nodeId, n.structTypeName});
} else {
tree.addNode(n);
}
childOffset += nodeSize > 0 ? nodeSize : 0;
continue;
}
tree.addNode(n);
childOffset += nodeSize > 0 ? nodeSize : sizeForKind(kind);
}
}
}
if (xml.hasError() && xml.error() != QXmlStreamReader::PrematureEndOfDocumentError) {
qDebug() << "[ImportXML] XML parse error at line" << xml.lineNumber() << ":" << xml.errorString();
if (errorMsg)
*errorMsg = QStringLiteral("XML parse error at line %1: %2")
.arg(xml.lineNumber())
.arg(xml.errorString());
return {};
}
qDebug() << "[ImportXML] Parsing complete. Total nodes:" << tree.nodes.size()
<< "classes:" << classIds.size() << "pending refs:" << pendingRefs.size();
if (tree.nodes.isEmpty()) {
qDebug() << "[ImportXML] ERROR: No classes found";
if (errorMsg) *errorMsg = QStringLiteral("No classes found in file");
return {};
}
// Resolve deferred pointer/struct references
int resolved = 0, unresolved = 0;
for (const auto& ref : pendingRefs) {
int nodeIdx = tree.indexOfId(ref.nodeId);
if (nodeIdx < 0) continue;
auto it = classIds.find(ref.className);
if (it != classIds.end()) {
tree.nodes[nodeIdx].refId = it.value();
tree.invalidateIdCache();
resolved++;
} else {
qDebug() << "[ImportXML] Unresolved ref:" << ref.className << "for node" << ref.nodeId;
unresolved++;
}
}
qDebug() << "[ImportXML] Refs resolved:" << resolved << "unresolved:" << unresolved;
qDebug() << "[ImportXML] Import complete. Returning tree with" << tree.nodes.size() << "nodes";
return tree;
}
} // namespace rcx

11
src/import_reclass_xml.h Normal file
View File

@@ -0,0 +1,11 @@
#pragma once
#include "core.h"
namespace rcx {
// Import a ReClass XML file (.reclass, .MemeCls, etc.) into a NodeTree.
// Supports ReClassEx, MemeClsEx, ReClass 2011/2013/2016 XML formats.
// Returns an empty NodeTree on failure; populates errorMsg if non-null.
NodeTree importReclassXml(const QString& filePath, QString* errorMsg = nullptr);
} // namespace rcx

1066
src/import_source.cpp Normal file

File diff suppressed because it is too large Load Diff

13
src/import_source.h Normal file
View File

@@ -0,0 +1,13 @@
#pragma once
#include "core.h"
namespace rcx {
// Import C/C++ struct definitions from source code into a NodeTree.
// Supports two modes (auto-detected):
// 1. With comment offsets (// 0xNN) - trusts the offset values
// 2. Without comment offsets - computes offsets from type sizes
// Returns an empty NodeTree on failure; populates errorMsg if non-null.
NodeTree importFromSource(const QString& sourceCode, QString* errorMsg = nullptr);
} // namespace rcx

View File

@@ -1,5 +1,8 @@
#include "mainwindow.h"
#include "generator.h"
#include "import_reclass_xml.h"
#include "import_source.h"
#include "export_reclass_xml.h"
#include "mcp/mcp_bridge.h"
#include <QApplication>
#include <QMainWindow>
@@ -43,6 +46,7 @@
#include <QDesktopServices>
#include "themes/thememanager.h"
#include "themes/themeeditor.h"
#include "optionsdialog.h"
#ifdef _WIN32
#include <windows.h>
@@ -63,29 +67,56 @@ static void setDarkTitleBar(QWidget* widget) {
}
}
// Guard flag to prevent re-entrant crash inside the handler
static volatile LONG s_inCrashHandler = 0;
static LONG WINAPI crashHandler(EXCEPTION_POINTERS* ep) {
// Prevent re-entrant crash: if we fault inside the handler, skip the
// risky dbghelp work and just terminate with what we already printed.
if (InterlockedCompareExchange(&s_inCrashHandler, 1, 0) != 0) {
fprintf(stderr, "\n(re-entrant fault inside crash handler — aborting)\n");
fflush(stderr);
return EXCEPTION_EXECUTE_HANDLER;
}
// Phase 1: always-safe output (no allocations, no complex APIs)
fprintf(stderr, "\n=== UNHANDLED EXCEPTION ===\n");
fprintf(stderr, "Code : 0x%08lX\n", ep->ExceptionRecord->ExceptionCode);
fprintf(stderr, "Addr : %p\n", ep->ExceptionRecord->ExceptionAddress);
#ifdef _M_X64
fprintf(stderr, "RIP : 0x%016llx\n", (unsigned long long)ep->ContextRecord->Rip);
fprintf(stderr, "RSP : 0x%016llx\n", (unsigned long long)ep->ContextRecord->Rsp);
#else
fprintf(stderr, "EIP : 0x%08lx\n", (unsigned long)ep->ContextRecord->Eip);
#endif
fflush(stderr);
// Phase 2: attempt symbol resolution + stack walk
// Copy context so StackWalk64 can mutate it safely
CONTEXT ctxCopy = *ep->ContextRecord;
HANDLE process = GetCurrentProcess();
HANDLE thread = GetCurrentThread();
SymSetOptions(SYMOPT_LOAD_LINES | SYMOPT_UNDNAME);
SymInitialize(process, NULL, TRUE);
SymSetOptions(SYMOPT_LOAD_LINES | SYMOPT_UNDNAME | SYMOPT_FAIL_CRITICAL_ERRORS);
if (!SymInitialize(process, NULL, TRUE)) {
fprintf(stderr, "\n(SymInitialize failed — no stack trace available)\n");
fprintf(stderr, "=== END CRASH ===\n");
fflush(stderr);
return EXCEPTION_EXECUTE_HANDLER;
}
CONTEXT* ctx = ep->ContextRecord;
STACKFRAME64 frame = {};
DWORD machineType;
#ifdef _M_X64
machineType = IMAGE_FILE_MACHINE_AMD64;
frame.AddrPC.Offset = ctx->Rip;
frame.AddrFrame.Offset = ctx->Rbp;
frame.AddrStack.Offset = ctx->Rsp;
frame.AddrPC.Offset = ctxCopy.Rip;
frame.AddrFrame.Offset = ctxCopy.Rbp;
frame.AddrStack.Offset = ctxCopy.Rsp;
#else
machineType = IMAGE_FILE_MACHINE_I386;
frame.AddrPC.Offset = ctx->Eip;
frame.AddrFrame.Offset = ctx->Ebp;
frame.AddrStack.Offset = ctx->Esp;
frame.AddrPC.Offset = ctxCopy.Eip;
frame.AddrFrame.Offset = ctxCopy.Ebp;
frame.AddrStack.Offset = ctxCopy.Esp;
#endif
frame.AddrPC.Mode = AddrModeFlat;
frame.AddrFrame.Mode = AddrModeFlat;
@@ -93,7 +124,7 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS* ep) {
fprintf(stderr, "\nStack trace:\n");
for (int i = 0; i < 64; i++) {
if (!StackWalk64(machineType, process, thread, &frame, ctx,
if (!StackWalk64(machineType, process, thread, &frame, &ctxCopy,
NULL, SymFunctionTableAccess64,
SymGetModuleBase64, NULL))
break;
@@ -141,7 +172,9 @@ public:
if ((w->windowFlags() & Qt::Window) == Qt::Window
&& !w->property("DarkTitleBar").toBool()) {
w->setProperty("DarkTitleBar", true);
#ifdef _WIN32
setDarkTitleBar(w);
#endif
}
}
return QApplication::notify(receiver, event);
@@ -160,6 +193,13 @@ public:
s = QSize(s.width() + 24, s.height() + 4);
return s;
}
int pixelMetric(PixelMetric metric, const QStyleOption* opt,
const QWidget* w) const override {
// Kill the 1px frame margin Fusion reserves around QMenu contents
if (metric == PM_MenuPanelWidth)
return 0;
return QProxyStyle::pixelMetric(metric, opt, w);
}
void drawPrimitive(PrimitiveElement elem, const QStyleOption* opt,
QPainter* p, const QWidget* w) const override {
// Kill Fusion's 3D bevel on QMenu — the OS drop shadow is enough
@@ -205,13 +245,13 @@ static void applyGlobalTheme(const rcx::Theme& theme) {
QPalette pal;
pal.setColor(QPalette::Window, theme.background);
pal.setColor(QPalette::WindowText, theme.text);
pal.setColor(QPalette::Base, theme.backgroundAlt);
pal.setColor(QPalette::Base, theme.background);
pal.setColor(QPalette::AlternateBase, theme.surface);
pal.setColor(QPalette::Text, theme.text);
pal.setColor(QPalette::Button, theme.button);
pal.setColor(QPalette::ButtonText, theme.text);
pal.setColor(QPalette::Highlight, theme.hover);
pal.setColor(QPalette::HighlightedText, theme.indHoverSpan);
pal.setColor(QPalette::Highlight, theme.selection);
pal.setColor(QPalette::HighlightedText, theme.text);
pal.setColor(QPalette::ToolTipBase, theme.backgroundAlt);
pal.setColor(QPalette::ToolTipText, theme.text);
pal.setColor(QPalette::Mid, theme.border);
@@ -301,6 +341,13 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
createMenus();
createStatusBar();
// Restore menu bar title case setting (after menus are created)
{
QSettings s("Reclass", "Reclass");
m_titleBar->setMenuBarTitleCase(s.value("menuBarTitleCase", true).toBool());
if (s.value("showIcon", false).toBool())
m_titleBar->setShowIcon(true);
}
// MenuBarStyle is set as app style in main() — covers both QMenuBar and QMenu
@@ -310,9 +357,10 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
// Load plugins
m_pluginManager.LoadPlugins();
// MCP bridge (on by default)
// Start MCP bridge
m_mcp = new McpBridge(this, this);
m_mcp->start();
if (QSettings("Reclass", "Reclass").value("autoStartMcp", false).toBool())
m_mcp->start();
connect(m_mdiArea, &QMdiArea::subWindowActivated,
this, [this](QMdiSubWindow*) {
@@ -338,33 +386,63 @@ QIcon MainWindow::makeIcon(const QString& svgPath) {
return QIcon(svgPath);
}
template < typename...Args >
inline QAction* Qt5Qt6AddAction(QMenu* menu, const QString &text, const QKeySequence &shortcut, const QIcon &icon, Args&&...args)
{
QAction *result = menu->addAction(icon, text);
if (!shortcut.isEmpty())
result->setShortcut(shortcut);
QObject::connect(result, &QAction::triggered, std::forward<Args>(args)...);
return result;
}
void MainWindow::createMenus() {
// File
auto* file = m_titleBar->menuBar()->addMenu("&File");
file->addAction("&New", this, &MainWindow::newDocument, QKeySequence::New);
file->addAction("New &Tab", this, &MainWindow::newFile, QKeySequence(Qt::CTRL | Qt::Key_T));
file->addAction(makeIcon(":/vsicons/folder-opened.svg"), "&Open...", this, &MainWindow::openFile, QKeySequence::Open);
Qt5Qt6AddAction(file, "&New", QKeySequence::New, QIcon(), this, &MainWindow::newDocument);
Qt5Qt6AddAction(file, "New &Tab", QKeySequence(Qt::CTRL | Qt::Key_T), QIcon(), this, &MainWindow::newFile);
Qt5Qt6AddAction(file, "&Open...", QKeySequence::Open, makeIcon(":/vsicons/folder-opened.svg"), this, &MainWindow::openFile);
file->addSeparator();
file->addAction(makeIcon(":/vsicons/save.svg"), "&Save", this, &MainWindow::saveFile, QKeySequence::Save);
file->addAction(makeIcon(":/vsicons/save-as.svg"), "Save &As...", this, &MainWindow::saveFileAs, QKeySequence::SaveAs);
Qt5Qt6AddAction(file, "&Save", QKeySequence::Save, makeIcon(":/vsicons/save.svg"), this, &MainWindow::saveFile);
Qt5Qt6AddAction(file, "Save &As...", QKeySequence::SaveAs, makeIcon(":/vsicons/save-as.svg"), this, &MainWindow::saveFileAs);
file->addSeparator();
file->addAction(makeIcon(":/vsicons/export.svg"), "Export &C++ Header...", this, &MainWindow::exportCpp);
Qt5Qt6AddAction(file, "&Unload Project", QKeySequence(Qt::CTRL | Qt::Key_W), QIcon(), this, &MainWindow::closeFile);
file->addSeparator();
m_mcpAction = file->addAction("Stop &MCP Server", this, &MainWindow::toggleMcp);
Qt5Qt6AddAction(file, "Export &C++ Header...", QKeySequence::UnknownKey, makeIcon(":/vsicons/export.svg"), this, &MainWindow::exportCpp);
Qt5Qt6AddAction(file, "Export ReClass &XML...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportReclassXmlAction);
Qt5Qt6AddAction(file, "Import from &Source...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::importFromSource);
Qt5Qt6AddAction(file, "&Import ReClass XML...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::importReclassXml);
// Examples submenu — scan once at init
{
QDir exDir(QCoreApplication::applicationDirPath() + "/examples");
QStringList rcxFiles = exDir.entryList({"*.rcx"}, QDir::Files, QDir::Name);
if (!rcxFiles.isEmpty()) {
auto* examples = file->addMenu("&Examples");
for (const QString& fn : rcxFiles) {
QString fullPath = exDir.absoluteFilePath(fn);
examples->addAction(fn, this, [this, fullPath]() { project_open(fullPath); });
}
}
}
file->addSeparator();
file->addAction(makeIcon(":/vsicons/close.svg"), "E&xit", this, &QMainWindow::close, QKeySequence(Qt::Key_Close));
const auto itemName = QSettings("Reclass", "Reclass").value("autoStartMcp", false).toBool() ? "Stop &MCP Server" : "Start &MCP Server";
m_mcpAction = Qt5Qt6AddAction(file, itemName, QKeySequence::UnknownKey, QIcon(), this, &MainWindow::toggleMcp);
file->addSeparator();
Qt5Qt6AddAction(file, "&Options...", QKeySequence::UnknownKey, makeIcon(":/vsicons/settings-gear.svg"), this, &MainWindow::showOptionsDialog);
file->addSeparator();
Qt5Qt6AddAction(file, "E&xit", QKeySequence(Qt::Key_Close), makeIcon(":/vsicons/close.svg"), this, &QMainWindow::close);
// Edit
auto* edit = m_titleBar->menuBar()->addMenu("&Edit");
edit->addAction(makeIcon(":/vsicons/arrow-left.svg"), "&Undo", this, &MainWindow::undo, QKeySequence::Undo);
edit->addAction(makeIcon(":/vsicons/arrow-right.svg"), "&Redo", this, &MainWindow::redo, QKeySequence::Redo);
Qt5Qt6AddAction(edit, "&Undo", QKeySequence::Undo, makeIcon(":/vsicons/arrow-left.svg"), this, &MainWindow::undo);
Qt5Qt6AddAction(edit, "&Redo", QKeySequence::Redo, makeIcon(":/vsicons/arrow-right.svg"), this, &MainWindow::redo);
edit->addSeparator();
edit->addAction("&Type Aliases...", this, &MainWindow::showTypeAliasesDialog);
Qt5Qt6AddAction(edit, "&Type Aliases...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::showTypeAliasesDialog);
// View
auto* view = m_titleBar->menuBar()->addMenu("&View");
view->addAction(makeIcon(":/vsicons/split-horizontal.svg"), "Split &Horizontal", this, &MainWindow::splitView);
view->addAction(makeIcon(":/vsicons/chrome-close.svg"), "&Unsplit", this, &MainWindow::unsplitView);
Qt5Qt6AddAction(view, "Split &Horizontal", QKeySequence::UnknownKey, makeIcon(":/vsicons/split-horizontal.svg"), this, &MainWindow::splitView);
Qt5Qt6AddAction(view, "&Unsplit", QKeySequence::UnknownKey, makeIcon(":/vsicons/chrome-close.svg"), this, &MainWindow::unsplitView);
view->addSeparator();
auto* fontMenu = view->addMenu(makeIcon(":/vsicons/text-size.svg"), "&Font");
auto* fontGroup = new QActionGroup(this);
@@ -399,35 +477,18 @@ void MainWindow::createMenus() {
});
}
themeMenu->addSeparator();
themeMenu->addAction("Edit Theme...", this, &MainWindow::editTheme);
Qt5Qt6AddAction(themeMenu, "Edit Theme...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::editTheme);
view->addSeparator();
auto* actShowIcon = view->addAction("Show &Icon");
actShowIcon->setCheckable(true);
actShowIcon->setChecked(settings.value("showIcon", false).toBool());
if (actShowIcon->isChecked()) m_titleBar->setShowIcon(true);
connect(actShowIcon, &QAction::toggled, this, [this](bool checked) {
m_titleBar->setShowIcon(checked);
QSettings s("Reclass", "Reclass");
s.setValue("showIcon", checked);
});
view->addAction(m_workspaceDock->toggleViewAction());
// Node
auto* node = m_titleBar->menuBar()->addMenu("&Node");
node->addAction(makeIcon(":/vsicons/add.svg"), "&Add Field", this, &MainWindow::addNode, QKeySequence(Qt::Key_Insert));
node->addAction(makeIcon(":/vsicons/remove.svg"), "&Remove Field", this, &MainWindow::removeNode, QKeySequence::Delete);
node->addAction(makeIcon(":/vsicons/symbol-structure.svg"), "Change &Type", this, &MainWindow::changeNodeType, QKeySequence(Qt::Key_T));
node->addAction(makeIcon(":/vsicons/edit.svg"), "Re&name", this, &MainWindow::renameNodeAction, QKeySequence(Qt::Key_F2));
node->addAction(makeIcon(":/vsicons/files.svg"), "D&uplicate", this, &MainWindow::duplicateNodeAction)->setShortcut(QKeySequence(Qt::CTRL | Qt::Key_D));
// Plugins
auto* plugins = m_titleBar->menuBar()->addMenu("&Plugins");
plugins->addAction("&Manage Plugins...", this, &MainWindow::showPluginsDialog);
Qt5Qt6AddAction(plugins, "&Manage Plugins...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::showPluginsDialog);
// Help
auto* help = m_titleBar->menuBar()->addMenu("&Help");
help->addAction(makeIcon(":/vsicons/question.svg"), "&About Reclass", this, &MainWindow::about);
Qt5Qt6AddAction(help, "&About Reclass", QKeySequence::UnknownKey, makeIcon(":/vsicons/question.svg"), this, &MainWindow::about);
}
void MainWindow::createStatusBar() {
@@ -665,6 +726,7 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) {
sub->setWindowTitle(rootName(it2->doc->tree, it2->ctrl->viewRootId()));
}
updateWindowTitle();
rebuildWorkspaceModel();
});
});
@@ -682,61 +744,25 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) {
return sub;
}
// Build Ball + Material demo structs into a tree
static void buildBallDemo(NodeTree& tree) {
// Ball struct (128 bytes = 0x80)
Node ball;
ball.kind = NodeKind::Struct;
ball.name = "aBall";
ball.structTypeName = "Ball";
ball.parentId = 0;
ball.offset = 0;
int bi = tree.addNode(ball);
uint64_t ballId = tree.nodes[bi].id;
// Build a minimal empty struct for new documents
static void buildEmptyStruct(NodeTree& tree) {
Node root;
root.kind = NodeKind::Struct;
root.name = "instance";
root.structTypeName = "Unnamed";
root.parentId = 0;
root.offset = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_00"; n.parentId = ballId; n.offset = 0; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_08"; n.parentId = ballId; n.offset = 8; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Vec4; n.name = "position"; n.parentId = ballId; n.offset = 16; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Vec3; n.name = "velocity"; n.parentId = ballId; n.offset = 32; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex32; n.name = "field_2C"; n.parentId = ballId; n.offset = 44; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Float; n.name = "speed"; n.parentId = ballId; n.offset = 48; tree.addNode(n); }
{ Node n; n.kind = NodeKind::UInt32; n.name = "color"; n.parentId = ballId; n.offset = 52; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Float; n.name = "radius"; n.parentId = ballId; n.offset = 56; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex32; n.name = "field_3C"; n.parentId = ballId; n.offset = 60; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Float; n.name = "mass"; n.parentId = ballId; n.offset = 64; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_44"; n.parentId = ballId; n.offset = 68; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Bool; n.name = "bouncy"; n.parentId = ballId; n.offset = 76; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex8; n.name = "field_4D"; n.parentId = ballId; n.offset = 77; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex16; n.name = "field_4E"; n.parentId = ballId; n.offset = 78; tree.addNode(n); }
{ Node n; n.kind = NodeKind::UInt32; n.name = "color"; n.parentId = ballId; n.offset = 80; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex32; n.name = "field_54"; n.parentId = ballId; n.offset = 84; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_58"; n.parentId = ballId; n.offset = 88; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_60"; n.parentId = ballId; n.offset = 96; tree.addNode(n); }
// Material struct (renamed from Physics, 40 bytes = 0x28)
Node mat;
mat.kind = NodeKind::Struct;
mat.name = "aMaterial";
mat.structTypeName = "Material";
mat.parentId = 0;
mat.offset = 0;
int mi = tree.addNode(mat);
uint64_t matId = tree.nodes[mi].id;
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_00"; n.parentId = matId; n.offset = 0; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_08"; n.parentId = matId; n.offset = 8; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_10"; n.parentId = matId; n.offset = 16; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_18"; n.parentId = matId; n.offset = 24; tree.addNode(n); }
{ Node n; n.kind = NodeKind::Hex64; n.name = "field_20"; n.parentId = matId; n.offset = 32; tree.addNode(n); }
// Pointer to Material in Ball struct
{ Node n; n.kind = NodeKind::Pointer64; n.name = "material"; n.parentId = ballId; n.offset = 104; n.refId = matId; n.collapsed = true; tree.addNode(n); }
// float[4] scores at offset 112
{ Node n; n.kind = NodeKind::Array; n.name = "scores"; n.parentId = ballId; n.offset = 112; n.elementKind = NodeKind::Float; n.arrayLen = 4; tree.addNode(n); }
// Material[2] materials at offset 128 (112 + 16 for float[4])
{ Node n; n.kind = NodeKind::Array; n.name = "materials"; n.parentId = ballId; n.offset = 128; n.elementKind = NodeKind::Struct; n.arrayLen = 2; n.refId = matId; tree.addNode(n); }
for (int i = 0; i < 16; i++) {
Node n;
n.kind = NodeKind::Hex64;
n.name = QStringLiteral("field_%1").arg(i * 8, 2, 16, QChar('0'));
n.parentId = rootId;
n.offset = i * 8;
tree.addNode(n);
}
}
void MainWindow::newFile() {
@@ -760,14 +786,12 @@ void MainWindow::newDocument() {
doc->typeAliases.clear();
doc->modified = false;
// Build Ball + Material structs
buildBallDemo(doc->tree);
buildEmptyStruct(doc->tree);
// Cross-platform writable buffer, zeroed (256 bytes covers Ball + spare)
QByteArray data(256, '\0');
doc->provider = std::make_shared<BufferProvider>(data);
// Focus on Ball struct
// Focus on first struct
ctrl->setViewRootId(0);
for (const auto& n : doc->tree.nodes) {
if (n.parentId == 0 && n.kind == NodeKind::Struct) {
@@ -785,41 +809,22 @@ void MainWindow::newDocument() {
}
void MainWindow::selfTest() {
// Tab 1: Ball demo
project_new();
// Tab 2: Unnamed struct with hex64 fields
{
auto* doc = new RcxDocument(this);
QByteArray data(256, '\0');
doc->loadData(data);
doc->tree.baseAddress = 0x00400000;
Node s;
s.kind = NodeKind::Struct;
s.name = "instance";
s.structTypeName = "Unnamed";
s.parentId = 0;
s.offset = 0;
int si = doc->tree.addNode(s);
uint64_t sId = doc->tree.nodes[si].id;
for (int i = 0; i < 16; i++) {
Node n;
n.kind = NodeKind::Hex64;
n.name = QStringLiteral("field_%1").arg(i * 8, 2, 16, QChar('0'));
n.parentId = sId;
n.offset = i * 8;
doc->tree.addNode(n);
}
createTab(doc);
rebuildWorkspaceModel();
// Auto-open KUSER_SHARED_DATA example if available
QString exPath = QCoreApplication::applicationDirPath()
+ "/examples/KUSER_SHARED_DATA.rcx";
if (QFile::exists(exPath)) {
project_open(exPath);
} else {
project_new();
}
// Focus Ball tab
if (auto* first = m_mdiArea->subWindowList().value(0))
m_mdiArea->setActiveSubWindow(first);
// Auto-attach process memory plugin to self
auto* ctrl = activeController();
if (ctrl) {
DWORD pid = GetCurrentProcessId();
QString target = QString("%1:Reclass.exe").arg(pid);
ctrl->attachViaPlugin(QStringLiteral("processmemory"), target);
}
}
void MainWindow::openFile() {
@@ -834,6 +839,10 @@ void MainWindow::saveFileAs() {
project_save(nullptr, true);
}
void MainWindow::closeFile() {
project_close();
}
void MainWindow::addNode() {
auto* ctrl = activeController();
if (!ctrl) return;
@@ -1005,6 +1014,13 @@ void MainWindow::applyTheme(const Theme& theme) {
statusBar()->setPalette(sbPal);
}
// Workspace tree: text color matches menu bar
if (m_workspaceTree) {
QPalette tp = m_workspaceTree->palette();
tp.setColor(QPalette::Text, theme.textDim);
m_workspaceTree->setPalette(tp);
}
// Split pane tab widgets
for (auto& state : m_tabs) {
for (auto& pane : state.panes) {
@@ -1024,6 +1040,52 @@ void MainWindow::editTheme() {
}
}
// TODO: when adding more and more options, this func becomes very clunky. Fix
void MainWindow::showOptionsDialog() {
auto& tm = ThemeManager::instance();
OptionsResult current;
current.themeIndex = tm.currentIndex();
current.fontName = QSettings("Reclass", "Reclass").value("font", "JetBrains Mono").toString();
current.menuBarTitleCase = m_titleBar->menuBarTitleCase();
current.showIcon = QSettings("Reclass", "Reclass").value("showIcon", false).toBool();
current.safeMode = QSettings("Reclass", "Reclass").value("safeMode", false).toBool();
current.autoStartMcp = QSettings("Reclass", "Reclass").value("autoStartMcp", false).toBool();
current.refreshMs = QSettings("Reclass", "Reclass").value("refreshMs", 660).toInt();
OptionsDialog dlg(current, this);
if (dlg.exec() != QDialog::Accepted) return; // OptionsDialog doesn't apply anything. Only apply on OK
auto r = dlg.result();
if (r.themeIndex != current.themeIndex)
tm.setCurrent(r.themeIndex);
if (r.fontName != current.fontName)
setEditorFont(r.fontName);
if (r.menuBarTitleCase != current.menuBarTitleCase) {
m_titleBar->setMenuBarTitleCase(r.menuBarTitleCase);
QSettings("Reclass", "Reclass").setValue("menuBarTitleCase", r.menuBarTitleCase);
}
if (r.showIcon != current.showIcon) {
m_titleBar->setShowIcon(r.showIcon);
QSettings("Reclass", "Reclass").setValue("showIcon", r.showIcon);
}
if (r.safeMode != current.safeMode)
QSettings("Reclass", "Reclass").setValue("safeMode", r.safeMode);
if (r.autoStartMcp != current.autoStartMcp)
QSettings("Reclass", "Reclass").setValue("autoStartMcp", r.autoStartMcp);
if (r.refreshMs != current.refreshMs) {
QSettings("Reclass", "Reclass").setValue("refreshMs", r.refreshMs);
for (auto& tab : m_tabs)
tab.ctrl->setRefreshInterval(r.refreshMs);
}
}
void MainWindow::setEditorFont(const QString& fontName) {
QSettings settings("Reclass", "Reclass");
settings.setValue("font", fontName);
@@ -1259,6 +1321,110 @@ void MainWindow::exportCpp() {
m_statusLabel->setText("Exported to " + QFileInfo(path).fileName());
}
// ── Export ReClass XML ──
void MainWindow::exportReclassXmlAction() {
auto* tab = activeTab();
if (!tab) return;
QString path = QFileDialog::getSaveFileName(this,
"Export ReClass XML", {}, "ReClass XML (*.reclass);;All Files (*)");
if (path.isEmpty()) return;
QString error;
if (!rcx::exportReclassXml(tab->doc->tree, path, &error)) {
QMessageBox::warning(this, "Export Failed",
error.isEmpty() ? QStringLiteral("Could not export") : error);
return;
}
int classCount = 0;
for (const auto& n : tab->doc->tree.nodes)
if (n.parentId == 0 && n.kind == NodeKind::Struct) classCount++;
m_statusLabel->setText(QStringLiteral("Exported %1 classes to %2")
.arg(classCount).arg(QFileInfo(path).fileName()));
}
// ── Import ReClass XML ──
void MainWindow::importReclassXml() {
QString filePath = QFileDialog::getOpenFileName(this,
"Import ReClass XML", {},
"ReClass XML (*.reclass *.MemeCls *.xml);;All Files (*)");
if (filePath.isEmpty()) return;
QString error;
NodeTree tree = rcx::importReclassXml(filePath, &error);
if (tree.nodes.isEmpty()) {
QMessageBox::warning(this, "Import Failed", error.isEmpty()
? QStringLiteral("No data found in file") : error);
return;
}
// Count root structs for status message
int classCount = 0;
for (const auto& n : tree.nodes)
if (n.parentId == 0 && n.kind == NodeKind::Struct) classCount++;
auto* doc = new RcxDocument(this);
doc->tree = std::move(tree);
m_mdiArea->closeAllSubWindows();
createTab(doc);
rebuildWorkspaceModel();
m_statusLabel->setText(QStringLiteral("Imported %1 classes from %2")
.arg(classCount).arg(QFileInfo(filePath).fileName()));
}
// ── Import from Source ──
void MainWindow::importFromSource() {
QDialog dlg(this);
dlg.setWindowTitle("Import from Source");
dlg.resize(700, 600);
auto* layout = new QVBoxLayout(&dlg);
auto* sci = new QsciScintilla(&dlg);
setupRenderedSci(sci);
sci->setReadOnly(false);
sci->setMarginWidth(0, "00000");
layout->addWidget(sci);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, &dlg);
buttons->button(QDialogButtonBox::Ok)->setText("Import");
layout->addWidget(buttons);
connect(buttons, &QDialogButtonBox::accepted, &dlg, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject);
if (dlg.exec() != QDialog::Accepted) return;
QString source = sci->text();
if (source.trimmed().isEmpty()) return;
QString error;
NodeTree tree = rcx::importFromSource(source, &error);
if (tree.nodes.isEmpty()) {
QMessageBox::warning(this, "Import Failed", error.isEmpty()
? QStringLiteral("No struct definitions found") : error);
return;
}
int classCount = 0;
for (const auto& n : tree.nodes)
if (n.parentId == 0 && n.kind == NodeKind::Struct) classCount++;
auto* doc = new RcxDocument(this);
doc->tree = std::move(tree);
m_mdiArea->closeAllSubWindows();
createTab(doc);
rebuildWorkspaceModel();
m_statusLabel->setText(QStringLiteral("Imported %1 classes from source").arg(classCount));
}
// ── Type Aliases Dialog ──
void MainWindow::showTypeAliasesDialog() {
@@ -1321,13 +1487,11 @@ void MainWindow::showTypeAliasesDialog() {
QMdiSubWindow* MainWindow::project_new() {
auto* doc = new RcxDocument(this);
// Cross-platform writable buffer, zeroed (256 bytes covers Ball struct + spare)
QByteArray data(256, '\0');
doc->loadData(data);
doc->tree.baseAddress = 0x00400000;
// Build Ball + Material demo structs
buildBallDemo(doc->tree);
buildEmptyStruct(doc->tree);
auto* sub = createTab(doc);
rebuildWorkspaceModel();
@@ -1338,16 +1502,57 @@ QMdiSubWindow* MainWindow::project_open(const QString& path) {
QString filePath = path;
if (filePath.isEmpty()) {
filePath = QFileDialog::getOpenFileName(this,
"Open Definition", {}, "Reclass (*.rcx);;JSON (*.json);;All (*)");
"Open Definition", {},
"All Supported (*.rcx *.json *.reclass *.MemeCls *.xml)"
";;Reclass (*.rcx)"
";;JSON (*.json)"
";;ReClass XML (*.reclass *.MemeCls *.xml)"
";;All (*)");
if (filePath.isEmpty()) return nullptr;
}
// Detect if this is an XML-based ReClass file by checking first bytes
bool isXml = false;
{
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");
}
}
if (isXml) {
QString error;
NodeTree tree = rcx::importReclassXml(filePath, &error);
if (tree.nodes.isEmpty()) {
QMessageBox::warning(this, "Import Failed", error.isEmpty()
? QStringLiteral("No data found in file") : error);
return nullptr;
}
auto* doc = new RcxDocument(this);
doc->tree = std::move(tree);
m_mdiArea->closeAllSubWindows();
auto* sub = createTab(doc);
rebuildWorkspaceModel();
int classCount = 0;
for (const auto& n : doc->tree.nodes)
if (n.parentId == 0 && n.kind == NodeKind::Struct) classCount++;
m_statusLabel->setText(QStringLiteral("Imported %1 classes from %2")
.arg(classCount).arg(QFileInfo(filePath).fileName()));
return sub;
}
auto* doc = new RcxDocument(this);
if (!doc->load(filePath)) {
QMessageBox::warning(this, "Error", "Failed to load: " + filePath);
delete doc;
return nullptr;
}
// Close all existing tabs so the project replaces the current state
m_mdiArea->closeAllSubWindows();
auto* sub = createTab(doc);
rebuildWorkspaceModel();
return sub;
@@ -1380,7 +1585,7 @@ void MainWindow::project_close(QMdiSubWindow* sub) {
// ── Workspace Dock ──
void MainWindow::createWorkspaceDock() {
m_workspaceDock = new QDockWidget("Workspace", this);
m_workspaceDock = new QDockWidget("Project Tree", this);
m_workspaceDock->setObjectName("WorkspaceDock");
m_workspaceDock->setAllowedAreas(Qt::LeftDockWidgetArea | Qt::RightDockWidgetArea);
@@ -1390,81 +1595,76 @@ void MainWindow::createWorkspaceDock() {
m_workspaceTree->setModel(m_workspaceModel);
m_workspaceTree->setHeaderHidden(true);
m_workspaceTree->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_workspaceTree->setExpandsOnDoubleClick(false);
m_workspaceTree->setMouseTracking(true);
// Match editor font
{
QSettings settings("Reclass", "Reclass");
QString fontName = settings.value("font", "JetBrains Mono").toString();
QFont f(fontName, 12);
f.setFixedPitch(true);
m_workspaceTree->setFont(f);
}
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;
if (structId == 0 || structId == rcx::kGroupSentinel) return;
auto subVar = index.data(Qt::UserRole);
if (!subVar.isValid()) return;
auto* sub = static_cast<QMdiSubWindow*>(subVar.value<void*>());
if (!sub || !m_tabs.contains(sub)) return;
QMenu menu;
auto* deleteAction = menu.addAction(QIcon(":/vsicons/remove.svg"), "Delete");
if (menu.exec(m_workspaceTree->viewport()->mapToGlobal(pos)) == deleteAction) {
auto& tab = m_tabs[sub];
int ni = tab.doc->tree.indexOfId(structId);
if (ni >= 0) {
tab.ctrl->removeNode(ni);
rebuildWorkspaceModel();
}
}
});
m_workspaceDock->setWidget(m_workspaceTree);
addDockWidget(Qt::LeftDockWidgetArea, m_workspaceDock);
m_workspaceDock->hide();
connect(m_workspaceTree, &QTreeView::doubleClicked, this, [this](const QModelIndex& index) {
// Data roles: UserRole=QMdiSubWindow*, UserRole+1=structId, UserRole+2=nodeId
auto structIdVar = index.data(Qt::UserRole + 1);
uint64_t structId = structIdVar.isValid() ? structIdVar.toULongLong() : 0;
if (structId == rcx::kGroupSentinel) {
// "Project" folder: toggle expand/collapse
m_workspaceTree->setExpanded(index, !m_workspaceTree->isExpanded(index));
return;
}
auto subVar = index.data(Qt::UserRole);
if (!subVar.isValid()) return;
auto* sub = static_cast<QMdiSubWindow*>(subVar.value<void*>());
if (!sub || !m_tabs.contains(sub)) return;
m_mdiArea->setActiveSubWindow(sub);
auto structIdVar = index.data(Qt::UserRole + 1);
auto nodeIdVar = index.data(Qt::UserRole + 2);
if (structIdVar.isValid()) {
// Double-clicked a struct: set as view root
uint64_t structId = structIdVar.toULongLong();
auto& tree = m_tabs[sub].doc->tree;
int ni = tree.indexOfId(structId);
if (ni >= 0) tree.nodes[ni].collapsed = false;
m_tabs[sub].ctrl->setViewRootId(structId);
m_tabs[sub].ctrl->scrollToNodeId(structId);
} else if (nodeIdVar.isValid()) {
// Double-clicked a field: find its root struct, set as view root, scroll to field
uint64_t nodeId = nodeIdVar.toULongLong();
auto& tree = m_tabs[sub].doc->tree;
// Walk up to find root struct
uint64_t rootId = 0;
uint64_t cur = nodeId;
while (cur != 0) {
int idx = tree.indexOfId(cur);
if (idx < 0) break;
if (tree.nodes[idx].parentId == 0) { rootId = cur; break; }
cur = tree.nodes[idx].parentId;
}
if (rootId != 0) {
int ri = tree.indexOfId(rootId);
if (ri >= 0) tree.nodes[ri].collapsed = false;
m_tabs[sub].ctrl->setViewRootId(rootId);
}
m_tabs[sub].ctrl->scrollToNodeId(nodeId);
} else if (!index.parent().isValid()) {
// Double-clicked project root: clear view root to show all
m_tabs[sub].ctrl->setViewRootId(0);
}
// Type/Enum node: navigate to it
auto& tree = m_tabs[sub].doc->tree;
int ni = tree.indexOfId(structId);
if (ni >= 0) tree.nodes[ni].collapsed = false;
m_tabs[sub].ctrl->setViewRootId(structId);
m_tabs[sub].ctrl->scrollToNodeId(structId);
});
}
void MainWindow::rebuildWorkspaceModel() {
m_workspaceModel->clear();
auto* sub = m_mdiArea->activeSubWindow();
if (!sub || !m_tabs.contains(sub)) return;
TabState& tab = m_tabs[sub];
QString tabName = tab.doc->filePath.isEmpty()
? rootName(tab.doc->tree, tab.ctrl->viewRootId())
: QFileInfo(tab.doc->filePath).fileName();
buildWorkspaceModel(m_workspaceModel, tab.doc->tree, tabName,
static_cast<void*>(sub));
m_workspaceTree->expandAll();
QVector<rcx::TabInfo> tabs;
for (auto it = m_tabs.begin(); it != m_tabs.end(); ++it) {
TabState& tab = it.value();
QString name = tab.doc->filePath.isEmpty()
? rootName(tab.doc->tree, tab.ctrl->viewRootId())
: QFileInfo(tab.doc->filePath).fileName();
tabs.append({ &tab.doc->tree, name, static_cast<void*>(it.key()) });
}
rcx::buildProjectExplorer(m_workspaceModel, tabs);
m_workspaceTree->expandToDepth(1);
}
void MainWindow::showPluginsDialog() {

View File

@@ -31,7 +31,7 @@ private slots:
void openFile();
void saveFile();
void saveFileAs();
void closeFile();
void addNode();
void removeNode();
@@ -47,8 +47,12 @@ private slots:
void toggleMcp();
void setEditorFont(const QString& fontName);
void exportCpp();
void exportReclassXmlAction();
void importFromSource();
void importReclassXml();
void showTypeAliasesDialog();
void editTheme();
void showOptionsDialog();
public:
// Project Lifecycle API

View File

@@ -248,7 +248,7 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
"collapse: {op:'collapse', nodeId:'ID', collapsed:true}. "
"Insert ops get auto-assigned IDs; use $0, $1 etc. to reference them in later ops. "
"Kinds: Hex8 Hex16 Hex32 Hex64 Int8 Int16 Int32 Int64 UInt8 UInt16 UInt32 UInt64 "
"Float Double Bool Pointer32 Pointer64 Vec2 Vec3 Vec4 Mat4x4 UTF8 UTF16 Padding Struct Array"},
"Float Double Bool Pointer32 Pointer64 Vec2 Vec3 Vec4 Mat4x4 UTF8 UTF16 Struct Array"},
{"inputSchema", QJsonObject{
{"type", "object"},
{"properties", QJsonObject{
@@ -793,7 +793,7 @@ QJsonObject McpBridge::toolSourceSwitch(const QJsonObject& args) {
}
if (args.contains("pid")) {
uint32_t pid = (uint32_t)args.value("pid").toInteger();
uint32_t pid = (uint32_t)args.value("pid").toInt();
QString name = args.value("processName").toString();
if (name.isEmpty()) name = QString("PID %1").arg(pid);
QString target = QString("%1:%2").arg(pid).arg(name);

261
src/optionsdialog.cpp Normal file
View File

@@ -0,0 +1,261 @@
#include "optionsdialog.h"
#include "themes/thememanager.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QFormLayout>
#include <QDialogButtonBox>
#include <QPushButton>
#include <QGroupBox>
#include <QLabel>
#include <QTreeWidgetItem>
#include <functional>
namespace rcx {
OptionsDialog::OptionsDialog(const OptionsResult& current, QWidget* parent)
: QDialog(parent)
{
setWindowTitle("Options");
setFixedSize(700, 450);
auto* mainLayout = new QVBoxLayout(this);
mainLayout->setSpacing(8);
mainLayout->setContentsMargins(10, 10, 10, 10);
// -- Middle: left column (search + tree) | right column (pages) --
auto* middleLayout = new QHBoxLayout;
middleLayout->setSpacing(8);
// Left column: search bar + tree
auto* leftColumn = new QVBoxLayout;
leftColumn->setSpacing(4);
m_search = new QLineEdit;
m_search->setPlaceholderText("Search Options (Ctrl+E)");
m_search->setClearButtonEnabled(true);
connect(m_search, &QLineEdit::textChanged, this, &OptionsDialog::filterTree);
leftColumn->addWidget(m_search);
m_tree = new QTreeWidget;
m_tree->setHeaderHidden(true);
m_tree->setRootIsDecorated(true);
m_tree->setFixedWidth(200);
auto* envItem = new QTreeWidgetItem(m_tree, {"Environment"});
auto* generalItem = new QTreeWidgetItem(envItem, {"General"});
m_tree->expandAll();
m_tree->setCurrentItem(generalItem);
leftColumn->addWidget(m_tree, 1);
middleLayout->addLayout(leftColumn);
// Right column: stacked pages with group boxes
m_pages = new QStackedWidget;
// -- General page --
auto* generalPage = new QWidget;
auto* generalLayout = new QVBoxLayout(generalPage);
generalLayout->setContentsMargins(0, 0, 0, 0);
generalLayout->setSpacing(8);
// Refresh Rate group box
auto* refreshGroup = new QGroupBox("Refresh Rate");
auto* refreshLayout = new QFormLayout(refreshGroup);
refreshLayout->setSpacing(8);
refreshLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
m_refreshSpin = new QSpinBox;
m_refreshSpin->setRange(1, 60000);
m_refreshSpin->setSingleStep(50);
m_refreshSpin->setValue(current.refreshMs);
m_refreshSpin->setSuffix(" ms");
m_refreshSpin->setObjectName("refreshSpin");
refreshLayout->addRow("Interval:", m_refreshSpin);
auto* refreshDesc = new QLabel(
"How often live memory is re-read and the view is updated, in milliseconds. "
"Lower values give faster updates but use more CPU. Default: 660 ms.");
refreshDesc->setWordWrap(true);
refreshDesc->setContentsMargins(0, 0, 0, 0);
refreshLayout->addRow(refreshDesc);
generalLayout->addWidget(refreshGroup);
// Visual Experience group box
auto* visualGroup = new QGroupBox("Visual Experience");
auto* visualLayout = new QFormLayout(visualGroup);
visualLayout->setSpacing(8);
visualLayout->setFieldGrowthPolicy(QFormLayout::ExpandingFieldsGrow);
m_themeCombo = new QComboBox;
auto& tm = ThemeManager::instance();
for (const auto& theme : tm.themes())
m_themeCombo->addItem(theme.name);
m_themeCombo->setCurrentIndex(current.themeIndex);
m_themeCombo->setObjectName("themeCombo");
visualLayout->addRow("Color theme:", m_themeCombo);
m_fontCombo = new QComboBox;
m_fontCombo->addItem("JetBrains Mono");
m_fontCombo->addItem("Consolas");
m_fontCombo->setCurrentText(current.fontName);
m_fontCombo->setObjectName("fontCombo");
visualLayout->addRow("Editor Font:", m_fontCombo);
m_titleCaseCheck = new QCheckBox("Apply title case styling to menu bar");
m_titleCaseCheck->setChecked(current.menuBarTitleCase);
visualLayout->addRow(m_titleCaseCheck);
m_showIconCheck = new QCheckBox("Show icon in title bar");
m_showIconCheck->setChecked(current.showIcon);
visualLayout->addRow(m_showIconCheck);
generalLayout->addWidget(visualGroup);
// Safe Mode group box
auto* safeModeGroup = new QGroupBox("Preview Features");
auto* safeModeLayout = new QVBoxLayout(safeModeGroup);
safeModeLayout->setSpacing(4);
m_safeModeCheck = new QCheckBox("Safe Mode");
m_safeModeCheck->setChecked(current.safeMode);
safeModeLayout->addWidget(m_safeModeCheck);
auto* safeModeDesc = new QLabel(
"Enable to use the default OS icon for this application and "
"create the window with the name of the executable file.");
safeModeDesc->setWordWrap(true);
safeModeDesc->setContentsMargins(20, 0, 0, 0); // indent under checkbox
safeModeLayout->addWidget(safeModeDesc);
generalLayout->addWidget(safeModeGroup);
generalLayout->addStretch();
m_pages->addWidget(generalPage); // index 0
m_pageKeywords[generalItem] = collectPageKeywords(generalPage);
// -- AI Features page --
auto* aiItem = new QTreeWidgetItem(envItem, {"AI Features"});
auto* aiPage = new QWidget;
auto* aiLayout = new QVBoxLayout(aiPage);
aiLayout->setContentsMargins(0, 0, 0, 0);
aiLayout->setSpacing(8);
auto* mcpGroup = new QGroupBox("MCP Server");
auto* mcpLayout = new QVBoxLayout(mcpGroup);
mcpLayout->setSpacing(4);
m_autoMcpCheck = new QCheckBox("Auto-start MCP server");
m_autoMcpCheck->setChecked(current.autoStartMcp);
mcpLayout->addWidget(m_autoMcpCheck);
auto* mcpDesc = new QLabel(
"Automatically start the MCP bridge server when the application launches, "
"allowing external AI tools to connect and interact with the editor.");
mcpDesc->setWordWrap(true);
mcpDesc->setContentsMargins(20, 0, 0, 0);
mcpLayout->addWidget(mcpDesc);
aiLayout->addWidget(mcpGroup);
aiLayout->addStretch();
m_pages->addWidget(aiPage); // index 1
m_pageKeywords[aiItem] = collectPageKeywords(aiPage);
// -- Generator page --
auto* generatorItem = new QTreeWidgetItem(envItem, {"Generator"});
auto* generatorPage = new QWidget;
auto* generatorLayout = new QVBoxLayout(generatorPage);
generatorLayout->setContentsMargins(0, 0, 0, 0);
generatorLayout->setSpacing(8);
generatorLayout->addStretch();
m_pages->addWidget(generatorPage); // index 2
m_pageKeywords[generatorItem] = collectPageKeywords(generatorPage);
middleLayout->addWidget(m_pages, 1);
mainLayout->addLayout(middleLayout, 1);
// Tree <-> page connection
m_itemPageIndex[generalItem] = 0;
m_itemPageIndex[aiItem] = 1;
m_itemPageIndex[generatorItem] = 2;
connect(m_tree, &QTreeWidget::currentItemChanged, this,
[this](QTreeWidgetItem* item, QTreeWidgetItem*) {
if (!item) return;
auto it = m_itemPageIndex.find(item);
if (it != m_itemPageIndex.end())
m_pages->setCurrentIndex(it.value());
});
// -- Button box --
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
mainLayout->addWidget(buttons);
}
OptionsResult OptionsDialog::result() const {
OptionsResult r;
r.themeIndex = m_themeCombo->currentIndex();
r.fontName = m_fontCombo->currentText();
r.menuBarTitleCase = m_titleCaseCheck->isChecked();
r.showIcon = m_showIconCheck->isChecked();
r.safeMode = m_safeModeCheck->isChecked();
r.autoStartMcp = m_autoMcpCheck->isChecked();
r.refreshMs = m_refreshSpin->value();
return r;
}
QStringList OptionsDialog::collectPageKeywords(QWidget* page) {
QStringList keywords;
for (auto* child : page->findChildren<QWidget*>()) {
if (auto* label = qobject_cast<QLabel*>(child))
keywords << label->text();
else if (auto* cb = qobject_cast<QCheckBox*>(child))
keywords << cb->text();
else if (auto* gb = qobject_cast<QGroupBox*>(child))
keywords << gb->title();
else if (auto* combo = qobject_cast<QComboBox*>(child)) {
for (int i = 0; i < combo->count(); ++i)
keywords << combo->itemText(i);
}
}
return keywords;
}
void OptionsDialog::filterTree(const QString& text) {
std::function<bool(QTreeWidgetItem*)> filter = [&](QTreeWidgetItem* item) -> bool {
bool anyChildVisible = false;
for (int i = 0; i < item->childCount(); ++i) {
if (filter(item->child(i)))
anyChildVisible = true;
}
bool selfMatch = item->text(0).contains(text, Qt::CaseInsensitive);
if (!selfMatch) {
for (const auto& kw : m_pageKeywords.value(item)) {
if (kw.contains(text, Qt::CaseInsensitive)) {
selfMatch = true;
break;
}
}
}
bool visible = selfMatch || anyChildVisible;
item->setHidden(!visible);
if (visible && item->childCount() > 0)
item->setExpanded(true);
return visible;
};
for (int i = 0; i < m_tree->topLevelItemCount(); ++i)
filter(m_tree->topLevelItem(i));
}
} // namespace rcx

51
src/optionsdialog.h Normal file
View File

@@ -0,0 +1,51 @@
#pragma once
#include <QDialog>
#include <QLineEdit>
#include <QTreeWidget>
#include <QStackedWidget>
#include <QComboBox>
#include <QCheckBox>
#include <QHash>
#include <QSpinBox>
namespace rcx {
struct OptionsResult {
int themeIndex = 0;
QString fontName;
bool menuBarTitleCase = true;
bool showIcon = false;
bool safeMode = false;
bool autoStartMcp = false;
int refreshMs = 660;
};
class OptionsDialog : public QDialog {
Q_OBJECT
public:
explicit OptionsDialog(const OptionsResult& current, QWidget* parent = nullptr);
OptionsResult result() const;
private:
void filterTree(const QString& text);
static QStringList collectPageKeywords(QWidget* page);
QLineEdit* m_search = nullptr;
QTreeWidget* m_tree = nullptr;
QStackedWidget* m_pages = nullptr;
QComboBox* m_themeCombo = nullptr;
QComboBox* m_fontCombo = nullptr;
QCheckBox* m_titleCaseCheck = nullptr;
QCheckBox* m_showIconCheck = nullptr;
QCheckBox* m_safeModeCheck = nullptr;
QCheckBox* m_autoMcpCheck = nullptr;
QSpinBox* m_refreshSpin = nullptr;
// searchable keywords per leaf tree item
QHash<QTreeWidgetItem*, QStringList> m_pageKeywords;
// tree item → stacked widget page index
QHash<QTreeWidgetItem*, int> m_itemPageIndex;
};
} // namespace rcx

View File

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

View File

@@ -10,8 +10,8 @@
"textDim": "#858585",
"textMuted": "#585858",
"textFaint": "#505050",
"hover": "#2b2b2b",
"selected": "#232323",
"hover": "#1e1e1e",
"selected": "#1e1e1e",
"selection": "#2b2b2b",
"syntaxKeyword": "#569cd6",
"syntaxNumber": "#b5cea8",
@@ -22,6 +22,9 @@
"indHoverSpan": "#E6B450",
"indCmdPill": "#2a2a2a",
"indDataChanged": "#8fbc7a",
"indHeatCold": "#D4A945",
"indHeatWarm": "#E6B450",
"indHeatHot": "#f44747",
"indHintGreen": "#5a8248",
"markerPtr": "#f44747",
"markerCycle": "#e5a00d",

View File

@@ -10,8 +10,8 @@
"textDim": "#858585",
"textMuted": "#636369",
"textFaint": "#4d4d55",
"hover": "#3e3e42",
"selected": "#2d2d30",
"hover": "#2c2c2f",
"selected": "#262629",
"selection": "#264f78",
"syntaxKeyword": "#569cd6",
"syntaxNumber": "#b5cea8",
@@ -22,6 +22,9 @@
"indHoverSpan": "#b180d7",
"indCmdPill": "#2d2d30",
"indDataChanged": "#8fbc7a",
"indHeatCold": "#D4A945",
"indHeatWarm": "#d69d85",
"indHeatHot": "#f44747",
"indHintGreen": "#5a8248",
"markerPtr": "#f44747",
"markerCycle": "#e5a00d",

View File

@@ -10,8 +10,8 @@
"textDim": "#7a7a6e",
"textMuted": "#555550",
"textFaint": "#464646",
"hover": "#373737",
"selected": "#2d2d2d",
"hover": "#282828",
"selected": "#262626",
"selection": "#21213A",
"syntaxKeyword": "#AA9565",
"syntaxNumber": "#AAA98C",
@@ -22,6 +22,9 @@
"indHoverSpan": "#AA9565",
"indCmdPill": "#2a2a2a",
"indDataChanged": "#6B959F",
"indHeatCold": "#C4A44A",
"indHeatWarm": "#AA9565",
"indHeatHot": "#A05040",
"indHintGreen": "#464646",
"markerPtr": "#6B3B21",
"markerCycle": "#AA9565",

View File

@@ -28,6 +28,9 @@ const ThemeFieldMeta kThemeFields[] = {
{"indHoverSpan", "Hover Span", "Indicators", &Theme::indHoverSpan},
{"indCmdPill", "Cmd Pill", "Indicators", &Theme::indCmdPill},
{"indDataChanged","Data Changed", "Indicators", &Theme::indDataChanged},
{"indHeatCold", "Heat Cold", "Indicators", &Theme::indHeatCold},
{"indHeatWarm", "Heat Warm", "Indicators", &Theme::indHeatWarm},
{"indHeatHot", "Heat Hot", "Indicators", &Theme::indHeatHot},
{"indHintGreen", "Hint Green", "Indicators", &Theme::indHintGreen},
{"markerPtr", "Pointer", "Markers", &Theme::markerPtr},
{"markerCycle", "Cycle", "Markers", &Theme::markerCycle},
@@ -50,6 +53,14 @@ Theme Theme::fromJson(const QJsonObject& o) {
if (o.contains(kThemeFields[i].key))
t.*kThemeFields[i].ptr = QColor(o[kThemeFields[i].key].toString());
}
// Derive heat colors from the theme's own palette when keys are absent
// cold = muted yellow, warm = hover/string amber, hot = marker red
if (!t.indHeatCold.isValid())
t.indHeatCold = QColor("#D4A945");
if (!t.indHeatWarm.isValid())
t.indHeatWarm = t.indHoverSpan.isValid() ? t.indHoverSpan : t.syntaxString;
if (!t.indHeatHot.isValid())
t.indHeatHot = t.markerPtr;
return t;
}

View File

@@ -38,7 +38,10 @@ struct Theme {
// ── Indicators ──
QColor indHoverSpan; // hover link text
QColor indCmdPill; // command row pill bg
QColor indDataChanged; // changed data values
QColor indDataChanged; // changed data values (legacy, fallback for old themes)
QColor indHeatCold; // heatmap level 1 (changed once)
QColor indHeatWarm; // heatmap level 2 (moderate changes)
QColor indHeatHot; // heatmap level 3 (frequent changes)
QColor indHintGreen; // comment/hint text
// ── Markers ──

View File

@@ -114,6 +114,33 @@ void TitleBarWidget::setShowIcon(bool show) {
}
}
void TitleBarWidget::setMenuBarTitleCase(bool titleCase) {
m_titleCase = titleCase;
for (QAction* action : m_menuBar->actions()) {
QString text = action->text();
QString clean = text;
clean.remove('&');
if (titleCase) {
action->setText("&" + clean.toUpper());
} else {
QString result;
bool capitalizeNext = true;
for (int i = 0; i < clean.length(); ++i) {
QChar ch = clean[i];
if (ch.isLetter()) {
result += capitalizeNext ? ch.toUpper() : ch.toLower();
capitalizeNext = false;
} else {
result += ch;
if (ch.isSpace()) capitalizeNext = true;
}
}
action->setText("&" + result);
}
}
}
void TitleBarWidget::updateMaximizeIcon() {
if (window()->isMaximized())
m_btnMax->setIcon(QIcon(":/vsicons/chrome-restore.svg"));

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,6 +8,26 @@
using namespace rcx;
// Provider with a configurable base address (for testing source-switch logic)
class BaseAwareProvider : public Provider {
QByteArray m_data;
uint64_t m_base;
public:
BaseAwareProvider(QByteArray data, uint64_t base)
: m_data(std::move(data)), m_base(base) {}
bool read(uint64_t addr, void* buf, int len) const override {
if (addr + len > (uint64_t)m_data.size()) return false;
std::memcpy(buf, m_data.constData() + addr, len);
return true;
}
int size() const override { return m_data.size(); }
uint64_t base() const override { return m_base; }
void setBase(uint64_t b) override { m_base = b; }
bool isLive() const override { return true; }
QString name() const override { return QStringLiteral("test"); }
QString kind() const override { return QStringLiteral("Process"); }
};
// Small tree: one root struct with a few typed fields at known offsets.
// Keeps tests fast and deterministic (no giant PEB tree).
static void buildSmallTree(NodeTree& tree) {
@@ -34,9 +54,8 @@ static void buildSmallTree(NodeTree& tree) {
field(0, NodeKind::UInt32, "field_u32"); // 4 bytes
field(4, NodeKind::Float, "field_float"); // 4 bytes
field(8, NodeKind::UInt8, "field_u8"); // 1 byte
field(9, NodeKind::Padding, "pad0"); // 3 bytes padding
// Set padding arrayLen = 3 for 3-byte padding
tree.nodes.last().arrayLen = 3;
field(9, NodeKind::Hex16, "pad0"); // 2 bytes
field(11, NodeKind::Hex8, "pad1"); // 1 byte
field(12, NodeKind::Hex32, "field_hex"); // 4 bytes
}
@@ -282,47 +301,6 @@ private slots:
QVERIFY(newIdx >= 0);
}
// ── Test: Padding value edit is effectively blocked at controller level ──
void testPaddingValueEditIsBlocked() {
// Find the padding node
int padIdx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].kind == NodeKind::Padding) { padIdx = i; break; }
}
QVERIFY(padIdx >= 0);
uint64_t addr = m_doc->tree.computeOffset(padIdx);
// Read original data at padding offset
int padSize = m_doc->tree.nodes[padIdx].byteSize();
QByteArray origData = m_doc->provider->readBytes(addr, padSize);
// The context menu blocks Padding editing, so the controller's setNodeValue
// would only be called if the editing UI somehow allows it. But let's verify
// the editor correctly blocks it.
// Find padding line in composed output
ComposeResult result = m_doc->compose();
int paddingLine = -1;
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].nodeKind == NodeKind::Padding &&
result.meta[i].lineKind == LineKind::Field) {
paddingLine = i;
break;
}
}
QVERIFY(paddingLine >= 0);
m_editor->applyDocument(result);
QApplication::processEvents();
// beginInlineEdit(Value) on Padding line must be rejected
QVERIFY(!m_editor->beginInlineEdit(EditTarget::Value, paddingLine));
QVERIFY(!m_editor->isEditing());
// Data must be unchanged
QByteArray afterData = m_doc->provider->readBytes(addr, padSize);
QCOMPARE(afterData, origData);
}
// ── Test: setNodeValue with Hex32 (space-separated hex bytes) ──
void testSetNodeValueHex() {
int idx = -1;
@@ -425,6 +403,48 @@ private slots:
QCOMPARE((uint8_t)bytes[0], (uint8_t)0xFF);
}
// ── Test: source switch preserves existing base address ──
void testSourceSwitchPreservesBase() {
// Document already has baseAddress = 0x1000 from buildSmallTree()
QCOMPARE(m_doc->tree.baseAddress, (uint64_t)0x1000);
// Simulate attaching a new provider whose base differs (e.g. 0x400000)
auto prov = std::make_shared<BaseAwareProvider>(makeSmallBuffer(), 0x400000);
uint64_t newBase = prov->base();
QCOMPARE(newBase, (uint64_t)0x400000);
m_doc->provider = prov;
// This is the controller logic under test:
if (m_doc->tree.baseAddress == 0)
m_doc->tree.baseAddress = newBase;
else
m_doc->provider->setBase(m_doc->tree.baseAddress);
// baseAddress must stay at the original value
QCOMPARE(m_doc->tree.baseAddress, (uint64_t)0x1000);
// provider base must be synced to match
QCOMPARE(m_doc->provider->base(), (uint64_t)0x1000);
}
// ── Test: source switch on fresh doc uses provider default ──
void testSourceSwitchFreshDocUsesProviderBase() {
// Simulate a fresh document (no loaded .rcx → baseAddress == 0)
m_doc->tree.baseAddress = 0;
auto prov = std::make_shared<BaseAwareProvider>(makeSmallBuffer(), 0x7FFE0000);
uint64_t newBase = prov->base();
m_doc->provider = prov;
if (m_doc->tree.baseAddress == 0)
m_doc->tree.baseAddress = newBase;
else
m_doc->provider->setBase(m_doc->tree.baseAddress);
// Fresh doc should adopt the provider's default base
QCOMPARE(m_doc->tree.baseAddress, (uint64_t)0x7FFE0000);
QCOMPARE(m_doc->provider->base(), (uint64_t)0x7FFE0000);
}
// ── Test: toggleCollapse + undo ──
void testToggleCollapse() {
// Root is index 0, a Struct node
@@ -448,6 +468,181 @@ private slots:
QApplication::processEvents();
QCOMPARE(m_doc->tree.nodes[0].collapsed, false);
}
// ── Test: value history popup only appears during inline editing ──
void testValueHistoryPopupOnlyDuringEdit() {
// Record value history for field_u32 so it has heat
auto& tree = m_doc->tree;
int idx = -1;
for (int i = 0; i < tree.nodes.size(); i++) {
if (tree.nodes[i].name == "field_u32") { idx = i; break; }
}
QVERIFY(idx >= 0);
uint64_t nodeId = tree.nodes[idx].id;
QHash<uint64_t, ValueHistory> history;
history[nodeId].record("100");
history[nodeId].record("200");
history[nodeId].record("300");
QVERIFY(history[nodeId].uniqueCount() > 1);
m_editor->setValueHistoryRef(&history);
// Refresh and compose so editor has meta with heatLevel
m_ctrl->refresh();
QApplication::processEvents();
ComposeResult result = m_doc->compose();
// Manually set heat on the node's line meta
for (auto& lm : result.meta) {
if (lm.nodeId == nodeId) lm.heatLevel = 2;
}
m_editor->applyDocument(result);
QApplication::processEvents();
// Popup should not exist or not be visible (no editing active)
auto* popup = m_editor->findChild<QWidget*>(QString(), Qt::FindDirectChildrenOnly);
// Even if popup widget exists, it should not be visible
bool popupVisible = false;
for (auto* child : m_editor->findChildren<QFrame*>(QString(), Qt::FindDirectChildrenOnly)) {
if (child->isVisible() && child->windowFlags() & Qt::ToolTip)
popupVisible = true;
}
QVERIFY2(!popupVisible, "Popup should not be visible when not editing");
// Start inline edit on value column of field_u32
int fieldLine = -1;
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].nodeId == nodeId && result.meta[i].lineKind == LineKind::Field) {
fieldLine = i; break;
}
}
QVERIFY(fieldLine >= 0);
bool ok = m_editor->beginInlineEdit(EditTarget::Value, fieldLine);
QVERIFY(ok);
QVERIFY(m_editor->isEditing());
// Trigger hover cursor update (simulates mouse move during editing)
QApplication::processEvents();
// Cancel edit to clean up
m_editor->cancelInlineEdit();
QApplication::processEvents();
m_editor->setValueHistoryRef(nullptr);
}
// ── Test: delete node clears value history for shifted siblings ──
void testDeleteClearsHeatForShiftedNodes() {
// Replace with a live provider so refresh() actually records values
m_doc->provider = std::make_unique<BaseAwareProvider>(makeSmallBuffer(), 0x1000);
m_ctrl->refresh();
QApplication::processEvents();
auto& tree = m_doc->tree;
// Locate field_u32 (the node we'll delete) and the siblings after it.
// The small tree has: field_u32(0), field_float(4), field_u8(8),
// pad0/Hex16(9), pad1/Hex8(11), field_hex/Hex32(12)
// field_float and field_u8 are regular (non-hex) types.
int delIdx = -1;
for (int i = 0; i < tree.nodes.size(); i++) {
if (tree.nodes[i].name == "field_u32") { delIdx = i; break; }
}
QVERIFY(delIdx >= 0);
uint64_t delId = tree.nodes[delIdx].id;
// Collect sibling node IDs that come after field_u32 (will be shifted)
uint64_t parentId = tree.nodes[delIdx].parentId;
int deletedSize = tree.nodes[delIdx].byteSize(); // 4 bytes
int deletedEnd = tree.nodes[delIdx].offset + deletedSize;
QVector<uint64_t> shiftedIds;
QHash<uint64_t, QString> nameMap; // for debug messages
for (int i = 0; i < tree.nodes.size(); i++) {
if (tree.nodes[i].parentId == parentId && i != delIdx
&& tree.nodes[i].offset >= deletedEnd) {
shiftedIds.append(tree.nodes[i].id);
nameMap[tree.nodes[i].id] = tree.nodes[i].name;
}
}
QVERIFY2(!shiftedIds.isEmpty(), "Should have siblings after field_u32");
// Seed value history for shifted siblings (simulate accumulated heat)
auto& history = const_cast<QHash<uint64_t, ValueHistory>&>(m_ctrl->valueHistory());
for (uint64_t id : shiftedIds) {
history[id].record("old_val_1");
history[id].record("old_val_2");
history[id].record("old_val_3");
QVERIFY2(history[id].heatLevel() >= 2,
qPrintable(QString("Pre-delete: %1 should have heat>=2")
.arg(nameMap[id])));
}
// Also seed the to-be-deleted node
history[delId].record("del_1");
history[delId].record("del_2");
QVERIFY(history.contains(delId));
// Delete field_u32 — this shifts all subsequent siblings
m_ctrl->removeNode(delIdx);
QApplication::processEvents();
// The deleted node's history should be gone
QVERIFY2(!m_ctrl->valueHistory().contains(delId),
"Deleted node's value history should be cleared");
// All shifted siblings should have heat=0 after the delete.
// With a live provider, refresh() inside removeNode re-records one new
// value at the new offset → count=1 → heatLevel=0.
for (uint64_t id : shiftedIds) {
int heat = m_ctrl->valueHistory().contains(id)
? m_ctrl->valueHistory()[id].heatLevel() : 0;
QVERIFY2(heat == 0,
qPrintable(QString("Shifted node '%1' (id=%2) should have heat=0, got %3")
.arg(nameMap[id]).arg(id).arg(heat)));
}
}
// ── Test: value history records and cycles correctly ──
void testValueHistoryRingBuffer() {
ValueHistory vh;
QCOMPARE(vh.count, 0);
QCOMPARE(vh.heatLevel(), 0);
vh.record("10");
QCOMPARE(vh.count, 1);
QCOMPARE(vh.heatLevel(), 0); // 1 unique = static
// Duplicate should not increase count
vh.record("10");
QCOMPARE(vh.count, 1);
vh.record("20");
QCOMPARE(vh.count, 2);
QCOMPARE(vh.heatLevel(), 1); // cold
vh.record("30");
QCOMPARE(vh.count, 3);
QCOMPARE(vh.heatLevel(), 2); // warm
vh.record("40");
vh.record("50");
QCOMPARE(vh.count, 5);
QCOMPARE(vh.heatLevel(), 3); // hot
QCOMPARE(vh.last(), QString("50"));
// Ring buffer: uniqueCount() caps at kCapacity
for (int i = 0; i < 20; i++)
vh.record(QString::number(100 + i));
QCOMPARE(vh.uniqueCount(), ValueHistory::kCapacity);
QVERIFY(vh.count > ValueHistory::kCapacity);
// forEach iterates oldest→newest within ring
QStringList vals;
vh.forEach([&](const QString& v) { vals.append(v); });
QCOMPARE(vals.size(), ValueHistory::kCapacity);
QCOMPARE(vals.last(), vh.last());
}
};
QTEST_MAIN(TestController)

View File

@@ -583,6 +583,94 @@ private slots:
QCOMPARE(norm.size(), 1);
QVERIFY(norm.contains(rootId));
}
// ── ValueHistory tests ──
void testValueHistory_empty() {
rcx::ValueHistory h;
QCOMPARE(h.heatLevel(), 0);
QCOMPARE(h.uniqueCount(), 0);
QCOMPARE(h.last(), QString());
}
void testValueHistory_singleValue() {
rcx::ValueHistory h;
h.record("42");
QCOMPARE(h.heatLevel(), 0); // only 1 unique → static
QCOMPARE(h.uniqueCount(), 1);
QCOMPARE(h.last(), QString("42"));
}
void testValueHistory_duplicateIgnored() {
rcx::ValueHistory h;
h.record("42");
h.record("42");
h.record("42");
QCOMPARE(h.count, 1);
QCOMPARE(h.heatLevel(), 0);
}
void testValueHistory_heatLevels() {
rcx::ValueHistory h;
h.record("a");
QCOMPARE(h.heatLevel(), 0); // 1 unique
h.record("b");
QCOMPARE(h.heatLevel(), 1); // 2 unique → cold
h.record("c");
QCOMPARE(h.heatLevel(), 2); // 3 unique → warm
h.record("d");
QCOMPARE(h.heatLevel(), 2); // 4 unique → warm
h.record("e");
QCOMPARE(h.heatLevel(), 3); // 5 unique → hot
}
void testValueHistory_ringWrap() {
rcx::ValueHistory h;
// Fill beyond capacity
for (int i = 0; i < 15; i++)
h.record(QString::number(i));
QCOMPARE(h.count, 15);
QCOMPARE(h.uniqueCount(), 10); // capped at kCapacity
QCOMPARE(h.heatLevel(), 3); // hot
QCOMPARE(h.last(), QString("14"));
// Verify oldest values were pushed out, newest 10 remain
QStringList collected;
h.forEach([&](const QString& v) { collected.append(v); });
QCOMPARE(collected.size(), 10);
QCOMPARE(collected.first(), QString("5")); // oldest surviving
QCOMPARE(collected.last(), QString("14")); // newest
}
void testValueHistory_forEach() {
rcx::ValueHistory h;
h.record("x");
h.record("y");
h.record("z");
QStringList items;
h.forEach([&](const QString& v) { items.append(v); });
QCOMPARE(items.size(), 3);
QCOMPARE(items[0], QString("x"));
QCOMPARE(items[1], QString("y"));
QCOMPARE(items[2], QString("z"));
}
void testValueHistory_oscillation() {
// Values that oscillate (A → B → A → B) should still count each unique transition
rcx::ValueHistory h;
h.record("A");
h.record("B");
h.record("A");
h.record("B");
QCOMPARE(h.count, 4); // 4 transitions
QCOMPARE(h.heatLevel(), 2); // warm (count=4 → 3-4 range)
}
};
QTEST_MAIN(TestCore)

View File

@@ -170,9 +170,10 @@ static NodeTree makeTestTree() {
n.parentId = rootId; n.offset = off;
tree.addNode(n);
};
auto pad = [&](int off, int len, const char* name) {
Node n; n.kind = NodeKind::Padding; n.name = name;
n.parentId = rootId; n.offset = off; n.arrayLen = len;
auto pad = [&](int off, int /*len*/, const char* name) {
// 4-byte padding → Hex32 (all usages in this test pass len=4)
Node n; n.kind = NodeKind::Hex32; n.name = name;
n.parentId = rootId; n.offset = off;
tree.addNode(n);
};
auto arr = [&](int off, NodeKind ek, int len, const char* name) {
@@ -278,8 +279,8 @@ static NodeTree makeTestTree() {
n.kind = NodeKind::UInt16; n.name = "Length"; n.offset = 0; tree.addNode(n);
n.kind = NodeKind::UInt16; n.name = "MaximumLength"; n.offset = 2; tree.addNode(n);
n.kind = NodeKind::Padding; n.name = "Pad";
n.offset = 4; n.arrayLen = 4; tree.addNode(n);
n.kind = NodeKind::Hex32; n.name = "Pad";
n.offset = 4; n.arrayLen = 1; tree.addNode(n);
n.kind = NodeKind::Pointer64; n.name = "Buffer"; n.offset = 8; n.arrayLen = 1;
tree.addNode(n);
}
@@ -751,70 +752,6 @@ private slots:
m_editor->applyDocument(m_result);
}
// ── Test: Padding line rejects value editing ──
void testPaddingLineRejectsValueEdit() {
m_editor->applyDocument(m_result);
// Find a Padding line in the composed output
int paddingLine = -1;
for (int i = 0; i < m_result.meta.size(); i++) {
if (m_result.meta[i].nodeKind == NodeKind::Padding &&
m_result.meta[i].lineKind == LineKind::Field) {
paddingLine = i;
break;
}
}
QVERIFY2(paddingLine >= 0, "Should have at least one Padding line in test tree");
const LineMeta* lm = m_editor->metaForLine(paddingLine);
QVERIFY(lm);
QCOMPARE(lm->nodeKind, NodeKind::Padding);
// Value edit on Padding MUST be rejected (the bug fix)
QVERIFY2(!m_editor->beginInlineEdit(EditTarget::Value, paddingLine),
"Value edit should be rejected on Padding lines");
QVERIFY(!m_editor->isEditing());
// Name edit on Padding SHOULD succeed (ASCII preview column is editable)
bool ok = m_editor->beginInlineEdit(EditTarget::Name, paddingLine);
QVERIFY2(ok, "Name edit should be allowed on Padding lines (ASCII preview)");
QVERIFY(m_editor->isEditing());
m_editor->cancelInlineEdit();
// Type edit on Padding SHOULD succeed (emits popup signal)
QSignalSpy typeSpy(m_editor, &RcxEditor::typePickerRequested);
ok = m_editor->beginInlineEdit(EditTarget::Type, paddingLine);
QVERIFY2(ok, "Type edit should be allowed on Padding lines");
QCOMPARE(typeSpy.count(), 1);
}
// ── Test: resolvedSpanFor rejects Value on Padding (defense-in-depth) ──
void testPaddingLineRejectsValueSpan() {
m_editor->applyDocument(m_result);
// Find a Padding line
int paddingLine = -1;
for (int i = 0; i < m_result.meta.size(); i++) {
if (m_result.meta[i].nodeKind == NodeKind::Padding &&
m_result.meta[i].lineKind == LineKind::Field) {
paddingLine = i;
break;
}
}
QVERIFY(paddingLine >= 0);
const LineMeta* lm = m_editor->metaForLine(paddingLine);
QVERIFY(lm);
// valueSpanFor returns valid (shared with Hex via KF_HexPreview)
ColumnSpan vs = RcxEditor::valueSpan(*lm, 200);
QVERIFY2(vs.valid, "valueSpanFor should return valid for Padding (shared HexPreview flag)");
// But beginInlineEdit should still reject it
QVERIFY(!m_editor->beginInlineEdit(EditTarget::Value, paddingLine));
QVERIFY(!m_editor->isEditing());
}
// ── Test: value edit commit fires signal with typed text ──
void testValueEditCommitUpdatesSignal() {
m_editor->applyDocument(m_result);
@@ -823,8 +760,6 @@ private slots:
const LineMeta* lm = m_editor->metaForLine(kFirstDataLine);
QVERIFY(lm);
QCOMPARE(lm->lineKind, LineKind::Field);
QVERIFY(lm->nodeKind != NodeKind::Padding);
// Begin value edit
bool ok = m_editor->beginInlineEdit(EditTarget::Value, kFirstDataLine);
QVERIFY(ok);
@@ -1064,6 +999,144 @@ private slots:
"Root header should be suppressed from compose output");
}
// ── Test: command row hover indicator survives refresh cycle ──
void testCommandRowHoverSurvivesRefresh() {
// IND_HOVER_SPAN = 11 (defined in editor.cpp, replicate for test)
constexpr int IND_HOVER_SPAN = 11;
m_editor->applyDocument(m_result);
// Set command row text (simulates controller.updateCommandRow)
QString cmdText = QStringLiteral(
"source\u25BE \u00B7 0xD87B5E5000 \u00B7 struct\u25BE _PEB64 {");
m_editor->setCommandRowText(cmdText);
QApplication::processEvents();
// Parse the source span on line 0
auto* sci = m_editor->scintilla();
int len = (int)sci->SendScintilla(
QsciScintillaBase::SCI_LINELENGTH, (unsigned long)0);
QVERIFY(len > 0);
QByteArray buf(len + 1, '\0');
sci->SendScintilla(QsciScintillaBase::SCI_GETLINE, (unsigned long)0,
(void*)buf.data());
QString lineText = QString::fromUtf8(buf.constData(), len);
while (lineText.endsWith('\n') || lineText.endsWith('\r'))
lineText.chop(1);
ColumnSpan srcSpan = commandRowSrcSpan(lineText);
QVERIFY2(srcSpan.valid, "Source span should be valid on command row");
// Programmatically move mouse to the source span
int hoverCol = srcSpan.start + 1;
QPoint hoverPos = colToViewport(sci, 0, hoverCol);
sendMouseMove(sci->viewport(), hoverPos);
QApplication::processEvents();
// Verify IND_HOVER_SPAN is set at the hover position
long pos = sci->SendScintilla(QsciScintillaBase::SCI_FINDCOLUMN,
(unsigned long)0, (long)hoverCol);
sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT,
(unsigned long)IND_HOVER_SPAN);
int valBefore = (int)sci->SendScintilla(
QsciScintillaBase::SCI_INDICATORVALUEAT,
(unsigned long)IND_HOVER_SPAN, pos);
QVERIFY2(valBefore != 0,
"IND_HOVER_SPAN should be set on source span after hover");
// Verify cursor is PointingHand (Source target = clickable)
QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor);
// ── Simulate a full refresh cycle (same order as controller.refresh) ──
ViewState vs = m_editor->saveViewState();
m_editor->applyDocument(m_result);
m_editor->restoreViewState(vs);
// Cursor must NOT have flipped to Arrow during applyDocument
// (applyHoverCursor is not called prematurely on composed text)
QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor);
// updateCommandRow() — replaces line 0 text
m_editor->setCommandRowText(cmdText);
// applySelectionOverlays() — must run AFTER updateCommandRow
m_editor->applySelectionOverlay(QSet<uint64_t>());
QApplication::processEvents();
// Re-query the position (text was replaced, byte offset may have shifted)
long posAfter = sci->SendScintilla(QsciScintillaBase::SCI_FINDCOLUMN,
(unsigned long)0, (long)hoverCol);
int valAfter = (int)sci->SendScintilla(
QsciScintillaBase::SCI_INDICATORVALUEAT,
(unsigned long)IND_HOVER_SPAN, posAfter);
QVERIFY2(valAfter != 0,
"IND_HOVER_SPAN must survive refresh on command row "
"(hover should not flicker)");
// Cursor must still be PointingHand after full refresh cycle
QCOMPARE(viewportCursor(m_editor), Qt::PointingHandCursor);
m_editor->applyDocument(m_result);
}
// ── Test: command row hover survives multiple rapid refresh cycles ──
void testCommandRowHoverSurvivesRepeatedRefresh() {
constexpr int IND_HOVER_SPAN = 11;
m_editor->applyDocument(m_result);
QString cmdText = QStringLiteral(
"source\u25BE \u00B7 0xD87B5E5000 \u00B7 struct\u25BE _PEB64 {");
m_editor->setCommandRowText(cmdText);
QApplication::processEvents();
auto* sci = m_editor->scintilla();
int lineLen = (int)sci->SendScintilla(
QsciScintillaBase::SCI_LINELENGTH, (unsigned long)0);
QByteArray buf(lineLen + 1, '\0');
sci->SendScintilla(QsciScintillaBase::SCI_GETLINE, (unsigned long)0,
(void*)buf.data());
QString lineText = QString::fromUtf8(buf.constData(), lineLen);
while (lineText.endsWith('\n') || lineText.endsWith('\r'))
lineText.chop(1);
ColumnSpan srcSpan = commandRowSrcSpan(lineText);
QVERIFY(srcSpan.valid);
int hoverCol = srcSpan.start + 1;
// Move mouse into position
QPoint hoverPos = colToViewport(sci, 0, hoverCol);
sendMouseMove(sci->viewport(), hoverPos);
QApplication::processEvents();
// Simulate 5 rapid refresh cycles (like ~660ms timer x5)
for (int cycle = 0; cycle < 5; cycle++) {
ViewState vs = m_editor->saveViewState();
m_editor->applyDocument(m_result);
m_editor->restoreViewState(vs);
m_editor->setCommandRowText(cmdText);
m_editor->applySelectionOverlay(QSet<uint64_t>());
// Re-send mouse move each cycle (mouse is still there physically)
sendMouseMove(sci->viewport(), hoverPos);
QApplication::processEvents();
long pos = sci->SendScintilla(QsciScintillaBase::SCI_FINDCOLUMN,
(unsigned long)0, (long)hoverCol);
int val = (int)sci->SendScintilla(
QsciScintillaBase::SCI_INDICATORVALUEAT,
(unsigned long)IND_HOVER_SPAN, pos);
QVERIFY2(val != 0,
qPrintable(QString(
"IND_HOVER_SPAN lost on refresh cycle %1").arg(cycle)));
QVERIFY2(viewportCursor(m_editor) == Qt::PointingHandCursor,
qPrintable(QString(
"Cursor flipped away from PointingHand on cycle %1").arg(cycle)));
}
m_editor->applyDocument(m_result);
}
// ── Test: MenuBarStyle gives QMenu items generous click targets ──
// ── Test: M_ACCENT marker appears on selected rows ──
void testAccentMarkerOnSelectedRows() {
@@ -1182,6 +1255,157 @@ private slots:
.arg(styled.height()).arg(base.height())));
}
// ── Test: non-hex nodes don't show false heat coloring after offset shift ──
void testDeleteClearsHeatOnShiftedNodes() {
// Heat indicator constants (replicated from editor.cpp)
constexpr int IND_HEAT_COLD = 13;
constexpr int IND_HEAT_WARM = 17;
constexpr int IND_HEAT_HOT = 18;
// Build a small tree: root struct with mixed regular (non-hex) + hex fields
NodeTree tree;
tree.baseAddress = 0x1000;
Node root;
root.kind = NodeKind::Struct;
root.structTypeName = "SmallStruct";
root.name = "s";
root.parentId = 0;
root.offset = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
// field0: UInt32 at offset 0 (4 bytes) — will be deleted
// field1: UInt32 at offset 4 (4 bytes) — regular type, will shift
// field2: Float at offset 8 (4 bytes) — regular type, will shift
// field3: Hex32 at offset 12 (4 bytes) — hex type, will shift
struct FieldDef { int off; NodeKind kind; const char* name; };
FieldDef defs[] = {
{ 0, NodeKind::UInt32, "count"},
{ 4, NodeKind::UInt32, "flags"},
{ 8, NodeKind::Float, "speed"},
{12, NodeKind::Hex32, "raw"},
};
QVector<uint64_t> fieldIds;
for (auto& d : defs) {
Node n;
n.kind = d.kind;
n.name = d.name;
n.parentId = rootId;
n.offset = d.off;
int idx = tree.addNode(n);
fieldIds.append(tree.nodes[idx].id);
}
// Create a provider with 16 bytes of recognizable data
QByteArray data(16, '\0');
uint32_t v0 = 42; memcpy(data.data() + 0, &v0, 4); // count=42
uint32_t v1 = 0xFF; memcpy(data.data() + 4, &v1, 4); // flags=255
float v2 = 3.14f; memcpy(data.data() + 8, &v2, 4); // speed=3.14
uint32_t v3 = 0xCAFE; memcpy(data.data() + 12, &v3, 4); // raw=0xCAFE
BufferProvider prov(data);
// Compose the initial document
ComposeResult result = compose(tree, prov);
// Inject heatLevel=2 (warm) on field1, field2, field3 — simulates
// heat accumulated before the delete
for (auto& lm : result.meta) {
for (int i = 1; i <= 3; i++) {
if (lm.nodeId == fieldIds[i])
lm.heatLevel = 2;
}
}
// Apply to editor — heat indicators should appear
m_editor->applyDocument(result);
QApplication::processEvents();
auto* sci = m_editor->scintilla();
// Helper: check if any heat indicator is set anywhere on a line
auto hasHeatOnLine = [&](int line) -> bool {
int lineLen = (int)sci->SendScintilla(
QsciScintillaBase::SCI_LINELENGTH, (unsigned long)line);
long lineStart = sci->SendScintilla(
QsciScintillaBase::SCI_POSITIONFROMLINE, (unsigned long)line);
for (long pos = lineStart; pos < lineStart + lineLen; pos++) {
for (int ind : { IND_HEAT_COLD, IND_HEAT_WARM, IND_HEAT_HOT }) {
int val = (int)sci->SendScintilla(
QsciScintillaBase::SCI_INDICATORVALUEAT,
(unsigned long)ind, pos);
if (val != 0) return true;
}
}
return false;
};
// Find lines for each shifted field
auto findFieldLine = [&](const ComposeResult& cr, uint64_t nodeId) -> int {
for (int i = 0; i < cr.meta.size(); i++) {
if (cr.meta[i].nodeId == nodeId && cr.meta[i].lineKind == LineKind::Field)
return i;
}
return -1;
};
int line1 = findFieldLine(result, fieldIds[1]);
int line2 = findFieldLine(result, fieldIds[2]);
int line3 = findFieldLine(result, fieldIds[3]);
QVERIFY(line1 >= 0);
QVERIFY(line2 >= 0);
QVERIFY(line3 >= 0);
// Verify heat indicators ARE present (UInt32, Float, and Hex32)
QVERIFY2(hasHeatOnLine(line1),
"Heat should be present on UInt32 'flags' before delete");
QVERIFY2(hasHeatOnLine(line2),
"Heat should be present on Float 'speed' before delete");
QVERIFY2(hasHeatOnLine(line3),
"Heat should be present on Hex32 'raw' before delete");
// ── Simulate delete of field0 (UInt32 'count' at offset 0) ──
int field0Idx = tree.indexOfId(fieldIds[0]);
QVERIFY(field0Idx >= 0);
tree.nodes.remove(field0Idx);
tree.invalidateIdCache();
// Shift remaining fields' offsets down by 4
for (int i = 1; i <= 3; i++) {
int fi = tree.indexOfId(fieldIds[i]);
if (fi >= 0) tree.nodes[fi].offset -= 4;
}
// Recompose — heatLevel defaults to 0 (simulates cleared history)
ComposeResult afterResult = compose(tree, prov);
// Apply the post-delete document to the editor
m_editor->applyDocument(afterResult);
QApplication::processEvents();
// Find new line positions
int newLine1 = findFieldLine(afterResult, fieldIds[1]);
int newLine2 = findFieldLine(afterResult, fieldIds[2]);
int newLine3 = findFieldLine(afterResult, fieldIds[3]);
QVERIFY(newLine1 >= 0);
QVERIFY(newLine2 >= 0);
QVERIFY(newLine3 >= 0);
// After applying heatLevel=0, NO heat indicators should appear
QVERIFY2(!hasHeatOnLine(newLine1),
"UInt32 'flags' should NOT show heat after offset shift "
"(old values are from wrong address)");
QVERIFY2(!hasHeatOnLine(newLine2),
"Float 'speed' should NOT show heat after offset shift "
"(old values are from wrong address)");
QVERIFY2(!hasHeatOnLine(newLine3),
"Hex32 'raw' should NOT show heat after offset shift "
"(old values are from wrong address)");
// Restore original document
m_editor->applyDocument(m_result);
}
void testMenuHoverRendersAmberText() {
// Replicate MenuBarStyle with drawControl hover override
class TestMenuStyle : public QProxyStyle {

360
tests/test_export_xml.cpp Normal file
View File

@@ -0,0 +1,360 @@
#include <QtTest/QtTest>
#include <QTemporaryFile>
#include "core.h"
#include "export_reclass_xml.h"
#include "import_reclass_xml.h"
using namespace rcx;
class TestExportXml : public QObject {
Q_OBJECT
private slots:
void exportEmptyTree();
void exportSingleStruct();
void exportPointerRef();
void exportEmbeddedStruct();
void exportArray();
void exportTextNodes();
void exportVectors();
void exportHexCollapse();
void exportMultiClass();
void roundTripImportExport();
};
static int countRoots(const NodeTree& tree) {
int n = 0;
for (const auto& node : tree.nodes)
if (node.parentId == 0 && node.kind == NodeKind::Struct) n++;
return n;
}
static QVector<int> childrenOf(const NodeTree& tree, uint64_t parentId) {
QVector<int> result;
for (int i = 0; i < tree.nodes.size(); i++)
if (tree.nodes[i].parentId == parentId) result.append(i);
return result;
}
static QString exportToString(const NodeTree& tree) {
QTemporaryFile tmp;
tmp.setAutoRemove(true);
if (!tmp.open()) return {};
QString path = tmp.fileName();
tmp.close();
QString err;
if (!exportReclassXml(tree, path, &err)) return {};
QFile f(path);
if (!f.open(QIODevice::ReadOnly | QIODevice::Text)) return {};
return QString::fromUtf8(f.readAll());
}
static NodeTree roundTrip(const NodeTree& tree) {
QTemporaryFile tmp;
tmp.setAutoRemove(true);
if (!tmp.open()) return {};
QString path = tmp.fileName();
tmp.close();
QString err;
if (!exportReclassXml(tree, path, &err)) return {};
return importReclassXml(path, &err);
}
// ── Tests ──
void TestExportXml::exportEmptyTree() {
NodeTree tree;
QString err;
QVERIFY(!exportReclassXml(tree, "dummy.xml", &err));
QVERIFY(!err.isEmpty());
}
void TestExportXml::exportSingleStruct() {
NodeTree tree;
Node s; s.kind = NodeKind::Struct; s.name = QStringLiteral("Player");
s.structTypeName = QStringLiteral("Player"); s.parentId = 0;
int si = tree.addNode(s);
uint64_t sid = tree.nodes[si].id;
Node f1; f1.kind = NodeKind::Int32; f1.name = QStringLiteral("health");
f1.parentId = sid; f1.offset = 0; tree.addNode(f1);
Node f2; f2.kind = NodeKind::Float; f2.name = QStringLiteral("speed");
f2.parentId = sid; f2.offset = 4; tree.addNode(f2);
Node f3; f3.kind = NodeKind::UInt64; f3.name = QStringLiteral("id");
f3.parentId = sid; f3.offset = 8; tree.addNode(f3);
QString xml = exportToString(tree);
QVERIFY(!xml.isEmpty());
QVERIFY(xml.contains(QStringLiteral("Player")));
QVERIFY(xml.contains(QStringLiteral("health")));
QVERIFY(xml.contains(QStringLiteral("speed")));
QVERIFY(xml.contains(QStringLiteral("ReClassEx")));
// Round-trip
NodeTree rt = roundTrip(tree);
QCOMPARE(countRoots(rt), 1);
QCOMPARE(rt.nodes[0].name, QStringLiteral("Player"));
auto kids = childrenOf(rt, rt.nodes[0].id);
QCOMPARE(kids.size(), 3);
QCOMPARE(rt.nodes[kids[0]].kind, NodeKind::Int32);
QCOMPARE(rt.nodes[kids[1]].kind, NodeKind::Float);
QCOMPARE(rt.nodes[kids[2]].kind, NodeKind::UInt64);
}
void TestExportXml::exportPointerRef() {
NodeTree tree;
Node s1; s1.kind = NodeKind::Struct; s1.name = QStringLiteral("Target");
s1.structTypeName = QStringLiteral("Target"); s1.parentId = 0;
int s1i = tree.addNode(s1);
uint64_t s1id = tree.nodes[s1i].id;
Node f; f.kind = NodeKind::Int32; f.name = QStringLiteral("val");
f.parentId = s1id; f.offset = 0; tree.addNode(f);
Node s2; s2.kind = NodeKind::Struct; s2.name = QStringLiteral("HasPtr");
s2.structTypeName = QStringLiteral("HasPtr"); s2.parentId = 0;
int s2i = tree.addNode(s2);
uint64_t s2id = tree.nodes[s2i].id;
Node ptr; ptr.kind = NodeKind::Pointer64; ptr.name = QStringLiteral("pTarget");
ptr.parentId = s2id; ptr.offset = 0; ptr.refId = s1id;
tree.addNode(ptr);
QString xml = exportToString(tree);
QVERIFY(xml.contains(QStringLiteral("Pointer=\"Target\"")));
// Round-trip: pointer should resolve
NodeTree rt = roundTrip(tree);
QCOMPARE(countRoots(rt), 2);
bool foundPtr = false;
for (const auto& n : rt.nodes) {
if (n.kind == NodeKind::Pointer64 && n.name == QStringLiteral("pTarget")) {
QVERIFY(n.refId != 0);
foundPtr = true;
}
}
QVERIFY(foundPtr);
}
void TestExportXml::exportEmbeddedStruct() {
NodeTree tree;
Node inner; inner.kind = NodeKind::Struct; inner.name = QStringLiteral("Inner");
inner.structTypeName = QStringLiteral("Inner"); inner.parentId = 0;
int ii = tree.addNode(inner);
uint64_t iid = tree.nodes[ii].id;
Node iv; iv.kind = NodeKind::Int32; iv.name = QStringLiteral("x");
iv.parentId = iid; iv.offset = 0; tree.addNode(iv);
Node outer; outer.kind = NodeKind::Struct; outer.name = QStringLiteral("Outer");
outer.structTypeName = QStringLiteral("Outer"); outer.parentId = 0;
int oi = tree.addNode(outer);
uint64_t oid = tree.nodes[oi].id;
Node embed; embed.kind = NodeKind::Struct; embed.name = QStringLiteral("embedded");
embed.structTypeName = QStringLiteral("Inner"); embed.parentId = oid;
embed.offset = 0; embed.refId = iid;
tree.addNode(embed);
QString xml = exportToString(tree);
QVERIFY(xml.contains(QStringLiteral("Instance=\"Inner\"")));
}
void TestExportXml::exportArray() {
NodeTree tree;
Node s; s.kind = NodeKind::Struct; s.name = QStringLiteral("Container");
s.structTypeName = QStringLiteral("Container"); s.parentId = 0;
int si = tree.addNode(s);
uint64_t sid = tree.nodes[si].id;
Node arr; arr.kind = NodeKind::Array; arr.name = QStringLiteral("items");
arr.parentId = sid; arr.offset = 0; arr.arrayLen = 10;
arr.elementKind = NodeKind::Int32;
tree.addNode(arr);
QString xml = exportToString(tree);
QVERIFY(xml.contains(QStringLiteral("Total=\"10\"")));
QVERIFY(xml.contains(QStringLiteral("<Array")));
}
void TestExportXml::exportTextNodes() {
NodeTree tree;
Node s; s.kind = NodeKind::Struct; s.name = QStringLiteral("TextStruct");
s.structTypeName = QStringLiteral("TextStruct"); s.parentId = 0;
int si = tree.addNode(s);
uint64_t sid = tree.nodes[si].id;
Node u8; u8.kind = NodeKind::UTF8; u8.name = QStringLiteral("name");
u8.parentId = sid; u8.offset = 0; u8.strLen = 32; tree.addNode(u8);
Node u16; u16.kind = NodeKind::UTF16; u16.name = QStringLiteral("wname");
u16.parentId = sid; u16.offset = 32; u16.strLen = 16; tree.addNode(u16);
NodeTree rt = roundTrip(tree);
QCOMPARE(countRoots(rt), 1);
auto kids = childrenOf(rt, rt.nodes[0].id);
QCOMPARE(kids.size(), 2);
QCOMPARE(rt.nodes[kids[0]].kind, NodeKind::UTF8);
QCOMPARE(rt.nodes[kids[0]].strLen, 32);
QCOMPARE(rt.nodes[kids[1]].kind, NodeKind::UTF16);
QCOMPARE(rt.nodes[kids[1]].strLen, 16);
}
void TestExportXml::exportVectors() {
NodeTree tree;
Node s; s.kind = NodeKind::Struct; s.name = QStringLiteral("Vectors");
s.structTypeName = QStringLiteral("Vectors"); s.parentId = 0;
int si = tree.addNode(s);
uint64_t sid = tree.nodes[si].id;
Node v2; v2.kind = NodeKind::Vec2; v2.name = QStringLiteral("pos2");
v2.parentId = sid; v2.offset = 0; tree.addNode(v2);
Node v3; v3.kind = NodeKind::Vec3; v3.name = QStringLiteral("pos3");
v3.parentId = sid; v3.offset = 8; tree.addNode(v3);
Node v4; v4.kind = NodeKind::Vec4; v4.name = QStringLiteral("rot");
v4.parentId = sid; v4.offset = 20; tree.addNode(v4);
Node m; m.kind = NodeKind::Mat4x4; m.name = QStringLiteral("matrix");
m.parentId = sid; m.offset = 36; tree.addNode(m);
NodeTree rt = roundTrip(tree);
auto kids = childrenOf(rt, rt.nodes[0].id);
QCOMPARE(kids.size(), 4);
QCOMPARE(rt.nodes[kids[0]].kind, NodeKind::Vec2);
QCOMPARE(rt.nodes[kids[1]].kind, NodeKind::Vec3);
QCOMPARE(rt.nodes[kids[2]].kind, NodeKind::Vec4);
QCOMPARE(rt.nodes[kids[3]].kind, NodeKind::Mat4x4);
}
void TestExportXml::exportHexCollapse() {
NodeTree tree;
Node s; s.kind = NodeKind::Struct; s.name = QStringLiteral("HexTest");
s.structTypeName = QStringLiteral("HexTest"); s.parentId = 0;
int si = tree.addNode(s);
uint64_t sid = tree.nodes[si].id;
// 4 consecutive Hex8 nodes should collapse to one Custom node
for (int i = 0; i < 4; i++) {
Node h; h.kind = NodeKind::Hex8; h.parentId = sid; h.offset = i;
tree.addNode(h);
}
// Followed by a real field
Node f; f.kind = NodeKind::Int32; f.name = QStringLiteral("val");
f.parentId = sid; f.offset = 4; tree.addNode(f);
QString xml = exportToString(tree);
// Should have Type="21" (Custom) for the collapsed hex
QVERIFY(xml.contains(QStringLiteral("Type=\"21\"")));
// Size should be 4
QVERIFY(xml.contains(QStringLiteral("Size=\"4\"")));
// Round-trip: custom expands back to hex nodes
NodeTree rt = roundTrip(tree);
QCOMPARE(countRoots(rt), 1);
auto kids = childrenOf(rt, rt.nodes[0].id);
// Import expands Custom(4 bytes) to best-fit hex: Hex32 (1 node) + Int32 = 2
QVERIFY(kids.size() >= 2);
// Last child should be Int32
QCOMPARE(rt.nodes[kids.last()].kind, NodeKind::Int32);
}
void TestExportXml::exportMultiClass() {
NodeTree tree;
for (int c = 0; c < 5; c++) {
Node s; s.kind = NodeKind::Struct;
s.name = QStringLiteral("Class%1").arg(c);
s.structTypeName = s.name; s.parentId = 0;
int si = tree.addNode(s);
uint64_t sid = tree.nodes[si].id;
Node f; f.kind = NodeKind::Int32;
f.name = QStringLiteral("field%1").arg(c);
f.parentId = sid; f.offset = 0; tree.addNode(f);
}
NodeTree rt = roundTrip(tree);
QCOMPARE(countRoots(rt), 5);
// All class names preserved
QSet<QString> names;
for (const auto& n : rt.nodes)
if (n.parentId == 0 && n.kind == NodeKind::Struct) names.insert(n.name);
for (int c = 0; c < 5; c++)
QVERIFY(names.contains(QStringLiteral("Class%1").arg(c)));
}
void TestExportXml::roundTripImportExport() {
// Build a comprehensive tree and verify it survives export->import
NodeTree tree;
Node s; s.kind = NodeKind::Struct; s.name = QStringLiteral("FullTest");
s.structTypeName = QStringLiteral("FullTest"); s.parentId = 0;
int si = tree.addNode(s);
uint64_t sid = tree.nodes[si].id;
int offset = 0;
auto addField = [&](NodeKind kind, const QString& name) {
Node n; n.kind = kind; n.name = name; n.parentId = sid; n.offset = offset;
tree.addNode(n);
offset += sizeForKind(kind);
};
addField(NodeKind::Int8, QStringLiteral("a"));
addField(NodeKind::Int16, QStringLiteral("b"));
addField(NodeKind::Int32, QStringLiteral("c"));
addField(NodeKind::Int64, QStringLiteral("d"));
addField(NodeKind::UInt8, QStringLiteral("e"));
addField(NodeKind::UInt16, QStringLiteral("f"));
addField(NodeKind::UInt32, QStringLiteral("g"));
addField(NodeKind::UInt64, QStringLiteral("h"));
addField(NodeKind::Float, QStringLiteral("i"));
addField(NodeKind::Double, QStringLiteral("j"));
addField(NodeKind::Vec2, QStringLiteral("k"));
addField(NodeKind::Vec3, QStringLiteral("l"));
addField(NodeKind::Vec4, QStringLiteral("m"));
// Self-pointer
Node ptr; ptr.kind = NodeKind::Pointer64; ptr.name = QStringLiteral("self");
ptr.parentId = sid; ptr.offset = offset; ptr.refId = sid;
tree.addNode(ptr);
offset += 8;
// UTF8
Node u8; u8.kind = NodeKind::UTF8; u8.name = QStringLiteral("str");
u8.parentId = sid; u8.offset = offset; u8.strLen = 64;
tree.addNode(u8);
NodeTree rt = roundTrip(tree);
QCOMPARE(countRoots(rt), 1);
QCOMPARE(rt.nodes[0].name, QStringLiteral("FullTest"));
auto origKids = childrenOf(tree, sid);
auto rtKids = childrenOf(rt, rt.nodes[0].id);
QCOMPARE(rtKids.size(), origKids.size());
// Verify each field kind matches
for (int i = 0; i < origKids.size(); i++) {
QCOMPARE(rt.nodes[rtKids[i]].kind, tree.nodes[origKids[i]].kind);
QCOMPARE(rt.nodes[rtKids[i]].name, tree.nodes[origKids[i]].name);
}
// Verify self-pointer resolved
bool foundSelf = false;
for (const auto& n : rt.nodes) {
if (n.name == QStringLiteral("self") && n.kind == NodeKind::Pointer64) {
QVERIFY(n.refId != 0);
QCOMPARE(n.refId, rt.nodes[0].id);
foundSelf = true;
}
}
QVERIFY(foundSelf);
}
QTEST_MAIN(TestExportXml)
#include "test_export_xml.moc"

View File

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

View File

@@ -0,0 +1,846 @@
#include <QtTest/QtTest>
#include "core.h"
#include "import_source.h"
using namespace rcx;
class TestImportSource : public QObject {
Q_OBJECT
private slots:
// Basic type tests
void emptyInput();
void noStructs();
void singleEmptyStruct();
void stdintTypes();
void windowsTypes();
void platformPointerTypes();
void standardCTypes();
void multiWordTypes();
void floatDouble();
void boolType();
// Pointer tests
void voidPointer();
void typedPointer();
void selfReferencingPointer();
void doublePointer();
// Array tests
void primitiveArray();
void charArrayToUtf8();
void wcharArrayToUtf16();
void floatArrayToVec2();
void floatArrayToVec3();
void floatArrayToVec4();
void floatArray4x4ToMat4x4();
void genericFloatArray();
void structArray();
// Comment offset tests
void commentOffsets();
void computedOffsets();
void mixedOffsetsAutoDetect();
// Multi-struct tests
void multiStruct();
void pointerCrossRef();
// Forward declarations
void forwardDeclaration();
// Union handling
void unionPickFirst();
// Padding fields
void paddingFieldExpansion();
// static_assert
void staticAssertTailPadding();
// Embedded struct
void embeddedStruct();
// Typedef
void typedefBasic();
// Qualifiers
void constVolatileQualifiers();
void structPrefixOnType();
// Edge cases
void bitfieldSkipped();
void hexArraySizes();
void windowsStylePEB();
void classKeyword();
void inheritanceSkipped();
// Round-trip test (requires generator.h)
void basicRoundTrip();
};
// ── Helper ──
static int countRoots(const NodeTree& tree) {
int n = 0;
for (const auto& node : tree.nodes)
if (node.parentId == 0 && node.kind == NodeKind::Struct) n++;
return n;
}
static QVector<int> childrenOf(const NodeTree& tree, uint64_t parentId) {
QVector<int> result;
for (int i = 0; i < tree.nodes.size(); i++)
if (tree.nodes[i].parentId == parentId) result.append(i);
return result;
}
// ── Tests ──
void TestImportSource::emptyInput() {
QString err;
NodeTree tree = importFromSource(QString(), &err);
QVERIFY(tree.nodes.isEmpty());
QVERIFY(!err.isEmpty());
}
void TestImportSource::noStructs() {
QString err;
NodeTree tree = importFromSource(QStringLiteral("int x = 42;"), &err);
QVERIFY(tree.nodes.isEmpty());
QVERIFY(!err.isEmpty());
}
void TestImportSource::singleEmptyStruct() {
NodeTree tree = importFromSource(QStringLiteral(
"struct Empty {};\n"
));
QCOMPARE(countRoots(tree), 1);
QCOMPARE(tree.nodes[0].name, QStringLiteral("Empty"));
QCOMPARE(tree.nodes[0].kind, NodeKind::Struct);
}
void TestImportSource::stdintTypes() {
NodeTree tree = importFromSource(QStringLiteral(
"struct Test {\n"
" uint8_t a;\n"
" int8_t b;\n"
" uint16_t c;\n"
" int16_t d;\n"
" uint32_t e;\n"
" int32_t f;\n"
" uint64_t g;\n"
" int64_t h;\n"
"};\n"
));
QCOMPARE(countRoots(tree), 1);
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 8);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::UInt8);
QCOMPARE(tree.nodes[kids[1]].kind, NodeKind::Int8);
QCOMPARE(tree.nodes[kids[2]].kind, NodeKind::UInt16);
QCOMPARE(tree.nodes[kids[3]].kind, NodeKind::Int16);
QCOMPARE(tree.nodes[kids[4]].kind, NodeKind::UInt32);
QCOMPARE(tree.nodes[kids[5]].kind, NodeKind::Int32);
QCOMPARE(tree.nodes[kids[6]].kind, NodeKind::UInt64);
QCOMPARE(tree.nodes[kids[7]].kind, NodeKind::Int64);
}
void TestImportSource::windowsTypes() {
NodeTree tree = importFromSource(QStringLiteral(
"struct WinTypes {\n"
" BYTE a;\n"
" WORD b;\n"
" DWORD c;\n"
" QWORD d;\n"
" ULONG e;\n"
" LONG f;\n"
" USHORT g;\n"
" UCHAR h;\n"
" BOOLEAN i;\n"
" BOOL j;\n"
" CHAR k;\n"
" WCHAR l;\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 12);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::UInt8); // BYTE
QCOMPARE(tree.nodes[kids[1]].kind, NodeKind::UInt16); // WORD
QCOMPARE(tree.nodes[kids[2]].kind, NodeKind::UInt32); // DWORD
QCOMPARE(tree.nodes[kids[3]].kind, NodeKind::UInt64); // QWORD
QCOMPARE(tree.nodes[kids[4]].kind, NodeKind::UInt32); // ULONG
QCOMPARE(tree.nodes[kids[5]].kind, NodeKind::Int32); // LONG
QCOMPARE(tree.nodes[kids[6]].kind, NodeKind::UInt16); // USHORT
QCOMPARE(tree.nodes[kids[7]].kind, NodeKind::UInt8); // UCHAR
QCOMPARE(tree.nodes[kids[8]].kind, NodeKind::UInt8); // BOOLEAN
QCOMPARE(tree.nodes[kids[9]].kind, NodeKind::Int32); // BOOL
QCOMPARE(tree.nodes[kids[10]].kind, NodeKind::Int8); // CHAR
QCOMPARE(tree.nodes[kids[11]].kind, NodeKind::UInt16); // WCHAR
}
void TestImportSource::platformPointerTypes() {
NodeTree tree = importFromSource(QStringLiteral(
"struct PtrTypes {\n"
" PVOID a;\n"
" HANDLE b;\n"
" SIZE_T c;\n"
" ULONG_PTR d;\n"
" uintptr_t e;\n"
" size_t f;\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 6);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Pointer64);
QCOMPARE(tree.nodes[kids[1]].kind, NodeKind::Pointer64);
QCOMPARE(tree.nodes[kids[2]].kind, NodeKind::UInt64);
QCOMPARE(tree.nodes[kids[3]].kind, NodeKind::UInt64);
QCOMPARE(tree.nodes[kids[4]].kind, NodeKind::UInt64);
QCOMPARE(tree.nodes[kids[5]].kind, NodeKind::UInt64);
}
void TestImportSource::standardCTypes() {
NodeTree tree = importFromSource(QStringLiteral(
"struct CTypes {\n"
" char a;\n"
" short b;\n"
" int c;\n"
" long d;\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 4);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Int8); // char
QCOMPARE(tree.nodes[kids[1]].kind, NodeKind::Int16); // short
QCOMPARE(tree.nodes[kids[2]].kind, NodeKind::Int32); // int
QCOMPARE(tree.nodes[kids[3]].kind, NodeKind::Int32); // long
}
void TestImportSource::multiWordTypes() {
NodeTree tree = importFromSource(QStringLiteral(
"struct MultiWord {\n"
" unsigned char a;\n"
" unsigned short b;\n"
" unsigned int c;\n"
" unsigned long d;\n"
" long long e;\n"
" unsigned long long f;\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 6);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::UInt8);
QCOMPARE(tree.nodes[kids[1]].kind, NodeKind::UInt16);
QCOMPARE(tree.nodes[kids[2]].kind, NodeKind::UInt32);
QCOMPARE(tree.nodes[kids[3]].kind, NodeKind::UInt32);
QCOMPARE(tree.nodes[kids[4]].kind, NodeKind::Int64);
QCOMPARE(tree.nodes[kids[5]].kind, NodeKind::UInt64);
}
void TestImportSource::floatDouble() {
NodeTree tree = importFromSource(QStringLiteral(
"struct FD {\n"
" float a;\n"
" double b;\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 2);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Float);
QCOMPARE(tree.nodes[kids[1]].kind, NodeKind::Double);
}
void TestImportSource::boolType() {
NodeTree tree = importFromSource(QStringLiteral(
"struct B {\n"
" bool a;\n"
" _Bool b;\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 2);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Bool);
QCOMPARE(tree.nodes[kids[1]].kind, NodeKind::Bool);
}
void TestImportSource::voidPointer() {
NodeTree tree = importFromSource(QStringLiteral(
"struct VP {\n"
" void* ptr;\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 1);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Pointer64);
QCOMPARE(tree.nodes[kids[0]].name, QStringLiteral("ptr"));
QCOMPARE(tree.nodes[kids[0]].refId, uint64_t(0)); // void* has no target
}
void TestImportSource::typedPointer() {
NodeTree tree = importFromSource(QStringLiteral(
"struct Target {\n"
" int x;\n"
"};\n"
"struct HasPtr {\n"
" Target* pTarget;\n"
"};\n"
));
QCOMPARE(countRoots(tree), 2);
// Find HasPtr
int hasPtrIdx = -1;
for (int i = 0; i < tree.nodes.size(); i++) {
if (tree.nodes[i].name == QStringLiteral("HasPtr") && tree.nodes[i].parentId == 0) {
hasPtrIdx = i; break;
}
}
QVERIFY(hasPtrIdx >= 0);
auto kids = childrenOf(tree, tree.nodes[hasPtrIdx].id);
QCOMPARE(kids.size(), 1);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Pointer64);
QVERIFY(tree.nodes[kids[0]].refId != 0);
// refId should point to Target struct
int targetIdx = tree.indexOfId(tree.nodes[kids[0]].refId);
QVERIFY(targetIdx >= 0);
QCOMPARE(tree.nodes[targetIdx].name, QStringLiteral("Target"));
}
void TestImportSource::selfReferencingPointer() {
NodeTree tree = importFromSource(QStringLiteral(
"struct Node {\n"
" int value;\n"
" Node* next;\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 2);
QCOMPARE(tree.nodes[kids[1]].kind, NodeKind::Pointer64);
QCOMPARE(tree.nodes[kids[1]].refId, tree.nodes[0].id);
}
void TestImportSource::doublePointer() {
NodeTree tree = importFromSource(QStringLiteral(
"struct DP {\n"
" void** ppData;\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 1);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Pointer64);
}
void TestImportSource::primitiveArray() {
NodeTree tree = importFromSource(QStringLiteral(
"struct PA {\n"
" int32_t values[10];\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 1);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Array);
QCOMPARE(tree.nodes[kids[0]].arrayLen, 10);
QCOMPARE(tree.nodes[kids[0]].elementKind, NodeKind::Int32);
}
void TestImportSource::charArrayToUtf8() {
NodeTree tree = importFromSource(QStringLiteral(
"struct CA {\n"
" char name[64];\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 1);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::UTF8);
QCOMPARE(tree.nodes[kids[0]].strLen, 64);
}
void TestImportSource::wcharArrayToUtf16() {
NodeTree tree = importFromSource(QStringLiteral(
"struct WC {\n"
" wchar_t name[32];\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 1);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::UTF16);
QCOMPARE(tree.nodes[kids[0]].strLen, 32);
}
void TestImportSource::floatArrayToVec2() {
NodeTree tree = importFromSource(QStringLiteral(
"struct V {\n"
" float pos[2];\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 1);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Vec2);
}
void TestImportSource::floatArrayToVec3() {
NodeTree tree = importFromSource(QStringLiteral(
"struct V {\n"
" float pos[3];\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 1);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Vec3);
}
void TestImportSource::floatArrayToVec4() {
NodeTree tree = importFromSource(QStringLiteral(
"struct V {\n"
" float rot[4];\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 1);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Vec4);
}
void TestImportSource::floatArray4x4ToMat4x4() {
NodeTree tree = importFromSource(QStringLiteral(
"struct M {\n"
" float matrix[4][4];\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 1);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Mat4x4);
}
void TestImportSource::genericFloatArray() {
NodeTree tree = importFromSource(QStringLiteral(
"struct GF {\n"
" float values[8];\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 1);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Array);
QCOMPARE(tree.nodes[kids[0]].arrayLen, 8);
QCOMPARE(tree.nodes[kids[0]].elementKind, NodeKind::Float);
}
void TestImportSource::structArray() {
NodeTree tree = importFromSource(QStringLiteral(
"struct Item {\n"
" int id;\n"
"};\n"
"struct Container {\n"
" Item items[5];\n"
"};\n"
));
QCOMPARE(countRoots(tree), 2);
// Find Container
int contIdx = -1;
for (int i = 0; i < tree.nodes.size(); i++) {
if (tree.nodes[i].name == QStringLiteral("Container") && tree.nodes[i].parentId == 0) {
contIdx = i; break;
}
}
QVERIFY(contIdx >= 0);
auto kids = childrenOf(tree, tree.nodes[contIdx].id);
QCOMPARE(kids.size(), 1);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Array);
QCOMPARE(tree.nodes[kids[0]].arrayLen, 5);
QCOMPARE(tree.nodes[kids[0]].elementKind, NodeKind::Struct);
}
void TestImportSource::commentOffsets() {
NodeTree tree = importFromSource(QStringLiteral(
"struct Offsets {\n"
" uint64_t vtable; // 0x0\n"
" float health; // 0x8\n"
" uint8_t _pad000C[0x4]; // 0xC\n"
" double score; // 0x10\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
// vtable at 0x0
QCOMPARE(tree.nodes[kids[0]].offset, 0);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::UInt64);
// health at 0x8
QCOMPARE(tree.nodes[kids[1]].offset, 8);
QCOMPARE(tree.nodes[kids[1]].kind, NodeKind::Float);
// _pad at 0xC -> hex nodes
// score at 0x10
// Find the double
bool foundDouble = false;
for (int k : kids) {
if (tree.nodes[k].kind == NodeKind::Double) {
QCOMPARE(tree.nodes[k].offset, 0x10);
foundDouble = true;
}
}
QVERIFY(foundDouble);
}
void TestImportSource::computedOffsets() {
NodeTree tree = importFromSource(QStringLiteral(
"struct Computed {\n"
" uint8_t a;\n"
" uint16_t b;\n"
" uint32_t c;\n"
" uint64_t d;\n"
"};\n"
));
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
}
void TestImportSource::mixedOffsetsAutoDetect() {
// If any field has a comment offset, all should use comment mode
NodeTree tree = importFromSource(QStringLiteral(
"struct Mixed {\n"
" uint32_t a; // 0x0\n"
" uint32_t b;\n"
" uint32_t c; // 0x10\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(tree.nodes[kids[0]].offset, 0);
// b has no comment offset, in comment mode it gets computed offset 4
QCOMPARE(tree.nodes[kids[1]].offset, 4);
// c has comment offset 0x10
QCOMPARE(tree.nodes[kids[2]].offset, 0x10);
}
void TestImportSource::multiStruct() {
NodeTree tree = importFromSource(QStringLiteral(
"struct A {\n"
" int x;\n"
"};\n"
"struct B {\n"
" float y;\n"
"};\n"
"struct C {\n"
" double z;\n"
"};\n"
));
QCOMPARE(countRoots(tree), 3);
}
void TestImportSource::pointerCrossRef() {
NodeTree tree = importFromSource(QStringLiteral(
"struct A {\n"
" int value;\n"
"};\n"
"struct B {\n"
" A* ref;\n"
"};\n"
));
// Find B's pointer field
int bIdx = -1;
for (int i = 0; i < tree.nodes.size(); i++) {
if (tree.nodes[i].name == QStringLiteral("B") && tree.nodes[i].parentId == 0) {
bIdx = i; break;
}
}
QVERIFY(bIdx >= 0);
auto kids = childrenOf(tree, tree.nodes[bIdx].id);
QCOMPARE(kids.size(), 1);
QVERIFY(tree.nodes[kids[0]].refId != 0);
// Should point to A
int aIdx = tree.indexOfId(tree.nodes[kids[0]].refId);
QVERIFY(aIdx >= 0);
QCOMPARE(tree.nodes[aIdx].name, QStringLiteral("A"));
}
void TestImportSource::forwardDeclaration() {
NodeTree tree = importFromSource(QStringLiteral(
"struct Bar;\n"
"struct Foo {\n"
" Bar* pBar;\n"
"};\n"
"struct Bar {\n"
" int val;\n"
"};\n"
));
QCOMPARE(countRoots(tree), 2);
// Foo's pBar should resolve to Bar
int fooIdx = -1;
for (int i = 0; i < tree.nodes.size(); i++) {
if (tree.nodes[i].name == QStringLiteral("Foo") && tree.nodes[i].parentId == 0) {
fooIdx = i; break;
}
}
QVERIFY(fooIdx >= 0);
auto kids = childrenOf(tree, tree.nodes[fooIdx].id);
QCOMPARE(kids.size(), 1);
QVERIFY(tree.nodes[kids[0]].refId != 0);
}
void TestImportSource::unionPickFirst() {
NodeTree tree = importFromSource(QStringLiteral(
"struct WithUnion {\n"
" union {\n"
" float asFloat;\n"
" uint32_t asInt;\n"
" };\n"
" int after;\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
// Should have 2 fields: asFloat (first union member) + after
QCOMPARE(kids.size(), 2);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Float);
QCOMPARE(tree.nodes[kids[0]].name, QStringLiteral("asFloat"));
QCOMPARE(tree.nodes[kids[1]].kind, NodeKind::Int32);
QCOMPARE(tree.nodes[kids[1]].name, QStringLiteral("after"));
}
void TestImportSource::paddingFieldExpansion() {
NodeTree tree = importFromSource(QStringLiteral(
"struct Padded {\n"
" uint8_t _pad0000[0x10];\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
// 0x10 = 16 bytes, should be 2x Hex64 (best fit)
QCOMPARE(kids.size(), 2);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Hex64);
QCOMPARE(tree.nodes[kids[0]].offset, 0);
QCOMPARE(tree.nodes[kids[1]].kind, NodeKind::Hex64);
QCOMPARE(tree.nodes[kids[1]].offset, 8);
}
void TestImportSource::staticAssertTailPadding() {
NodeTree tree = importFromSource(QStringLiteral(
"struct Sized {\n"
" uint32_t x;\n"
"};\n"
"static_assert(sizeof(Sized) == 0x10, \"Size check\");\n"
));
// x is 4 bytes, static_assert says 0x10 = 16
// Should have tail padding from offset 4 to 16 (12 bytes)
int span = tree.structSpan(tree.nodes[0].id);
QCOMPARE(span, 0x10);
}
void TestImportSource::embeddedStruct() {
NodeTree tree = importFromSource(QStringLiteral(
"struct Inner {\n"
" int a;\n"
"};\n"
"struct Outer {\n"
" Inner embedded;\n"
" float after;\n"
"};\n"
));
int outerIdx = -1;
for (int i = 0; i < tree.nodes.size(); i++) {
if (tree.nodes[i].name == QStringLiteral("Outer") && tree.nodes[i].parentId == 0) {
outerIdx = i; break;
}
}
QVERIFY(outerIdx >= 0);
auto kids = childrenOf(tree, tree.nodes[outerIdx].id);
QCOMPARE(kids.size(), 2);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Struct);
QCOMPARE(tree.nodes[kids[0]].structTypeName, QStringLiteral("Inner"));
QVERIFY(tree.nodes[kids[0]].refId != 0);
QCOMPARE(tree.nodes[kids[1]].kind, NodeKind::Float);
}
void TestImportSource::typedefBasic() {
NodeTree tree = importFromSource(QStringLiteral(
"typedef uint32_t MyInt;\n"
"struct TD {\n"
" MyInt value;\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 1);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::UInt32);
}
void TestImportSource::constVolatileQualifiers() {
NodeTree tree = importFromSource(QStringLiteral(
"struct Quals {\n"
" const uint32_t a;\n"
" volatile int32_t b;\n"
" const volatile uint8_t c;\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 3);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::UInt32);
QCOMPARE(tree.nodes[kids[1]].kind, NodeKind::Int32);
QCOMPARE(tree.nodes[kids[2]].kind, NodeKind::UInt8);
}
void TestImportSource::structPrefixOnType() {
NodeTree tree = importFromSource(QStringLiteral(
"struct Inner {\n"
" int val;\n"
"};\n"
"struct Outer {\n"
" struct Inner member;\n"
"};\n"
));
int outerIdx = -1;
for (int i = 0; i < tree.nodes.size(); i++) {
if (tree.nodes[i].name == QStringLiteral("Outer") && tree.nodes[i].parentId == 0) {
outerIdx = i; break;
}
}
QVERIFY(outerIdx >= 0);
auto kids = childrenOf(tree, tree.nodes[outerIdx].id);
QCOMPARE(kids.size(), 1);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Struct);
QCOMPARE(tree.nodes[kids[0]].structTypeName, QStringLiteral("Inner"));
}
void TestImportSource::bitfieldSkipped() {
NodeTree tree = importFromSource(QStringLiteral(
"struct BF {\n"
" uint32_t normal;\n"
" uint32_t bitA : 4;\n"
" uint32_t bitB : 12;\n"
" uint32_t after;\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
// Bitfields should be skipped, only normal + after
QCOMPARE(kids.size(), 2);
QCOMPARE(tree.nodes[kids[0]].name, QStringLiteral("normal"));
QCOMPARE(tree.nodes[kids[1]].name, QStringLiteral("after"));
}
void TestImportSource::hexArraySizes() {
NodeTree tree = importFromSource(QStringLiteral(
"struct HexArr {\n"
" uint8_t data[0x20];\n"
"};\n"
));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 1);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Array);
QCOMPARE(tree.nodes[kids[0]].arrayLen, 0x20);
}
void TestImportSource::windowsStylePEB() {
// Test with Windows PEB-style struct (no comment offsets)
NodeTree tree = importFromSource(QStringLiteral(
"struct PEB64 {\n"
" BOOLEAN InheritedAddressSpace;\n"
" BOOLEAN ReadImageFileExecOptions;\n"
" BOOLEAN BeingDebugged;\n"
" BOOLEAN BitField;\n"
" PVOID Mutant;\n"
" PVOID ImageBaseAddress;\n"
"};\n"
));
QCOMPARE(countRoots(tree), 1);
QCOMPARE(tree.nodes[0].name, QStringLiteral("PEB64"));
auto kids = childrenOf(tree, tree.nodes[0].id);
QCOMPARE(kids.size(), 6);
// First 4 are BOOLEAN (UInt8)
for (int i = 0; i < 4; i++)
QCOMPARE(tree.nodes[kids[i]].kind, NodeKind::UInt8);
// Last 2 are PVOID (Pointer64)
QCOMPARE(tree.nodes[kids[4]].kind, NodeKind::Pointer64);
QCOMPARE(tree.nodes[kids[5]].kind, NodeKind::Pointer64);
}
void TestImportSource::classKeyword() {
NodeTree tree = importFromSource(QStringLiteral(
"class MyClass {\n"
" int value;\n"
"};\n"
));
QCOMPARE(countRoots(tree), 1);
QCOMPARE(tree.nodes[0].classKeyword, QStringLiteral("class"));
}
void TestImportSource::inheritanceSkipped() {
NodeTree tree = importFromSource(QStringLiteral(
"struct Base {\n"
" int a;\n"
"};\n"
"struct Derived : public Base {\n"
" float b;\n"
"};\n"
));
QCOMPARE(countRoots(tree), 2);
int derivedIdx = -1;
for (int i = 0; i < tree.nodes.size(); i++) {
if (tree.nodes[i].name == QStringLiteral("Derived") && tree.nodes[i].parentId == 0) {
derivedIdx = i; break;
}
}
QVERIFY(derivedIdx >= 0);
auto kids = childrenOf(tree, tree.nodes[derivedIdx].id);
QCOMPARE(kids.size(), 1);
QCOMPARE(tree.nodes[kids[0]].kind, NodeKind::Float);
}
void TestImportSource::basicRoundTrip() {
// Build a simple tree manually, export it, then re-import and compare
NodeTree original;
{
Node s;
s.kind = NodeKind::Struct;
s.name = QStringLiteral("RoundTrip");
s.structTypeName = QStringLiteral("RoundTrip");
s.parentId = 0;
s.offset = 0;
int sIdx = original.addNode(s);
uint64_t sId = original.nodes[sIdx].id;
Node f1;
f1.kind = NodeKind::UInt32;
f1.name = QStringLiteral("field_a");
f1.parentId = sId;
f1.offset = 0;
original.addNode(f1);
Node f2;
f2.kind = NodeKind::Float;
f2.name = QStringLiteral("field_b");
f2.parentId = sId;
f2.offset = 4;
original.addNode(f2);
Node f3;
f3.kind = NodeKind::UInt64;
f3.name = QStringLiteral("field_c");
f3.parentId = sId;
f3.offset = 8;
original.addNode(f3);
}
// Create source text that matches what generator would produce
QString source = QStringLiteral(
"struct RoundTrip {\n"
" uint32_t field_a; // 0x0\n"
" float field_b; // 0x4\n"
" uint64_t field_c; // 0x8\n"
"};\n"
"static_assert(sizeof(RoundTrip) == 0x10, \"Size mismatch\");\n"
);
NodeTree reimported = importFromSource(source);
QCOMPARE(countRoots(reimported), 1);
QCOMPARE(reimported.nodes[0].name, QStringLiteral("RoundTrip"));
auto origKids = childrenOf(original, original.nodes[0].id);
auto reimpKids = childrenOf(reimported, reimported.nodes[0].id);
// Compare field count (reimported may have extra padding nodes from static_assert)
// Check that the first 3 fields match
QVERIFY(reimpKids.size() >= 3);
for (int i = 0; i < 3; i++) {
QCOMPARE(reimported.nodes[reimpKids[i]].kind, original.nodes[origKids[i]].kind);
QCOMPARE(reimported.nodes[reimpKids[i]].name, original.nodes[origKids[i]].name);
QCOMPARE(reimported.nodes[reimpKids[i]].offset, original.nodes[origKids[i]].offset);
}
}
QTEST_MAIN(TestImportSource)
#include "test_import_source.moc"

70
tests/test_import_xml.cpp Normal file
View File

@@ -0,0 +1,70 @@
#include <QtTest/QtTest>
#include "core.h"
#include "import_reclass_xml.h"
using namespace rcx;
class TestImportXml : public QObject {
Q_OBJECT
private slots:
void importSmallXml();
};
void TestImportXml::importSmallXml() {
// Create a minimal XML in a temp file and test parsing
QTemporaryFile tmp;
tmp.setAutoRemove(true);
QVERIFY(tmp.open());
tmp.write(R"(<?xml version="1.0" encoding="UTF-8"?>
<ReClass>
<!--ReClassEx-->
<Class Name="TestClass" Type="28" Comment="" Offset="0" strOffset="0" Code="">
<Node Name="vtable" Type="9" Size="8" bHidden="false" Comment=""/>
<Node Name="health" Type="13" Size="4" bHidden="false" Comment=""/>
<Node Name="name" Type="18" Size="32" bHidden="false" Comment=""/>
<Node Name="position" Type="23" Size="12" bHidden="false" Comment=""/>
<Node Name="pNext" Type="8" Size="8" bHidden="false" Comment="" Pointer="TestClass"/>
</Class>
</ReClass>
)");
tmp.flush();
QString error;
NodeTree tree = importReclassXml(tmp.fileName(), &error);
QVERIFY2(!tree.nodes.isEmpty(), qPrintable(error));
// Should have 1 root struct + 5 children = 6 nodes
QCOMPARE(tree.nodes.size(), 6);
// Root struct
QCOMPARE(tree.nodes[0].kind, NodeKind::Struct);
QCOMPARE(tree.nodes[0].name, QStringLiteral("TestClass"));
// vtable = Int64
QCOMPARE(tree.nodes[1].kind, NodeKind::Int64);
QCOMPARE(tree.nodes[1].name, QStringLiteral("vtable"));
QCOMPARE(tree.nodes[1].offset, 0);
// health = Float
QCOMPARE(tree.nodes[2].kind, NodeKind::Float);
QCOMPARE(tree.nodes[2].name, QStringLiteral("health"));
QCOMPARE(tree.nodes[2].offset, 8);
// name = UTF8 with strLen=32
QCOMPARE(tree.nodes[3].kind, NodeKind::UTF8);
QCOMPARE(tree.nodes[3].strLen, 32);
QCOMPARE(tree.nodes[3].offset, 12);
// position = Vec3
QCOMPARE(tree.nodes[4].kind, NodeKind::Vec3);
QCOMPARE(tree.nodes[4].offset, 44);
// pNext = Pointer64 with resolved refId
QCOMPARE(tree.nodes[5].kind, NodeKind::Pointer64);
QCOMPARE(tree.nodes[5].name, QStringLiteral("pNext"));
QVERIFY(tree.nodes[5].refId != 0);
QCOMPARE(tree.nodes[5].refId, tree.nodes[0].id); // points to TestClass
}
QTEST_MAIN(TestImportXml)
#include "test_import_xml.moc"

View File

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

View File

@@ -0,0 +1,291 @@
#include <QtTest/QTest>
#include <QApplication>
#include <QComboBox>
#include <QCheckBox>
#include <QTreeWidget>
#include <QStackedWidget>
#include <QDialogButtonBox>
#include <QPushButton>
#include <QGroupBox>
#include <QLineEdit>
#include <QSpinBox>
#include <QLabel>
#include "optionsdialog.h"
#include "themes/thememanager.h"
using namespace rcx;
// Helper: apply the global palette the same way main.cpp does
static void applyGlobalTheme(const Theme& theme) {
QPalette pal;
pal.setColor(QPalette::Window, theme.background);
pal.setColor(QPalette::WindowText, theme.text);
pal.setColor(QPalette::Base, theme.background);
pal.setColor(QPalette::AlternateBase, theme.surface);
pal.setColor(QPalette::Text, theme.text);
pal.setColor(QPalette::Button, theme.button);
pal.setColor(QPalette::ButtonText, theme.text);
pal.setColor(QPalette::Highlight, theme.selection);
pal.setColor(QPalette::HighlightedText, theme.text);
pal.setColor(QPalette::ToolTipBase, theme.backgroundAlt);
pal.setColor(QPalette::ToolTipText, theme.text);
pal.setColor(QPalette::Mid, theme.border);
pal.setColor(QPalette::Dark, theme.background);
pal.setColor(QPalette::Light, theme.textFaint);
pal.setColor(QPalette::Link, theme.indHoverSpan);
pal.setColor(QPalette::Disabled, QPalette::WindowText, theme.textMuted);
pal.setColor(QPalette::Disabled, QPalette::Text, theme.textMuted);
pal.setColor(QPalette::Disabled, QPalette::ButtonText, theme.textMuted);
pal.setColor(QPalette::Disabled, QPalette::HighlightedText, theme.textMuted);
pal.setColor(QPalette::Disabled, QPalette::Light, theme.background);
qApp->setPalette(pal);
qApp->setStyleSheet(QString());
}
class TestOptionsDialog : public QObject {
Q_OBJECT
private slots:
void initTestCase() {
// Apply theme palette so dialog inherits real colors
auto& tm = ThemeManager::instance();
applyGlobalTheme(tm.current());
}
void dialogCreatesAllWidgets() {
OptionsResult defaults;
defaults.themeIndex = 0;
defaults.fontName = "JetBrains Mono";
defaults.menuBarTitleCase = true;
defaults.safeMode = false;
defaults.autoStartMcp = false;
OptionsDialog dlg(defaults);
// Core widgets exist
auto* tree = dlg.findChild<QTreeWidget*>();
QVERIFY(tree);
auto* pages = dlg.findChild<QStackedWidget*>();
QVERIFY(pages);
QCOMPARE(pages->count(), 3);
auto* themeCombo = dlg.findChild<QComboBox*>("themeCombo");
QVERIFY(themeCombo);
QVERIFY(themeCombo->count() >= 3);
auto* fontCombo = dlg.findChild<QComboBox*>("fontCombo");
QVERIFY(fontCombo);
QCOMPARE(fontCombo->count(), 2);
auto* showIconCheck = dlg.findChild<QCheckBox*>();
QVERIFY(showIconCheck);
auto* buttons = dlg.findChild<QDialogButtonBox*>();
QVERIFY(buttons);
QVERIFY(buttons->button(QDialogButtonBox::Ok));
QVERIFY(buttons->button(QDialogButtonBox::Cancel));
}
void resultReflectsInput() {
OptionsResult input;
input.themeIndex = 1;
input.fontName = "Consolas";
input.menuBarTitleCase = false;
input.safeMode = true;
input.autoStartMcp = true;
OptionsDialog dlg(input);
auto r = dlg.result();
QCOMPARE(r.themeIndex, 1);
QCOMPARE(r.fontName, QString("Consolas"));
QCOMPARE(r.menuBarTitleCase, false);
QCOMPARE(r.safeMode, true);
QCOMPARE(r.autoStartMcp, true);
}
void noStyleSheetOnDialog() {
OptionsResult defaults;
OptionsDialog dlg(defaults);
// Dialog itself must have no stylesheet override
QVERIFY(dlg.styleSheet().isEmpty());
// Combo boxes must have no stylesheet override
auto* themeCombo = dlg.findChild<QComboBox*>("themeCombo");
QVERIFY(themeCombo->styleSheet().isEmpty());
auto* fontCombo = dlg.findChild<QComboBox*>("fontCombo");
QVERIFY(fontCombo->styleSheet().isEmpty());
// No child widget should have a stylesheet set
for (auto* child : dlg.findChildren<QWidget*>()) {
QVERIFY2(child->styleSheet().isEmpty(),
qPrintable(QString("Widget %1 (%2) has unexpected stylesheet: %3")
.arg(child->objectName(),
child->metaObject()->className(),
child->styleSheet())));
}
}
void highlightColorDiffersFromBackground() {
// Verify the palette Highlight is distinguishable from Window background
// This is the root cause of broken hover: if they're the same, hover is invisible
auto& tm = ThemeManager::instance();
const auto themes = tm.themes();
for (const auto& theme : themes) {
QVERIFY2(theme.selection != theme.background,
qPrintable(QString("Theme '%1': selection == background (%2)")
.arg(theme.name, theme.background.name())));
}
}
void paletteHighlightIsSelection() {
// After applying theme, QPalette::Highlight must be theme.selection (not theme.hover)
auto& tm = ThemeManager::instance();
const auto& theme = tm.current();
applyGlobalTheme(theme);
QPalette pal = qApp->palette();
QCOMPARE(pal.color(QPalette::Highlight), theme.selection);
}
void treePageSwitching() {
OptionsResult defaults;
OptionsDialog dlg(defaults);
auto* tree = dlg.findChild<QTreeWidget*>();
auto* pages = dlg.findChild<QStackedWidget*>();
QVERIFY(tree && pages);
// General is selected by default -> page 0
QCOMPARE(pages->currentIndex(), 0);
// Find "AI Features" item and select it
auto* envItem = tree->topLevelItem(0);
QVERIFY(envItem);
QTreeWidgetItem* aiItem = nullptr;
for (int i = 0; i < envItem->childCount(); ++i) {
if (envItem->child(i)->text(0) == "AI Features") {
aiItem = envItem->child(i);
break;
}
}
QVERIFY(aiItem);
tree->setCurrentItem(aiItem);
QCOMPARE(pages->currentIndex(), 1);
// Switch back to General
QTreeWidgetItem* generalItem = nullptr;
for (int i = 0; i < envItem->childCount(); ++i) {
if (envItem->child(i)->text(0) == "General") {
generalItem = envItem->child(i);
break;
}
}
QVERIFY(generalItem);
tree->setCurrentItem(generalItem);
QCOMPARE(pages->currentIndex(), 0);
}
void searchFilterHidesItems() {
OptionsResult defaults;
OptionsDialog dlg(defaults);
auto* search = dlg.findChild<QLineEdit*>();
auto* tree = dlg.findChild<QTreeWidget*>();
QVERIFY(search && tree);
auto* envItem = tree->topLevelItem(0);
QVERIFY(envItem);
// All children visible initially
for (int i = 0; i < envItem->childCount(); ++i)
QVERIFY(!envItem->child(i)->isHidden());
// Search for "MCP" - should hide General, show AI Features
search->setText("MCP");
QTreeWidgetItem* generalItem = nullptr;
QTreeWidgetItem* aiItem = nullptr;
for (int i = 0; i < envItem->childCount(); ++i) {
auto* child = envItem->child(i);
if (child->text(0) == "General") generalItem = child;
if (child->text(0) == "AI Features") aiItem = child;
}
QVERIFY(generalItem && aiItem);
QVERIFY(generalItem->isHidden());
QVERIFY(!aiItem->isHidden());
// Clear search - all visible again
search->setText("");
QVERIFY(!generalItem->isHidden());
QVERIFY(!aiItem->isHidden());
}
void refreshRateSpinBoxExists() {
OptionsResult defaults;
defaults.refreshMs = 660;
OptionsDialog dlg(defaults);
auto* spin = dlg.findChild<QSpinBox*>("refreshSpin");
QVERIFY(spin);
QCOMPARE(spin->value(), 660);
QCOMPARE(spin->minimum(), 1);
QCOMPARE(spin->maximum(), 60000);
}
void refreshRateResultReflectsInput() {
OptionsResult input;
input.refreshMs = 200;
OptionsDialog dlg(input);
auto r = dlg.result();
QCOMPARE(r.refreshMs, 200);
// Change via spin box
auto* spin = dlg.findChild<QSpinBox*>("refreshSpin");
QVERIFY(spin);
spin->setValue(100);
r = dlg.result();
QCOMPARE(r.refreshMs, 100);
}
void refreshRateClampsMin() {
OptionsResult input;
input.refreshMs = 0; // below minimum
OptionsDialog dlg(input);
auto* spin = dlg.findChild<QSpinBox*>("refreshSpin");
QVERIFY(spin);
// QSpinBox clamps to minimum
QCOMPARE(spin->value(), 1);
}
void dialogInheritsPalette() {
auto& tm = ThemeManager::instance();
const auto& theme = tm.current();
applyGlobalTheme(theme);
OptionsResult defaults;
OptionsDialog dlg(defaults);
dlg.show();
QTest::qWaitForWindowExposed(&dlg);
// Dialog's effective palette should match the app palette
QPalette dlgPal = dlg.palette();
QPalette appPal = qApp->palette();
QCOMPARE(dlgPal.color(QPalette::Window), appPal.color(QPalette::Window));
QCOMPARE(dlgPal.color(QPalette::WindowText), appPal.color(QPalette::WindowText));
QCOMPARE(dlgPal.color(QPalette::Highlight), appPal.color(QPalette::Highlight));
QCOMPARE(dlgPal.color(QPalette::Button), appPal.color(QPalette::Button));
QCOMPARE(dlgPal.color(QPalette::ButtonText), appPal.color(QPalette::ButtonText));
// Highlight must be visible against background
QVERIFY(dlgPal.color(QPalette::Highlight) != dlgPal.color(QPalette::Window));
}
};
QTEST_MAIN(TestOptionsDialog)
#include "test_options_dialog.moc"

View File

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

View File

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

View File

@@ -325,7 +325,7 @@ private slots:
// Verify it's not all zeros (the old failure mode)
bool allZero = true;
for (int i = 0; i < data.size(); ++i) {
if (data[i] != 0) { allZero = false; break; }
if (data[i] != '\0') { allZero = false; break; }
}
QVERIFY2(!allZero, "Data is all zeros — background thread read failed");
}

View File

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