Compare commits

..

122 Commits

Author SHA1 Message Date
IChooseYou
b089e20d36 ci: retrigger build 2026-02-20 17:01:57 -07:00
IChooseYou
5fa1dd0ab4 fix: add missing header declarations and editor scroll fixes
- mainwindow.h: add m_viewBtnGroup, m_btnReclass, m_btnRendered members,
  syncViewButtons() declaration, QButtonGroup/QPushButton includes,
  remove applyTabWidgetStyle() declaration
- editor.cpp: reset xOffset on applyDocument, clamp in restoreViewState
- test_editor.cpp: add horizontal scroll reset test
2026-02-20 13:22:23 -07:00
IChooseYou
3b1fe7ff35 fix: use findChild<QWidget*> for ResizeGrip to fix GCC 15 static_assert
GCC 15.2 on CI enforces Q_OBJECT requirement for findChild template
parameter. ResizeGrip is a local class without Q_OBJECT, so use
QWidget* with static_cast instead.
2026-02-20 13:18:03 -07:00
IChooseYou
4595b366e3 ci: use system MinGW from runner, drop tools_mingw1310 2026-02-20 13:05:13 -07:00
IChooseYou
33d7dc74cb ci: switch Windows CI from MSVC to MinGW, run Linux in parallel 2026-02-20 12:57:51 -07:00
IChooseYou
e118231bb1 docs: add screenshots to README 2026-02-20 12:32:04 -07:00
IChooseYou
0cfd7ad87a feat: sort primitives alphabetically in type chooser 2026-02-20 07:37:32 -07:00
IChooseYou
2d3ce63b54 ci: disable UI tests in CI, delete test_com_security
CI now passes -DBUILD_UI_TESTS=OFF so only headless tests
(core, format, compose, provider, command_row, generator,
import_xml, import_source, export_xml, disasm) build and run.

Removed xvfb-run and exclude-regex hacks from both Windows
and Linux CI — the CMake option handles it cleanly.

Deleted test_com_security (windbg-only, not needed in CI).
2026-02-20 07:27:23 -07:00
IChooseYou
0e087fa3a4 feat: primitive pointer modifiers, type chooser fixes, double-click to edit
Type chooser:
- Fix PointerTarget mode hiding primitives due to stale modifier state
- Preselect */[n] modifier buttons to reflect current node type
- Primitive pointer support: int32*, double**, etc with provider deref
- hex64*/ptr64* with * modifier falls back to void* (meaningless deref)
- isValidPrimitivePtrTarget guard in controller, compose, format
- Modifier toggle no longer resets list selection
- Primitive pointers open FieldType mode (not PointerTarget)
- Type edit requires double-click (was single-click, too easy to misclick)

Other:
- Custom dock titlebar with themed close button, no float button
- Status bar font synced at startup
- Resize grip reworked as direct MainWindow child, font-independent
- File menu "Source" renamed to "Current Tab Source"

Tests: 41 type_selector, 39 editor, 17 controller (200 total, 0 failures)
2026-02-20 07:21:02 -07:00
IChooseYou
c7afe363f3 feat: custom dock titlebar, resize grip symmetry fix, status bar font sync
- Replace default dock widget titlebar with custom label + themed ✕ close button
- Remove float/popout button from project tree dock
- Fix resize grip corner symmetry (bottom margin 4→0)
- Sync editor font to status bar and dock titlebar at startup
- Add testResizeGripCornerSymmetry test
2026-02-19 18:10:52 -07:00
IChooseYou
2a44d2ac57 fix: narrow inline editor selection for pointer values, resolve correct write address
resolvedSpanFor() now applies narrowPtrValueSpan() to trim the "// Module+offset"
symbol comment from the editable span, matching hitTestTarget(). Previously the
full value column text was selected, making the parser fail on commit (toULongLong
rejected the non-hex suffix), so pointer value saves were silently no-ops.

With the parse now succeeding, a second bug was exposed: setNodeValue() computed
write addresses via computeOffset() which sums tree offsets without dereferencing
pointers. For nodes inside expanded pointer targets (e.g. VTable entries), this
wrote to struct_base+child_offset instead of *ptr_value+child_offset, causing an
access violation crash. The fix passes lm->offsetAddr (the compose-resolved
absolute address) through the inlineEditCommitted signal so setNodeValue() uses
the correct dereferenced address.
2026-02-19 13:05:25 -07:00
IChooseYou
d989e2a947 feat: safe workspace tree deletion with reference cleanup and confirmation
- Add deleteRootStruct() that clears orphaned refId references before removal
- Show confirmation dialog listing all fields that reference the deleted type
- Auto-switch view to next root struct when the viewed one is deleted
- Entire operation is a single undo macro (Ctrl+Z restores everything)
2026-02-19 10:06:13 -07:00
IChooseYou
7678da033d feat: source management, cross-tab type visibility, default VS2022 theme
- Add clearSources() and File→Source submenu for provider management
- Fix type picker not showing newly created structs (empty structTypeName)
- Add cross-tab type visibility via shared project document list
- Import external types into local document on selection
- Default theme to VS2022 on first launch
- Add test_source_management and test_type_visibility test suites

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-19 09:29:18 -07:00
IChooseYou
acc3ebf5db feat: track value changes toggle, hover scroll fix, ptr* convert, hex split 2026-02-19 06:32:58 -07:00
IChooseYou
26217f5de8 feat: switch provider addressing from RVA to absolute, add pointer expansion tests 2026-02-18 13:07:48 -07:00
IChooseYou
fa0d9a377b fix: type chooser updates colors when theme changes
Add applyTheme() to TypeSelectorPopup that refreshes palette and
stylesheets for all child widgets. Controller connects it to
ThemeManager::themeChanged on popup creation.
2026-02-18 09:59:50 -07:00
IChooseYou
b1d3e52204 fix: type chooser SVG icons and gutter scale with editor zoom level
Derive icon size, gutter width, and icon column width from font
metrics instead of hardcoded 16/10/20 pixel values. Popup width
calculation also scales with font.
2026-02-18 09:47:25 -07:00
IChooseYou
1cccd320b0 feat: simplify cmd bar keyword, add File menu class/struct/enum, remove Align Members
- Command bar shows static keyword (struct/class/enum) without dropdown or colon
- Right-click keyword in cmd bar for class↔struct conversion (enum blocked)
- File menu: New Class (Ctrl+N), New Struct (Ctrl+T), New Enum (Ctrl+E)
- Project explorer right-click: New Class/Struct/Enum on Project node
- Explorer right-click: Convert to Class/Struct on class/struct items
- Remove Align Members submenu, performRealignment, computeStructAlignment
- Remove screenshot code and screenshot.png

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-18 09:38:54 -07:00
IChooseYou
5b6e0473cb remove screenshot from README 2026-02-18 08:22:56 -07:00
IChooseYou
57d55456a8 fix: type chooser [n] array modifier now works for primitive types
Array count defaults to 1 when clicking the [n] toggle.
2026-02-18 08:16:02 -07:00
IChooseYou
bb466516ba fix: context menu shows full node menu when right-clicking past line text
Also add Mid theme and remove flaky test_theme test.
2026-02-18 07:57:49 -07:00
IChooseYou
444ba34fa3 feat: disasm popup, symbol separation, context menu improvements, RVA fixes
- Add Fadec x86 disassembler with hover popup for FuncPtr/void Pointer nodes
- Separate pointer symbol from address: // prefix, green comment coloring,
  independent hover/click zones (address triggers popup, symbol is passive)
- Fix RVA margin and inline local offset for pointer-expanded vtable children
  using ptrBase field threaded through composition
- Expand multi-select context menu with quick-convert, duplicate, copy address
- Remove Edit Value from hex node context menu
- Fix heatmap flickering on hex nodes (remove per-byte alternation)
- Fix popup repositioning when moving mouse between lines
- Truncate disasm popup to 6 lines with ... indicator
- Add BUILD_UI_TESTS option to skip widget tests on headless CI
- Add test_disasm with 35 test cases for disassembly and hex dump
- Add KUSER_SHARED_DATA example .rcx file
2026-02-18 07:10:13 -07:00
ichooseyou
91633169a0 fix: guard Windows-only selfTest code for Linux build
- Wrap DWORD/GetCurrentProcessId and KUSER_SHARED_DATA example behind Q_OS_WIN
- Linux selfTest falls back to project_new() with basic hex class
2026-02-17 12:32:44 -07:00
ichooseyou
f041761b62 feat: add FuncPtr32/FuncPtr64 node kinds, darken menu hover, remove refresh log
- Add FuncPtr32/FuncPtr64 types with display, parsing, validation, code generation
- Add quick-convert context menu actions between pointer and funcptr types
- Darken QMenu hover highlight from theme.border to theme.hover for better contrast
- Remove noisy [Refresh] reading debug log from console output
2026-02-17 12:29:51 -07:00
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
IChooseYou
c856ba2697 WinDbg plugin, ProcessMemoryWindows, dialog cleanup, and misc fixes
- Add WinDbgMemory plugin with debug server connection support
- Replace ProcessMemory plugin with Windows-specific ProcessMemoryWindows
- Simplify WinDbg dialog: single panel, no tabs, palette-based theming
- Fix example text visibility on dark themes (QPalette::Dark -> Disabled WindowText)
- Fix "file" -> "File" capitalization in source menu
- Add windbg_provider and com_security tests
2026-02-14 13:40:58 -07:00
IChooseYou
b44dc9e96b Update screenshot 2026-02-13 17:58:27 -07:00
IChooseYou
0f2ded471f Update screenshot 2026-02-13 17:56:07 -07:00
IChooseYou
c9377c3afd Show Icon uses 24x24 icon instead of 16x16 2026-02-13 17:47:14 -07:00
IChooseYou
a86912add1 Theme system overhaul, UI polish, and VS2022 Dark theme
- Replaced hardcoded theme factories with JSON files + CMake build step
- Shared ThemeFieldMeta table for DRY serialization and editor UI
- Fixed live preview (auto-triggers on color change, no toggle button)
- Fixed duplicate theme entries when editing built-in themes
- Moved title bar from icon to bold "Reclass" text with View > Show Icon toggle
- MDI tabs: 24px height, unicode close button styled like TypeSelectorPopup
- Added VS2022 Dark theme with purple accent colors
- Status bar padding, removed monospace font overrides on tabs/statusbar
- Default startup opens Ball demo + Unnamed hex64 tabs
2026-02-13 16:23:12 -07:00
Sen66
5a9a6b754f Expand recursive structs instead of silently skipping cycle fields
Self-referential struct children (e.g. Ball containing Ball) now render
as collapsed struct headers with a cycle marker. Clicking the fold margin
materializes the referenced children and auto-expands the recursive child,
allowing unlimited depth exploration.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-13 19:11:10 +01:00
Sen66
0df52e82b8 Added custom title bar & border color when focused 2026-02-13 19:09:11 +01:00
Sen66
9a342286ee codicon.ttf isn't actually used 2026-02-13 19:01:13 +01:00
IChooseYou
1af0e17ef8 Remove contrast tool from theme editor, keep color config only 2026-02-13 10:00:21 -07:00
IChooseYou
a45d66dd4e Remove live process note from README 2026-02-13 07:35:43 -07:00
IChooseYou
6922166e3c Add MCP bridge connection instructions to README 2026-02-13 07:34:08 -07:00
ichooseu
ffde3343dd Type chooser: fix composite type changes (struct, pointer, array modifiers) 2026-02-13 07:30:40 -07:00
iamacontributor
c86a6dbc73 Tab titles show root struct name instead of Untitled, sync MDI tab font with editor, rename MCP pipe/exe to ReclassMcpBridge 2026-02-13 06:51:09 -07:00
Sen66
b153665059 Properly delete selected nodes 2026-02-13 02:27:01 +01:00
Sen66
a88b584ca0 Show relative offsets or absolute on the left side 2026-02-13 02:11:09 +01:00
Sen66
3e827194b8 Adjustments to scrollbars 2026-02-13 01:55:52 +01:00
Sen66
9f1c85913c Fix highlighting of hover value for Vec & OOB checks for line 2026-02-13 01:52:03 +01:00
Sen66
cb151ab850 Fix fonts not being themed after switching 2026-02-13 00:45:51 +01:00
sysadmin
b0aa7cda67 Align hex byte preview column with value column using dynamic nameW 2026-02-12 13:54:52 -07:00
IChooseYou
4b1d3e9d3f Theme preview/revert, theme editor enhancements, build and deploy updates 2026-02-12 12:37:09 -07:00
IChooseYou
e73b783cda QMenu + QMenuBar hover: amber indHoverSpan text via QProxyStyle
MenuBarStyle.drawControl overrides CE_MenuBarItem and CE_MenuItem:
- Menu bar: strips hover state flags, swaps ButtonText to Link (amber),
  delegates to Fusion for identical layout (no text shift)
- Popup menus: patches Highlight→Mid, HighlightedText→Link, delegates
  to Fusion for icons/shortcuts/checkmarks

Test: real QEvent::Enter + MouseMove delivery, QScreen::grabWindow
screenshot, pixel-scan for amber, always saves PNGs for inspection.
2026-02-12 10:22:00 -07:00
IChooseYou
7e0b995f4d Update README with current project state 2026-02-11 14:04:15 -07:00
IChooseYou
52d65b4a23 Update screenshot with Vehicle demo 2026-02-11 13:54:48 -07:00
IChooseYou
db5d3ae311 Unified type popup: explicit TypeEntry model, modifier toggles, section headers
- Replace sentinel-id scheme (kPrimBase) with TypeEntry::Kind discriminant
- Merge showTypeSelectorPopup + showTypePickerPopup into single showTypePopup
- Merge applyTypePickerResult into applyTypePopupResult matching on entryKind
- Add modifier toggle buttons (plain, *, **, [n]) with array count input
- Add section headers (primitives / project types) with dim centered styling
- Root mode shows project types first; non-Root sorts same-size primitives first
- Remove popup outside border, flat "Create new type" button
- Add parseTypeSpec parser, update tests with new TypeEntry API
2026-02-11 13:39:43 -07:00
batallaion caputa
33a093ae7d Widen value column to 96 chars, remove Mat4x4 truncation
kColValue 32 -> 96 to prevent float truncation with ellipsis.
Mat4x4 rows bypass fit() entirely so long matrix rows display fully.
Updated test expectations to use kColValue instead of hardcoded 32.
2026-02-11 10:44:24 -07:00
IChooseYou
968476b65a Mat4x4 row labels, no scientific notation, per-component inline editing
- Add row0..row3 labels to Mat4x4 grid display with aligned columns
- Rewrite fmtFloat() to never use scientific notation (plain decimal + trim)
- Enable per-component inline editing for all 16 Mat4x4 floats
- Fix click-to-edit always selecting first component (thread column from hitTestTarget)
- Add isMatrixKind() helper, remove Mat4x4 from context menu edit exclusion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 10:36:15 -07:00
IChooseYou
df07b61144 Array element offset display, fold arrow UX, type picker popup, and provider cleanup
- Show relative hex offset on array element separators ([N] +0x...)
- Dim fold arrows and add hover highlight for better visibility
- Extend fold/chevron click areas for easier interaction
- Add type picker popup for array element type and pointer target editing
- Remove process_provider.h in favor of plugin-based provider system
- Expand compose/format to handle struct-of-array type names and widths

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 09:13:17 -07:00
IChooseYou
3db051f4ba Fix computeDataExtent undercount, setNodeValue signed offset, and dead isStringArray
- computeDataExtent: use structSpan() for Struct/Array nodes instead of
  byteSize() which returns 0 for Array-of-Struct; use int64_t intermediates
  to prevent truncation of offsets beyond 2GB
- setNodeValue: guard against negative computeOffset results before
  casting to uint64_t (prevents wrapping to huge addresses on malformed trees)
- isStringArray: comment out unused method (was checking UInt8/UInt16
  instead of UTF8/UTF16); corrected version preserved in comment
2026-02-11 08:43:34 -07:00
IChooseYou
fc48fd6d2d Merge IChooseYou/Reclass qt5-support branch 2026-02-11 05:20:56 -07:00
Duncan Ogilvie
0ffb7d6f58 Fix test_type_selector for Qt5 and stale expectation
- Register uint64_t metatype for QSignalSpy in Qt5 (not needed in Qt6)
- Update command row expectation: "NoName" matches controller output
2026-02-10 23:08:24 +01:00
Duncan Ogilvie
0b0d9f23e1 Add dark title bar support for Qt5 2026-02-10 23:07:16 +01:00
Duncan Ogilvie
7194322831 Add support for Qt5 2026-02-10 23:06:55 +01:00
IChooseYou
5f1fd56171 Fix process memory provider base address sync and live refresh
Provider base address now stays in sync with tree base address when
changed via ChangeBase command, fixing reads from arbitrary memory
regions like KUSER_SHARED_DATA at 0x7FFE0000. ReadProcessMemory
handles partial reads gracefully. Snapshot extent uses tree-based
calculation instead of provider size to avoid oversized reads.
MCP source.switch gains pid parameter for programmatic process attach.
MCP server starts by default with logging and slow mode support.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 13:04:28 -07:00
IChooseYou
6bd61a6b78 Fix indentation in README build instructions 2026-02-10 11:19:30 -07:00
IChooseYou
60990362a0 Remove live data line from README 2026-02-10 11:14:23 -07:00
IChooseYou
55ef83a39f Update README description and project status 2026-02-10 11:13:24 -07:00
IChooseYou
e034fe6f6c Remove outdated video link from README 2026-02-10 11:09:16 -07:00
IChooseYou
2e387f2dfc Update screenshot 2026-02-10 11:07:58 -07:00
IChooseYou
4295460597 Add MCP bridge for external tool integration
Embedded JSON-RPC server over named pipes (rcx-mcp) enabling external
tools like Claude Code to inspect and manipulate the node tree, read/write
hex data, switch sources, and trigger UI actions. Includes stdio adapter
(rcx-mcp-stdio) for stdin/stdout transport. Server is stopped by default;
user starts via File > Start MCP Server.

Also extracts MainWindow class declaration to mainwindow.h and improves
type selector popup Esc button styling.
2026-02-10 10:55:27 -07:00
IChooseYou
df566064ba Fix root class rename targeting wrong struct when using type selector
RootClassName and RootClassType edit handlers now use m_viewRootId
instead of blindly picking the first root struct. Default name for
unnamed structs changed from <no name> to NoName. Dim the opening
brace on the command row to match the rest of the bar's grey text.
2026-02-10 08:06:43 -07:00
IChooseYou
24a7e68136 Add theme system with Reclass Dark and Warm built-in themes
Replaces ~40 hardcoded color values with 27 semantic color roles.
Adds ThemeManager singleton, theme editor dialog, View > Theme menu,
JSON persistence for user themes, and fixes inline edit selection
color from blue #264f78 to #2b2b2b.
2026-02-10 07:46:18 -07:00
IChooseYou
8eab304538 Dim opening brace on root command row to match child struct headers 2026-02-10 06:19:34 -07:00
IChooseYou
9dd104ff34 Copy All as Text now includes margin offset text prepended to each line 2026-02-10 06:17:10 -07:00
IChooseYou
910b607b79 Rename ReclassX to Reclass in window titles and About dialog 2026-02-10 06:10:09 -07:00
IChooseYou
c415b11825 Replace About dialog with build timestamp and GitHub link 2026-02-10 06:06:18 -07:00
IChooseYou
1d6fddb51e Replace Iosevka with JetBrains Mono, fix scrollbar and inline edit UX
- Replace embedded Iosevka font with JetBrains Mono as default font
- Fix wide horizontal scrollbar by enabling SCI_SETSCROLLWIDTHTRACKING
- Remove 28-char trailing whitespace padding from all lines
- Fix arrow keys not collapsing selection in inline edit mode
- Dim struct/array braces ({ and };) to match hex node styling
- Resize margin immediately on font change
2026-02-10 06:00:17 -07:00
penguin time
276dcae444 Reduce status bar left padding to 10px 2026-02-10 04:26:42 -07:00
sysadmin
659fb7bd32 Merge remote-tracking branch 'origin/linux' 2026-02-10 04:25:39 -07:00
administrator
85b840379d Remove Load Binary, View C++/Reclass actions, Debug tab, and dead code
Data sources are now provided by source plugins. View switching is
handled by bottom tabs (Reclass / C/C++ only). Also applies grey
highlight/selected styling globally and removes per-menu stylesheet.
2026-02-09 14:06:22 -07:00
batallion2
f4149faa9a Add type selector popup, view root switching, and new type creation
- Type selector chevron [▸] on command row opens searchable popup
- Popup lists all root structs with filter, keyboard nav, side-triangle indicator
- Selecting a type switches the editor view via setViewRootId
- "Create new type" inserts a new root struct with no name
- Command row displays the active view root's name
- Tests for chevron detection, span compatibility, view switching, undo
2026-02-09 12:21:03 -07:00
Sen66
b6c713eb29 Updated Readme 2026-02-09 15:14:35 +01:00
Sen66
4029b05298 Added linux support (tested on Ubuntu)
CMakeList: fixed for building on linux
processpicker: linux process enumeration
main.cpp: "_Exit()" works on linux & windows
"ProcessMemory" plugin: added linux support
2026-02-09 15:09:42 +01:00
batallion caputa
0e65b9997e Update README with build instructions and project description 2026-02-09 06:42:19 -07:00
battalion caputa
4caa7daa44 Merge branch 'plugin-system' into main
Adds basic plugin system support with plugin manager, provider registry,
plugin interface, and ProcessMemory example plugin.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-08 16:13:56 -07:00
131 changed files with 37756 additions and 2491 deletions

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

@@ -0,0 +1,193 @@
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_mingw'
cache: true
aqtversion: '==3.1.21'
- name: Configure
shell: bash
run: |
export PATH="/c/mingw64/bin:$PATH"
cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_UI_TESTS=OFF \
-DCMAKE_C_COMPILER=gcc -DCMAKE_CXX_COMPILER=g++
- name: Build
shell: bash
run: |
export PATH="/c/mingw64/bin:$PATH"
cmake --build build
- name: Test
shell: bash
run: |
export PATH="/c/mingw64/bin:$PATH"
ctest --test-dir build --output-on-failure
- 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:
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 -DBUILD_UI_TESTS=OFF
- name: Build
run: cmake --build build
- name: Test
run: ctest --test-dir build --output-on-failure
- 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

@@ -1,18 +1,37 @@
cmake_minimum_required(VERSION 3.20)
project(ReclassX VERSION 0.1 LANGUAGES CXX)
project(Reclass VERSION 0.1 LANGUAGES C CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_C_STANDARD 11)
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
set(CMAKE_AUTOUIC ON)
find_package(Qt6 REQUIRED COMPONENTS Widgets PrintSupport Svg Concurrent)
list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake")
# Find Qt6 or Qt5 (config mode first, then FindQt5.cmake module for auto-download)
set(_QT_COMPONENTS Core Widgets PrintSupport Svg Concurrent Network)
find_package(QT NAMES Qt6 Qt5 COMPONENTS ${_QT_COMPONENTS} QUIET)
if(NOT QT_FOUND)
find_package(Qt5 REQUIRED COMPONENTS ${_QT_COMPONENTS})
set(QT_VERSION_MAJOR 5)
endif()
# The NAMES variant only detects the version; load the actual component targets
find_package(Qt${QT_VERSION_MAJOR} REQUIRED COMPONENTS ${_QT_COMPONENTS})
set(QT Qt${QT_VERSION_MAJOR})
message(STATUS "Using ${QT}: ${${QT}_DIR}")
# Qt5 on Windows needs WinExtras for HICON conversion
set(_QT_WINEXTRAS "")
if(QT_VERSION_MAJOR EQUAL 5 AND WIN32)
find_package(Qt5 REQUIRED COMPONENTS WinExtras)
set(_QT_WINEXTRAS Qt5::WinExtras)
endif()
find_package(QScintilla REQUIRED)
add_executable(ReclassX
add_executable(Reclass
src/main.cpp
src/editor.h
src/editor.cpp
@@ -28,39 +47,75 @@ add_executable(ReclassX
src/resources.qrc
src/core.h
src/workspace_model.h
src/providers/buffer_provider.h src/providers/null_provider.h src/providers/process_provider.h src/providers/provider.h src/providers/snapshot_provider.h
src/providers/buffer_provider.h src/providers/null_provider.h src/providers/provider.h src/providers/snapshot_provider.h
src/providerregistry.cpp
src/providerregistry.h
src/pluginmanager.cpp
src/pluginmanager.h
src/typeselectorpopup.h
src/typeselectorpopup.cpp
src/themes/theme.h
src/themes/theme.cpp
src/themes/thememanager.h
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
src/mcp/mcp_bridge.cpp
src/disasm.h
src/disasm.cpp
third_party/fadec/decode.c
third_party/fadec/format.c
$<$<PLATFORM_ID:Windows>:src/app.rc>
)
target_include_directories(ReclassX PRIVATE src)
target_include_directories(Reclass PRIVATE src third_party/fadec)
target_link_libraries(ReclassX PRIVATE
Qt6::Widgets
Qt6::PrintSupport
Qt6::Svg
Qt6::Concurrent
target_link_libraries(Reclass PRIVATE
${QT}::Widgets
${QT}::PrintSupport
${QT}::Svg
${QT}::Concurrent
${QT}::Network
QScintilla::QScintilla
dbghelp
psapi
${_QT_WINEXTRAS}
)
if(WIN32)
target_link_libraries(Reclass PRIVATE dbghelp dwmapi psapi)
endif()
add_custom_target(screenshot ALL
COMMAND ReclassX --screenshot ${CMAKE_BINARY_DIR}/screenshot.png
DEPENDS ReclassX
WORKING_DIRECTORY ${CMAKE_BINARY_DIR}
COMMENT "Capturing UI screenshot with class open..."
)
add_executable(ReclassMcpBridge tools/rcx-mcp-stdio.cpp)
target_link_libraries(ReclassMcpBridge PRIVATE ${QT}::Core ${QT}::Network)
# Copy built-in theme JSON files to build directory
file(GLOB _theme_files "${CMAKE_SOURCE_DIR}/src/themes/defaults/*.json")
file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/themes")
foreach(_tf ${_theme_files})
get_filename_component(_name ${_tf} NAME)
configure_file(${_tf} "${CMAKE_BINARY_DIR}/themes/${_name}" COPYONLY)
endforeach()
# 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(copy_demo ALL
COMMAND ${CMAKE_COMMAND} -E copy_if_different
${CMAKE_BINARY_DIR}/demo.rcx
${CMAKE_SOURCE_DIR}/src/examples/demo.rcx
DEPENDS screenshot
COMMENT "Copying demo.rcx to src/examples..."
)
set(_combine_script "${CMAKE_BINARY_DIR}/combine_sources.cmake")
file(WRITE ${_combine_script} "
@@ -77,7 +132,7 @@ foreach(_f
\"${CMAKE_SOURCE_DIR}/src/generator.cpp\"
\"${CMAKE_SOURCE_DIR}/src/main.cpp\")
file(READ \${_f} _content)
file(APPEND \${_out} \${_content})
file(APPEND \${_out} \"\${_content}\")
file(APPEND \${_out} \"\\n\")
endforeach()
message(STATUS \"Combined sources -> \${_out}\")
@@ -85,95 +140,228 @@ message(STATUS \"Combined sources -> \${_out}\")
add_custom_target(combined ALL
COMMAND ${CMAKE_COMMAND} -P ${_combine_script}
DEPENDS ReclassX
DEPENDS Reclass
COMMENT "Combining all source files into h_cpp_combined.txt"
)
include(CTest)
if(BUILD_TESTING)
find_package(Qt6 REQUIRED COMPONENTS Test)
find_package(${QT} REQUIRED COMPONENTS Test)
enable_testing()
# Disasm/Fadec sources needed by any test that links editor.cpp
set(DISASM_SRCS src/disasm.cpp third_party/fadec/decode.c third_party/fadec/format.c)
# ── Headless tests (Qt::Core only — safe for CI without a display) ──
add_executable(test_core tests/test_core.cpp src/format.cpp src/compose.cpp)
target_include_directories(test_core PRIVATE src)
target_link_libraries(test_core PRIVATE Qt6::Core Qt6::Test)
target_link_libraries(test_core PRIVATE ${QT}::Core ${QT}::Test)
add_test(NAME test_core COMMAND test_core)
add_executable(test_format tests/test_format.cpp src/format.cpp)
target_include_directories(test_format PRIVATE src)
target_link_libraries(test_format PRIVATE Qt6::Core Qt6::Test)
target_link_libraries(test_format PRIVATE ${QT}::Core ${QT}::Test)
add_test(NAME test_format COMMAND test_format)
add_executable(test_compose tests/test_compose.cpp src/compose.cpp src/format.cpp)
target_include_directories(test_compose PRIVATE src)
target_link_libraries(test_compose PRIVATE Qt6::Core Qt6::Test)
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)
target_include_directories(test_editor PRIVATE src)
target_link_libraries(test_editor PRIVATE
Qt6::Widgets Qt6::PrintSupport Qt6::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)
target_link_libraries(test_provider PRIVATE Qt6::Core Qt6::Test)
target_link_libraries(test_provider PRIVATE ${QT}::Core ${QT}::Test)
add_test(NAME test_provider COMMAND test_provider)
add_executable(test_command_row tests/test_command_row.cpp)
target_include_directories(test_command_row PRIVATE src)
target_link_libraries(test_command_row PRIVATE Qt6::Core Qt6::Test)
target_link_libraries(test_command_row PRIVATE ${QT}::Core ${QT}::Test)
add_test(NAME test_command_row COMMAND test_command_row)
add_executable(test_provider_getSymbol tests/test_provider_getSymbol.cpp)
target_include_directories(test_provider_getSymbol PRIVATE src)
target_link_libraries(test_provider_getSymbol PRIVATE Qt6::Core Qt6::Test)
if(WIN32)
target_link_libraries(test_provider_getSymbol PRIVATE psapi)
endif()
add_test(NAME test_provider_getSymbol COMMAND test_provider_getSymbol)
add_executable(test_controller tests/test_controller.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp)
target_include_directories(test_controller PRIVATE src)
target_link_libraries(test_controller PRIVATE
Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test
QScintilla::QScintilla dbghelp psapi)
add_test(NAME test_controller COMMAND test_controller)
add_executable(test_validation tests/test_validation.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp)
target_include_directories(test_validation PRIVATE src)
target_link_libraries(test_validation PRIVATE
Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test
QScintilla::QScintilla dbghelp psapi)
add_test(NAME test_validation COMMAND test_validation)
add_executable(test_generator tests/test_generator.cpp
src/generator.cpp src/compose.cpp src/format.cpp)
target_include_directories(test_generator PRIVATE src)
target_link_libraries(test_generator PRIVATE Qt6::Core Qt6::Test)
target_link_libraries(test_generator PRIVATE ${QT}::Core ${QT}::Test)
add_test(NAME test_generator COMMAND test_generator)
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)
add_executable(test_disasm tests/test_disasm.cpp
src/disasm.cpp src/compose.cpp src/format.cpp
third_party/fadec/decode.c third_party/fadec/format.c)
target_include_directories(test_disasm PRIVATE src third_party/fadec)
target_link_libraries(test_disasm PRIVATE ${QT}::Core ${QT}::Test)
add_test(NAME test_disasm COMMAND test_disasm)
# ── UI tests (require Qt::Widgets / QScintilla / display — skip on headless CI) ──
option(BUILD_UI_TESTS "Build tests that require a display (Qt Widgets)" ON)
if(BUILD_UI_TESTS)
add_executable(test_controller tests/test_controller.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
target_include_directories(test_controller PRIVATE src third_party/fadec)
target_link_libraries(test_controller PRIVATE
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
QScintilla::QScintilla)
if(WIN32)
target_link_libraries(test_controller PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
endif()
add_test(NAME test_controller COMMAND test_controller)
add_executable(test_validation tests/test_validation.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
target_include_directories(test_validation PRIVATE src third_party/fadec)
target_link_libraries(test_validation PRIVATE
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
QScintilla::QScintilla)
if(WIN32)
target_link_libraries(test_validation PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
endif()
add_test(NAME test_validation COMMAND test_validation)
add_executable(test_context_menu tests/test_context_menu.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp)
target_include_directories(test_context_menu PRIVATE src)
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
target_include_directories(test_context_menu PRIVATE src third_party/fadec)
target_link_libraries(test_context_menu PRIVATE
Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test
QScintilla::QScintilla dbghelp psapi)
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
QScintilla::QScintilla)
if(WIN32)
target_link_libraries(test_context_menu PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
endif()
add_test(NAME test_context_menu COMMAND test_context_menu)
add_executable(test_source_management tests/test_source_management.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
target_include_directories(test_source_management PRIVATE src third_party/fadec)
target_link_libraries(test_source_management PRIVATE
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
QScintilla::QScintilla)
if(WIN32)
target_link_libraries(test_source_management PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
endif()
add_test(NAME test_source_management COMMAND test_source_management)
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 ${DISASM_SRCS})
target_include_directories(test_editor PRIVATE src third_party/fadec)
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)
target_link_libraries(test_rendered_view PRIVATE
${QT}::Widgets ${QT}::PrintSupport ${QT}::Test
QScintilla::QScintilla)
add_test(NAME test_rendered_view COMMAND test_rendered_view)
add_executable(test_new_features tests/test_new_features.cpp
src/generator.cpp src/compose.cpp src/format.cpp src/controller.cpp
src/editor.cpp src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp)
target_include_directories(test_new_features PRIVATE src)
src/editor.cpp src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
target_include_directories(test_new_features PRIVATE src third_party/fadec)
target_link_libraries(test_new_features PRIVATE
Qt6::Widgets Qt6::PrintSupport Qt6::Concurrent Qt6::Test
QScintilla::QScintilla dbghelp psapi)
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
QScintilla::QScintilla)
if(WIN32)
target_link_libraries(test_new_features PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
endif()
add_test(NAME test_new_features COMMAND test_new_features)
add_executable(test_type_selector tests/test_type_selector.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
target_include_directories(test_type_selector PRIVATE src third_party/fadec)
target_link_libraries(test_type_selector PRIVATE
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
QScintilla::QScintilla)
if(WIN32)
target_link_libraries(test_type_selector PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
endif()
add_test(NAME test_type_selector COMMAND test_type_selector)
add_executable(test_type_visibility tests/test_type_visibility.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/controller.cpp
src/processpicker.cpp src/processpicker.ui src/providerregistry.cpp
src/typeselectorpopup.cpp
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
target_include_directories(test_type_visibility PRIVATE src third_party/fadec)
target_link_libraries(test_type_visibility PRIVATE
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
QScintilla::QScintilla)
if(WIN32)
target_link_libraries(test_type_visibility PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
endif()
add_test(NAME test_type_visibility COMMAND test_type_visibility)
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)
if(WIN32)
add_executable(test_windbg_provider tests/test_windbg_provider.cpp
plugins/WinDbgMemory/WinDbgMemoryPlugin.cpp)
target_include_directories(test_windbg_provider PRIVATE src plugins/WinDbgMemory)
target_link_libraries(test_windbg_provider PRIVATE
${QT}::Widgets ${QT}::Concurrent ${QT}::Test dbgeng ole32)
add_test(NAME test_windbg_provider COMMAND test_windbg_provider)
endif()
# Deploy Qt runtime DLLs for tests (run windeployqt on a representative test exe
# that links the broadest set of Qt modules; all test exes share the same output dir)
if(TARGET ${QT}::windeployqt)
add_custom_target(deploy_tests ALL
COMMAND $<TARGET_FILE:${QT}::windeployqt>
--no-compiler-runtime --no-translations
--no-opengl-sw --no-system-d3d-compiler
$<TARGET_FILE:test_controller>
DEPENDS test_controller
COMMENT "Deploying Qt runtime DLLs for tests..."
)
endif()
endif() # BUILD_UI_TESTS
endif()
add_subdirectory(plugins/ProcessMemory)
if(WIN32)
add_subdirectory(plugins/WinDbgMemory)
add_subdirectory(plugins/RcNetPluginCompatLayer)
endif()

View File

@@ -1,16 +1,55 @@
# ReclassX
This tool helps you inspect raw bytes and interpret them as types (structs, arrays, primitives, pointers, padding) instead of just hex. It is essentially a debugging tool for figuring out unknown data structures either runtime or from some static source.
An improvement over other reclass like editors.
![Type chooser and struct inspection](docs/README_PIC1.png)
https://github.com/IChooseYou/ReclassX/raw/main/video.mp4
![VTable pointer expansion with disassembly preview](docs/README_PIC2.png)
![screenshot](screenshot.png)
![Split view with rendered C/C++ output](docs/README_PIC3.png)
## Data Sources
- **File** — open any binary file and inspect its contents as structured data
- **Process** — attach to a live process and read its memory in real time
- **WinDbg** — load `.dmp` crash dump files or connect to live debugging sessions
## MCP Integration
Built-in [Model Context Protocol](https://modelcontextprotocol.io/) bridge via `ReclassMcpBridge`. The server does not start by default and can be toggled from the File menu. It exposes all tool functionality to any MCP-compatible client (e.g. Claude Code) and falls back to UI prompts when the client requests something not yet covered by tools. To connect, add this to your MCP client config (e.g. `.mcp.json`):
```json
{
"mcpServers": {
"ReclassMcpBridge": {
"command": "path/to/build/ReclassMcpBridge",
"args": []
}
}
}
```
## Build
Requires Qt 6, QScintilla, and MinGW on Windows.
1. Prerequisites
```
cmake -B build -G Ninja
cmake --build build
```
- Qt 6 with MinGW - Qt Online Installer https://doc.qt.io/qt-6/qt-online-installation.html , note to select MinGW kit + CMake/Ninja from Tools section (online installers index: https://download.qt.io/official_releases/online_installers/)
- CMake 3.20+ - https://cmake.org/download/ - bundled with Qt
- windeployqt docs - https://doc.qt.io/qt-6/windows-deployment.html
2. Quick Build (relies on powershell| for manual build skip to step 3)
git clone --recurse-submodules https://github.com/IChooseYou/Reclass.git
cd Reclass
.\scripts\build_qscintilla.ps1
.\scripts\build.ps1
^ script above tries to autodetect Qt install (as we learned not everyone installs to C:/Qt/)
3. Manual Build
Step by step for peoplewho want to run commands themselves:
1. Clone with --recurse-submodules (+ fallback git submodule update --init --recursive)
2. Build QScintilla: qmake + mingw32-make in third_party/qscintilla/src
3. CMake configure + build with -DCMAKE_PREFIX_PATH
4. optionallly windeployqt the exe
## Alternatives
- ReClass.NET (reclass.net) - https://github.com/ReClassNET/ReClass.NET
- ReClassEx - https://github.com/ajkhoury/ReClassEx

View File

@@ -1,5 +1,6 @@
set(_QSCI_ROOT "${CMAKE_SOURCE_DIR}/third_party/qscintilla")
# Try to find a pre-built library first
find_path(QScintilla_INCLUDE_DIR
NAMES Qsci/qsciscintilla.h
PATHS "${_QSCI_ROOT}/src" "${_QSCI_ROOT}/include"
@@ -7,7 +8,10 @@ find_path(QScintilla_INCLUDE_DIR
)
find_library(QScintilla_LIBRARY
NAMES qscintilla2_qt6 libqscintilla2_qt6
NAMES
qscintilla2_qt${QT_VERSION_MAJOR} libqscintilla2_qt${QT_VERSION_MAJOR}
qscintilla2_qt6 libqscintilla2_qt6
qscintilla2_qt5 libqscintilla2_qt5
PATHS
"${_QSCI_ROOT}/src/release"
"${_QSCI_ROOT}/src"
@@ -15,13 +19,11 @@ find_library(QScintilla_LIBRARY
NO_DEFAULT_PATH
)
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(QScintilla DEFAULT_MSG
QScintilla_LIBRARY QScintilla_INCLUDE_DIR)
if(QScintilla_FOUND)
set(QScintilla_INCLUDE_DIRS ${QScintilla_INCLUDE_DIR})
set(QScintilla_LIBRARIES ${QScintilla_LIBRARY})
if(QScintilla_LIBRARY AND QScintilla_INCLUDE_DIR)
# Use pre-built library
include(FindPackageHandleStandardArgs)
find_package_handle_standard_args(QScintilla DEFAULT_MSG
QScintilla_LIBRARY QScintilla_INCLUDE_DIR)
if(NOT TARGET QScintilla::QScintilla)
add_library(QScintilla::QScintilla STATIC IMPORTED)
set_target_properties(QScintilla::QScintilla PROPERTIES
@@ -29,4 +31,118 @@ if(QScintilla_FOUND)
INTERFACE_INCLUDE_DIRECTORIES "${QScintilla_INCLUDE_DIR}"
)
endif()
elseif(EXISTS "${_QSCI_ROOT}/src/qsciscintilla.cpp")
# Build from source
message(STATUS "Building QScintilla from source")
file(GLOB _QSCI_LEXER_SOURCES "${_QSCI_ROOT}/scintilla/lexers/*.cpp")
file(GLOB _QSCI_LEXLIB_SOURCES "${_QSCI_ROOT}/scintilla/lexlib/*.cpp")
file(GLOB _QSCI_SCI_SOURCES "${_QSCI_ROOT}/scintilla/src/*.cpp")
file(GLOB _QSCI_HEADERS "${_QSCI_ROOT}/src/Qsci/*.h")
set(_QSCI_QT_SOURCES
"${_QSCI_ROOT}/src/qsciscintilla.cpp"
"${_QSCI_ROOT}/src/qsciscintillabase.cpp"
"${_QSCI_ROOT}/src/qsciabstractapis.cpp"
"${_QSCI_ROOT}/src/qsciapis.cpp"
"${_QSCI_ROOT}/src/qscicommand.cpp"
"${_QSCI_ROOT}/src/qscicommandset.cpp"
"${_QSCI_ROOT}/src/qscidocument.cpp"
"${_QSCI_ROOT}/src/qscilexer.cpp"
"${_QSCI_ROOT}/src/qscilexerasm.cpp"
"${_QSCI_ROOT}/src/qscilexeravs.cpp"
"${_QSCI_ROOT}/src/qscilexerbash.cpp"
"${_QSCI_ROOT}/src/qscilexerbatch.cpp"
"${_QSCI_ROOT}/src/qscilexercmake.cpp"
"${_QSCI_ROOT}/src/qscilexercoffeescript.cpp"
"${_QSCI_ROOT}/src/qscilexercpp.cpp"
"${_QSCI_ROOT}/src/qscilexercsharp.cpp"
"${_QSCI_ROOT}/src/qscilexercss.cpp"
"${_QSCI_ROOT}/src/qscilexercustom.cpp"
"${_QSCI_ROOT}/src/qscilexerd.cpp"
"${_QSCI_ROOT}/src/qscilexerdiff.cpp"
"${_QSCI_ROOT}/src/qscilexeredifact.cpp"
"${_QSCI_ROOT}/src/qscilexerfortran.cpp"
"${_QSCI_ROOT}/src/qscilexerfortran77.cpp"
"${_QSCI_ROOT}/src/qscilexerhex.cpp"
"${_QSCI_ROOT}/src/qscilexerhtml.cpp"
"${_QSCI_ROOT}/src/qscilexeridl.cpp"
"${_QSCI_ROOT}/src/qscilexerintelhex.cpp"
"${_QSCI_ROOT}/src/qscilexerjava.cpp"
"${_QSCI_ROOT}/src/qscilexerjavascript.cpp"
"${_QSCI_ROOT}/src/qscilexerjson.cpp"
"${_QSCI_ROOT}/src/qscilexerlua.cpp"
"${_QSCI_ROOT}/src/qscilexermakefile.cpp"
"${_QSCI_ROOT}/src/qscilexermarkdown.cpp"
"${_QSCI_ROOT}/src/qscilexermasm.cpp"
"${_QSCI_ROOT}/src/qscilexermatlab.cpp"
"${_QSCI_ROOT}/src/qscilexernasm.cpp"
"${_QSCI_ROOT}/src/qscilexeroctave.cpp"
"${_QSCI_ROOT}/src/qscilexerpascal.cpp"
"${_QSCI_ROOT}/src/qscilexerperl.cpp"
"${_QSCI_ROOT}/src/qscilexerpostscript.cpp"
"${_QSCI_ROOT}/src/qscilexerpo.cpp"
"${_QSCI_ROOT}/src/qscilexerpov.cpp"
"${_QSCI_ROOT}/src/qscilexerproperties.cpp"
"${_QSCI_ROOT}/src/qscilexerpython.cpp"
"${_QSCI_ROOT}/src/qscilexerruby.cpp"
"${_QSCI_ROOT}/src/qscilexerspice.cpp"
"${_QSCI_ROOT}/src/qscilexersql.cpp"
"${_QSCI_ROOT}/src/qscilexersrec.cpp"
"${_QSCI_ROOT}/src/qscilexertcl.cpp"
"${_QSCI_ROOT}/src/qscilexertekhex.cpp"
"${_QSCI_ROOT}/src/qscilexertex.cpp"
"${_QSCI_ROOT}/src/qscilexerverilog.cpp"
"${_QSCI_ROOT}/src/qscilexervhdl.cpp"
"${_QSCI_ROOT}/src/qscilexerxml.cpp"
"${_QSCI_ROOT}/src/qscilexeryaml.cpp"
"${_QSCI_ROOT}/src/qscimacro.cpp"
"${_QSCI_ROOT}/src/qsciprinter.cpp"
"${_QSCI_ROOT}/src/qscistyle.cpp"
"${_QSCI_ROOT}/src/qscistyledtext.cpp"
"${_QSCI_ROOT}/src/InputMethod.cpp"
"${_QSCI_ROOT}/src/ListBoxQt.cpp"
"${_QSCI_ROOT}/src/PlatQt.cpp"
"${_QSCI_ROOT}/src/SciAccessibility.cpp"
"${_QSCI_ROOT}/src/SciClasses.cpp"
"${_QSCI_ROOT}/src/ScintillaQt.cpp"
)
add_library(qscintilla2 STATIC
${_QSCI_QT_SOURCES}
${_QSCI_HEADERS}
${_QSCI_LEXER_SOURCES}
${_QSCI_LEXLIB_SOURCES}
${_QSCI_SCI_SOURCES}
)
target_include_directories(qscintilla2 PUBLIC
"${_QSCI_ROOT}/src"
)
target_include_directories(qscintilla2 PRIVATE
"${_QSCI_ROOT}/scintilla/include"
"${_QSCI_ROOT}/scintilla/lexlib"
"${_QSCI_ROOT}/scintilla/src"
)
target_compile_definitions(qscintilla2 PRIVATE
SCINTILLA_QT
SCI_LEXER
INCLUDE_DEPRECATED_FEATURES
)
target_link_libraries(qscintilla2 PUBLIC
${QT}::Widgets
${QT}::PrintSupport
)
set_target_properties(qscintilla2 PROPERTIES AUTOMOC ON)
add_library(QScintilla::QScintilla ALIAS qscintilla2)
set(QScintilla_FOUND TRUE)
else()
set(QScintilla_FOUND FALSE)
if(QScintilla_FIND_REQUIRED)
message(FATAL_ERROR "Could NOT find QScintilla (missing source and pre-built library)")
endif()
endif()

36
cmake/FindQt5.cmake Normal file
View File

@@ -0,0 +1,36 @@
# Documentation: https://cmake.org/cmake/help/latest/manual/cmake-developer.7.html#find-modules
# Always try config mode for the requested components (handles repeated calls)
find_package(Qt5 COMPONENTS ${Qt5_FIND_COMPONENTS} QUIET CONFIG)
if(Qt5_FOUND)
if(NOT Qt5_FIND_QUIETLY)
message(STATUS "Qt5 found: ${Qt5_DIR}")
endif()
return()
endif()
if(Qt5_FIND_REQUIRED AND WIN32)
message(STATUS "Downloading Qt5...")
# Fix warnings about DOWNLOAD_EXTRACT_TIMESTAMP
if(POLICY CMP0135)
cmake_policy(SET CMP0135 NEW)
endif()
include(FetchContent)
set(FETCHCONTENT_QUIET OFF)
if(CMAKE_SIZEOF_VOID_P EQUAL 8)
FetchContent_Declare(Qt5
URL "https://github.com/x64dbg/deps/releases/download/2025.07.02/qt5.12.12-msvc2017_64.7z"
URL_HASH SHA256=770490bf09514982c8192ebde9a1fac8821108ba42b021f167bac54e85ada48a
)
else()
FetchContent_Declare(Qt5
URL "https://github.com/x64dbg/deps/releases/download/2025.07.02/qt5.12.12-msvc2017.7z"
URL_HASH SHA256=3ff2a58e5ed772be475643cd7bb2df3e5499d7169d794ddf1ed5df5c5e862cb6
)
endif()
FetchContent_MakeAvailable(Qt5)
unset(FETCHCONTENT_QUIET)
set(Qt5_ROOT ${qt5_SOURCE_DIR})
find_package(Qt5 COMPONENTS ${Qt5_FIND_COMPONENTS} CONFIG REQUIRED)
endif()

82
cmake/deploy.cmake Normal file
View File

@@ -0,0 +1,82 @@
# cmake/deploy.cmake - Dual-mode script for deploying Qt runtime DLLs
#
# Script mode: cmake -P deploy.cmake <target_exe> <windeployqt>
# Include mode: include(deploy) from CMakeLists.txt (creates "deploy" target)
if(CMAKE_SCRIPT_MODE_FILE)
set(TARGET_EXE ${CMAKE_ARGV3})
set(WINDEPLOYQT ${CMAKE_ARGV4})
get_filename_component(TARGET_DIR ${TARGET_EXE} DIRECTORY)
# Skip if already deployed for this build
if(EXISTS "${TARGET_DIR}/.qt_deployed")
return()
endif()
message(STATUS "Running windeployqt on ${TARGET_EXE}")
execute_process(
COMMAND ${WINDEPLOYQT}
--pdb
--no-compiler-runtime
--no-translations
--no-opengl-sw
--no-system-d3d-compiler
--force
${TARGET_EXE}
RESULT_VARIABLE _result
)
if(_result EQUAL 0)
file(WRITE "${TARGET_DIR}/.qt_deployed" "")
message(STATUS "windeployqt completed successfully")
else()
message(WARNING "windeployqt failed with exit code ${_result}")
endif()
return()
endif()
# ── Include mode: configure the deploy target ──
if(NOT WIN32)
return()
endif()
# Discover windeployqt from qmake
if(NOT TARGET ${QT}::windeployqt AND TARGET ${QT}::qmake)
get_target_property(_qt_qmake_location ${QT}::qmake IMPORTED_LOCATION)
execute_process(
COMMAND "${_qt_qmake_location}" -query QT_INSTALL_PREFIX
RESULT_VARIABLE _return_code
OUTPUT_VARIABLE _qt_install_prefix
OUTPUT_STRIP_TRAILING_WHITESPACE
)
set(_windeployqt "${_qt_install_prefix}/bin/windeployqt.exe")
if(EXISTS ${_windeployqt})
add_executable(${QT}::windeployqt IMPORTED)
set_target_properties(${QT}::windeployqt PROPERTIES
IMPORTED_LOCATION ${_windeployqt}
)
message(STATUS "Found windeployqt: ${_windeployqt}")
else()
message(WARNING "windeployqt not found at ${_windeployqt}")
endif()
endif()
if(TARGET ${QT}::windeployqt)
add_custom_target(deploy
COMMAND ${CMAKE_COMMAND} -P ${CMAKE_CURRENT_LIST_DIR}/deploy.cmake
$<TARGET_FILE:Reclass>
$<TARGET_FILE:${QT}::windeployqt>
DEPENDS Reclass
COMMENT "Deploying Qt runtime DLLs..."
)
# Force re-deploy on rebuild
set_target_properties(deploy PROPERTIES
ADDITIONAL_CLEAN_FILES $<TARGET_FILE_DIR:Reclass>/.qt_deployed
)
endif()

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

BIN
docs/README_PIC1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

BIN
docs/README_PIC2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

BIN
docs/README_PIC3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

View File

@@ -4,8 +4,7 @@ project(ProcessMemoryPlugin LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Find Qt
find_package(Qt6 REQUIRED COMPONENTS Widgets)
# Qt is found by the parent project; QT variable (Qt5 or Qt6) is inherited
set(CMAKE_AUTOMOC ON)
set(CMAKE_AUTORCC ON)
@@ -24,7 +23,17 @@ set(PLUGIN_SOURCES
add_library(ProcessMemoryPlugin SHARED ${PLUGIN_SOURCES})
# Link Qt
target_link_libraries(ProcessMemoryPlugin PRIVATE Qt6::Widgets)
target_link_libraries(ProcessMemoryPlugin PRIVATE ${QT}::Widgets ${_QT_WINEXTRAS})
# Platform-specific linking
if(WIN32)
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(ProcessMemoryPlugin PRIVATE -fvisibility=hidden)
endif()
# Include directories
target_include_directories(ProcessMemoryPlugin PRIVATE

View File

@@ -1,17 +1,43 @@
#include "ProcessMemoryPlugin.h"
#include "../../src/processpicker.h"
#include <QStyle>
#include <QApplication>
#include <QRegularExpression>
#include <QMessageBox>
#include <QPixmap>
#include <QImage>
#include <QDir>
#include <QFileInfo>
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0) && defined(_WIN32)
#include <QtWin>
#endif
#ifdef _WIN32
#include <windows.h>
#include <tlhelp32.h>
#include <psapi.h>
#include <shellapi.h>
#elif defined(__linux__)
#include <climits>
#include <sys/types.h>
#include <dirent.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/uio.h>
#include <fstream>
#include <sstream>
#include <cstring>
#endif
// ──────────────────────────────────────────────────────────────────────────
// ProcessMemoryProvider implementation
// ──────────────────────────────────────────────────────────────────────────
ProcessMemoryProvider::ProcessMemoryProvider(DWORD pid, const QString& processName)
#ifdef _WIN32
ProcessMemoryProvider::ProcessMemoryProvider(uint32_t pid, const QString& processName)
: m_handle(nullptr)
, m_pid(pid)
, m_processName(processName)
@@ -31,15 +57,7 @@ ProcessMemoryProvider::ProcessMemoryProvider(DWORD pid, const QString& processNa
}
if (m_handle)
{
cacheModules();
}
}
ProcessMemoryProvider::~ProcessMemoryProvider()
{
if (m_handle)
CloseHandle(m_handle);
}
bool ProcessMemoryProvider::read(uint64_t addr, void* buf, int len) const
@@ -47,9 +65,10 @@ bool ProcessMemoryProvider::read(uint64_t addr, void* buf, int len) const
if (!m_handle || len <= 0) return false;
SIZE_T bytesRead = 0;
if (ReadProcessMemory(m_handle, (LPCVOID)(m_base + addr), buf, (SIZE_T)len, &bytesRead))
return bytesRead == (SIZE_T)len;
return false;
ReadProcessMemory(m_handle, (LPCVOID)(addr), buf, (SIZE_T)len, &bytesRead);
if ((int)bytesRead < len)
memset((char*)buf + bytesRead, 0, len - bytesRead);
return bytesRead > 0;
}
bool ProcessMemoryProvider::write(uint64_t addr, const void* buf, int len)
@@ -57,16 +76,23 @@ bool ProcessMemoryProvider::write(uint64_t addr, const void* buf, int len)
if (!m_handle || !m_writable || len <= 0) return false;
SIZE_T bytesWritten = 0;
if (WriteProcessMemory(m_handle, (LPVOID)(m_base + addr), buf, (SIZE_T)len, &bytesWritten))
if (WriteProcessMemory(m_handle, (LPVOID)(addr), buf, (SIZE_T)len, &bytesWritten))
return bytesWritten == (SIZE_T)len;
return false;
}
QString ProcessMemoryProvider::getSymbol(uint64_t addr) const
{
// TODO: Implement module enumeration with EnumProcessModules
// For now, just return empty (no symbol resolution)
Q_UNUSED(addr);
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 {};
}
@@ -98,6 +124,186 @@ void ProcessMemoryProvider::cacheModules()
}
}
#elif defined(__linux__)
ProcessMemoryProvider::ProcessMemoryProvider(uint32_t pid, const QString& processName)
: m_fd(-1)
, m_pid(pid)
, m_processName(processName)
, m_writable(false)
, m_base(0)
{
QString memPath = QStringLiteral("/proc/%1/mem").arg(pid);
QByteArray pathUtf8 = memPath.toUtf8();
// Try read-write first
m_fd = ::open(pathUtf8.constData(), O_RDWR);
if (m_fd >= 0)
m_writable = true;
else
{
// Fall back to read-only
m_fd = ::open(pathUtf8.constData(), O_RDONLY);
m_writable = false;
}
if (m_fd >= 0)
cacheModules();
}
bool ProcessMemoryProvider::read(uint64_t addr, void* buf, int len) const
{
if (m_fd < 0 || len <= 0) return false;
// Try process_vm_readv first (faster, no fd seek contention)
struct iovec local;
local.iov_base = buf;
local.iov_len = static_cast<size_t>(len);
struct iovec remote;
remote.iov_base = reinterpret_cast<void*>(addr);
remote.iov_len = static_cast<size_t>(len);
ssize_t nread = process_vm_readv(m_pid, &local, 1, &remote, 1, 0);
if (nread == static_cast<ssize_t>(len))
return true;
// Fallback: pread on /proc/<pid>/mem
nread = ::pread(m_fd, buf, static_cast<size_t>(len), static_cast<off_t>(addr));
return nread == static_cast<ssize_t>(len);
}
bool ProcessMemoryProvider::write(uint64_t addr, const void* buf, int len)
{
if (m_fd < 0 || !m_writable || len <= 0) return false;
// Try process_vm_writev first
struct iovec local;
local.iov_base = const_cast<void*>(buf);
local.iov_len = static_cast<size_t>(len);
struct iovec remote;
remote.iov_base = reinterpret_cast<void*>(addr);
remote.iov_len = static_cast<size_t>(len);
ssize_t nwritten = process_vm_writev(m_pid, &local, 1, &remote, 1, 0);
if (nwritten == static_cast<ssize_t>(len))
return true;
// Fallback: pwrite on /proc/<pid>/mem
nwritten = ::pwrite(m_fd, buf, static_cast<size_t>(len), static_cast<off_t>(addr));
return nwritten == static_cast<ssize_t>(len);
}
QString ProcessMemoryProvider::getSymbol(uint64_t addr) const
{
for (const auto& mod : m_modules)
{
if (addr >= mod.base && addr < mod.base + mod.size)
{
uint64_t offset = addr - mod.base;
return QStringLiteral("%1+0x%2")
.arg(mod.name)
.arg(offset, 0, 16, QChar('0'));
}
}
return {};
}
void ProcessMemoryProvider::cacheModules()
{
// Parse /proc/<pid>/maps to discover loaded modules
QString mapsPath = QStringLiteral("/proc/%1/maps").arg(m_pid);
std::ifstream mapsFile(mapsPath.toStdString());
if (!mapsFile.is_open()) return;
// Accumulate base/end per path, then convert to ModuleInfo
struct Range { uint64_t base; uint64_t end; };
QMap<QString, Range> moduleRanges;
std::string line;
bool firstExec = true;
while (std::getline(mapsFile, line))
{
// Format: addr_start-addr_end perms offset dev inode pathname
// Example: 00400000-00452000 r-xp 00000000 08:02 173521 /usr/bin/foo
std::istringstream iss(line);
std::string addrRange, perms, offset, dev, inode, pathname;
iss >> addrRange >> perms >> offset >> dev >> inode;
std::getline(iss, pathname);
// Trim leading whitespace from pathname
size_t start = pathname.find_first_not_of(" \t");
if (start == std::string::npos) continue;
pathname = pathname.substr(start);
// Skip non-file mappings
if (pathname.empty() || pathname[0] != '/') continue;
// Skip special mappings
if (pathname.find("/dev/") == 0 || pathname.find("/memfd:") == 0) continue;
// Parse address range
auto dash = addrRange.find('-');
if (dash == std::string::npos) continue;
uint64_t addrStart = std::stoull(addrRange.substr(0, dash), nullptr, 16);
uint64_t addrEnd = std::stoull(addrRange.substr(dash + 1), nullptr, 16);
QString qpath = QString::fromStdString(pathname);
// Track first executable mapping as the base address
if (firstExec && perms.size() >= 3 && perms[2] == 'x')
{
m_base = addrStart;
firstExec = false;
}
auto it = moduleRanges.find(qpath);
if (it != moduleRanges.end())
{
if (addrStart < it->base) it->base = addrStart;
if (addrEnd > it->end) it->end = addrEnd;
}
else
{
moduleRanges.insert(qpath, {addrStart, addrEnd});
}
}
m_modules.reserve(moduleRanges.size());
for (auto it = moduleRanges.begin(); it != moduleRanges.end(); ++it)
{
QFileInfo fi(it.key());
m_modules.append({
fi.fileName(),
it->base,
it->end - it->base
});
}
}
#endif // platform
ProcessMemoryProvider::~ProcessMemoryProvider()
{
#ifdef _WIN32
if (m_handle)
CloseHandle(m_handle);
#elif defined(__linux__)
if (m_fd >= 0)
::close(m_fd);
#endif
}
int ProcessMemoryProvider::size() const
{
#ifdef _WIN32
return m_handle ? 0x10000 : 0;
#elif defined(__linux__)
return (m_fd >= 0) ? 0x10000 : 0;
#endif
}
// ──────────────────────────────────────────────────────────────────────────
// ProcessMemoryPlugin implementation
// ──────────────────────────────────────────────────────────────────────────
@@ -119,9 +325,10 @@ std::unique_ptr<rcx::Provider> ProcessMemoryPlugin::createProvider(const QString
// Parse target: "pid:name" or just "pid"
QStringList parts = target.split(':');
bool ok = false;
DWORD pid = parts[0].toUInt(&ok);
uint32_t pid = parts[0].toUInt(&ok);
if (!ok || pid == 0) {
if (!ok || pid == 0)
{
if (errorMsg) *errorMsg = "Invalid PID: " + target;
return nullptr;
}
@@ -132,11 +339,9 @@ std::unique_ptr<rcx::Provider> ProcessMemoryPlugin::createProvider(const QString
if (!provider->isValid())
{
if (errorMsg)
{
*errorMsg = QString("Failed to open process %1 (PID: %2)\n"
"Ensure the process is running and you have sufficient permissions.")
.arg(name).arg(pid);
}
return nullptr;
}
@@ -164,13 +369,36 @@ uint64_t ProcessMemoryPlugin::getInitialBaseAddress(const QString& target) const
{
MODULEINFO mi{};
if (GetModuleInformation(hProc, hMod, &mi, sizeof(mi)))
{
base = (uint64_t)mi.lpBaseOfDll;
}
}
CloseHandle(hProc);
return base;
#elif defined(__linux__)
// Parse PID from target
QStringList parts = target.split(':');
bool ok = false;
uint32_t pid = parts[0].toUInt(&ok);
if (!ok || pid == 0) return 0;
// Find first executable mapping from /proc/<pid>/maps
QString mapsPath = QStringLiteral("/proc/%1/maps").arg(pid);
std::ifstream mapsFile(mapsPath.toStdString());
if (!mapsFile.is_open()) return 0;
std::string line;
while (std::getline(mapsFile, line)) {
std::istringstream iss(line);
std::string addrRange, perms;
iss >> addrRange >> perms;
if (perms.size() >= 3 && perms[2] == 'x') {
auto dash = addrRange.find('-');
if (dash != std::string::npos) {
return std::stoull(addrRange.substr(0, dash), nullptr, 16);
}
}
}
return 0;
#else
Q_UNUSED(target);
return 0;
@@ -241,7 +469,11 @@ QVector<PluginProcessInfo> ProcessMemoryPlugin::enumerateProcesses()
SHFILEINFOW sfi = {};
if (SHGetFileInfoW(path, 0, &sfi, sizeof(sfi), SHGFI_ICON | SHGFI_SMALLICON)) {
if (sfi.hIcon) {
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
QPixmap pixmap = QPixmap::fromImage(QImage::fromHICON(sfi.hIcon));
#else
QPixmap pixmap = QtWin::fromHICON(sfi.hIcon);
#endif
info.icon = QIcon(pixmap);
DestroyIcon(sfi.hIcon);
}
@@ -257,6 +489,45 @@ QVector<PluginProcessInfo> ProcessMemoryPlugin::enumerateProcesses()
}
CloseHandle(snapshot);
#elif defined(__linux__)
QDir procDir("/proc");
QStringList entries = procDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
QIcon defaultIcon = qApp->style()->standardIcon(QStyle::SP_ComputerIcon);
for (const QString& entry : entries) {
bool ok = false;
uint32_t pid = entry.toUInt(&ok);
if (!ok || pid == 0) continue;
// Read process name from /proc/<pid>/comm
QString commPath = QStringLiteral("/proc/%1/comm").arg(pid);
QFile commFile(commPath);
QString procName;
if (commFile.open(QIODevice::ReadOnly)) {
procName = QString::fromUtf8(commFile.readAll()).trimmed();
commFile.close();
}
if (procName.isEmpty()) continue; // Skip kernel threads with no name
// Read exe path from /proc/<pid>/exe symlink
QString exePath = QStringLiteral("/proc/%1/exe").arg(pid);
QFileInfo exeInfo(exePath);
QString resolvedPath;
if (exeInfo.exists())
resolvedPath = exeInfo.symLinkTarget();
// Skip if we can't read the process memory (no access)
QString memPath = QStringLiteral("/proc/%1/mem").arg(pid);
if (::access(memPath.toUtf8().constData(), R_OK) != 0)
continue;
PluginProcessInfo info;
info.pid = pid;
info.name = procName;
info.path = resolvedPath;
info.icon = defaultIcon;
processes.append(info);
}
#endif
return processes;
@@ -266,7 +537,7 @@ QVector<PluginProcessInfo> ProcessMemoryPlugin::enumerateProcesses()
// Plugin factory
// ──────────────────────────────────────────────────────────────────────────
extern "C" __declspec(dllexport) IPlugin* CreatePlugin()
extern "C" RCX_PLUGIN_EXPORT IPlugin* CreatePlugin()
{
return new ProcessMemoryPlugin();
}

View File

@@ -1,23 +1,22 @@
#pragma once
#include "../../src/iplugin.h"
#include "../../src/core.h"
#include <windows.h>
#include <tlhelp32.h>
#include <psapi.h>
#include <shellapi.h>
#include <cstdint>
/**
* Windows process memory provider
* Reads/writes memory from a live process using Win32 API
* Process memory provider
* Reads/writes memory from a live process using platform APIs
*/
class ProcessMemoryProvider : public rcx::Provider {
class ProcessMemoryProvider : public rcx::Provider
{
public:
ProcessMemoryProvider(DWORD pid, const QString& processName);
ProcessMemoryProvider(uint32_t pid, const QString& processName);
~ProcessMemoryProvider() override;
// Required overrides
bool read(uint64_t addr, void* buf, int len) const override;
int size() const override { return m_handle ? INT_MAX : NULL; } // Process memory has no fixed size
int size() const override;
// Optional overrides
bool write(uint64_t addr, const void* buf, int len) override;
@@ -26,17 +25,30 @@ public:
QString kind() const override { return QStringLiteral("LocalProcess"); }
QString getSymbol(uint64_t addr) const override;
bool isLive() const override { return true; }
uint64_t base() const override { return m_base; }
bool isReadable(uint64_t, int len) const override {
#ifdef _WIN32
return m_handle && len >= 0;
#elif defined(__linux__)
return m_fd >= 0 && len >= 0;
#endif
}
// Process-specific helpers
DWORD pid() const { return m_pid; }
uint64_t baseAddress() const { return m_base; }
uint32_t pid() const { return m_pid; }
void refreshModules() { m_modules.clear(); cacheModules(); }
private:
void cacheModules();
private:
HANDLE m_handle;
DWORD m_pid;
#ifdef _WIN32
void* m_handle;
#elif defined(__linux__)
int m_fd;
#endif
uint32_t m_pid;
QString m_processName;
bool m_writable;
uint64_t m_base;
@@ -52,12 +64,13 @@ private:
/**
* Plugin that provides ProcessMemoryProvider
*/
class ProcessMemoryPlugin : public IProviderPlugin {
class ProcessMemoryPlugin : public IProviderPlugin
{
public:
std::string Name() const override { return "Process Memory"; }
std::string Version() const override { return "1.0.0"; }
std::string Author() const override { return "ReclassX"; }
std::string Description() const override { return "Read and write memory from local running Windows processes"; }
std::string Author() const override { return "Reclass"; }
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;
@@ -72,4 +85,4 @@ public:
};
// Plugin export
extern "C" __declspec(dllexport) IPlugin* CreatePlugin();
extern "C" RCX_PLUGIN_EXPORT IPlugin* CreatePlugin();

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,123 @@
#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;
return m_fns.ReadRemoteMemory(m_handle,
reinterpret_cast<RC_Pointer>(addr),
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;
return m_fns.WriteRemoteMemory(m_handle,
reinterpret_cast<RC_Pointer>(addr),
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,47 @@
#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; }
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>

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

View File

@@ -1,4 +1,4 @@
# PowerShell script to build ReclassX
# PowerShell script to build Reclass
# Automatically detects Qt installation and configures build environment
#Requires -Version 5.1
@@ -303,7 +303,7 @@ function Find-MinGWDirectory {
# ──────────────────────────────────────────────────────────────────────────────
Write-ColorOutput "`n========================================" Cyan
Write-ColorOutput "ReclassX Build Script" Cyan
Write-ColorOutput "Reclass Build Script" Cyan
Write-ColorOutput "========================================`n" Cyan
# Get script directory and project root
@@ -426,7 +426,7 @@ try {
Write-ColorOutput "`nCMake configuration completed successfully.`n" Green
# Build
Write-ColorOutput "Building ReclassX..." Cyan
Write-ColorOutput "Building Reclass..." Cyan
$cores = (Get-CimInstance -ClassName Win32_Processor).NumberOfLogicalProcessors
if (-not $cores -or $cores -lt 1) {
@@ -445,8 +445,8 @@ try {
# Find executable
Write-ColorOutput "Locating executable..." Cyan
$exePaths = @(
(Join-Path $buildDir "ReclassX.exe"),
(Join-Path $buildDir "$BuildType\ReclassX.exe")
(Join-Path $buildDir "Reclass.exe"),
(Join-Path $buildDir "$BuildType\Reclass.exe")
)
$exePath = $null
@@ -477,7 +477,7 @@ try {
# Count deployed files
$deployedFiles = Get-ChildItem -Path $exeDir -Recurse -File | Where-Object {
$_.Name -ne "ReclassX.exe" -and $_.Extension -match '\.(dll|qm)$'
$_.Name -ne "Reclass.exe" -and $_.Extension -match '\.(dll|qm)$'
}
if ($deployedFiles) {
Write-ColorOutput "Deployed $($deployedFiles.Count) Qt dependency files." Gray
@@ -491,7 +491,7 @@ try {
Write-ColorOutput "Application may not run without Qt DLLs in PATH" Yellow
}
} else {
Write-ColorOutput "WARNING: Could not locate ReclassX.exe" Yellow
Write-ColorOutput "WARNING: Could not locate Reclass.exe" Yellow
}
} catch {
@@ -507,5 +507,5 @@ Write-ColorOutput "========================================`n" Cyan
if ($exePath) {
Write-ColorOutput "Run the application with:" White
Write-ColorOutput " .\build\ReclassX.exe`n" Cyan
Write-ColorOutput " .\build\Reclass.exe`n" Cyan
}

View File

@@ -1,4 +1,4 @@
# PowerShell script to build QScintilla static library for ReclassX
# PowerShell script to build QScintilla static library for Reclass
# This script checks for Qt installation, prompts if missing, and builds QScintilla
#Requires -Version 5.1
@@ -272,7 +272,7 @@ function Find-MakeCommand {
# ──────────────────────────────────────────────────────────────────────────────
Write-ColorOutput "`n========================================" Cyan
Write-ColorOutput "QScintilla Build Script for ReclassX" Cyan
Write-ColorOutput "QScintilla Build Script for Reclass" Cyan
Write-ColorOutput "========================================`n" Cyan
# Get script directory and project root
@@ -423,7 +423,7 @@ try {
Write-Host " - $($lib.Name) ($sizeMB MB)" -ForegroundColor Green
Write-Host " Path: $($lib.Path)" -ForegroundColor Gray
}
Write-ColorOutput "`nYou can now build ReclassX with CMake." Green
Write-ColorOutput "`nYou can now build Reclass with CMake." Green
} else {
Write-ColorOutput "`nWARNING: Build completed but no library files found." Yellow
Write-ColorOutput "Expected files: qscintilla2_qt6.a or qscintilla2_qt6.lib" Yellow

1
src/app.rc Normal file
View File

@@ -0,0 +1 @@
IDI_ICON1 ICON "icons/class.ico"

View File

@@ -1,5 +1,6 @@
#include "core.h"
#include <algorithm>
#include <numeric>
namespace rcx {
@@ -13,12 +14,15 @@ 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)
int offsetHexDigits = 8; // hex digit tier for offset margin
bool baseEmitted = false; // only first root struct shows base address
uint64_t currentPtrBase = 0; // absolute addr of current pointer expansion target
// Precomputed for O(1) lookups
QHash<uint64_t, QVector<int>> childMap;
@@ -62,7 +66,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;
}
@@ -75,12 +78,6 @@ static QString resolvePointerTarget(const NodeTree& tree, uint64_t refId) {
return ref.structTypeName.isEmpty() ? ref.name : ref.structTypeName;
}
static inline uint64_t ptrToProviderAddr(const NodeTree& tree, uint64_t ptr) {
if (tree.baseAddress == 0) return ptr;
if (ptr >= tree.baseAddress) return ptr - tree.baseAddress;
return UINT64_MAX; // Invalid: ptr below base address
}
static int64_t relOffsetFromRoot(const NodeTree& tree, int idx, uint64_t rootId) {
int64_t total = 0;
QSet<uint64_t> visited;
@@ -116,21 +113,23 @@ 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;
QString ptrTargetName;
if (node.kind == NodeKind::Pointer32 || node.kind == NodeKind::Pointer64) {
ptrTargetName = resolvePointerTarget(tree, node.refId);
ptrTypeOverride = fmt::pointerTypeName(node.kind, ptrTargetName);
if (node.ptrDepth > 0 && isValidPrimitivePtrTarget(node.elementKind)) {
// Primitive pointer: e.g. "int32*" or "f64**"
const auto* meta = kindMeta(node.elementKind);
QString baseName = meta ? QString::fromLatin1(meta->typeName)
: QStringLiteral("void");
QString stars = (node.ptrDepth >= 2) ? QStringLiteral("**") : QStringLiteral("*");
ptrTypeOverride = baseName + stars;
} else {
ptrTargetName = resolvePointerTarget(tree, node.refId);
ptrTypeOverride = fmt::pointerTypeName(node.kind, ptrTargetName);
}
}
for (int sub = 0; sub < numLines; sub++) {
@@ -144,7 +143,9 @@ void composeLeaf(ComposeState& state, const NodeTree& tree,
lm.isContinuation = isCont;
lm.lineKind = isCont ? LineKind::Continuation : LineKind::Field;
lm.nodeKind = node.kind;
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, isCont);
lm.offsetText = fmt::fmtOffsetMargin(absAddr, isCont, state.offsetHexDigits);
lm.offsetAddr = absAddr;
lm.ptrBase = state.currentPtrBase;
lm.markerMask = computeMarkers(node, prov, absAddr, isCont, depth);
lm.foldLevel = computeFoldLevel(depth, false);
lm.effectiveTypeW = typeW;
@@ -153,12 +154,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,
@@ -171,16 +167,19 @@ void composeLeaf(ComposeState& state, const NodeTree& tree,
void composeNode(ComposeState& state, const NodeTree& tree,
const Provider& prov, int nodeIdx, int depth,
uint64_t base = 0, uint64_t rootId = 0, bool isArrayChild = false,
uint64_t scopeId = 0, int arrayElementIdx = -1);
uint64_t scopeId = 0, int arrayElementIdx = -1,
uint64_t arrayContainerAddr = 0);
void composeParent(ComposeState& state, const NodeTree& tree,
const Provider& prov, int nodeIdx, int depth,
uint64_t base = 0, uint64_t rootId = 0, bool isArrayChild = false,
uint64_t scopeId = 0, int arrayElementIdx = -1);
uint64_t scopeId = 0, int arrayElementIdx = -1,
uint64_t arrayContainerAddr = 0);
void composeParent(ComposeState& state, const NodeTree& tree,
const Provider& prov, int nodeIdx, int depth,
uint64_t base, uint64_t rootId, bool isArrayChild,
uint64_t scopeId, int arrayElementIdx) {
uint64_t scopeId, int arrayElementIdx,
uint64_t arrayContainerAddr) {
const Node& node = tree.nodes[nodeIdx];
uint64_t absAddr = resolveAddr(state, tree, nodeIdx, base, rootId);
@@ -191,7 +190,9 @@ void composeParent(ComposeState& state, const NodeTree& tree,
lm.nodeId = node.id;
lm.depth = depth;
lm.lineKind = LineKind::Field;
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false);
lm.offsetText = fmt::fmtOffsetMargin(absAddr, false, state.offsetHexDigits);
lm.offsetAddr = absAddr;
lm.ptrBase = state.currentPtrBase;
lm.nodeKind = node.kind;
lm.markerMask = (1u << M_CYCLE) | (1u << M_ERR);
lm.foldLevel = computeFoldLevel(depth, false);
@@ -208,12 +209,16 @@ void composeParent(ComposeState& state, const NodeTree& tree,
lm.nodeId = node.id;
lm.depth = depth;
lm.lineKind = LineKind::ArrayElementSeparator;
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false);
lm.offsetText = fmt::fmtOffsetMargin(absAddr, false, state.offsetHexDigits);
lm.offsetAddr = absAddr;
lm.ptrBase = state.currentPtrBase;
lm.nodeKind = node.kind;
lm.foldLevel = computeFoldLevel(depth, false);
lm.markerMask = 0;
lm.arrayElementIdx = arrayElementIdx;
state.emitLine(fmt::indent(depth) + QStringLiteral("[%1]").arg(arrayElementIdx), lm);
uint64_t relOff = absAddr - arrayContainerAddr;
QString relOffHex = QString::number(relOff, 16).toUpper();
state.emitLine(fmt::indent(depth) + QStringLiteral("[%1] +0x%2").arg(arrayElementIdx).arg(relOffHex), lm);
}
// Detect root header: first root-level struct — suppressed from display
@@ -234,7 +239,9 @@ void composeParent(ComposeState& state, const NodeTree& tree,
lm.nodeId = node.id;
lm.depth = depth;
lm.lineKind = LineKind::Header;
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false);
lm.offsetText = fmt::fmtOffsetMargin(absAddr, false, state.offsetHexDigits);
lm.offsetAddr = absAddr;
lm.ptrBase = state.currentPtrBase;
lm.nodeKind = node.kind;
lm.isRootHeader = false;
lm.foldHead = true;
@@ -251,7 +258,9 @@ void composeParent(ComposeState& state, const NodeTree& tree,
lm.elementKind = node.elementKind;
lm.arrayViewIdx = node.viewIndex;
lm.arrayCount = node.arrayLen;
headerText = fmt::fmtArrayHeader(node, depth, node.viewIndex, node.collapsed, typeW, nameW);
QString elemStructName = (node.elementKind == NodeKind::Struct)
? resolvePointerTarget(tree, node.refId) : QString();
headerText = fmt::fmtArrayHeader(node, depth, node.viewIndex, node.collapsed, typeW, nameW, elemStructName);
} else {
// All structs (root and nested) use the same header format
headerText = fmt::fmtStructHeader(node, depth, node.collapsed, typeW, nameW);
@@ -267,6 +276,107 @@ void composeParent(ComposeState& state, const NodeTree& tree,
int childDepth = depth + 1;
// Primitive arrays with no child nodes: synthesize element lines dynamically
if (node.kind == NodeKind::Array && children.isEmpty()
&& node.elementKind != NodeKind::Struct && node.elementKind != NodeKind::Array) {
int elemSize = sizeForKind(node.elementKind);
int eTW = state.effectiveTypeW(node.id);
int eNW = state.effectiveNameW(node.id);
for (int i = 0; i < node.arrayLen; i++) {
uint64_t elemAddr = absAddr + i * elemSize;
// Type override: "float[0]", "uint32_t[1]", etc.
QString elemTypeStr = fmt::typeNameRaw(node.elementKind)
+ QStringLiteral("[%1]").arg(i);
Node elem;
elem.kind = node.elementKind;
elem.name = QString(); // no name for array elements
elem.offset = node.offset + i * elemSize;
elem.parentId = node.id;
elem.id = 0;
LineMeta lm;
lm.nodeIdx = nodeIdx;
lm.nodeId = node.id;
lm.depth = childDepth;
lm.lineKind = LineKind::Field;
lm.nodeKind = node.elementKind;
lm.isArrayElement = true;
lm.offsetText = fmt::fmtOffsetMargin(elemAddr, false, state.offsetHexDigits);
lm.offsetAddr = elemAddr;
lm.ptrBase = state.currentPtrBase;
lm.markerMask = computeMarkers(elem, prov, elemAddr, false, childDepth);
lm.foldLevel = computeFoldLevel(childDepth, false);
lm.effectiveTypeW = eTW;
lm.effectiveNameW = eNW;
state.emitLine(fmt::fmtNodeLine(elem, prov, elemAddr, childDepth, 0,
{}, eTW, eNW, elemTypeStr), lm);
}
}
// Struct arrays with refId but no child nodes: synthesize by expanding the
// referenced struct for each element (like repeated pointer deref)
if (node.kind == NodeKind::Array && children.isEmpty()
&& node.elementKind == NodeKind::Struct && node.refId != 0) {
int refIdx = tree.indexOfId(node.refId);
if (refIdx >= 0) {
int elemSize = tree.structSpan(node.refId, &state.childMap);
if (elemSize <= 0) elemSize = 1;
for (int i = 0; i < node.arrayLen; i++) {
uint64_t elemBase = absAddr + (uint64_t)i * elemSize;
// Use base offset that maps refStruct's children to the right provider address
composeParent(state, tree, prov, refIdx, childDepth, elemBase, node.refId,
/*isArrayChild=*/true, node.id, i, absAddr);
}
}
}
// Embedded struct with refId but no child nodes: expand referenced struct's
// children at this node's offset (single instance, like array with count=1)
if (node.kind == NodeKind::Struct && children.isEmpty() && node.refId != 0) {
int refIdx = tree.indexOfId(node.refId);
if (refIdx >= 0) {
QVector<int> refChildren = state.childMap.value(node.refId);
std::sort(refChildren.begin(), refChildren.end(), [&](int a, int b) {
return tree.nodes[a].offset < tree.nodes[b].offset;
});
// Use the referenced struct's scope widths (children come from there)
uint64_t refScopeId = node.refId;
for (int childIdx : refChildren) {
const Node& child = tree.nodes[childIdx];
// Self-referential child → show as collapsed struct (non-expandable)
if (state.visiting.contains(child.id)) {
int typeW = state.effectiveTypeW(refScopeId);
int nameW = state.effectiveNameW(refScopeId);
LineMeta lm;
lm.nodeIdx = nodeIdx; // parent struct — materialize target
lm.nodeId = child.id;
lm.depth = childDepth;
lm.lineKind = LineKind::Header;
lm.offsetText = fmt::fmtOffsetMargin(
absAddr + child.offset, false,
state.offsetHexDigits);
lm.offsetAddr = absAddr + child.offset;
lm.ptrBase = state.currentPtrBase;
lm.nodeKind = child.kind;
lm.foldHead = true;
lm.foldCollapsed = true;
lm.foldLevel = computeFoldLevel(childDepth, true);
lm.markerMask = (1u << M_STRUCT_BG) | (1u << M_CYCLE);
lm.effectiveTypeW = typeW;
lm.effectiveNameW = nameW;
state.emitLine(fmt::fmtStructHeader(child, childDepth,
/*collapsed=*/true, typeW, nameW), lm);
continue;
}
composeNode(state, tree, prov, childIdx, childDepth,
absAddr, node.refId, false, refScopeId);
}
}
}
// For arrays, render children as condensed (no header/footer for struct elements)
bool childrenAreArrayElements = (node.kind == NodeKind::Array);
int elementIdx = 0;
@@ -275,7 +385,8 @@ void composeParent(ComposeState& state, const NodeTree& tree,
// For array elements, also pass the element index for [N] separator
composeNode(state, tree, prov, childIdx, childDepth, base, rootId,
childrenAreArrayElements, node.id,
childrenAreArrayElements ? elementIdx++ : -1);
childrenAreArrayElements ? elementIdx++ : -1,
childrenAreArrayElements ? absAddr : 0);
}
}
@@ -288,10 +399,12 @@ void composeParent(ComposeState& state, const NodeTree& tree,
lm.lineKind = LineKind::Footer;
lm.nodeKind = node.kind;
lm.isRootHeader = isRootHeader; // root footer: flush left (no fold prefix)
lm.offsetText.clear();
lm.foldLevel = computeFoldLevel(depth, false);
lm.markerMask = 0;
int sz = tree.structSpan(node.id, &state.childMap);
lm.offsetText = fmt::fmtOffsetMargin(absAddr + sz, false, state.offsetHexDigits);
lm.offsetAddr = absAddr + sz;
lm.ptrBase = state.currentPtrBase;
state.emitLine(fmt::fmtStructFooter(node, depth, sz), lm);
}
@@ -301,7 +414,8 @@ void composeParent(ComposeState& state, const NodeTree& tree,
void composeNode(ComposeState& state, const NodeTree& tree,
const Provider& prov, int nodeIdx, int depth,
uint64_t base, uint64_t rootId, bool isArrayChild,
uint64_t scopeId, int arrayElementIdx) {
uint64_t scopeId, int arrayElementIdx,
uint64_t arrayContainerAddr) {
const Node& node = tree.nodes[nodeIdx];
uint64_t absAddr = resolveAddr(state, tree, nodeIdx, base, rootId);
@@ -315,56 +429,108 @@ 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.offsetText = fmt::fmtOffsetMargin(tree.baseAddress + absAddr, false);
lm.lineKind = effectiveCollapsed ? LineKind::Field : LineKind::Header;
lm.offsetText = fmt::fmtOffsetMargin(absAddr, false, state.offsetHexDigits);
lm.offsetAddr = absAddr;
lm.ptrBase = state.currentPtrBase;
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)) {
ptrVal = (node.kind == NodeKind::Pointer32)
? (uint64_t)prov.readU32(absAddr) : prov.readU64(absAddr);
if (ptrVal != 0) {
uint64_t pBase = ptrToProviderAddr(tree, ptrVal);
if (pBase == UINT64_MAX) ptrVal = 0; // ptr below base: invalid
// Treat sentinel values as invalid pointers
if (ptrVal == UINT64_MAX || (node.kind == NodeKind::Pointer32 && ptrVal == 0xFFFFFFFF))
ptrVal = 0;
}
}
// Show referenced struct children: at dereferenced address if non-NULL,
// otherwise at offset 0 as a struct template preview
uint64_t pBase = (ptrVal != 0) ? ptrToProviderAddr(tree, ptrVal) : 0;
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, prov, refIdx,
depth, pBase, ref.id,
/*isArrayChild=*/true);
// Pointer target address is used directly (absolute)
uint64_t pBase = ptrVal;
bool ptrReadable = (ptrVal != 0) && prov.isReadable(pBase, 1);
// For invalid/unreadable pointers: use NullProvider (shows zeros)
static NullProvider s_nullProv;
const Provider& childProv = ptrReadable ? prov : static_cast<const Provider&>(s_nullProv);
if (!ptrReadable)
pBase = 0;
uint64_t savedPtrBase = state.currentPtrBase;
state.currentPtrBase = pBase;
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);
}
state.currentPtrBase = savedPtrBase;
// Footer for pointer fold
{
LineMeta lm;
@@ -383,7 +549,7 @@ void composeNode(ComposeState& state, const NodeTree& tree,
}
if (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) {
composeParent(state, tree, prov, nodeIdx, depth, base, rootId, isArrayChild, scopeId, arrayElementIdx);
composeParent(state, tree, prov, nodeIdx, depth, base, rootId, isArrayChild, scopeId, arrayElementIdx, arrayContainerAddr);
} else {
composeLeaf(state, tree, prov, nodeIdx, depth, absAddr, scopeId);
}
@@ -398,15 +564,31 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
for (int i = 0; i < tree.nodes.size(); i++)
state.childMap[tree.nodes[i].parentId].append(i);
// Precompute absolute offsets
// Precompute absolute offsets (baseAddress + structure-relative offset)
state.absOffsets.resize(tree.nodes.size());
for (int i = 0; i < tree.nodes.size(); i++)
state.absOffsets[i] = tree.computeOffset(i);
state.absOffsets[i] = tree.baseAddress + tree.computeOffset(i);
// Compute hex digit tier from max absolute address
{
uint64_t maxAddr = tree.baseAddress;
for (int i = 0; i < tree.nodes.size(); i++) {
uint64_t addr = (uint64_t)state.absOffsets[i];
if (addr > maxAddr) maxAddr = addr;
}
if (maxAddr <= 0xFFFFULL) state.offsetHexDigits = 4;
else if (maxAddr <= 0xFFFFFFFFULL) state.offsetHexDigits = 8;
else if (maxAddr <= 0xFFFFFFFFFFFFULL) state.offsetHexDigits = 12;
else state.offsetHexDigits = 16;
}
// Helper: compute the display type string for a node (for width calculation)
auto nodeTypeName = [&](const Node& n) -> QString {
if (n.kind == NodeKind::Array)
return fmt::arrayTypeName(n.elementKind, n.arrayLen);
if (n.kind == NodeKind::Array) {
QString sn = (n.elementKind == NodeKind::Struct)
? resolvePointerTarget(tree, n.refId) : QString();
return fmt::arrayTypeName(n.elementKind, n.arrayLen, sn);
}
if (n.kind == NodeKind::Struct)
return fmt::structTypeName(n);
if (n.kind == NodeKind::Pointer32 || n.kind == NodeKind::Pointer64)
@@ -426,7 +608,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());
}
@@ -445,12 +627,25 @@ 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());
}
}
// Primitive arrays with no tree children: account for synthesized element types
// e.g. "uint32_t[0]", "uint32_t[99]" — longest index determines width
if (container.kind == NodeKind::Array
&& state.childMap.value(container.id).isEmpty()
&& container.elementKind != NodeKind::Struct
&& container.elementKind != NodeKind::Array
&& container.arrayLen > 0) {
int maxIdx = container.arrayLen - 1;
QString longestElemType = fmt::typeNameRaw(container.elementKind)
+ QStringLiteral("[%1]").arg(maxIdx);
scopeMaxType = qMax(scopeMaxType, (int)longestElemType.size());
}
state.scopeTypeW[container.id] = qBound(kMinTypeW, scopeMaxType, kMaxTypeW);
state.scopeNameW[container.id] = qBound(kMinNameW, scopeMaxName, kMaxNameW);
}
@@ -464,7 +659,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());
}
@@ -474,7 +669,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
}
// Emit CommandRow as line 0 (combined: source + address + root class type + name)
const QString cmdRowText = QStringLiteral("source\u25BE \u00B7 0x0 \u00B7 struct\u25BE <no class> {");
const QString cmdRowText = QStringLiteral("[\u25B8] source\u25BE \u00B7 0x0 \u00B7 struct NoName {");
{
LineMeta lm;
lm.nodeIdx = -1;
@@ -483,7 +678,9 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
lm.lineKind = LineKind::CommandRow;
lm.foldLevel = SC_FOLDLEVELBASE;
lm.foldHead = false;
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress, false);
lm.offsetText = fmt::fmtOffsetMargin(tree.baseAddress, false, state.offsetHexDigits);
lm.offsetAddr = tree.baseAddress;
lm.ptrBase = state.currentPtrBase;
lm.markerMask = 0;
lm.effectiveTypeW = state.typeW;
lm.effectiveNameW = state.nameW;
@@ -502,7 +699,7 @@ ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewR
composeNode(state, tree, prov, idx, 0);
}
return { state.text, state.meta, LayoutInfo{state.typeW, state.nameW} };
return { state.text, state.meta, LayoutInfo{state.typeW, state.nameW, state.offsetHexDigits, tree.baseAddress} };
}
QSet<uint64_t> NodeTree::normalizePreferAncestors(const QSet<uint64_t>& ids) const {
@@ -544,20 +741,5 @@ QSet<uint64_t> NodeTree::normalizePreferDescendants(const QSet<uint64_t>& ids) c
return result;
}
int NodeTree::computeStructAlignment(uint64_t structId) const {
int idx = indexOfId(structId);
if (idx < 0) return 1;
int maxAlign = 1;
QVector<int> kids = childrenOf(structId);
for (int ci : kids) {
const Node& c = nodes[ci];
if (c.kind == NodeKind::Struct || c.kind == NodeKind::Array) {
maxAlign = qMax(maxAlign, computeStructAlignment(c.id));
} else {
maxAlign = qMax(maxAlign, alignmentFor(c.kind));
}
}
return maxAlign;
}
} // namespace rcx

File diff suppressed because it is too large Load Diff

View File

@@ -7,13 +7,15 @@
#include <QUndoCommand>
#include <QTimer>
#include <QFutureWatcher>
#include <QPointer>
#include <memory>
class QSplitter;
namespace rcx {
class RcxController;
class TypeSelectorPopup;
struct TypeEntry;
enum class TypePopupMode;
// ── Document ──
@@ -63,11 +65,10 @@ private:
// ── Saved source entry ──
struct SavedSourceEntry {
QString kind; // "File" or "Process"
QString kind; // "File" or provider identifier (e.g. "processmemory")
QString displayName; // filename or process name
QString filePath; // for File sources
uint32_t pid = 0; // for Process sources
QString processName; // for Process sources
QString providerTarget; // for plugin providers (e.g. "pid:name")
uint64_t baseAddress = 0;
};
@@ -80,23 +81,31 @@ public:
~RcxController() override;
RcxEditor* primaryEditor() const;
RcxEditor* addSplitEditor(QSplitter* splitter);
RcxEditor* addSplitEditor(QWidget* parent = nullptr);
void removeSplitEditor(RcxEditor* editor);
QList<RcxEditor*> editors() const { return m_editors; }
void convertRootKeyword(const QString& newKeyword);
void changeNodeKind(int nodeIdx, NodeKind newKind);
void renameNode(int nodeIdx, const QString& newName);
void insertNode(uint64_t parentId, int offset, NodeKind kind, const QString& name);
void removeNode(int nodeIdx);
void toggleCollapse(int nodeIdx);
void setNodeValue(int nodeIdx, int subLine, const QString& text, bool isAscii = false);
void materializeRefChildren(int nodeIdx);
void setNodeValue(int nodeIdx, int subLine, const QString& text,
bool isAscii = false, uint64_t resolvedAddr = 0);
void duplicateNode(int nodeIdx);
void convertToTypedPointer(uint64_t nodeId);
void splitHexNode(uint64_t nodeId);
void showContextMenu(RcxEditor* editor, int line, int nodeIdx, int subLine, const QPoint& globalPos);
void batchRemoveNodes(const QVector<int>& nodeIndices);
void batchChangeKind(const QVector<int>& nodeIndices, NodeKind newKind);
void deleteRootStruct(uint64_t structId);
void applyCommand(const Command& cmd, bool isUndo);
void refresh();
void applyTypePopupResult(TypePopupMode mode, int nodeIdx, const TypeEntry& entry, const QString& fullText);
uint64_t findOrCreateStructByName(const QString& typeName);
// Selection
void handleNodeClick(RcxEditor* source, int line, uint64_t nodeId,
@@ -111,6 +120,26 @@ 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; }
void attachViaPlugin(const QString& providerIdentifier, const QString& target);
const QVector<SavedSourceEntry>& savedSources() const { return m_savedSources; }
int activeSourceIndex() const { return m_activeSourceIdx; }
void switchSource(int idx) { switchToSavedSource(idx); }
void clearSources();
void selectSource(const QString& text);
// Value tracking toggle (per-tab, off by default)
bool trackValues() const { return m_trackValues; }
void setTrackValues(bool on);
// Cross-tab type visibility: point at the project's full document list
void setProjectDocuments(QVector<RcxDocument*>* docs) { m_projectDocs = docs; }
// Test accessor
const QHash<uint64_t, ValueHistory>& valueHistory() const { return m_valueHistory; }
signals:
void nodeSelected(int nodeIdx);
@@ -129,23 +158,31 @@ private:
QVector<SavedSourceEntry> m_savedSources;
int m_activeSourceIdx = -1;
// ── Cached type selector popup (avoids ~350ms cold-start on first show) ──
QPointer<TypeSelectorPopup> m_cachedPopup;
// ── Auto-refresh state ──
using PageMap = QHash<uint64_t, QByteArray>;
QTimer* m_refreshTimer = nullptr;
QFutureWatcher<QByteArray>* m_refreshWatcher = nullptr;
QFutureWatcher<PageMap>* m_refreshWatcher = nullptr;
std::unique_ptr<SnapshotProvider> m_snapshotProv;
QByteArray m_prevSnapshot;
PageMap m_prevPages;
QSet<int64_t> m_changedOffsets;
QHash<uint64_t, ValueHistory> m_valueHistory;
bool m_trackValues = false;
uint64_t m_refreshGen = 0;
uint64_t m_readGen = 0;
bool m_readInFlight = false;
QVector<RcxDocument*>* m_projectDocs = nullptr;
void connectEditor(RcxEditor* editor);
void handleMarginClick(RcxEditor* editor, int margin, int line, Qt::KeyboardModifiers mods);
void updateCommandRow();
void performRealignment(uint64_t structId, int targetAlign);
void attachToProcess(uint32_t pid, const QString& processName);
void switchToSavedSource(int idx);
void pushSavedSourcesToEditors();
void showTypePopup(RcxEditor* editor, TypePopupMode mode, int nodeIdx, QPoint globalPos);
TypeSelectorPopup* ensurePopup(RcxEditor* editor);
// ── Auto-refresh methods ──
void setupAutoRefresh();
@@ -153,6 +190,10 @@ private:
void onReadComplete();
int computeDataExtent() const;
void resetSnapshot();
void collectPointerRanges(uint64_t structId, uint64_t memBase,
int depth, int maxDepth,
QSet<QPair<uint64_t,uint64_t>>& visited,
QVector<QPair<uint64_t,int>>& ranges) const;
};
} // namespace rcx

View File

@@ -8,6 +8,7 @@
#include <QHash>
#include <QSet>
#include <cstdint>
#include <array>
#include <memory>
#include <variant>
@@ -25,17 +26,23 @@ enum class NodeKind : uint8_t {
UInt8, UInt16, UInt32, UInt64,
Float, Double, Bool,
Pointer32, Pointer64,
FuncPtr32, FuncPtr64,
Vec2, Vec3, Vec4, Mat4x4,
UTF8, UTF16,
Padding,
Struct, Array
};
// ── Kind flags (replaces repeated Hex/Padding switches) ──
} // 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<int>(key), seed); }
#endif
namespace rcx { // reopen
// ── 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
@@ -72,13 +79,14 @@ inline constexpr KindMeta kKindMeta[] = {
{NodeKind::Bool, "Bool", "bool", 1, 1, 1, KF_None},
{NodeKind::Pointer32, "Pointer32", "ptr32", 4, 1, 4, KF_None},
{NodeKind::Pointer64, "Pointer64", "ptr64", 8, 1, 8, KF_None},
{NodeKind::FuncPtr32, "FuncPtr32", "fnptr32", 4, 1, 4, KF_None},
{NodeKind::FuncPtr64, "FuncPtr64", "fnptr64", 8, 1, 8, KF_None},
{NodeKind::Vec2, "Vec2", "vec2", 8, 1, 4, KF_Vector},
{NodeKind::Vec3, "Vec3", "vec3", 12, 1, 4, KF_Vector},
{NodeKind::Vec4, "Vec4", "vec4", 16, 1, 4, KF_Vector},
{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},
};
@@ -128,6 +136,21 @@ inline constexpr bool isHexNode(NodeKind k) {
inline constexpr bool isVectorKind(NodeKind k) {
return k == NodeKind::Vec2 || k == NodeKind::Vec3 || k == NodeKind::Vec4;
}
inline constexpr bool isMatrixKind(NodeKind k) {
return k == NodeKind::Mat4x4;
}
inline constexpr bool isFuncPtr(NodeKind k) {
return k == NodeKind::FuncPtr32 || k == NodeKind::FuncPtr64;
}
// Hex types, pointer types, function pointers, and containers are not meaningful
// primitive-pointer targets — dereferencing them produces the same output as void*.
inline constexpr bool isValidPrimitivePtrTarget(NodeKind k) {
if (isHexNode(k)) return false;
if (k == NodeKind::Pointer32 || k == NodeKind::Pointer64) return false;
if (isFuncPtr(k)) return false;
if (k == NodeKind::Struct || k == NodeKind::Array) return false;
return true;
}
inline QStringList allTypeNamesForUI(bool stripBrackets = false) {
QStringList out;
@@ -146,7 +169,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,
@@ -154,6 +176,7 @@ enum Marker : int {
M_HOVER = 6,
M_SELECTED = 7,
M_CMD_ROW = 8,
M_ACCENT = 9,
};
// ── Node ──
@@ -170,16 +193,20 @@ struct Node {
int strLen = 64;
bool collapsed = false;
uint64_t refId = 0; // Pointer32/64: id of Struct to expand at *ptr
NodeKind elementKind = NodeKind::UInt8; // Array: element type
NodeKind elementKind = NodeKind::UInt8; // Array: element type; Pointer with ptrDepth>0: target type
int ptrDepth = 0; // Pointer: 0=struct/void ptr, 1=primitive*, 2=primitive**
int viewIndex = 0; // Array: current view offset (transient)
// Note: Returns 0 for Array-of-Struct/Array. Use tree.structSpan() for accurate size.
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);
}
}
@@ -200,6 +227,8 @@ struct Node {
o["collapsed"] = collapsed;
o["refId"] = QString::number(refId);
o["elementKind"] = kindToString(elementKind);
if (ptrDepth > 0)
o["ptrDepth"] = ptrDepth;
return o;
}
static Node fromJson(const QJsonObject& o) {
@@ -211,11 +240,12 @@ 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"));
n.ptrDepth = qBound(0, o["ptrDepth"].toInt(0), 2);
return n;
}
@@ -224,11 +254,12 @@ struct Node {
return classKeyword.isEmpty() ? QStringLiteral("struct") : classKeyword;
}
// Helper: is this a string-like array (char[] or wchar_t[])?
bool isStringArray() const {
return kind == NodeKind::Array &&
(elementKind == NodeKind::UInt8 || elementKind == NodeKind::UInt16);
}
// NOTE: isStringArray() was checking UInt8/UInt16 instead of UTF8/UTF16.
// Currently unused — commented out until a caller needs it.
// bool isStringArray() const {
// return kind == NodeKind::Array &&
// (elementKind == NodeKind::UTF8 || elementKind == NodeKind::UTF16);
// }
};
// ── NodeTree ──
@@ -355,12 +386,13 @@ struct NodeTree {
if (end > maxEnd) maxEnd = end;
}
// Embedded struct reference: no own children but refId points to a struct definition
if (kids.isEmpty() && node.kind == NodeKind::Struct && node.refId != 0)
maxEnd = qMax(maxEnd, structSpan(node.refId, childMap, visited));
return qMax(declaredSize, maxEnd);
}
// Compute natural alignment of a struct (max alignment of direct children)
int computeStructAlignment(uint64_t structId) const;
// Batch selection normalizers
QSet<uint64_t> normalizePreferAncestors(const QSet<uint64_t>& ids) const;
QSet<uint64_t> normalizePreferDescendants(const QSet<uint64_t>& ids) const;
@@ -390,6 +422,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 {
@@ -421,13 +496,17 @@ struct LineMeta {
int arrayCount = 0; // Array: total element count
int arrayElementIdx = -1; // Index of this element within parent array (-1 if not array element)
QString offsetText;
uint64_t offsetAddr = 0; // Raw absolute address (for margin toggle)
uint64_t ptrBase = 0; // Pointer expansion base (non-zero = use for RVA)
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
int effectiveNameW = 22; // Per-line name column width used for rendering
QString pointerTargetName; // Resolved target type name for Pointer32/64 (empty = "void")
bool isArrayElement = false; // true for synthesized primitive array element lines
};
inline bool isSyntheticLine(const LineMeta& lm) {
@@ -439,6 +518,8 @@ inline bool isSyntheticLine(const LineMeta& lm) {
struct LayoutInfo {
int typeW = 14; // Effective type column width (default = kColType)
int nameW = 22; // Effective name column width (default = kColName)
int offsetHexDigits = 8; // Hex digits for offset margin (4/8/12/16)
uint64_t baseAddress = 0; // Base address for relative offset computation
};
// ── ComposeResult ──
@@ -489,13 +570,13 @@ struct ColumnSpan {
enum class EditTarget { Name, Type, Value, BaseAddress, Source, ArrayIndex, ArrayCount,
ArrayElementType, ArrayElementCount, PointerTarget,
RootClassType, RootClassName };
RootClassType, RootClassName, TypeSelector };
// Column layout constants (shared with format.cpp span computation)
inline constexpr int kFoldCol = 3; // 3-char fold indicator prefix per line
inline constexpr int kColType = 14; // Max type column width (fits "uint64_t[999]")
inline constexpr int kColName = 22;
inline constexpr int kColValue = 32;
inline constexpr int kColValue = 96;
inline constexpr int kColComment = 28; // "// Enter=Save Esc=Cancel" fits
inline constexpr int kColBaseAddr = 12; // "0x" + up to 10 hex digits (40-bit address)
inline constexpr int kSepWidth = 1;
@@ -516,9 +597,9 @@ 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 takes the name column position (8 chars)
// Hex: ASCII preview occupies the name column (padded to nameW)
if (isHexPreview(lm.nodeKind))
return {start, start + 8, true};
return {start, start + nameW, true};
return {start, start + nameW, true};
}
@@ -528,22 +609,19 @@ inline ColumnSpan valueSpanFor(const LineMeta& lm, int /*lineLength*/, int typeW
lm.lineKind == LineKind::ArrayElementSeparator) return {};
int ind = kFoldCol + lm.depth * 3;
// Hex/Padding layout: [Type][sep][ASCII(8)][sep][hex bytes(23)]
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;
if (lm.isContinuation) {
int prefixW = isHexPad
? (typeW + kSepWidth + 8 + kSepWidth)
: (typeW + nameW + 2 * kSepWidth);
int start = ind + prefixW;
return {start, start + valWidth, true};
}
if (lm.lineKind != LineKind::Field) return {};
int start = isHexPad
? (ind + typeW + kSepWidth + 8 + kSepWidth)
: (ind + typeW + kSepWidth + nameW + kSepWidth);
int start = ind + prefixW;
return {start, start + valWidth, true};
}
@@ -551,19 +629,15 @@ 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;
if (lm.isContinuation) {
int prefixW = isHexPad
? (typeW + kSepWidth + 8 + kSepWidth)
: (typeW + nameW + 2 * kSepWidth);
start = ind + prefixW + valWidth;
} else {
start = isHexPad
? (ind + typeW + kSepWidth + 8 + kSepWidth + valWidth)
: (ind + typeW + kSepWidth + nameW + kSepWidth + valWidth);
start = ind + prefixW + valWidth;
}
return {start, lineLength, start < lineLength};
}
@@ -596,16 +670,17 @@ inline ColumnSpan commandRowAddrSpan(const QString& lineText) {
}
// ── CommandRow root-class spans ──
// Combined CommandRow format ends with: " struct ClassName {"
// Combined CommandRow format ends with: " struct ClassName {"
inline int commandRowRootStart(const QString& lineText) {
int best = -1;
int i;
i = lineText.lastIndexOf(QStringLiteral("struct\u25BE"));
// Match "struct " / "class " / "enum " as whole words before the class name
i = lineText.lastIndexOf(QStringLiteral("struct "));
if (i > best) best = i;
i = lineText.lastIndexOf(QStringLiteral("class\u25BE"));
i = lineText.lastIndexOf(QStringLiteral("class "));
if (i > best) best = i;
i = lineText.lastIndexOf(QStringLiteral("enum\u25BE"));
i = lineText.lastIndexOf(QStringLiteral("enum "));
if (i > best) best = i;
return best;
}
@@ -614,8 +689,7 @@ inline ColumnSpan commandRowRootTypeSpan(const QString& lineText) {
int start = commandRowRootStart(lineText);
if (start < 0) return {};
int end = start;
while (end < lineText.size() && lineText[end] != QChar(' ')
&& lineText[end] != QChar(0x25BE)) end++;
while (end < lineText.size() && lineText[end] != QChar(' ')) end++;
if (end <= start) return {};
return {start, end, true};
}
@@ -635,6 +709,16 @@ inline ColumnSpan commandRowRootNameSpan(const QString& lineText) {
return {nameStart, nameEnd, true};
}
// ── CommandRow type-selector chevron span ──
// Detects "[▸]" at the start of the command row text
inline ColumnSpan commandRowChevronSpan(const QString& lineText) {
if (lineText.size() < 3) return {};
if (lineText[0] == '[' && lineText[1] == QChar(0x25B8) && lineText[2] == ']')
return {0, qMin(4, (int)lineText.size()), true}; // include trailing space for easier clicking
return {};
}
// ── Array element type/count spans (within type column of array headers) ──
// Line format: " int32_t[10] name {"
// arrayElemTypeSpan covers "int32_t", arrayElemCountSpan covers "10"
@@ -657,6 +741,16 @@ inline ColumnSpan arrayElemCountSpanFor(const LineMeta& lm, const QString& lineT
return {openBracket + 1, closeBracket, true};
}
// Click-area version: includes brackets [N] for hit testing
inline ColumnSpan arrayElemCountClickSpanFor(const LineMeta& lm, const QString& lineText) {
if (lm.lineKind != LineKind::Header || !lm.isArrayHeader) return {};
int ind = kFoldCol + lm.depth * 3;
int openBracket = lineText.indexOf('[', ind);
int closeBracket = lineText.indexOf(']', openBracket);
if (openBracket < 0 || closeBracket < 0 || closeBracket <= openBracket + 1) return {};
return {openBracket, closeBracket + 1, true};
}
// ── Pointer kind/target spans (within type column of pointer fields) ──
// Line format: " void* name -> 0x..."
// pointerTargetSpan covers the target name before '*'
@@ -740,12 +834,12 @@ namespace fmt {
uint64_t addr, int depth, int subLine = 0,
const QString& comment = {}, int colType = kColType, int colName = kColName,
const QString& typeOverride = {});
QString fmtOffsetMargin(uint64_t absoluteOffset, bool isContinuation);
QString fmtOffsetMargin(uint64_t absoluteOffset, bool isContinuation, int hexDigits = 8);
QString fmtStructHeader(const Node& node, int depth, bool collapsed, int colType = kColType, int colName = kColName);
QString fmtStructFooter(const Node& node, int depth, int totalSize = -1);
QString fmtArrayHeader(const Node& node, int depth, int viewIdx, bool collapsed, int colType = kColType, int colName = kColName);
QString fmtArrayHeader(const Node& node, int depth, int viewIdx, bool collapsed, int colType = kColType, int colName = kColName, const QString& elemStructName = {});
QString structTypeName(const Node& node); // Full type string for struct headers
QString arrayTypeName(NodeKind elemKind, int count);
QString arrayTypeName(NodeKind elemKind, int count, const QString& structName = {});
QString pointerTypeName(NodeKind kind, const QString& targetName);
QString fmtPointerHeader(const Node& node, int depth, bool collapsed,
const Provider& prov, uint64_t addr,

76
src/disasm.cpp Normal file
View File

@@ -0,0 +1,76 @@
#include "disasm.h"
extern "C" {
#include <fadec.h>
}
namespace rcx {
QString disassemble(const QByteArray& bytes, uint64_t baseAddr, int bitness, int maxBytes) {
if (bytes.isEmpty() || (bitness != 32 && bitness != 64))
return {};
int len = qMin((int)bytes.size(), maxBytes);
const auto* buf = reinterpret_cast<const uint8_t*>(bytes.constData());
QString result;
int off = 0;
while (off < len) {
FdInstr instr;
int ret = fd_decode(buf + off, len - off, bitness, baseAddr + off, &instr);
if (ret < 0)
break;
char fmtBuf[128];
fd_format(&instr, fmtBuf, sizeof(fmtBuf));
if (!result.isEmpty())
result += QLatin1Char('\n');
result += QStringLiteral("%1 %2")
.arg(baseAddr + off, bitness == 64 ? 16 : 8, 16, QLatin1Char('0'))
.arg(QString::fromLatin1(fmtBuf));
off += ret;
}
return result;
}
QString hexDump(const QByteArray& bytes, uint64_t baseAddr, int maxBytes) {
if (bytes.isEmpty())
return {};
int len = qMin((int)bytes.size(), maxBytes);
QString result;
for (int off = 0; off < len; off += 16) {
int lineLen = qMin(16, len - off);
if (!result.isEmpty())
result += QLatin1Char('\n');
// Address
bool wide = (baseAddr + len > 0xFFFFFFFFULL);
result += QStringLiteral("%1 ").arg(baseAddr + off, wide ? 16 : 8, 16, QLatin1Char('0'));
// Hex bytes
for (int i = 0; i < 16; i++) {
if (i < lineLen) {
uint8_t b = static_cast<uint8_t>(bytes[off + i]);
result += QStringLiteral("%1 ").arg(b, 2, 16, QLatin1Char('0'));
} else {
result += QStringLiteral(" ");
}
if (i == 7) result += QLatin1Char(' ');
}
// ASCII
result += QLatin1Char(' ');
for (int i = 0; i < lineLen; i++) {
char c = bytes[off + i];
result += (c >= 0x20 && c < 0x7f) ? QLatin1Char(c) : QLatin1Char('.');
}
}
return result;
}
} // namespace rcx

15
src/disasm.h Normal file
View File

@@ -0,0 +1,15 @@
#pragma once
#include <QString>
#include <QByteArray>
#include <cstdint>
namespace rcx {
// Disassemble up to maxBytes of x86 code, returning formatted asm lines.
// bitness: 32 or 64. Returns one line per instruction, prefixed with offset.
QString disassemble(const QByteArray& bytes, uint64_t baseAddr, int bitness, int maxBytes = 128);
// Format bytes as hex dump lines (16 bytes per line with ASCII sidebar).
QString hexDump(const QByteArray& bytes, uint64_t baseAddr, int maxBytes = 128);
} // namespace rcx

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
#pragma once
#include "core.h"
#include "themes/theme.h"
#include <QWidget>
#include <QSet>
#include <QPoint>
@@ -26,6 +27,7 @@ public:
void restoreViewState(const ViewState& vs);
QsciScintilla* scintilla() const { return m_sci; }
QWidget* structPreviewPopup() const { return m_structPreviewPopup; }
const LineMeta* metaForLine(int line) const;
int currentNodeIndex() const;
void scrollToNodeId(uint64_t nodeId);
@@ -40,16 +42,23 @@ public:
// ── Inline editing ──
bool isEditing() const { return m_editState.active; }
bool beginInlineEdit(EditTarget target, int line = -1);
bool beginInlineEdit(EditTarget target, int line = -1, int col = -1);
void cancelInlineEdit();
void applySelectionOverlay(const QSet<uint64_t>& selIds);
void setCommandRowText(const QString& line);
void setEditorFont(const QString& fontName);
static void setGlobalFontName(const QString& fontName);
static QString globalFontName();
void applyTheme(const Theme& theme);
// 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; }
void setProviderRef(const Provider* prov, const Provider* realProv, const NodeTree* tree) {
m_disasmProvider = prov; m_disasmRealProv = realProv; m_disasmTree = tree;
}
// Saved sources for quick-switch in source picker
void setSavedSources(const QVector<SavedSourceDisplay>& sources) { m_savedSourceDisplay = sources; }
@@ -57,10 +66,14 @@ public:
signals:
void marginClicked(int margin, int line, Qt::KeyboardModifiers mods);
void contextMenuRequested(int line, int nodeIdx, int subLine, QPoint globalPos);
void keywordConvertRequested(const QString& newKeyword);
void nodeClicked(int line, uint64_t nodeId, Qt::KeyboardModifiers mods);
void inlineEditCommitted(int nodeIdx, int subLine,
EditTarget target, const QString& text);
EditTarget target, const QString& text,
uint64_t resolvedAddr = 0);
void inlineEditCancelled();
void typeSelectorRequested();
void typePickerRequested(EditTarget target, int nodeIdx, QPoint globalPos);
protected:
bool eventFilter(QObject* obj, QEvent* event) override;
@@ -71,6 +84,9 @@ private:
QVector<LineMeta> m_meta;
LayoutInfo m_layout; // cached from ComposeResult
// ── Toggle: absolute vs relative offset margin
bool m_relativeOffsets = true;
int m_marginStyleBase = -1;
int m_hintLine = -1;
@@ -120,7 +136,17 @@ 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)
QWidget* m_disasmPopup = nullptr; // DisasmPopup (file-local class in editor.cpp)
QWidget* m_structPreviewPopup = nullptr; // StructPreviewPopup (file-local class in editor.cpp)
const Provider* m_disasmProvider = nullptr; // snapshot or real — for reading tree data
const Provider* m_disasmRealProv = nullptr; // real process provider — for reading code at arbitrary addresses
const NodeTree* m_disasmTree = nullptr;
// ── Reentrancy guards ──
bool m_applyingDocument = false;
bool m_clampingSelection = false;
bool m_updatingComment = false;
@@ -132,10 +158,12 @@ private:
void allocateMarginStyles();
void applyMarginText(const QVector<LineMeta>& meta);
void reformatMargins();
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 applySymbolColoring(const QVector<LineMeta>& meta);
void applyBaseAddressColoring(const QVector<LineMeta>& meta);
void applyCommandRowPills();

File diff suppressed because it is too large Load Diff

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": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "27",
"kind": "Hex64",
"name": "field_70",
"offset": 112,
"parentId": "1",
"refId": "0",
"strLen": 64
},
{
"arrayLen": 1,
"collapsed": false,
"elementKind": "UInt8",
"id": "28",
"kind": "Hex64",
"name": "field_78",
"offset": 120,
"parentId": "1",
"refId": "0",
"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

Binary file not shown.

BIN
src/fonts/JetBrainsMono.ttf Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -1,4 +1,5 @@
#include "core.h"
#include <cmath>
#include <cstring>
#include <limits>
@@ -41,13 +42,15 @@ QString typeName(NodeKind kind, int colType) {
return fit(m ? QString::fromLatin1(m->typeName) : QStringLiteral("???"), colType);
}
// Array type string: "uint32_t[16]" or "char[64]"
QString arrayTypeName(NodeKind elemKind, int count) {
auto* m = kindMeta(elemKind);
QString elem = m ? QString::fromLatin1(m->typeName) : QStringLiteral("???");
// char[] for UInt8, wchar_t[] for UInt16
if (elemKind == NodeKind::UInt8) elem = QStringLiteral("char");
else if (elemKind == NodeKind::UInt16) elem = QStringLiteral("wchar_t");
// Array type string: "uint32_t[16]" or "Material[2]"
QString arrayTypeName(NodeKind elemKind, int count, const QString& structName) {
QString elem;
if (elemKind == NodeKind::Struct && !structName.isEmpty())
elem = structName;
else {
auto* m = kindMeta(elemKind);
elem = m ? QString::fromLatin1(m->typeName) : QStringLiteral("???");
}
return elem + QStringLiteral("[") + QString::number(count) + QStringLiteral("]");
}
@@ -78,8 +81,24 @@ QString fmtUInt32(uint32_t v) { return hexVal(v); }
QString fmtUInt64(uint64_t v) { return hexVal(v); }
QString fmtFloat(float v) {
QString s = QString::number(v, 'g', 4);
if (!s.contains('.') && !s.contains('e') && !s.contains('E'))
if (std::isnan(v)) return QStringLiteral("NaN");
if (std::isinf(v)) return v > 0 ? QStringLiteral("inff") : QStringLiteral("-inff");
// 6 significant digits — covers full single-precision range
QString s = QString::number(v, 'g', 6);
// If 'g' chose scientific notation, reformat as plain decimal
if (s.contains('e') || s.contains('E')) {
s = QString::number(v, 'f', 8);
if (s.contains('.')) {
int i = s.size() - 1;
while (i > 0 && s[i] == '0') i--;
if (s[i] == '.') i++; // keep at least one decimal digit
s.truncate(i + 1);
}
}
if (!s.contains('.'))
s += QStringLiteral(".f");
else
s += QLatin1Char('f');
@@ -111,9 +130,10 @@ QString indent(int depth) {
// ── Offset margin ──
QString fmtOffsetMargin(uint64_t absoluteOffset, bool isContinuation) {
QString fmtOffsetMargin(uint64_t absoluteOffset, bool isContinuation, int hexDigits) {
if (isContinuation) return QStringLiteral(" \u00B7 ");
return QString::number(absoluteOffset, 16).toUpper() + QChar(' ');
return QString::number(absoluteOffset, 16).toUpper()
.rightJustified(hexDigits, '0') + QChar(' ');
}
// ── Struct type name (for width calculation) ──
@@ -142,9 +162,9 @@ QString fmtStructFooter(const Node& /*node*/, int depth, int /*totalSize*/) {
// ── Array header ──
// Columnar format: <type[count]> <name> { (or no brace when collapsed)
QString fmtArrayHeader(const Node& node, int depth, int /*viewIdx*/, bool collapsed, int colType, int colName) {
QString fmtArrayHeader(const Node& node, int depth, int /*viewIdx*/, bool collapsed, int colType, int colName, const QString& elemStructName) {
QString ind = indent(depth);
QString type = fit(arrayTypeName(node.elementKind, node.arrayLen), colType);
QString type = fit(arrayTypeName(node.elementKind, node.arrayLen, elemStructName), colType);
QString suffix = collapsed ? QString() : QStringLiteral("{");
return ind + type + SEP + node.name + SEP + suffix;
}
@@ -242,15 +262,55 @@ static QString readValueImpl(const Node& node, const Provider& prov,
if (!display) return rawHex(val, 8);
QString s = fmtPointer32(val);
QString sym = prov.getSymbol((uint64_t)val);
if (!sym.isEmpty()) s += QStringLiteral(" ") + sym;
if (!sym.isEmpty()) s += QStringLiteral(" // ") + sym;
return s;
}
case NodeKind::Pointer64: {
uint64_t val = prov.readU64(addr);
// Primitive pointer: dereference and show target value
// (hex/ptr/fnptr targets fall through to plain void* display)
if (node.ptrDepth > 0 && isValidPrimitivePtrTarget(node.elementKind) && val != 0) {
uint64_t target = val;
for (int d = 1; d < node.ptrDepth && target != 0; d++)
target = prov.isReadable(target, 8) ? prov.readU64(target) : 0;
if (target != 0 && prov.isReadable(target, sizeForKind(node.elementKind))) {
// Create a temporary node of the target kind to format the value
Node tmp;
tmp.kind = node.elementKind;
tmp.strLen = node.strLen;
QString derefVal = readValueImpl(tmp, prov, target, 0, mode);
if (display) {
QString arrow = QStringLiteral("-> ");
QString sym = prov.getSymbol(val);
if (!sym.isEmpty())
return arrow + derefVal + QStringLiteral(" // ") + sym;
return arrow + derefVal;
}
return derefVal;
}
if (!display) return rawHex(val, 16);
return fmtPointer64(val);
}
if (!display) return rawHex(val, 16);
QString s = fmtPointer64(val);
QString sym = prov.getSymbol(val);
if (!sym.isEmpty()) s += QStringLiteral(" ") + sym;
if (!sym.isEmpty()) s += QStringLiteral(" // ") + sym;
return s;
}
case NodeKind::FuncPtr32: {
uint32_t val = prov.readU32(addr);
if (!display) return rawHex(val, 8);
QString s = fmtPointer32(val);
QString sym = prov.getSymbol((uint64_t)val);
if (!sym.isEmpty()) s += QStringLiteral(" // ") + sym;
return s;
}
case NodeKind::FuncPtr64: {
uint64_t val = prov.readU64(addr);
if (!display) return rawHex(val, 16);
QString s = fmtPointer64(val);
QString sym = prov.getSymbol(val);
if (!sym.isEmpty()) s += QStringLiteral(" // ") + sym;
return s;
}
case NodeKind::Vec2:
@@ -265,7 +325,7 @@ static QString readValueImpl(const Node& node, const Provider& prov,
case NodeKind::Mat4x4: {
if (!display) return {}; // not editable as single value
if (subLine < 0 || subLine >= 4) return QStringLiteral("?");
QString line = QStringLiteral("[");
QString line = QStringLiteral("row%1 [").arg(subLine);
for (int c = 0; c < 4; c++) {
if (c > 0) line += QStringLiteral(", ");
line += fmtFloat(prov.readF32(addr + (subLine * 4 + c) * 4)).trimmed();
@@ -273,7 +333,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');
@@ -313,36 +372,23 @@ QString fmtNodeLine(const Node& node, const Provider& prov,
// Blank prefix for continuation lines (same width as type+sep+name+sep)
const int prefixW = colType + colName + 2 * kSepWidth;
// Comment suffix (padded or empty)
QString cmtSuffix = comment.isEmpty() ? QString(COL_COMMENT, ' ')
// Comment suffix (only present when a comment is provided; no trailing padding)
QString cmtSuffix = comment.isEmpty() ? QString()
: fit(comment, COL_COMMENT);
// Mat4x4: subLine 0..3 = rows
// Mat4x4: subLine 0..3 = rows — no truncation so large floats always display fully
if (node.kind == NodeKind::Mat4x4) {
QString val = fit(readValue(node, prov, addr, subLine), COL_VALUE);
QString val = readValue(node, prov, addr, subLine);
if (subLine == 0) return ind + type + SEP + name + SEP + val + cmtSuffix;
return ind + QString(prefixW, ' ') + val + cmtSuffix;
}
// Hex nodes and Padding: hex byte preview
// 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);
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 8 chars so hex column aligns
const int sz = sizeForKind(node.kind);
QByteArray b = prov.isReadable(addr, sz)
? prov.readBytes(addr, sz) : QByteArray(sz, '\0');
QString ascii = bytesToAscii(b, sz).leftJustified(8, ' ');
QString ascii = bytesToAscii(b, sz).leftJustified(colName, ' ');
QString hex = bytesToHex(b, sz).leftJustified(23, ' ');
return ind + type + SEP + ascii + SEP + hex + cmtSuffix;
}
@@ -537,6 +583,14 @@ QByteArray parseValue(NodeKind kind, const QString& text, bool* ok) {
qulonglong val = stripHex(s).toULongLong(ok, 16);
return *ok ? toBytes<uint64_t>(val) : QByteArray{};
}
case NodeKind::FuncPtr32: {
uint val = stripHex(s).toUInt(ok, 16);
return *ok ? toBytes<uint32_t>(val) : QByteArray{};
}
case NodeKind::FuncPtr64: {
qulonglong val = stripHex(s).toULongLong(ok, 16);
return *ok ? toBytes<uint64_t>(val) : QByteArray{};
}
case NodeKind::UTF8: {
*ok = true;
if (s.startsWith('"') && s.endsWith('"'))
@@ -565,7 +619,8 @@ QString validateValue(NodeKind kind, const QString& text) {
// For integer/hex types, validate character set first
bool isHexKind = (kind >= NodeKind::Hex8 && kind <= NodeKind::Hex64)
|| kind == NodeKind::Pointer32 || kind == NodeKind::Pointer64;
|| kind == NodeKind::Pointer32 || kind == NodeKind::Pointer64
|| kind == NodeKind::FuncPtr32 || kind == NodeKind::FuncPtr64;
bool isIntKind = (kind >= NodeKind::Int8 && kind <= NodeKind::UInt64);
if (isHexKind || isIntKind) {

View File

@@ -44,13 +44,14 @@ static QString cTypeName(NodeKind kind) {
case NodeKind::Bool: return QStringLiteral("bool");
case NodeKind::Pointer32: return QStringLiteral("uint32_t");
case NodeKind::Pointer64: return QStringLiteral("uint64_t");
case NodeKind::FuncPtr32: return QStringLiteral("uint32_t");
case NodeKind::FuncPtr64: return QStringLiteral("uint64_t");
case NodeKind::Vec2: return QStringLiteral("float");
case NodeKind::Vec3: return QStringLiteral("float");
case NodeKind::Vec4: return QStringLiteral("float");
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");
}
}
@@ -93,51 +94,63 @@ struct GenContext {
// Forward declarations
static void emitStruct(GenContext& ctx, uint64_t structId);
// ── Emit a single field declaration ──
// ── Field line with offset comment (code + marker + comment) ──
// We use a \x01 marker to separate the code part from the offset comment.
// After all output is generated, alignComments() replaces markers with padding.
static const QChar kCommentMarker = QChar(0x01);
static QString offsetComment(int offset) {
return QString(kCommentMarker) + QStringLiteral("// 0x%1").arg(QString::number(offset, 16).toUpper());
}
static QString emitField(GenContext& ctx, const Node& node) {
const NodeTree& tree = ctx.tree;
QString name = sanitizeIdent(node.name.isEmpty()
? QStringLiteral("field_%1").arg(node.offset, 2, 16, QChar('0'))
: node.name);
QString oc = offsetComment(node.offset);
switch (node.kind) {
case NodeKind::Vec2:
return QStringLiteral(" %1 %2[2];").arg(ctx.cType(NodeKind::Float), name);
return QStringLiteral(" %1 %2[2];").arg(ctx.cType(NodeKind::Float), name) + oc;
case NodeKind::Vec3:
return QStringLiteral(" %1 %2[3];").arg(ctx.cType(NodeKind::Float), name);
return QStringLiteral(" %1 %2[3];").arg(ctx.cType(NodeKind::Float), name) + oc;
case NodeKind::Vec4:
return QStringLiteral(" %1 %2[4];").arg(ctx.cType(NodeKind::Float), name);
return QStringLiteral(" %1 %2[4];").arg(ctx.cType(NodeKind::Float), name) + oc;
case NodeKind::Mat4x4:
return QStringLiteral(" %1 %2[4][4];").arg(ctx.cType(NodeKind::Float), name);
return QStringLiteral(" %1 %2[4][4];").arg(ctx.cType(NodeKind::Float), name) + oc;
case NodeKind::UTF8:
return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::UTF8), name).arg(node.strLen);
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);
case NodeKind::Padding:
return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::Padding), name).arg(qMax(1, node.arrayLen));
return QStringLiteral(" %1 %2[%3];").arg(ctx.cType(NodeKind::UTF16), name).arg(node.strLen) + oc;
case NodeKind::Pointer32: {
if (node.refId != 0) {
int refIdx = tree.indexOfId(node.refId);
if (refIdx >= 0) {
QString target = ctx.structName(tree.nodes[refIdx]);
return QStringLiteral(" %1 %2; // -> %3*").arg(ctx.cType(NodeKind::Pointer32), name, target);
return QStringLiteral(" %1 %2;").arg(ctx.cType(NodeKind::Pointer32), name) +
offsetComment(node.offset).replace(QStringLiteral("//"), QStringLiteral("// -> %1*").arg(target));
}
}
return QStringLiteral(" %1 %2;").arg(ctx.cType(NodeKind::Pointer32), name);
return QStringLiteral(" %1 %2;").arg(ctx.cType(NodeKind::Pointer32), name) + oc;
}
case NodeKind::Pointer64: {
if (node.refId != 0) {
int refIdx = tree.indexOfId(node.refId);
if (refIdx >= 0) {
QString target = ctx.structName(tree.nodes[refIdx]);
return QStringLiteral(" %1* %2;").arg(target, name);
return QStringLiteral(" %1* %2;").arg(target, name) + oc;
}
}
return QStringLiteral(" void* %1;").arg(name);
return QStringLiteral(" void* %1;").arg(name) + oc;
}
case NodeKind::FuncPtr32:
return QStringLiteral(" void (*%1)();").arg(name) + oc;
case NodeKind::FuncPtr64:
return QStringLiteral(" void (*%1)();").arg(name) + oc;
default:
return QStringLiteral(" %1 %2;").arg(ctx.cType(node.kind), name);
return QStringLiteral(" %1 %2;").arg(ctx.cType(node.kind), name) + oc;
}
}
@@ -155,10 +168,21 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) {
return tree.nodes[a].offset < tree.nodes[b].offset;
});
int cursor = 0;
// Helper: emit a padding/hex run as a single collapsed byte array
auto emitPadRun = [&](int offset, int size) {
if (size <= 0) return;
ctx.output += QStringLiteral(" %1 %2[0x%3];%4\n")
.arg(QStringLiteral("uint8_t"))
.arg(ctx.uniquePadName())
.arg(QString::number(size, 16).toUpper())
.arg(offsetComment(offset));
};
for (int ci : children) {
const Node& child = tree.nodes[ci];
int cursor = 0;
int i = 0;
while (i < children.size()) {
const Node& child = tree.nodes[children[i]];
int childSize;
if (child.kind == NodeKind::Struct || child.kind == NodeKind::Array)
childSize = tree.structSpan(child.id, &ctx.childMap);
@@ -166,28 +190,40 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) {
childSize = child.byteSize();
// Gap before this field
if (child.offset > cursor) {
int gap = child.offset - cursor;
ctx.output += QStringLiteral(" %1 %2[0x%3];\n")
.arg(ctx.cType(NodeKind::Padding))
.arg(ctx.uniquePadName())
.arg(QString::number(gap, 16).toUpper());
} else if (child.offset < cursor) {
// Overlap
if (child.offset > cursor)
emitPadRun(cursor, child.offset - cursor);
else if (child.offset < cursor)
ctx.output += QStringLiteral(" // WARNING: overlap at offset 0x%1 (previous field ends at 0x%2)\n")
.arg(QString::number(child.offset, 16).toUpper())
.arg(QString::number(cursor, 16).toUpper());
// Collapse consecutive hex nodes into a single padding array
if (isHexNode(child.kind)) {
int runStart = child.offset;
int runEnd = child.offset + childSize;
int j = i + 1;
while (j < children.size()) {
const Node& next = tree.nodes[children[j]];
if (!isHexNode(next.kind)) break;
int nextSize = next.byteSize();
// Allow gaps within the run (they become part of the pad)
if (next.offset < runEnd) break; // overlap — stop merging
runEnd = next.offset + nextSize;
j++;
}
emitPadRun(runStart, runEnd - runStart);
cursor = runEnd;
i = j;
continue;
}
// Emit the field
if (child.kind == NodeKind::Struct) {
// Ensure the nested struct type is emitted first
emitStruct(ctx, child.id);
QString typeName = ctx.structName(child);
QString fieldName = sanitizeIdent(child.name);
ctx.output += QStringLiteral(" %1 %2;\n").arg(typeName, fieldName);
ctx.output += QStringLiteral(" %1 %2;%3\n").arg(typeName, fieldName, offsetComment(child.offset));
} else if (child.kind == NodeKind::Array) {
// Check if array has struct element children
QVector<int> arrayKids = ctx.childMap.value(child.id);
bool hasStructChild = false;
QString elemTypeName;
@@ -203,11 +239,11 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) {
QString fieldName = sanitizeIdent(child.name);
if (hasStructChild && !elemTypeName.isEmpty()) {
ctx.output += QStringLiteral(" %1 %2[%3];\n")
.arg(elemTypeName, fieldName).arg(child.arrayLen);
ctx.output += QStringLiteral(" %1 %2[%3];%4\n")
.arg(elemTypeName, fieldName).arg(child.arrayLen).arg(offsetComment(child.offset));
} else {
ctx.output += QStringLiteral(" %1 %2[%3];\n")
.arg(ctx.cType(child.elementKind), fieldName).arg(child.arrayLen);
ctx.output += QStringLiteral(" %1 %2[%3];%4\n")
.arg(ctx.cType(child.elementKind), fieldName).arg(child.arrayLen).arg(offsetComment(child.offset));
}
} else {
ctx.output += emitField(ctx, child) + QStringLiteral("\n");
@@ -215,16 +251,12 @@ static void emitStructBody(GenContext& ctx, uint64_t structId) {
int childEnd = child.offset + childSize;
if (childEnd > cursor) cursor = childEnd;
i++;
}
// Tail padding
if (cursor < structSize) {
int gap = structSize - cursor;
ctx.output += QStringLiteral(" %1 %2[0x%3];\n")
.arg(ctx.cType(NodeKind::Padding))
.arg(ctx.uniquePadName())
.arg(QString::number(gap, 16).toUpper());
}
if (cursor < structSize)
emitPadRun(cursor, structSize - cursor);
}
// ── Emit a complete struct definition ──
@@ -294,7 +326,6 @@ static void emitStruct(GenContext& ctx, uint64_t structId) {
ctx.emittedTypeNames.insert(typeName);
int structSize = ctx.tree.structSpan(structId, &ctx.childMap);
ctx.output += QStringLiteral("#pragma pack(push, 1)\n");
QString kw = node.resolvedClassKeyword();
if (kw == QStringLiteral("enum")) kw = QStringLiteral("struct"); // enum is cosmetic
ctx.output += QStringLiteral("%1 %2 {\n").arg(kw, typeName);
@@ -302,7 +333,6 @@ static void emitStruct(GenContext& ctx, uint64_t structId) {
emitStructBody(ctx, structId);
ctx.output += QStringLiteral("};\n");
ctx.output += QStringLiteral("#pragma pack(pop)\n");
ctx.output += QStringLiteral("static_assert(sizeof(%1) == 0x%2, \"Size mismatch for %1\");\n\n")
.arg(typeName)
.arg(QString::number(structSize, 16).toUpper());
@@ -319,22 +349,39 @@ static QHash<uint64_t, QVector<int>> buildChildMap(const NodeTree& tree) {
return map;
}
// ── Path breadcrumb for header comment ──
// ── Align offset comments ──
// Replaces kCommentMarker with spaces so all "// 0x..." comments align to
// the same column (the longest code portion + 1 space).
static QString nodePath(const NodeTree& tree, uint64_t nodeId) {
QStringList parts;
QSet<uint64_t> seen;
uint64_t cur = nodeId;
while (cur != 0 && !seen.contains(cur)) {
seen.insert(cur);
int idx = tree.indexOfId(cur);
if (idx < 0) break;
const Node& n = tree.nodes[idx];
parts << (n.name.isEmpty() ? QStringLiteral("<unnamed>") : n.name);
cur = n.parentId;
static QString alignComments(const QString& raw) {
QStringList lines = raw.split('\n');
// First pass: find the maximum code width (text before the marker)
int maxCode = 0;
for (const QString& line : lines) {
int pos = line.indexOf(kCommentMarker);
if (pos >= 0)
maxCode = qMax(maxCode, pos);
}
std::reverse(parts.begin(), parts.end());
return parts.join(QStringLiteral(" > "));
// Second pass: replace markers with padding
QString result;
result.reserve(raw.size() + lines.size() * 8);
for (int i = 0; i < lines.size(); i++) {
if (i > 0) result += '\n';
const QString& line = lines[i];
int pos = line.indexOf(kCommentMarker);
if (pos >= 0) {
result += line.left(pos);
int pad = maxCode - pos + 1;
if (pad < 1) pad = 1;
result += QString(pad, ' ');
result += line.mid(pos + 1); // skip the marker char
} else {
result += line;
}
}
return result;
}
} // anonymous namespace
@@ -350,30 +397,19 @@ QString renderCpp(const NodeTree& tree, uint64_t rootStructId,
if (root.kind != NodeKind::Struct) return {};
GenContext ctx{tree, buildChildMap(tree), {}, {}, {}, {}, {}, 0, typeAliases};
int rootSize = tree.structSpan(rootStructId, &ctx.childMap);
QString typeName = ctx.structName(root);
ctx.output += QStringLiteral("// Generated by ReclassX\n");
ctx.output += QStringLiteral("// Rendered from: %1 (id=0x%2, size=0x%3)\n\n")
.arg(nodePath(tree, rootStructId))
.arg(QString::number(rootStructId, 16).toUpper())
.arg(QString::number(rootSize, 16).toUpper());
ctx.output += QStringLiteral("#pragma once\n");
ctx.output += QStringLiteral("#include <cstdint>\n\n");
ctx.output += QStringLiteral("#pragma once\n\n");
emitStruct(ctx, rootStructId);
return ctx.output;
return alignComments(ctx.output);
}
QString renderCppAll(const NodeTree& tree,
const QHash<NodeKind, QString>* typeAliases) {
GenContext ctx{tree, buildChildMap(tree), {}, {}, {}, {}, {}, 0, typeAliases};
ctx.output += QStringLiteral("// Generated by ReclassX\n");
ctx.output += QStringLiteral("// Full SDK export\n\n");
ctx.output += QStringLiteral("#pragma once\n");
ctx.output += QStringLiteral("#include <cstdint>\n\n");
ctx.output += QStringLiteral("#pragma once\n\n");
QVector<int> roots = ctx.childMap.value(0);
std::sort(roots.begin(), roots.end(), [&](int a, int b) {
@@ -385,7 +421,7 @@ QString renderCppAll(const NodeTree& tree,
emitStruct(ctx, tree.nodes[ri].id);
}
return ctx.output;
return alignComments(ctx.output);
}
QString renderNull(const NodeTree&, uint64_t) {

BIN
src/icons/class.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

BIN
src/icons/class.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 918 B

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

@@ -4,14 +4,20 @@
#include <memory>
#include <string>
#ifdef _WIN32
#define RCX_PLUGIN_EXPORT __declspec(dllexport)
#else
#define RCX_PLUGIN_EXPORT __attribute__((visibility("default")))
#endif
// Forward declaration
namespace rcx { class Provider; }
/**
* Plugin interface for ReclassX
* Plugin interface for Reclass
*
* Plugins are loaded from the "Plugins" folder as DLLs.
* Each plugin must export a C function: extern "C" __declspec(dllexport) IPlugin* CreatePlugin();
* Plugins are loaded from the "Plugins" folder as shared libraries.
* Each plugin must export a C function: extern "C" RCX_PLUGIN_EXPORT IPlugin* CreatePlugin();
*/
class IPlugin {
public:
@@ -127,4 +133,4 @@ public:
// Plugin factory function signature
typedef IPlugin* (*CreatePluginFunc)();
#define IPLUGIN_IID "com.reclassx.IPlugin/1.0"
#define IPLUGIN_IID "com.reclass.IPlugin/1.0"

File diff suppressed because it is too large Load Diff

143
src/mainwindow.h Normal file
View File

@@ -0,0 +1,143 @@
#pragma once
#include "controller.h"
#include "titlebar.h"
#include "pluginmanager.h"
#include <QMainWindow>
#include <QMdiArea>
#include <QMdiSubWindow>
#include <QLabel>
#include <QSplitter>
#include <QTabWidget>
#include <QDockWidget>
#include <QTreeView>
#include <QStandardItemModel>
#include <QMap>
#include <QButtonGroup>
#include <QPushButton>
#include <Qsci/qsciscintilla.h>
namespace rcx {
class McpBridge;
class MainWindow : public QMainWindow {
Q_OBJECT
friend class McpBridge;
public:
explicit MainWindow(QWidget* parent = nullptr);
private slots:
void newClass();
void newStruct();
void newEnum();
void selfTest();
void openFile();
void saveFile();
void saveFileAs();
void closeFile();
void addNode();
void removeNode();
void changeNodeType();
void renameNodeAction();
void duplicateNodeAction();
void splitView();
void unsplitView();
void undo();
void redo();
void about();
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
QMdiSubWindow* project_new(const QString& classKeyword = QString());
QMdiSubWindow* project_open(const QString& path = {});
bool project_save(QMdiSubWindow* sub = nullptr, bool saveAs = false);
void project_close(QMdiSubWindow* sub = nullptr);
private:
enum ViewMode { VM_Reclass, VM_Rendered };
QMdiArea* m_mdiArea;
QLabel* m_statusLabel;
QButtonGroup* m_viewBtnGroup = nullptr;
QPushButton* m_btnReclass = nullptr;
QPushButton* m_btnRendered = nullptr;
TitleBarWidget* m_titleBar = nullptr;
QWidget* m_borderOverlay = nullptr;
PluginManager m_pluginManager;
McpBridge* m_mcp = nullptr;
QAction* m_mcpAction = nullptr;
QMenu* m_sourceMenu = nullptr;
struct SplitPane {
QTabWidget* tabWidget = nullptr;
RcxEditor* editor = nullptr;
QsciScintilla* rendered = nullptr;
ViewMode viewMode = VM_Reclass;
uint64_t lastRenderedRootId = 0;
};
struct TabState {
RcxDocument* doc;
RcxController* ctrl;
QSplitter* splitter;
QVector<SplitPane> panes;
int activePaneIdx = 0;
};
QMap<QMdiSubWindow*, TabState> m_tabs;
QVector<RcxDocument*> m_allDocs; // all open docs, shared with controllers
void rebuildAllDocs();
void createMenus();
void createStatusBar();
void showPluginsDialog();
void populateSourceMenu();
QIcon makeIcon(const QString& svgPath);
RcxController* activeController() const;
TabState* activeTab();
TabState* tabByIndex(int index);
int tabCount() const { return m_tabs.size(); }
QMdiSubWindow* createTab(RcxDocument* doc);
void updateWindowTitle();
void setViewMode(ViewMode mode);
void updateRenderedView(TabState& tab, SplitPane& pane);
void updateAllRenderedPanes(TabState& tab);
uint64_t findRootStructForNode(const NodeTree& tree, uint64_t nodeId) const;
void setupRenderedSci(QsciScintilla* sci);
SplitPane createSplitPane(TabState& tab);
void applyTheme(const Theme& theme);
void styleTabCloseButtons();
void syncViewButtons(ViewMode mode);
SplitPane* findPaneByTabWidget(QTabWidget* tw);
SplitPane* findActiveSplitPane();
RcxEditor* activePaneEditor();
// Workspace dock
QDockWidget* m_workspaceDock = nullptr;
QTreeView* m_workspaceTree = nullptr;
QStandardItemModel* m_workspaceModel = nullptr;
QLabel* m_dockTitleLabel = nullptr;
QToolButton* m_dockCloseBtn = nullptr;
void createWorkspaceDock();
void rebuildWorkspaceModel();
void updateBorderColor(const QColor& color);
protected:
void changeEvent(QEvent* event) override;
void resizeEvent(QResizeEvent* event) override;
};
} // namespace rcx

1072
src/mcp/mcp_bridge.cpp Normal file

File diff suppressed because it is too large Load Diff

71
src/mcp/mcp_bridge.h Normal file
View File

@@ -0,0 +1,71 @@
#pragma once
#include "mainwindow.h"
#include <QObject>
#include <QLocalServer>
#include <QLocalSocket>
#include <QJsonObject>
#include <QJsonArray>
#include <QJsonDocument>
#include <QByteArray>
namespace rcx {
class McpBridge : public QObject {
Q_OBJECT
public:
explicit McpBridge(MainWindow* mainWindow, QObject* parent = nullptr);
~McpBridge() override;
void start();
void stop();
bool isRunning() const { return m_server != nullptr; }
bool slowMode() const { return m_slowMode; }
void setSlowMode(bool v) { m_slowMode = v; }
// Call from controller refresh / data change to notify MCP clients
void notifyTreeChanged();
void notifyDataChanged();
private:
MainWindow* m_mainWindow;
QLocalServer* m_server = nullptr;
QLocalSocket* m_client = nullptr; // single client for v1
QByteArray m_readBuffer;
bool m_initialized = false;
bool m_slowMode = false;
// JSON-RPC plumbing
void onNewConnection();
void onReadyRead();
void onDisconnected();
void processLine(const QByteArray& line);
void sendJson(const QJsonObject& obj);
QJsonObject okReply(const QJsonValue& id, const QJsonObject& result);
QJsonObject errReply(const QJsonValue& id, int code, const QString& msg);
void sendNotification(const QString& method, const QJsonObject& params = {});
// MCP method handlers
QJsonObject handleInitialize(const QJsonValue& id, const QJsonObject& params);
QJsonObject handleToolsList(const QJsonValue& id);
QJsonObject handleToolsCall(const QJsonValue& id, const QJsonObject& params);
// Tool implementations
QJsonObject toolProjectState(const QJsonObject& args);
QJsonObject toolTreeApply(const QJsonObject& args);
QJsonObject toolSourceSwitch(const QJsonObject& args);
QJsonObject toolHexRead(const QJsonObject& args);
QJsonObject toolHexWrite(const QJsonObject& args);
QJsonObject toolStatusSet(const QJsonObject& args);
QJsonObject toolUiAction(const QJsonObject& args);
// Helpers
QJsonObject makeTextResult(const QString& text, bool isError = false);
QString resolvePlaceholder(const QString& ref,
const QHash<QString, uint64_t>& placeholderMap);
// Smart tab resolution: tabIndex arg → activeTab → first tab → auto-create
MainWindow::TabState* resolveTab(const QJsonObject& args);
};
} // namespace rcx

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

@@ -80,7 +80,7 @@ bool PluginManager::LoadPlugin(const QString& path)
return false;
}
qDebug() << "PluginManager: Loaded plugin:" << plugin->Name() << plugin->Version() << "by" << plugin->Author();
qDebug() << "PluginManager: Loaded plugin:" << plugin->Name().c_str() << plugin->Version().c_str() << "by" << plugin->Author().c_str();
// Store plugin entry
m_entries.append({library, plugin});

View File

@@ -11,6 +11,14 @@
#include <tlhelp32.h>
#include <psapi.h>
#include <shellapi.h>
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
#include <QtWin>
#endif
#elif defined(__linux__)
#include <QDir>
#include <QStyle>
#include <QApplication>
#include <unistd.h>
#endif
ProcessPicker::ProcessPicker(QWidget *parent)
@@ -137,7 +145,11 @@ void ProcessPicker::enumerateProcesses()
SHFILEINFOW sfi = {};
if (SHGetFileInfoW(path, 0, &sfi, sizeof(sfi), SHGFI_ICON | SHGFI_SMALLICON)) {
if (sfi.hIcon) {
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
info.icon = QIcon(QPixmap::fromImage(QImage::fromHICON(sfi.hIcon)));
#else
info.icon = QIcon(QtWin::fromHICON(sfi.hIcon));
#endif
DestroyIcon(sfi.hIcon);
}
}
@@ -155,6 +167,45 @@ void ProcessPicker::enumerateProcesses()
}
CloseHandle(snapshot);
#elif defined(__linux__)
QDir procDir("/proc");
QStringList entries = procDir.entryList(QDir::Dirs | QDir::NoDotAndDotDot);
QIcon defaultIcon = qApp->style()->standardIcon(QStyle::SP_ComputerIcon);
for (const QString& entry : entries) {
bool ok = false;
uint32_t pid = entry.toUInt(&ok);
if (!ok || pid == 0) continue;
// Read process name from /proc/<pid>/comm
QString commPath = QStringLiteral("/proc/%1/comm").arg(pid);
QFile commFile(commPath);
QString procName;
if (commFile.open(QIODevice::ReadOnly)) {
procName = QString::fromUtf8(commFile.readAll()).trimmed();
commFile.close();
}
if (procName.isEmpty()) continue;
// Read exe path from /proc/<pid>/exe symlink
QString exePath = QStringLiteral("/proc/%1/exe").arg(pid);
QFileInfo exeInfo(exePath);
QString resolvedPath;
if (exeInfo.exists())
resolvedPath = exeInfo.symLinkTarget();
// Skip if we can't read the process memory
QString memPath = QStringLiteral("/proc/%1/mem").arg(pid);
if (::access(memPath.toUtf8().constData(), R_OK) != 0)
continue;
ProcessInfo info;
info.pid = pid;
info.name = procName;
info.path = resolvedPath;
info.icon = defaultIcon;
processes.append(info);
}
#else
// Platform not supported
QMessageBox::warning(this, "Error", "Process enumeration not supported on this platform.");

View File

@@ -36,7 +36,7 @@ void ProviderRegistry::unregisterProvider(const QString& identifier) {
for (int i = 0; i < m_providers.size(); ++i) {
if (m_providers[i].identifier == identifier) {
qDebug() << "ProviderRegistry: Unregistered provider:" << identifier;
m_providers.remove(i);
m_providers.removeAt(i);
return;
}
}

View File

@@ -1,6 +1,6 @@
#pragma once
#include "iplugin.h"
#include <QVector>
#include <QList>
#include <QString>
#include <functional>
@@ -45,7 +45,7 @@ public:
void unregisterProvider(const QString& identifier);
// Get all registered providers
const QVector<ProviderInfo>& providers() const { return m_providers; }
const QList<ProviderInfo>& providers() const { return m_providers; }
// Find provider by identifier
const ProviderInfo* findProvider(const QString& identifier) const;
@@ -55,5 +55,5 @@ public:
private:
ProviderRegistry() = default;
QVector<ProviderInfo> m_providers;
QList<ProviderInfo> m_providers;
};

View File

@@ -1,104 +0,0 @@
#pragma once
#include "provider.h"
#ifdef _WIN32
#include <windows.h>
#include <psapi.h>
namespace rcx {
class ProcessProvider : public Provider {
HANDLE m_handle = nullptr;
uint64_t m_base = 0;
int m_size = 0;
QString m_name;
struct ModuleInfo {
QString name;
uint64_t base;
uint64_t size;
};
QVector<ModuleInfo> m_modules;
public:
ProcessProvider(HANDLE proc, uint64_t base, int regionSize, const QString& name)
: m_handle(proc), m_base(base), m_size(regionSize), m_name(name)
{
cacheModules();
}
~ProcessProvider() override {
if (m_handle) CloseHandle(m_handle);
}
ProcessProvider(const ProcessProvider&) = delete;
ProcessProvider& operator=(const ProcessProvider&) = delete;
int size() const override { return m_size; }
bool isReadable(uint64_t, int len) const override { return len >= 0; }
bool read(uint64_t addr, void* buf, int len) const override {
SIZE_T got = 0;
BOOL ok = ReadProcessMemory(m_handle,
(LPCVOID)(m_base + addr), buf, len, &got);
return ok && (int)got == len;
}
bool isWritable() const override { return true; }
bool write(uint64_t addr, const void* buf, int len) override {
SIZE_T got = 0;
BOOL ok = WriteProcessMemory(m_handle,
(LPVOID)(m_base + addr), buf, len, &got);
return ok && (int)got == len;
}
QString name() const override { return m_name; }
QString kind() const override { return QStringLiteral("Process"); }
bool isLive() const override { return true; }
// getSymbol takes an absolute virtual address and resolves it to
// "module.dll+0xOFFSET" using the cached module list.
QString getSymbol(uint64_t absAddr) const override {
for (const auto& mod : m_modules) {
if (absAddr >= mod.base && absAddr < mod.base + mod.size) {
uint64_t offset = absAddr - mod.base;
return QStringLiteral("%1+0x%2")
.arg(mod.name)
.arg(offset, 0, 16, QChar('0'));
}
}
return {};
}
HANDLE handle() const { return m_handle; }
uint64_t baseAddress() const { return m_base; }
void refreshModules() { m_modules.clear(); cacheModules(); }
private:
void cacheModules() {
HMODULE mods[1024];
DWORD needed = 0;
if (!EnumProcessModulesEx(m_handle, mods, sizeof(mods),
&needed, LIST_MODULES_ALL))
return;
int count = qMin((int)(needed / sizeof(HMODULE)), 1024);
m_modules.reserve(count);
for (int i = 0; i < count; ++i) {
MODULEINFO mi{};
WCHAR modName[MAX_PATH];
if (GetModuleInformation(m_handle, mods[i], &mi, sizeof(mi))
&& GetModuleBaseNameW(m_handle, mods[i], modName, MAX_PATH))
{
m_modules.append({
QString::fromWCharArray(modName),
(uint64_t)mi.lpBaseOfDll,
(uint64_t)mi.SizeOfImage
});
}
}
}
};
} // namespace rcx
#endif // _WIN32

View File

@@ -33,9 +33,14 @@ public:
// Examples: "File", "Process", "Socket"
virtual QString kind() const { return QStringLiteral("File"); }
// Initial base address discovered by the provider (e.g. main module base).
// Used by the controller to set tree.baseAddress on first attach.
// For file/buffer providers this is always 0.
virtual uint64_t base() const { return 0; }
// Resolve an absolute address to a symbol name.
// Returns empty string if no symbol is known.
// ProcessProvider: "ntdll.dll+0x1A30"
// Example: "ntdll.dll+0x1A30"
// BufferProvider: "" (no symbols in flat files)
virtual QString getSymbol(uint64_t addr) const {
Q_UNUSED(addr);

View File

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

View File

@@ -2,9 +2,11 @@
<qresource prefix="/icons">
<file alias="chevron-right.png">icons/chevron-right.png</file>
<file alias="chevron-down.png">icons/chevron-down.png</file>
<file alias="class.png">icons/class.png</file>
</qresource>
<qresource prefix="/fonts">
<file alias="Iosevka-Regular.ttf">fonts/Iosevka-Regular.ttf</file>
<file alias="JetBrainsMono.ttf">fonts/JetBrainsMono.ttf</file>
</qresource>
<qresource prefix="/vsicons">
<file alias="file.svg">vsicons/file.svg</file>
@@ -19,6 +21,9 @@
<file alias="arrow-right.svg">vsicons/arrow-right.svg</file>
<file alias="split-horizontal.svg">vsicons/split-horizontal.svg</file>
<file alias="chrome-close.svg">vsicons/chrome-close.svg</file>
<file alias="chrome-minimize.svg">vsicons/chrome-minimize.svg</file>
<file alias="chrome-maximize.svg">vsicons/chrome-maximize.svg</file>
<file alias="chrome-restore.svg">vsicons/chrome-restore.svg</file>
<file alias="text-size.svg">vsicons/text-size.svg</file>
<file alias="add.svg">vsicons/add.svg</file>
<file alias="remove.svg">vsicons/remove.svg</file>
@@ -42,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

@@ -0,0 +1,32 @@
{
"name": "Mid",
"background": "#0D1219",
"backgroundAlt": "#121720",
"surface": "#161C28",
"border": "#1E2636",
"borderFocused": "#485068",
"button": "#181E2C",
"text": "#B0B8CC",
"textDim": "#505C74",
"textMuted": "#384258",
"textFaint": "#2C3448",
"hover": "#121720",
"selected": "#121720",
"selection": "#1A2038",
"syntaxKeyword": "#5688C0",
"syntaxNumber": "#90B480",
"syntaxString": "#B88060",
"syntaxComment": "#385030",
"syntaxPreproc": "#9868A8",
"syntaxType": "#8FDBFE",
"indHoverSpan": "#C09038",
"indCmdPill": "#141A26",
"indDataChanged": "#608C54",
"indHeatCold": "#B09030",
"indHeatWarm": "#C09038",
"indHeatHot": "#C83838",
"indHintGreen": "#385830",
"markerPtr": "#C83838",
"markerCycle": "#B89028",
"markerError": "#481818"
}

View File

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

View File

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

View File

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

67
src/themes/theme.cpp Normal file
View File

@@ -0,0 +1,67 @@
#include "theme.h"
#include <type_traits>
namespace rcx {
// ── Shared field metadata (serialization + editor UI) ──
const ThemeFieldMeta kThemeFields[] = {
{"background", "Background", "Chrome", &Theme::background},
{"backgroundAlt", "Background Alt", "Chrome", &Theme::backgroundAlt},
{"surface", "Surface", "Chrome", &Theme::surface},
{"border", "Border", "Chrome", &Theme::border},
{"borderFocused", "Border Focused", "Chrome", &Theme::borderFocused},
{"button", "Button", "Chrome", &Theme::button},
{"text", "Text", "Text", &Theme::text},
{"textDim", "Text Dim", "Text", &Theme::textDim},
{"textMuted", "Text Muted", "Text", &Theme::textMuted},
{"textFaint", "Text Faint", "Text", &Theme::textFaint},
{"hover", "Hover", "Interactive", &Theme::hover},
{"selected", "Selected", "Interactive", &Theme::selected},
{"selection", "Selection", "Interactive", &Theme::selection},
{"syntaxKeyword", "Keyword", "Syntax", &Theme::syntaxKeyword},
{"syntaxNumber", "Number", "Syntax", &Theme::syntaxNumber},
{"syntaxString", "String", "Syntax", &Theme::syntaxString},
{"syntaxComment", "Comment", "Syntax", &Theme::syntaxComment},
{"syntaxPreproc", "Preprocessor", "Syntax", &Theme::syntaxPreproc},
{"syntaxType", "Type", "Syntax", &Theme::syntaxType},
{"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},
{"markerError", "Error", "Markers", &Theme::markerError},
};
const int kThemeFieldCount = static_cast<int>(std::extent_v<decltype(kThemeFields)>);
QJsonObject Theme::toJson() const {
QJsonObject o;
o["name"] = name;
for (int i = 0; i < kThemeFieldCount; i++)
o[kThemeFields[i].key] = (this->*kThemeFields[i].ptr).name();
return o;
}
Theme Theme::fromJson(const QJsonObject& o) {
Theme t;
t.name = o["name"].toString("Untitled");
for (int i = 0; i < kThemeFieldCount; i++) {
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;
}
} // namespace rcx

68
src/themes/theme.h Normal file
View File

@@ -0,0 +1,68 @@
#pragma once
#include <QColor>
#include <QString>
#include <QJsonObject>
namespace rcx {
struct Theme {
QString name;
// ── Chrome ──
QColor background; // editor bg, margin bg, window
QColor backgroundAlt; // panels, tab selected, tooltips
QColor surface; // alternateBase
QColor border; // separators, menu borders
QColor borderFocused; // window border when focused
QColor button; // button bg
// ── Text ──
QColor text; // primary text, caret, identifiers
QColor textDim; // margin fg, status bar
QColor textMuted; // inactive tab, disabled menu
QColor textFaint; // margin dim, hex dim
// ── Interactive ──
QColor hover; // row hover, tab hover, menu hover
QColor selected; // row selection highlight
QColor selection; // text selection background
// ── Syntax ──
QColor syntaxKeyword;
QColor syntaxNumber;
QColor syntaxString;
QColor syntaxComment;
QColor syntaxPreproc;
QColor syntaxType; // custom types / GlobalClass
// ── Indicators ──
QColor indHoverSpan; // hover link text
QColor indCmdPill; // command row pill bg
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 ──
QColor markerPtr; // null pointer
QColor markerCycle; // cycle detection
QColor markerError; // error row bg
QJsonObject toJson() const;
static Theme fromJson(const QJsonObject& obj);
};
// ── Shared field metadata (serialization + editor UI) ──
struct ThemeFieldMeta {
const char* key; // JSON key
const char* label; // display label
const char* group; // section group name
QColor Theme::*ptr;
};
extern const ThemeFieldMeta kThemeFields[];
extern const int kThemeFieldCount;
} // namespace rcx

196
src/themes/themeeditor.cpp Normal file
View File

@@ -0,0 +1,196 @@
#include "themeeditor.h"
#include "thememanager.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QScrollArea>
#include <QDialogButtonBox>
#include <QColorDialog>
#include <QComboBox>
#include <cstring>
namespace rcx {
// ── Section header label ──
static QLabel* makeSectionLabel(const QString& text) {
auto* lbl = new QLabel(text);
lbl->setStyleSheet(QStringLiteral(
"font-weight: bold; font-size: 11px; color: #888;"
"padding: 6px 0 2px 0; border-bottom: 1px solid #444;"));
return lbl;
}
// ── Constructor ──
ThemeEditor::ThemeEditor(int themeIndex, QWidget* parent)
: QDialog(parent), m_themeIndex(themeIndex)
{
auto& tm = ThemeManager::instance();
auto all = tm.themes();
m_theme = (themeIndex >= 0 && themeIndex < all.size()) ? all[themeIndex] : tm.current();
setWindowTitle(QStringLiteral("Theme Editor"));
setMinimumSize(420, 480);
resize(440, 640);
auto* mainLayout = new QVBoxLayout(this);
mainLayout->setSpacing(6);
// ── Theme selector combo ──
{
auto* row = new QHBoxLayout;
row->addWidget(new QLabel(QStringLiteral("Theme:")));
m_themeCombo = new QComboBox;
for (const auto& t : all)
m_themeCombo->addItem(t.name);
m_themeCombo->setCurrentIndex(themeIndex);
connect(m_themeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, [this](int idx) { loadTheme(idx); });
row->addWidget(m_themeCombo, 1);
mainLayout->addLayout(row);
}
// ── Name field ──
{
auto* row = new QHBoxLayout;
row->addWidget(new QLabel(QStringLiteral("Name:")));
m_nameEdit = new QLineEdit(m_theme.name);
connect(m_nameEdit, &QLineEdit::textChanged, this, [this](const QString& t) {
m_theme.name = t;
});
row->addWidget(m_nameEdit, 1);
mainLayout->addLayout(row);
}
// ── File info ──
m_fileInfoLabel = new QLabel;
m_fileInfoLabel->setStyleSheet(QStringLiteral("color: #666; font-size: 10px; padding: 0 0 4px 0;"));
QString path = tm.themeFilePath(themeIndex);
m_fileInfoLabel->setText(path.isEmpty()
? QStringLiteral("Built-in theme (edits save as user copy)")
: QStringLiteral("File: %1").arg(path));
mainLayout->addWidget(m_fileInfoLabel);
// ── Scrollable area for swatches ──
auto* scroll = new QScrollArea;
scroll->setWidgetResizable(true);
scroll->setFrameShape(QFrame::NoFrame);
auto* scrollWidget = new QWidget;
auto* scrollLayout = new QVBoxLayout(scrollWidget);
scrollLayout->setContentsMargins(0, 0, 6, 0); // right margin for scrollbar
scrollLayout->setSpacing(2);
// ── Color swatches (driven by kThemeFields) ──
const char* currentGroup = nullptr;
for (int fi = 0; fi < kThemeFieldCount; fi++) {
const auto& f = kThemeFields[fi];
// Section header on group change
if (!currentGroup || std::strcmp(currentGroup, f.group) != 0) {
scrollLayout->addWidget(makeSectionLabel(QString::fromLatin1(f.group)));
currentGroup = f.group;
}
int idx = m_swatches.size();
auto* row = new QHBoxLayout;
row->setSpacing(6);
row->setContentsMargins(8, 1, 0, 1);
auto* lbl = new QLabel(QString::fromLatin1(f.label));
lbl->setFixedWidth(120);
row->addWidget(lbl);
auto* swatchBtn = new QPushButton;
swatchBtn->setFixedSize(32, 18);
swatchBtn->setCursor(Qt::PointingHandCursor);
connect(swatchBtn, &QPushButton::clicked, this, [this, idx]() { pickColor(idx); });
row->addWidget(swatchBtn);
auto* hexLbl = new QLabel;
hexLbl->setFixedWidth(60);
hexLbl->setStyleSheet(QStringLiteral("color: #aaa; font-size: 10px;"));
row->addWidget(hexLbl);
row->addStretch();
SwatchEntry se;
se.label = f.label;
se.field = f.ptr;
se.swatchBtn = swatchBtn;
se.hexLabel = hexLbl;
m_swatches.append(se);
scrollLayout->addLayout(row);
}
scrollLayout->addStretch();
scroll->setWidget(scrollWidget);
mainLayout->addWidget(scroll, 1);
// ── Bottom bar ──
auto* bottomRow = new QHBoxLayout;
bottomRow->addStretch();
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, [this]() {
ThemeManager::instance().revertPreview();
reject();
});
bottomRow->addWidget(buttons);
mainLayout->addLayout(bottomRow);
// Initial swatch update + start live preview
for (int i = 0; i < m_swatches.size(); i++)
updateSwatch(i);
tm.previewTheme(m_theme);
}
// ── Load a different theme into the editor ──
void ThemeEditor::loadTheme(int index) {
auto& tm = ThemeManager::instance();
auto all = tm.themes();
if (index < 0 || index >= all.size()) return;
m_themeIndex = index;
m_theme = all[index];
m_nameEdit->setText(m_theme.name);
QString path = tm.themeFilePath(index);
m_fileInfoLabel->setText(path.isEmpty()
? QStringLiteral("Built-in theme (edits save as user copy)")
: QStringLiteral("File: %1").arg(path));
for (int i = 0; i < m_swatches.size(); i++)
updateSwatch(i);
tm.previewTheme(m_theme);
}
// ── Swatch update ──
void ThemeEditor::updateSwatch(int idx) {
auto& s = m_swatches[idx];
QColor c = m_theme.*s.field;
s.swatchBtn->setStyleSheet(QStringLiteral(
"QPushButton { background: %1; border: 1px solid #555; border-radius: 2px; }")
.arg(c.name()));
s.hexLabel->setText(c.name());
}
// ── Color picker ──
void ThemeEditor::pickColor(int idx) {
auto& s = m_swatches[idx];
QColor c = QColorDialog::getColor(m_theme.*s.field, this, QString::fromLatin1(s.label));
if (c.isValid()) {
m_theme.*s.field = c;
updateSwatch(idx);
ThemeManager::instance().previewTheme(m_theme);
}
}
} // namespace rcx

45
src/themes/themeeditor.h Normal file
View File

@@ -0,0 +1,45 @@
#pragma once
#include "theme.h"
#include <QDialog>
#include <QVector>
#include <QPushButton>
#include <QLabel>
#include <QLineEdit>
class QScrollArea;
class QVBoxLayout;
class QComboBox;
namespace rcx {
class ThemeEditor : public QDialog {
Q_OBJECT
public:
explicit ThemeEditor(int themeIndex, QWidget* parent = nullptr);
Theme result() const { return m_theme; }
int selectedIndex() const { return m_themeIndex; }
private:
Theme m_theme;
int m_themeIndex;
// ── Swatch row (compact: label + swatch + hex) ──
struct SwatchEntry {
const char* label;
QColor Theme::*field;
QPushButton* swatchBtn = nullptr;
QLabel* hexLabel = nullptr;
};
QVector<SwatchEntry> m_swatches;
// ── UI ──
QComboBox* m_themeCombo = nullptr;
QLineEdit* m_nameEdit = nullptr;
QLabel* m_fileInfoLabel = nullptr;
void loadTheme(int index);
void updateSwatch(int idx);
void pickColor(int idx);
};
} // namespace rcx

209
src/themes/thememanager.cpp Normal file
View File

@@ -0,0 +1,209 @@
#include "thememanager.h"
#include <QSettings>
#include <QDir>
#include <QFile>
#include <QJsonDocument>
#include <QStandardPaths>
#include <QCoreApplication>
namespace rcx {
ThemeManager& ThemeManager::instance() {
static ThemeManager s;
return s;
}
ThemeManager::ThemeManager() {
loadBuiltInThemes();
loadUserThemes();
QSettings settings("Reclass", "Reclass");
QString fallback;
for (const auto& t : m_builtIn) {
if (t.name.contains("VS2022", Qt::CaseInsensitive)) { fallback = t.name; break; }
}
if (fallback.isEmpty() && !m_builtIn.isEmpty()) fallback = m_builtIn[0].name;
QString saved = settings.value("theme", fallback).toString();
auto all = themes();
for (int i = 0; i < all.size(); i++) {
if (all[i].name == saved) { m_currentIdx = i; break; }
}
}
// ── Load built-in themes from JSON files next to the executable ──
QString ThemeManager::builtInDir() const {
return QCoreApplication::applicationDirPath() + "/themes";
}
void ThemeManager::loadBuiltInThemes() {
m_builtIn.clear();
QDir dir(builtInDir());
if (!dir.exists()) return;
for (const QString& name : dir.entryList({"*.json"}, QDir::Files, QDir::Name)) {
QFile f(dir.filePath(name));
if (!f.open(QIODevice::ReadOnly)) continue;
QJsonDocument jdoc = QJsonDocument::fromJson(f.readAll());
if (jdoc.isObject())
m_builtIn.append(Theme::fromJson(jdoc.object()));
}
m_builtInDefaults = m_builtIn;
}
// ── themes / current ──
QVector<Theme> ThemeManager::themes() const {
QVector<Theme> all = m_builtIn;
all.append(m_user);
return all;
}
const Theme& ThemeManager::current() const {
if (m_currentIdx < m_builtIn.size())
return m_builtIn[m_currentIdx];
int userIdx = m_currentIdx - m_builtIn.size();
if (userIdx >= 0 && userIdx < m_user.size())
return m_user[userIdx];
if (!m_builtIn.isEmpty())
return m_builtIn[0];
static const Theme empty;
return empty;
}
void ThemeManager::setCurrent(int index) {
auto all = themes();
if (index < 0 || index >= all.size()) return;
m_currentIdx = index;
QSettings settings("Reclass", "Reclass");
settings.setValue("theme", all[index].name);
emit themeChanged(current());
}
void ThemeManager::addTheme(const Theme& theme) {
m_user.append(theme);
saveUserThemes();
}
void ThemeManager::updateTheme(int index, const Theme& theme) {
m_previewing = false; // commit any active preview
if (index < builtInCount()) {
m_builtIn[index] = theme;
m_currentIdx = index;
} else {
int ui = index - builtInCount();
if (ui >= 0 && ui < m_user.size())
m_user[ui] = theme;
}
saveUserThemes();
QSettings settings("Reclass", "Reclass");
settings.setValue("theme", current().name);
emit themeChanged(current());
}
void ThemeManager::removeTheme(int index) {
if (index < builtInCount()) return;
int ui = index - builtInCount();
if (ui < 0 || ui >= m_user.size()) return;
m_user.remove(ui);
if (m_currentIdx == index) {
m_currentIdx = 0;
emit themeChanged(current());
} else if (m_currentIdx > index) {
m_currentIdx--;
}
saveUserThemes();
}
// ── User theme persistence ──
QString ThemeManager::userDir() const {
QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation)
+ "/themes";
QDir().mkpath(dir);
return dir;
}
void ThemeManager::loadUserThemes() {
m_user.clear();
QDir dir(userDir());
for (const QString& name : dir.entryList({"*.json"}, QDir::Files)) {
QFile f(dir.filePath(name));
if (!f.open(QIODevice::ReadOnly)) continue;
QJsonDocument jdoc = QJsonDocument::fromJson(f.readAll());
if (!jdoc.isObject()) continue;
Theme t = Theme::fromJson(jdoc.object());
// If this overrides a built-in (same name), replace it in-place
bool isOverride = false;
for (int i = 0; i < m_builtIn.size(); i++) {
if (m_builtIn[i].name == t.name) {
m_builtIn[i] = t;
isOverride = true;
break;
}
}
if (!isOverride)
m_user.append(t);
}
}
void ThemeManager::saveUserThemes() const {
QString dir = userDir();
QDir d(dir);
for (const QString& name : d.entryList({"*.json"}, QDir::Files))
d.remove(name);
// Save modified built-ins (compare against on-disk originals)
for (int i = 0; i < m_builtIn.size() && i < m_builtInDefaults.size(); i++) {
if (m_builtIn[i].toJson() != m_builtInDefaults[i].toJson()) {
QString filename = m_builtIn[i].name.toLower().replace(' ', '_') + ".json";
QFile f(dir + "/" + filename);
if (f.open(QIODevice::WriteOnly))
f.write(QJsonDocument(m_builtIn[i].toJson()).toJson(QJsonDocument::Indented));
}
}
// Save user themes
for (int i = 0; i < m_user.size(); i++) {
QString filename = m_user[i].name.toLower().replace(' ', '_') + ".json";
QFile f(dir + "/" + filename);
if (f.open(QIODevice::WriteOnly))
f.write(QJsonDocument(m_user[i].toJson()).toJson(QJsonDocument::Indented));
}
}
QString ThemeManager::themeFilePath(int index) const {
if (index < builtInCount()) {
// Built-in has a user override file only if modified
if (index < m_builtInDefaults.size()
&& m_builtIn[index].toJson() != m_builtInDefaults[index].toJson()) {
QString filename = m_builtIn[index].name.toLower().replace(' ', '_') + ".json";
return userDir() + "/" + filename;
}
// Show the built-in source file
QString filename = m_builtIn[index].name.toLower().replace(' ', '_') + ".json";
return builtInDir() + "/" + filename;
}
int ui = index - builtInCount();
if (ui < 0 || ui >= m_user.size()) return {};
QString filename = m_user[ui].name.toLower().replace(' ', '_') + ".json";
return userDir() + "/" + filename;
}
void ThemeManager::previewTheme(const Theme& theme) {
if (!m_previewing) {
m_savedTheme = current();
m_previewing = true;
}
emit themeChanged(theme);
}
void ThemeManager::revertPreview() {
if (m_previewing) {
m_previewing = false;
emit themeChanged(m_savedTheme);
}
}
} // namespace rcx

47
src/themes/thememanager.h Normal file
View File

@@ -0,0 +1,47 @@
#pragma once
#include "theme.h"
#include <QObject>
#include <QVector>
namespace rcx {
class ThemeManager : public QObject {
Q_OBJECT
public:
static ThemeManager& instance();
QVector<Theme> themes() const;
int currentIndex() const { return m_currentIdx; }
const Theme& current() const;
void setCurrent(int index);
void addTheme(const Theme& theme);
void updateTheme(int index, const Theme& theme);
void removeTheme(int index);
void loadUserThemes();
void saveUserThemes() const;
QString themeFilePath(int index) const;
void previewTheme(const Theme& theme);
void revertPreview();
signals:
void themeChanged(const rcx::Theme& theme);
private:
ThemeManager();
QVector<Theme> m_builtIn; // built-in themes (possibly overridden)
QVector<Theme> m_builtInDefaults; // originals loaded from disk
QVector<Theme> m_user;
int m_currentIdx = 0;
int builtInCount() const { return m_builtIn.size(); }
void loadBuiltInThemes();
QString builtInDir() const;
QString userDir() const;
bool m_previewing = false;
Theme m_savedTheme;
};
} // namespace rcx

186
src/titlebar.cpp Normal file
View File

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

43
src/titlebar.h Normal file
View File

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

838
src/typeselectorpopup.cpp Normal file
View File

@@ -0,0 +1,838 @@
#include "typeselectorpopup.h"
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit>
#include <QListView>
#include <QToolButton>
#include <QButtonGroup>
#include <QStringListModel>
#include <QStyledItemDelegate>
#include <QPainter>
#include <QKeyEvent>
#include <QMouseEvent>
#include <QIcon>
#include <QApplication>
#include <QScreen>
#include <QIntValidator>
#include <QElapsedTimer>
#include "themes/thememanager.h"
namespace rcx {
// ── parseTypeSpec ──
TypeSpec parseTypeSpec(const QString& text) {
TypeSpec spec;
QString s = text.trimmed();
if (s.isEmpty()) return spec;
// Check for pointer suffix: "Ball*" or "Ball**"
if (s.endsWith('*')) {
spec.isPointer = true;
s.chop(1);
spec.ptrDepth = 1;
if (s.endsWith('*')) { s.chop(1); spec.ptrDepth = 2; }
spec.baseName = s.trimmed();
return spec;
}
// Check for array suffix: "int32_t[10]"
int bracket = s.indexOf('[');
if (bracket > 0 && s.endsWith(']')) {
spec.baseName = s.left(bracket).trimmed();
QString countStr = s.mid(bracket + 1, s.size() - bracket - 2);
bool ok;
int count = countStr.toInt(&ok);
if (ok && count > 0)
spec.arrayCount = count;
return spec;
}
spec.baseName = s;
return spec;
}
// ── Custom delegate: gutter checkmark + icon + text + sections ──
class TypeSelectorDelegate : public QStyledItemDelegate {
public:
explicit TypeSelectorDelegate(TypeSelectorPopup* popup, QObject* parent = nullptr)
: QStyledItemDelegate(parent), m_popup(popup) {}
void setFont(const QFont& f) { m_font = f; }
void setFilteredTypes(const QVector<TypeEntry>* filtered, const TypeEntry* current, bool hasCurrent) {
m_filtered = filtered;
m_current = current;
m_hasCurrent = hasCurrent;
}
void paint(QPainter* painter, const QStyleOptionViewItem& option,
const QModelIndex& index) const override {
painter->save();
const auto& t = ThemeManager::instance().current();
int row = index.row();
bool isSection = (m_filtered && row >= 0 && row < m_filtered->size()
&& (*m_filtered)[row].entryKind == TypeEntry::Section);
bool isDisabled = (m_filtered && row >= 0 && row < m_filtered->size()
&& !(*m_filtered)[row].enabled);
// Background
if (isSection) {
// No background highlight for sections
} else if (isDisabled) {
// Subtle background on hover only
if (option.state & QStyle::State_MouseOver)
painter->fillRect(option.rect, t.surface);
} else {
if (option.state & QStyle::State_Selected)
painter->fillRect(option.rect, t.selected);
else if (option.state & QStyle::State_MouseOver)
painter->fillRect(option.rect, t.hover);
}
int x = option.rect.x();
int y = option.rect.y();
int h = option.rect.height();
int w = option.rect.width();
// Scale metrics from font height
QFontMetrics fmMain(m_font);
int iconSz = fmMain.height(); // icon matches text height
int gutterW = fmMain.horizontalAdvance(QChar(0x25B8)) + 4;
int iconColW = iconSz + 4;
// Section: centered dim text with horizontal rules
if (isSection) {
painter->setPen(t.textDim);
QFont dimFont = m_font;
dimFont.setPointSize(qMax(7, m_font.pointSize() - 1));
painter->setFont(dimFont);
QFontMetrics fm(dimFont);
QString text = index.data().toString();
int textW = fm.horizontalAdvance(text);
int textX = x + (w - textW) / 2;
int lineY = y + h / 2;
// Left rule
if (textX > x + 8)
painter->drawLine(x + 8, lineY, textX - 6, lineY);
// Text
painter->drawText(QRect(textX, y, textW, h), Qt::AlignVCenter, text);
// Right rule
if (textX + textW + 6 < x + w - 8)
painter->drawLine(textX + textW + 6, lineY, x + w - 8, lineY);
painter->restore();
return;
}
// 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;
if (m_current->entryKind == TypeEntry::Primitive && entry.entryKind == TypeEntry::Primitive)
isCurrent = (entry.primitiveKind == m_current->primitiveKind);
else if (m_current->entryKind == TypeEntry::Composite && entry.entryKind == TypeEntry::Composite)
isCurrent = (entry.structId == m_current->structId);
if (isCurrent) {
painter->setPen(t.text);
painter->setFont(m_font);
painter->drawText(QRect(x, y, gutterW, h), Qt::AlignCenter,
QString(QChar(0x25B8)));
}
}
x += gutterW;
// Icon (scaled to font height) — only for composite entries
bool hasIcon = (m_filtered && row >= 0 && row < m_filtered->size()
&& (*m_filtered)[row].entryKind == TypeEntry::Composite);
if (hasIcon) {
static QIcon structIcon(QStringLiteral(":/vsicons/symbol-structure.svg"));
QPixmap pm = structIcon.pixmap(iconSz, iconSz);
if (isDisabled) {
// Paint dimmed
QPixmap dimmed(pm.size());
dimmed.fill(Qt::transparent);
QPainter p(&dimmed);
p.setOpacity(0.35);
p.drawPixmap(0, 0, pm);
p.end();
painter->drawPixmap(x, y + (h - iconSz) / 2, dimmed);
} else {
structIcon.paint(painter, x, y + (h - iconSz) / 2, iconSz, iconSz);
}
}
x += iconColW;
// Text
QColor textColor;
if (isDisabled)
textColor = t.textDim;
else if (option.state & QStyle::State_Selected)
textColor = option.palette.color(QPalette::HighlightedText);
else
textColor = option.palette.color(QPalette::Text);
painter->setPen(textColor);
painter->setFont(m_font);
painter->drawText(QRect(x, y, option.rect.right() - x, h),
Qt::AlignVCenter | Qt::AlignLeft,
index.data().toString());
painter->restore();
}
QSize sizeHint(const QStyleOptionViewItem& /*option*/,
const QModelIndex& index) const override {
QFontMetrics fm(m_font);
int row = index.row();
bool isSection = (m_filtered && row >= 0 && row < m_filtered->size()
&& (*m_filtered)[row].entryKind == TypeEntry::Section);
int h = isSection ? fm.height() + 2 : fm.height() + 8;
return QSize(200, h);
}
private:
TypeSelectorPopup* m_popup = nullptr;
QFont m_font;
const QVector<TypeEntry>* m_filtered = nullptr;
const TypeEntry* m_current = nullptr;
bool m_hasCurrent = false;
};
// ── TypeSelectorPopup ──
TypeSelectorPopup::TypeSelectorPopup(QWidget* parent)
: QFrame(parent, Qt::Popup | Qt::FramelessWindowHint)
{
setAttribute(Qt::WA_DeleteOnClose, false);
const auto& theme = ThemeManager::instance().current();
QPalette pal;
pal.setColor(QPalette::Window, theme.backgroundAlt);
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.hover);
pal.setColor(QPalette::HighlightedText, theme.text);
setPalette(pal);
setAutoFillBackground(true);
setFrameShape(QFrame::NoFrame);
setLineWidth(0);
auto* layout = new QVBoxLayout(this);
layout->setContentsMargins(6, 6, 6, 6);
layout->setSpacing(4);
// Row 1: title + Esc hint
{
auto* row = new QHBoxLayout;
row->setContentsMargins(0, 0, 0, 0);
m_titleLabel = new QLabel(QStringLiteral("Change type"));
m_titleLabel->setPalette(pal);
QFont bold = m_titleLabel->font();
bold.setBold(true);
m_titleLabel->setFont(bold);
row->addWidget(m_titleLabel);
row->addStretch();
m_escLabel = new QToolButton;
m_escLabel->setText(QStringLiteral("\u2715 Esc"));
m_escLabel->setAutoRaise(true);
m_escLabel->setCursor(Qt::PointingHandCursor);
m_escLabel->setStyleSheet(QStringLiteral(
"QToolButton { color: %1; border: none; padding: 2px 6px; }"
"QToolButton:hover { color: %2; }")
.arg(theme.textDim.name(), theme.indHoverSpan.name()));
connect(m_escLabel, &QToolButton::clicked, this, [this]() {
hide();
});
row->addWidget(m_escLabel);
layout->addLayout(row);
}
// Row 2: + Create new type button (flat, no gradient)
{
m_createBtn = new QToolButton;
m_createBtn->setText(QStringLiteral("+ Create new type\u2026"));
m_createBtn->setToolButtonStyle(Qt::ToolButtonTextOnly);
m_createBtn->setAutoRaise(true);
m_createBtn->setCursor(Qt::PointingHandCursor);
m_createBtn->setStyleSheet(QStringLiteral(
"QToolButton { color: %1; border: none; padding: 3px 6px; }"
"QToolButton:hover { color: %2; background: %3; }")
.arg(theme.textMuted.name(), theme.text.name(), theme.hover.name()));
connect(m_createBtn, &QToolButton::clicked, this, [this]() {
emit createNewTypeRequested();
hide();
});
layout->addWidget(m_createBtn);
}
// Separator
{
m_separator = new QFrame;
m_separator->setFrameShape(QFrame::HLine);
m_separator->setFrameShadow(QFrame::Plain);
QPalette sepPal = pal;
sepPal.setColor(QPalette::WindowText, theme.border);
m_separator->setPalette(sepPal);
m_separator->setFixedHeight(1);
layout->addWidget(m_separator);
}
// Row 3: Modifier toggles [ plain ] [ * ] [ ** ] [ [n] ]
{
m_modRow = new QWidget;
auto* modLayout = new QHBoxLayout(m_modRow);
modLayout->setContentsMargins(0, 0, 0, 0);
modLayout->setSpacing(3);
m_modGroup = new QButtonGroup(this);
m_modGroup->setExclusive(true);
QString btnStyle = QStringLiteral(
"QToolButton { color: %1; background: %2; border: 1px solid %3;"
" padding: 2px 8px; border-radius: 3px; }"
"QToolButton:checked { color: %4; background: %5; border-color: %5; }"
"QToolButton:hover:!checked { background: %6; }")
.arg(theme.textDim.name(), theme.background.name(), theme.border.name(),
theme.text.name(), theme.selected.name(), theme.hover.name());
auto makeToggle = [&](const QString& label, int id) -> QToolButton* {
auto* btn = new QToolButton;
btn->setText(label);
btn->setCheckable(true);
btn->setCursor(Qt::PointingHandCursor);
btn->setStyleSheet(btnStyle);
m_modGroup->addButton(btn, id);
modLayout->addWidget(btn);
return btn;
};
m_btnPlain = makeToggle(QStringLiteral("plain"), 0);
m_btnPtr = makeToggle(QStringLiteral("*"), 1);
m_btnDblPtr = makeToggle(QStringLiteral("**"), 2);
m_btnArray = makeToggle(QStringLiteral("[n]"), 3);
m_btnPlain->setChecked(true);
// Array count input (shown only when [n] is active)
m_arrayCountEdit = new QLineEdit;
m_arrayCountEdit->setPlaceholderText(QStringLiteral("n"));
m_arrayCountEdit->setValidator(new QIntValidator(1, 99999, m_arrayCountEdit));
m_arrayCountEdit->setFixedWidth(50);
m_arrayCountEdit->setPalette(pal);
m_arrayCountEdit->hide();
modLayout->addWidget(m_arrayCountEdit);
modLayout->addStretch();
layout->addWidget(m_modRow);
connect(m_modGroup, &QButtonGroup::idToggled,
this, [this](int id, bool checked) {
if (!checked) return;
m_arrayCountEdit->setVisible(id == 3);
if (id == 3) {
if (m_arrayCountEdit->text().trimmed().isEmpty())
m_arrayCountEdit->setText(QStringLiteral("1"));
m_arrayCountEdit->setFocus();
m_arrayCountEdit->selectAll();
}
updateModifierPreview();
});
connect(m_arrayCountEdit, &QLineEdit::textChanged,
this, [this]() { updateModifierPreview(); });
}
// Row 4: Filter + preview
{
m_filterEdit = new QLineEdit;
m_filterEdit->setPlaceholderText(QStringLiteral("Filter types\u2026"));
m_filterEdit->setClearButtonEnabled(true);
m_filterEdit->setPalette(pal);
m_filterEdit->installEventFilter(this);
connect(m_filterEdit, &QLineEdit::textChanged,
this, &TypeSelectorPopup::applyFilter);
layout->addWidget(m_filterEdit);
m_previewLabel = new QLabel;
m_previewLabel->setPalette(pal);
m_previewLabel->setStyleSheet(QStringLiteral(
"QLabel { color: %1; padding: 1px 6px; }").arg(theme.syntaxType.name()));
m_previewLabel->hide();
layout->addWidget(m_previewLabel);
}
// Row 4: List
{
m_model = new QStringListModel(this);
m_listView = new QListView;
m_listView->setModel(m_model);
m_listView->setPalette(pal);
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);
auto* delegate = new TypeSelectorDelegate(this, m_listView);
m_listView->setItemDelegate(delegate);
layout->addWidget(m_listView, 1);
connect(m_listView, &QListView::clicked,
this, [this](const QModelIndex& index) {
acceptIndex(index.row());
});
}
}
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 = QStringLiteral("warmup");
setTypes({dummy});
popup(QPoint(-9999, -9999));
hide();
QApplication::processEvents();
}
void TypeSelectorPopup::setFont(const QFont& font) {
m_font = font;
m_titleLabel->setFont([&]() {
QFont f = font; f.setBold(true); return f;
}());
m_escLabel->setFont(font);
m_createBtn->setFont(font);
m_filterEdit->setFont(font);
m_listView->setFont(font);
m_previewLabel->setFont(font);
QFont smallFont = font;
smallFont.setPointSize(qMax(7, font.pointSize() - 1));
m_btnPlain->setFont(smallFont);
m_btnPtr->setFont(smallFont);
m_btnDblPtr->setFont(smallFont);
m_btnArray->setFont(smallFont);
m_arrayCountEdit->setFont(smallFont);
auto* delegate = static_cast<TypeSelectorDelegate*>(m_listView->itemDelegate());
if (delegate)
delegate->setFont(font);
}
void TypeSelectorPopup::applyTheme(const Theme& theme) {
QPalette pal;
pal.setColor(QPalette::Window, theme.backgroundAlt);
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.hover);
pal.setColor(QPalette::HighlightedText, theme.text);
setPalette(pal);
m_titleLabel->setPalette(pal);
m_filterEdit->setPalette(pal);
m_listView->setPalette(pal);
m_previewLabel->setPalette(pal);
m_arrayCountEdit->setPalette(pal);
// Separator
QPalette sepPal = pal;
sepPal.setColor(QPalette::WindowText, theme.border);
m_separator->setPalette(sepPal);
// Esc button
m_escLabel->setStyleSheet(QStringLiteral(
"QToolButton { color: %1; border: none; padding: 2px 6px; }"
"QToolButton:hover { color: %2; }")
.arg(theme.textDim.name(), theme.indHoverSpan.name()));
// Create button
m_createBtn->setStyleSheet(QStringLiteral(
"QToolButton { color: %1; border: none; padding: 3px 6px; }"
"QToolButton:hover { color: %2; background: %3; }")
.arg(theme.textMuted.name(), theme.text.name(), theme.hover.name()));
// Modifier toggle buttons
QString btnStyle = QStringLiteral(
"QToolButton { color: %1; background: %2; border: 1px solid %3;"
" padding: 2px 8px; border-radius: 3px; }"
"QToolButton:checked { color: %4; background: %5; border-color: %5; }"
"QToolButton:hover:!checked { background: %6; }")
.arg(theme.textDim.name(), theme.background.name(), theme.border.name(),
theme.text.name(), theme.selected.name(), theme.hover.name());
m_btnPlain->setStyleSheet(btnStyle);
m_btnPtr->setStyleSheet(btnStyle);
m_btnDblPtr->setStyleSheet(btnStyle);
m_btnArray->setStyleSheet(btnStyle);
// Preview label
m_previewLabel->setStyleSheet(QStringLiteral(
"QLabel { color: %1; padding: 1px 6px; }").arg(theme.syntaxType.name()));
}
void TypeSelectorPopup::setTitle(const QString& title) {
m_titleLabel->setText(title);
}
void TypeSelectorPopup::setMode(TypePopupMode mode) {
m_mode = mode;
bool showMods = (mode == TypePopupMode::FieldType
|| mode == TypePopupMode::ArrayElement);
m_modRow->setVisible(showMods);
// Always reset to plain — prevents stale state from leaking across modes
// (PointerTarget hides buttons but applyFilter still reads their state)
m_btnPlain->setChecked(true);
m_arrayCountEdit->clear();
m_arrayCountEdit->hide();
}
void TypeSelectorPopup::setCurrentNodeSize(int bytes) {
m_currentNodeSize = bytes;
}
void TypeSelectorPopup::setModifier(int modId, int arrayCount) {
if (modId == 1) m_btnPtr->setChecked(true);
else if (modId == 2) m_btnDblPtr->setChecked(true);
else if (modId == 3) {
m_btnArray->setChecked(true);
m_arrayCountEdit->setText(QString::number(arrayCount));
m_arrayCountEdit->show();
} else {
m_btnPlain->setChecked(true);
}
}
void TypeSelectorPopup::setTypes(const QVector<TypeEntry>& types, const TypeEntry* current) {
m_allTypes = types;
if (current) {
m_currentEntry = *current;
m_hasCurrent = true;
} else {
m_currentEntry = TypeEntry{};
m_hasCurrent = false;
}
// Don't reset modifier buttons here — setMode() already resets to plain,
// and setModifier() may have preselected a button between setMode/setTypes.
m_previewLabel->hide();
m_filterEdit->clear();
applyFilter(QString());
}
void TypeSelectorPopup::popup(const QPoint& globalPos) {
QFontMetrics fm(m_font);
int maxTextW = fm.horizontalAdvance(QStringLiteral("Choose element type Esc"));
for (const auto& t : m_allTypes) {
QString text = t.classKeyword.isEmpty()
? t.displayName
: (t.classKeyword + QStringLiteral(" ") + t.displayName);
int gutterW = fm.horizontalAdvance(QChar(0x25B8)) + 4;
int iconColW = fm.height() + 4;
int w = gutterW + iconColW + fm.horizontalAdvance(text) + 16;
if (w > maxTextW) maxTextW = w;
}
int popupW = qBound(280, maxTextW + 24, 500);
int rowH = fm.height() + 8;
int headerH = rowH * 3 + 30;
if (m_modRow->isVisible())
headerH += rowH + 4; // extra row for modifier toggles
int listH = qBound(rowH * 3, rowH * (int)m_filteredTypes.size(), rowH * 14);
int popupH = headerH + listH;
QScreen* screen = QApplication::screenAt(globalPos);
if (screen) {
QRect avail = screen->availableGeometry();
if (globalPos.y() + popupH > avail.bottom())
popupH = avail.bottom() - globalPos.y();
if (globalPos.x() + popupW > avail.right())
popupW = avail.right() - globalPos.x();
}
setFixedSize(popupW, popupH);
move(globalPos);
show();
raise();
activateWindow();
m_filterEdit->setFocus();
// Pre-select current type in list
if (m_hasCurrent) {
for (int i = 0; i < m_filteredTypes.size(); i++) {
const auto& entry = m_filteredTypes[i];
if (entry.entryKind == TypeEntry::Section) continue;
bool match = false;
if (m_currentEntry.entryKind == TypeEntry::Primitive && entry.entryKind == TypeEntry::Primitive)
match = (entry.primitiveKind == m_currentEntry.primitiveKind);
else if (m_currentEntry.entryKind == TypeEntry::Composite && entry.entryKind == TypeEntry::Composite)
match = (entry.structId == m_currentEntry.structId);
if (match) {
m_listView->setCurrentIndex(m_model->index(i));
break;
}
}
}
}
void TypeSelectorPopup::updateModifierPreview() {
int modId = m_modGroup->checkedId();
if (modId <= 0) {
m_previewLabel->hide();
return;
}
QString suffix;
if (modId == 1) suffix = QStringLiteral("*");
else if (modId == 2) suffix = QStringLiteral("**");
else if (modId == 3) {
QString countText = m_arrayCountEdit->text().trimmed();
suffix = countText.isEmpty()
? QStringLiteral("[n]")
: QStringLiteral("[%1]").arg(countText);
}
m_previewLabel->setText(QStringLiteral("\u2192 <type>%1").arg(suffix));
m_previewLabel->show();
}
void TypeSelectorPopup::applyFilter(const QString& text) {
m_filteredTypes.clear();
QStringList displayStrings;
QString filterBase = text.trimmed();
// Separate primitives and composites (all types shown regardless of modifier)
QVector<TypeEntry> primitives, composites;
for (const auto& t : m_allTypes) {
if (t.entryKind == TypeEntry::Section) continue;
bool matchesFilter = filterBase.isEmpty()
|| t.displayName.contains(filterBase, Qt::CaseInsensitive)
|| t.classKeyword.contains(filterBase, Qt::CaseInsensitive);
if (!matchesFilter) continue;
if (t.entryKind == TypeEntry::Primitive)
primitives.append(t);
else if (t.entryKind == TypeEntry::Composite)
composites.append(t);
}
auto alphabetical = [](const TypeEntry& a, const TypeEntry& b) {
return a.displayName.compare(b.displayName, Qt::CaseInsensitive) < 0;
};
// For non-Root modes, sort primitives: same-size first, then rest — alphabetical within each group
if (m_mode != TypePopupMode::Root && m_currentNodeSize > 0 && !primitives.isEmpty()) {
QVector<TypeEntry> sameSize, other;
for (const auto& p : primitives) {
if (sizeForKind(p.primitiveKind) == m_currentNodeSize)
sameSize.append(p);
else
other.append(p);
}
std::sort(sameSize.begin(), sameSize.end(), alphabetical);
std::sort(other.begin(), other.end(), alphabetical);
primitives = sameSize + other;
} else {
std::sort(primitives.begin(), primitives.end(), alphabetical);
}
// Helper lambdas for appending sections
auto appendPrimitives = [&]() {
if (primitives.isEmpty()) return;
TypeEntry sec;
sec.entryKind = TypeEntry::Section;
sec.displayName = QStringLiteral("primitives");
sec.enabled = false;
m_filteredTypes.append(sec);
displayStrings << sec.displayName;
for (const auto& p : primitives) {
m_filteredTypes.append(p);
displayStrings << p.displayName;
}
};
auto appendComposites = [&]() {
if (composites.isEmpty()) return;
TypeEntry sec;
sec.entryKind = TypeEntry::Section;
sec.displayName = QStringLiteral("project types");
sec.enabled = false;
m_filteredTypes.append(sec);
displayStrings << sec.displayName;
for (const auto& c : composites) {
m_filteredTypes.append(c);
QString label = c.classKeyword.isEmpty()
? c.displayName
: (c.classKeyword + QStringLiteral(" ") + c.displayName);
displayStrings << label;
}
};
// Root mode: project types first (composites are the primary selection)
if (m_mode == TypePopupMode::Root) {
appendComposites();
appendPrimitives();
} else {
appendPrimitives();
appendComposites();
}
m_model->setStringList(displayStrings);
auto* delegate = static_cast<TypeSelectorDelegate*>(m_listView->itemDelegate());
if (delegate)
delegate->setFilteredTypes(&m_filteredTypes, &m_currentEntry, m_hasCurrent);
// Select first selectable item
int first = nextSelectableRow(0, 1);
if (first >= 0)
m_listView->setCurrentIndex(m_model->index(first));
}
void TypeSelectorPopup::acceptCurrent() {
QModelIndex idx = m_listView->currentIndex();
if (idx.isValid())
acceptIndex(idx.row());
}
void TypeSelectorPopup::acceptIndex(int row) {
if (row < 0 || row >= m_filteredTypes.size()) return;
const TypeEntry& entry = m_filteredTypes[row];
if (entry.entryKind == TypeEntry::Section) return;
if (!entry.enabled) return;
// Build full text with modifier from toggle buttons
int modId = m_modGroup->checkedId();
QString fullText = entry.displayName;
if (modId == 1)
fullText += QStringLiteral("*");
else if (modId == 2)
fullText += QStringLiteral("**");
else if (modId == 3) {
QString countText = m_arrayCountEdit->text().trimmed();
if (!countText.isEmpty())
fullText += QStringLiteral("[%1]").arg(countText);
}
emit typeSelected(entry, fullText);
hide();
}
int TypeSelectorPopup::nextSelectableRow(int from, int direction) const {
int i = from;
while (i >= 0 && i < m_filteredTypes.size()) {
const auto& e = m_filteredTypes[i];
if (e.entryKind != TypeEntry::Section && e.enabled)
return i;
i += direction;
}
return -1;
}
bool TypeSelectorPopup::eventFilter(QObject* obj, QEvent* event) {
if (event->type() == QEvent::KeyPress) {
auto* ke = static_cast<QKeyEvent*>(event);
if (ke->key() == Qt::Key_Escape) {
hide();
return true;
}
if (obj == m_filterEdit) {
if (ke->key() == Qt::Key_Down) {
m_listView->setFocus();
QModelIndex cur = m_listView->currentIndex();
int startRow = cur.isValid() ? cur.row() : 0;
int next = nextSelectableRow(startRow, 1);
if (next >= 0)
m_listView->setCurrentIndex(m_model->index(next));
return true;
}
if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) {
acceptCurrent();
return true;
}
}
if (obj == m_listView) {
if (ke->key() == Qt::Key_Up) {
QModelIndex cur = m_listView->currentIndex();
if (!cur.isValid() || cur.row() == 0) {
m_filterEdit->setFocus();
return true;
}
// Skip sections and disabled entries
int prev = nextSelectableRow(cur.row() - 1, -1);
if (prev < 0) {
m_filterEdit->setFocus();
return true;
}
m_listView->setCurrentIndex(m_model->index(prev));
return true;
}
if (ke->key() == Qt::Key_Down) {
QModelIndex cur = m_listView->currentIndex();
int startRow = cur.isValid() ? cur.row() + 1 : 0;
int next = nextSelectableRow(startRow, 1);
if (next >= 0)
m_listView->setCurrentIndex(m_model->index(next));
return true;
}
if (ke->key() == Qt::Key_Return || ke->key() == Qt::Key_Enter) {
acceptCurrent();
return true;
}
// Forward printable keys to filter edit for type-to-filter
if (!ke->text().isEmpty() && ke->text()[0].isPrint()) {
m_filterEdit->setFocus();
m_filterEdit->setText(m_filterEdit->text() + ke->text());
return true;
}
}
}
return QFrame::eventFilter(obj, event);
}
void TypeSelectorPopup::hideEvent(QHideEvent* event) {
QFrame::hideEvent(event);
emit dismissed();
}
} // namespace rcx

111
src/typeselectorpopup.h Normal file
View File

@@ -0,0 +1,111 @@
#pragma once
#include <QFrame>
#include <QFont>
#include <QVector>
#include <QString>
#include <cstdint>
#include "core.h"
class QLineEdit;
class QListView;
class QStringListModel;
class QLabel;
class QToolButton;
class QButtonGroup;
class QWidget;
namespace rcx {
struct Theme;
// ── Popup mode ──
enum class TypePopupMode { Root, FieldType, ArrayElement, PointerTarget };
// ── Type entry (explicit discriminant — no sentinel IDs) ──
struct TypeEntry {
enum Kind { Primitive, Composite, Section };
Kind entryKind = Primitive;
NodeKind primitiveKind = NodeKind::Hex8; // valid when entryKind==Primitive
uint64_t structId = 0; // valid when entryKind==Composite
QString displayName;
QString classKeyword; // "struct", "class", "enum" (Composite only)
bool enabled = true; // false = grayed out (visible but not selectable)
};
// ── Parsed type spec (shared between popup filter and inline edit) ──
struct TypeSpec {
QString baseName;
bool isPointer = false;
int ptrDepth = 0; // 1 = *, 2 = ** (only meaningful when isPointer)
int arrayCount = 0; // 0 = not array
};
TypeSpec parseTypeSpec(const QString& text);
// ── Popup widget ──
class TypeSelectorPopup : public QFrame {
Q_OBJECT
public:
explicit TypeSelectorPopup(QWidget* parent = nullptr);
void setFont(const QFont& font);
void setTitle(const QString& title);
void setMode(TypePopupMode mode);
void applyTheme(const Theme& theme);
void setCurrentNodeSize(int bytes);
void setModifier(int modId, int arrayCount = 0);
void setTypes(const QVector<TypeEntry>& types, const TypeEntry* current = nullptr);
void popup(const QPoint& globalPos);
/// Force native window creation to avoid cold-start delay.
void warmUp();
signals:
void typeSelected(const TypeEntry& entry, const QString& fullText);
void createNewTypeRequested();
void dismissed();
protected:
bool eventFilter(QObject* obj, QEvent* event) override;
void hideEvent(QHideEvent* event) override;
private:
QLabel* m_titleLabel = nullptr;
QToolButton* m_escLabel = nullptr;
QToolButton* m_createBtn = nullptr;
QLineEdit* m_filterEdit = nullptr;
QLabel* m_previewLabel = nullptr;
QListView* m_listView = nullptr;
QStringListModel* m_model = nullptr;
QFrame* m_separator = nullptr;
// Modifier toggles
QWidget* m_modRow = nullptr;
QToolButton* m_btnPlain = nullptr;
QToolButton* m_btnPtr = nullptr;
QToolButton* m_btnDblPtr = nullptr;
QToolButton* m_btnArray = nullptr;
QLineEdit* m_arrayCountEdit = nullptr;
QButtonGroup* m_modGroup = nullptr;
QVector<TypeEntry> m_allTypes;
QVector<TypeEntry> m_filteredTypes;
TypeEntry m_currentEntry;
bool m_hasCurrent = false;
TypePopupMode m_mode = TypePopupMode::FieldType;
int m_currentNodeSize = 0;
QFont m_font;
void applyFilter(const QString& text);
void updateModifierPreview();
void acceptCurrent();
void acceptIndex(int row);
int nextSelectableRow(int from, int direction) const;
};
} // namespace rcx

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

@@ -48,8 +48,8 @@ private slots:
QCOMPARE(result.meta[2].depth, 1);
// Offset text
QCOMPARE(result.meta[1].offsetText, QString("0"));
QCOMPARE(result.meta[2].offsetText, QString("4"));
QCOMPARE(result.meta[1].offsetText, QString("0000 "));
QCOMPARE(result.meta[2].offsetText, QString("0004 "));
// Line 3 is root footer
QCOMPARE(result.meta[3].lineKind, LineKind::Footer);
@@ -81,7 +81,7 @@ private slots:
// Line 1: single Vec3 line, not continuation, depth 1
QVERIFY(!result.meta[1].isContinuation);
QCOMPARE(result.meta[1].offsetText, QString("0"));
QCOMPARE(result.meta[1].offsetText, QString("0000 "));
QCOMPARE(result.meta[1].depth, 1);
QCOMPARE(result.meta[1].nodeKind, NodeKind::Vec3);
@@ -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
@@ -732,7 +731,7 @@ private slots:
}
void testArrayHeaderCharTypes() {
// UInt8 array → "char[N]", UInt16 → "wchar_t[N]"
// UInt8 array → "uint8_t[N]", UInt16 → "uint16_t[N]"
NodeTree tree;
tree.baseAddress = 0;
@@ -769,11 +768,11 @@ private slots:
for (int i = 0; i < result.meta.size(); i++) {
if (!result.meta[i].isArrayHeader) continue;
QString text = lines[i];
if (text.contains("char[64]")) foundChar = true;
if (text.contains("wchar_t[32]")) foundWchar = true;
if (text.contains("uint8_t[64]")) foundChar = true;
if (text.contains("uint16_t[32]")) foundWchar = true;
}
QVERIFY2(foundChar, "Should have 'char[64]' header");
QVERIFY2(foundWchar, "Should have 'wchar_t[32]' header");
QVERIFY2(foundChar, "Should have 'uint8_t[64]' header");
QVERIFY2(foundWchar, "Should have 'uint16_t[32]' header");
}
void testArraySpansClickable() {
@@ -995,13 +994,13 @@ private slots:
ComposeResult r2 = compose(tree, prov);
QStringList lines2 = r2.text.split('\n');
bool found42 = false;
bool still10 = false;
for (const QString& l : lines2) {
if (l.contains("[42]")) found42 = true;
if (l.contains("[10]")) still10 = true;
bool still10Header = false;
for (int i = 0; i < r2.meta.size(); i++) {
if (r2.meta[i].isArrayHeader && lines2[i].contains("uint8_t[42]")) found42 = true;
if (r2.meta[i].isArrayHeader && lines2[i].contains("uint8_t[10]")) still10Header = true;
}
QVERIFY2(found42, "Recomposed text should show [42]");
QVERIFY2(!still10, "Recomposed text should NOT still show [10]");
QVERIFY2(found42, "Recomposed header should show uint8_t[42]");
QVERIFY2(!still10Header, "Recomposed header should NOT still show uint8_t[10]");
// Spans must still work after recompose
int headerLine = -1;
@@ -1015,6 +1014,161 @@ private slots:
QCOMPARE(countText, QString("42"));
}
void testPrimitiveArrayElements() {
// Expanded primitive array should synthesize element lines dynamically
NodeTree tree;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.name = "Root";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node arr;
arr.kind = NodeKind::Array;
arr.name = "values";
arr.parentId = rootId;
arr.offset = 0;
arr.elementKind = NodeKind::UInt32;
arr.arrayLen = 4;
tree.addNode(arr);
// Buffer with known values: 0x11, 0x22, 0x33, 0x44
QByteArray data(64, '\0');
uint32_t v0 = 0x11, v1 = 0x22, v2 = 0x33, v3 = 0x44;
memcpy(data.data() + 0, &v0, 4);
memcpy(data.data() + 4, &v1, 4);
memcpy(data.data() + 8, &v2, 4);
memcpy(data.data() + 12, &v3, 4);
BufferProvider prov(data);
ComposeResult result = compose(tree, prov);
QStringList lines = result.text.split('\n');
// Find array header
int headerLine = -1;
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].isArrayHeader) { headerLine = i; break; }
}
QVERIFY2(headerLine >= 0, "Array header must exist");
QVERIFY2(lines[headerLine].contains("uint32_t[4]"),
qPrintable("Header should contain 'uint32_t[4]': " + lines[headerLine]));
// Count element field lines (depth >= 2, lineKind == Field)
int elemCount = 0;
bool found0 = false, found3 = false;
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].lineKind == LineKind::Field && result.meta[i].depth >= 2) {
elemCount++;
// Type column should have combined type+index: "uint32_t[0]"
if (lines[i].contains("uint32_t[0]")) found0 = true;
if (lines[i].contains("uint32_t[3]")) found3 = true;
// isArrayElement flag must be set
QVERIFY2(result.meta[i].isArrayElement,
qPrintable("Element line must have isArrayElement=true: " + lines[i]));
}
}
QCOMPARE(elemCount, 4);
QVERIFY2(found0, "Should have uint32_t[0] element");
QVERIFY2(found3, "Should have uint32_t[3] element");
// Check footer exists
bool hasFooter = false;
for (int i = headerLine + 1; i < result.meta.size(); i++) {
if (result.meta[i].lineKind == LineKind::Footer && result.meta[i].nodeKind == NodeKind::Array) {
hasFooter = true;
break;
}
}
QVERIFY2(hasFooter, "Array should have footer line");
}
void testPrimitiveArrayCollapsed() {
// Collapsed primitive array should show NO element lines
NodeTree tree;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.name = "Root";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node arr;
arr.kind = NodeKind::Array;
arr.name = "data";
arr.parentId = rootId;
arr.offset = 0;
arr.elementKind = NodeKind::UInt16;
arr.arrayLen = 8;
arr.collapsed = true;
tree.addNode(arr);
NullProvider prov;
ComposeResult result = compose(tree, prov);
// No field lines at depth >= 2 (no synthesized elements)
int elemFields = 0;
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].lineKind == LineKind::Field && result.meta[i].depth >= 2)
elemFields++;
}
QCOMPARE(elemFields, 0);
}
void testStructArrayStillUsesChildren() {
// Struct array with manual children should still render child nodes, not synthesize
NodeTree tree;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.name = "Root";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node arr;
arr.kind = NodeKind::Array;
arr.name = "items";
arr.parentId = rootId;
arr.offset = 0;
arr.elementKind = NodeKind::Struct;
arr.arrayLen = 1;
int ai = tree.addNode(arr);
uint64_t arrId = tree.nodes[ai].id;
// One struct child
Node elem;
elem.kind = NodeKind::Struct;
elem.name = "Item";
elem.parentId = arrId;
elem.offset = 0;
int ei = tree.addNode(elem);
uint64_t elemId = tree.nodes[ei].id;
Node field;
field.kind = NodeKind::UInt32;
field.name = "val";
field.parentId = elemId;
field.offset = 0;
tree.addNode(field);
NullProvider prov;
ComposeResult result = compose(tree, prov);
// Should have the child struct's field rendered
bool hasVal = false;
QStringList lines = result.text.split('\n');
for (int i = 0; i < lines.size(); i++) {
if (lines[i].contains("val")) { hasVal = true; break; }
}
QVERIFY2(hasVal, "Struct array child field 'val' should be rendered");
}
// ═════════════════════════════════════════════════════════════
// Pointer tests
// ═════════════════════════════════════════════════════════════
@@ -1766,54 +1920,9 @@ private slots:
}
}
void testComputeStructAlignment() {
NodeTree tree;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
root.name = "Root";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
// Int32 has alignment 4
Node f1;
f1.kind = NodeKind::Int32;
f1.name = "a";
f1.parentId = rootId;
f1.offset = 0;
tree.addNode(f1);
QCOMPARE(tree.computeStructAlignment(rootId), 4);
// Add Hex64 (alignment 8) — max should become 8
Node f2;
f2.kind = NodeKind::Hex64;
f2.name = "b";
f2.parentId = rootId;
f2.offset = 8;
tree.addNode(f2);
QCOMPARE(tree.computeStructAlignment(rootId), 8);
}
void testComputeStructAlignmentEmpty() {
NodeTree tree;
Node root;
root.kind = NodeKind::Struct;
root.name = "Empty";
root.parentId = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
// Empty struct → alignment 1
QCOMPARE(tree.computeStructAlignment(rootId), 1);
}
void testCommandRowRootNameSpan() {
// Name span should cover the class name in the merged command row
QString text = "source\u25BE \u00B7 0x0 \u00B7 struct\u25BE MyClass {";
QString text = "source\u25BE \u00B7 0x0 \u00B7 struct MyClass {";
ColumnSpan nameSpan = commandRowRootNameSpan(text);
QVERIFY(nameSpan.valid);
@@ -1825,7 +1934,7 @@ private slots:
void testTextIsNonEmpty() {
// Verify composed text is actually generated (not empty)
NodeTree tree;
tree.baseAddress = 0x1000;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;

View File

@@ -8,7 +8,7 @@
using namespace rcx;
static void buildTree(NodeTree& tree) {
tree.baseAddress = 0x1000;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
@@ -394,6 +394,65 @@ private slots:
QApplication::processEvents();
QCOMPARE(countNodes(), before);
}
// ── Change to Ptr* creates class and sets refId ──
void testChangeToPtrStarCreatesClassAndSetsRef() {
// Add a Hex64 node to the root struct
uint64_t rootId = m_doc->tree.nodes[0].id;
m_ctrl->insertNode(rootId, 16, NodeKind::Hex64, "ptrField");
QApplication::processEvents();
int ptrIdx = findNode("ptrField");
QVERIFY(ptrIdx >= 0);
uint64_t ptrNodeId = m_doc->tree.nodes[ptrIdx].id;
int before = countNodes();
// Convert to typed pointer
m_ctrl->convertToTypedPointer(ptrNodeId);
QApplication::processEvents();
// Re-find after tree mutation
ptrIdx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].id == ptrNodeId) { ptrIdx = i; break; }
}
QVERIFY(ptrIdx >= 0);
// Verify: node kind changed to Pointer64
QCOMPARE(m_doc->tree.nodes[ptrIdx].kind, NodeKind::Pointer64);
// Verify: node.refId != 0
uint64_t refId = m_doc->tree.nodes[ptrIdx].refId;
QVERIFY(refId != 0);
// Verify: a new Struct node exists with the refId as its id
int structIdx = m_doc->tree.indexOfId(refId);
QVERIFY(structIdx >= 0);
QCOMPARE(m_doc->tree.nodes[structIdx].kind, NodeKind::Struct);
// Verify: the new struct has children (Hex64 fields)
auto children = m_doc->tree.childrenOf(refId);
QVERIFY(children.size() == 16);
for (int ci : children)
QCOMPARE(m_doc->tree.nodes[ci].kind, NodeKind::Hex64);
// Verify: total nodes increased by 1 struct + 16 children = 17
QCOMPARE(countNodes(), before + 17);
// Verify: undo restores the original Hex64 kind and refId==0
m_doc->undoStack.undo();
QApplication::processEvents();
ptrIdx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].id == ptrNodeId) { ptrIdx = i; break; }
}
QVERIFY(ptrIdx >= 0);
QCOMPARE(m_doc->tree.nodes[ptrIdx].kind, NodeKind::Hex64);
QCOMPARE(m_doc->tree.nodes[ptrIdx].refId, (uint64_t)0);
QCOMPARE(countNodes(), before);
}
};
QTEST_MAIN(TestContextMenu)

View File

@@ -8,10 +8,29 @@
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; }
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) {
tree.baseAddress = 0x1000;
tree.baseAddress = 0;
Node root;
root.kind = NodeKind::Struct;
@@ -34,9 +53,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 +300,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 +402,44 @@ private slots:
QCOMPARE((uint8_t)bytes[0], (uint8_t)0xFF);
}
// ── Test: source switch preserves existing base address ──
void testSourceSwitchPreservesBase() {
// Set a non-zero baseAddress to simulate a loaded .rcx file
m_doc->tree.baseAddress = 0x1000;
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;
// Controller logic: keep existing baseAddress when non-zero
if (m_doc->tree.baseAddress == 0)
m_doc->tree.baseAddress = newBase;
// baseAddress must stay at the original value
QCOMPARE(m_doc->tree.baseAddress, (uint64_t)0x1000);
// provider base is unchanged (no setBase sync) — provider reports its own initial base
QCOMPARE(m_doc->provider->base(), (uint64_t)0x400000);
}
// ── 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;
// Fresh doc should adopt the provider's default base
QCOMPARE(m_doc->tree.baseAddress, (uint64_t)0x7FFE0000);
}
// ── Test: toggleCollapse + undo ──
void testToggleCollapse() {
// Root is index 0, a Struct node
@@ -448,6 +463,211 @@ 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());
}
// ── Test: inline edit "int32_t[4]" on primitive converts to array ──
void testInlineEditPrimitiveArray() {
// Find a primitive field to convert
int idx = -1;
for (int i = 0; i < m_doc->tree.nodes.size(); i++) {
if (m_doc->tree.nodes[i].name == "field_u32") { idx = i; break; }
}
QVERIFY(idx >= 0);
QCOMPARE(m_doc->tree.nodes[idx].kind, NodeKind::UInt32);
uint64_t nodeId = m_doc->tree.nodes[idx].id;
// Emit inlineEditCommitted with array syntax
emit m_editor->inlineEditCommitted(idx, 0, EditTarget::Type,
QStringLiteral("int32_t[4]"));
QApplication::processEvents();
// Node should now be an Array with elementKind=Int32, arrayLen=4
int newIdx = m_doc->tree.indexOfId(nodeId);
QVERIFY(newIdx >= 0);
QCOMPARE(m_doc->tree.nodes[newIdx].kind, NodeKind::Array);
QCOMPARE(m_doc->tree.nodes[newIdx].elementKind, NodeKind::Int32);
QCOMPARE(m_doc->tree.nodes[newIdx].arrayLen, 4);
// Undo should restore to UInt32
m_doc->undoStack.undo();
QApplication::processEvents();
newIdx = m_doc->tree.indexOfId(nodeId);
QVERIFY(newIdx >= 0);
QCOMPARE(m_doc->tree.nodes[newIdx].kind, NodeKind::UInt32);
}
};
QTEST_MAIN(TestController)

View File

@@ -336,7 +336,7 @@ private slots:
auto vs = rcx::valueSpanFor(lm, 100);
QVERIFY(vs.valid);
QCOMPARE(vs.start, 44); // 21 + 22 + 1 (kSepWidth)
QCOMPARE(vs.end, 76); // 44 + 32 (kColValue)
QCOMPARE(vs.end, 44 + rcx::kColValue);
}
void testColumnSpan_continuation() {
@@ -352,7 +352,7 @@ private slots:
auto vs = rcx::valueSpanFor(lm, 100);
QVERIFY(vs.valid);
QCOMPARE(vs.start, 6 + 14 + 22 + 2); // kFoldCol+indent + kColType(14) + kColName(22) + 2*kSepWidth
QCOMPARE(vs.end, 44 + 32); // start + kColValue
QCOMPARE(vs.end, 44 + rcx::kColValue);
}
void testColumnSpan_headerFooter() {
@@ -392,7 +392,7 @@ private slots:
auto vs = rcx::valueSpanFor(lm, 100);
QVERIFY(vs.valid);
QCOMPARE(vs.start, 41); // 18 + 22 + 1 (kSepWidth)
QCOMPARE(vs.end, 73); // 41 + 32 (kColValue)
QCOMPARE(vs.end, 41 + rcx::kColValue); // start + kColValue
}
void testNodeIdJsonRoundTrip() {
@@ -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)

65
tests/test_dbgconnect.cpp Normal file
View File

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

465
tests/test_disasm.cpp Normal file
View File

@@ -0,0 +1,465 @@
#include <QtTest/QTest>
#include "disasm.h"
#include "core.h"
#include "providers/buffer_provider.h"
using namespace rcx;
// Helper: extract mnemonic portion from disassembly output (after "addr ")
static QString mnemonic(const QString& line) {
int sep = line.indexOf(" ");
return sep >= 0 ? line.mid(sep + 2) : line;
}
class TestDisasm : public QObject {
Q_OBJECT
private slots:
// ──────────────────────────────────────────────────
// disassemble() unit tests exact mnemonic match
// ──────────────────────────────────────────────────
void testDisasm64_pushMov() {
QByteArray code("\x55\x48\x89\xe5", 4);
QString result = disassemble(code, 0x401000, 64);
QStringList lines = result.split('\n');
QCOMPARE(lines.size(), 2);
QVERIFY(lines[0].startsWith("0000000000401000"));
QVERIFY(lines[1].startsWith("0000000000401001"));
QCOMPARE(mnemonic(lines[0]), QStringLiteral("push rbp"));
QCOMPARE(mnemonic(lines[1]), QStringLiteral("mov rbp, rsp"));
}
void testDisasm64_ret() { QCOMPARE(mnemonic(disassemble(QByteArray("\xc3",1), 0x7FF000, 64)), QStringLiteral("ret")); }
void testDisasm64_nop() { QCOMPARE(mnemonic(disassemble(QByteArray("\x90",1), 0, 64)), QStringLiteral("nop")); }
void testDisasm64_xorEax() { QCOMPARE(mnemonic(disassemble(QByteArray("\x31\xc0",2), 0, 64)), QStringLiteral("xor eax, eax")); }
void testDisasm64_subRsp() { QCOMPARE(mnemonic(disassemble(QByteArray("\x48\x83\xec\x20",4), 0, 64)), QStringLiteral("sub rsp, 0x20")); }
void testDisasm64_int3() { QCOMPARE(mnemonic(disassemble(QByteArray("\xcc",1), 0, 64)), QStringLiteral("int3")); }
void testDisasm64_pushRdi() { QCOMPARE(mnemonic(disassemble(QByteArray("\x57",1), 0, 64)), QStringLiteral("push rdi")); }
void testDisasm64_popRsi() { QCOMPARE(mnemonic(disassemble(QByteArray("\x5e",1), 0, 64)), QStringLiteral("pop rsi")); }
void testDisasm64_testEax() { QCOMPARE(mnemonic(disassemble(QByteArray("\x85\xc0",2), 0, 64)), QStringLiteral("test eax, eax")); }
void testDisasm64_leaRipRel() {
QCOMPARE(mnemonic(disassemble(QByteArray("\x48\x8d\x05\x10\x00\x00\x00",7), 0x1000, 64)),
QStringLiteral("lea rax, [rip+0x10]"));
}
void testDisasm64_callRel() {
// call target = 0x1000 + 5 + 0x100 = 0x1105
QCOMPARE(mnemonic(disassemble(QByteArray("\xe8\x00\x01\x00\x00",5), 0x1000, 64)),
QStringLiteral("call 0x1105"));
}
void testDisasm64_jmpRel() {
// jmp target = 0x1000 + 2 + 0x10 = 0x1012
QCOMPARE(mnemonic(disassemble(QByteArray("\xeb\x10",2), 0x1000, 64)),
QStringLiteral("jmp 0x1012"));
}
void testDisasm64_movMemRead() {
QCOMPARE(mnemonic(disassemble(QByteArray("\x48\x8b\x43\x10",4), 0, 64)),
QStringLiteral("mov rax, qword ptr [rbx+0x10]"));
}
void testDisasm64_movMemWrite() {
QCOMPARE(mnemonic(disassemble(QByteArray("\x48\x89\x4c\x24\x08",5), 0, 64)),
QStringLiteral("mov qword ptr [rsp+0x8], rcx"));
}
void testDisasm64_functionPrologue() {
QByteArray code("\x55\x48\x89\xe5\x48\x83\xec\x20\xc3", 9);
QStringList lines = disassemble(code, 0x140001000ULL, 64).split('\n');
QCOMPARE(lines.size(), 4);
QVERIFY(lines[0].startsWith("0000000140001000"));
QCOMPARE(mnemonic(lines[0]), QStringLiteral("push rbp"));
QCOMPARE(mnemonic(lines[1]), QStringLiteral("mov rbp, rsp"));
QCOMPARE(mnemonic(lines[2]), QStringLiteral("sub rsp, 0x20"));
QCOMPARE(mnemonic(lines[3]), QStringLiteral("ret"));
}
void testDisasm64_multipleNops() {
QStringList lines = disassemble(QByteArray(5,'\x90'), 0x1000, 64).split('\n');
QCOMPARE(lines.size(), 5);
for (int i = 0; i < 5; i++) {
QCOMPARE(mnemonic(lines[i]), QStringLiteral("nop"));
QVERIFY(lines[i].startsWith(QStringLiteral("%1").arg(0x1000+i, 16, 16, QLatin1Char('0'))));
}
}
void testDisasm32_pushMov() {
QByteArray code("\x55\x89\xe5", 3);
QStringList lines = disassemble(code, 0x401000, 32).split('\n');
QCOMPARE(lines.size(), 2);
QVERIFY(lines[0].startsWith("00401000"));
QCOMPARE(mnemonic(lines[0]), QStringLiteral("push ebp"));
QCOMPARE(mnemonic(lines[1]), QStringLiteral("mov ebp, esp"));
}
void testDisasm_empty() { QVERIFY(disassemble({}, 0, 64).isEmpty()); QVERIFY(disassemble({}, 0, 32).isEmpty()); }
void testDisasm_invalidBitness() { QVERIFY(disassemble(QByteArray("\x90",1), 0, 16).isEmpty()); }
void testDisasm_maxBytes() { QCOMPARE(disassemble(QByteArray(200,'\x90'), 0, 64, 128).count('\n') + 1, 128); }
void testDisasm64_addrWidth() { QCOMPARE(disassemble(QByteArray("\x90",1), 0, 64).indexOf(" "), 16); }
void testDisasm32_addrWidth() { QCOMPARE(disassemble(QByteArray("\x90",1), 0, 32).indexOf(" "), 8); }
// ──────────────────────────────────────────────────
// hexDump() unit tests
// ──────────────────────────────────────────────────
void testHexDump_basic() {
QByteArray data; for (int i=0;i<32;i++) data.append((char)i);
QString r = hexDump(data, 0x1000, 128);
QCOMPARE(r.count('\n')+1, 2);
QVERIFY(r.startsWith("00001000"));
}
void testHexDump_ascii() {
QVERIFY(hexDump(QByteArray("Hello, World!xx",15), 0, 128).contains("Hello"));
}
void testHexDump_nonPrintable() {
QByteArray d(16,'\0'); d[0]='A'; d[15]='Z';
QVERIFY(hexDump(d, 0, 128).contains("A..............Z"));
}
void testHexDump_empty() { QVERIFY(hexDump({}, 0).isEmpty()); }
void testHexDump_maxBytes() { QCOMPARE(hexDump(QByteArray(200,'\xAA'), 0, 64).count('\n')+1, 4); }
void testHexDump_wideAddr() { QVERIFY(hexDump(QByteArray(16,'\0'), 0x100000000ULL, 128).startsWith("0000000100000000")); }
void testHexDump_hexValues() {
QByteArray d; d.append('\xDE'); d.append('\xAD'); d.append('\xBE'); d.append('\xEF');
while (d.size()<16) d.append('\0');
QVERIFY(hexDump(d, 0, 128).contains("de ad be ef", Qt::CaseInsensitive));
}
void testHexDump_secondLineAddr() {
QStringList lines = hexDump(QByteArray(32,'\x42'), 0x2000, 128).split('\n');
QCOMPARE(lines.size(), 2);
QVERIFY(lines[1].startsWith("00002010"));
}
// ──────────────────────────────────────────────────
// End-to-end: pointer-expanded VTable with FuncPtr64
// Verifies we read from the COMPOSED address, not node.offset
// ──────────────────────────────────────────────────
void testVTableDisasm_composedAddress() {
// Memory layout (absolute addresses, baseAddress = 0):
//
// [0x0000] Root "Obj" struct
// +0x00: Pointer64 __vptr => points to 0x100 (vtable)
//
// [0x0100] VTable (expanded via pointer deref)
// +0x00: func ptr 0 => value 0x200 (func0 code)
// +0x08: func ptr 1 => value 0x300 (func1 code)
//
// [0x0200] func0 code: push rbp; ret
// [0x0300] func1 code: xor eax, eax; ret
//
// Build a 4KB buffer
QByteArray mem(4096, '\0');
auto w64 = [&](int off, uint64_t val) {
memcpy(mem.data() + off, &val, 8);
};
// Root object at offset 0: __vptr points to vtable at 0x100
w64(0x00, 0x100);
// VTable at offset 0x100: two function pointers
w64(0x100, 0x200); // slot 0 -> func0
w64(0x108, 0x300); // slot 1 -> func1
// func0 at offset 0x200: push rbp; ret
mem[0x200] = '\x55';
mem[0x201] = '\xc3';
// func1 at offset 0x300: xor eax, eax; ret
mem[0x300] = '\x31';
mem[0x301] = '\xc0';
mem[0x302] = '\xc3';
BufferProvider prov(mem);
// Build node tree
NodeTree tree;
tree.baseAddress = 0;
// Root struct "Obj"
Node root;
root.kind = NodeKind::Struct;
root.name = "Obj";
root.parentId = 0;
root.offset = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
// VTable struct definition (template)
Node vtDef;
vtDef.kind = NodeKind::Struct;
vtDef.name = "VTable";
vtDef.parentId = 0;
vtDef.offset = 0x1000; // parked far away so it doesn't overlap
int vti = tree.addNode(vtDef);
uint64_t vtId = tree.nodes[vti].id;
// Two FuncPtr64 children inside VTable definition
Node fp0;
fp0.kind = NodeKind::FuncPtr64;
fp0.name = "func0";
fp0.parentId = vtId;
fp0.offset = 0;
tree.addNode(fp0);
Node fp1;
fp1.kind = NodeKind::FuncPtr64;
fp1.name = "func1";
fp1.parentId = vtId;
fp1.offset = 8;
tree.addNode(fp1);
// Pointer64 "__vptr" in root, pointing to VTable via refId
Node vptr;
vptr.kind = NodeKind::Pointer64;
vptr.name = "__vptr";
vptr.parentId = rootId;
vptr.offset = 0;
vptr.refId = vtId;
tree.addNode(vptr);
// Compose the tree
ComposeResult result = compose(tree, prov);
// Find the FuncPtr64 lines in the composed output that are inside the
// pointer-expanded VTable (near vtable address), not the standalone definition.
struct FuncInfo { int line; uint64_t offsetAddr; NodeKind kind; QString name; };
QVector<FuncInfo> funcPtrs;
for (int i = 0; i < result.meta.size(); i++) {
const LineMeta& lm = result.meta[i];
if (lm.nodeKind == NodeKind::FuncPtr64 && lm.lineKind == LineKind::Field) {
// Only include the pointer-expanded ones (near vtable at 0x100)
if (lm.offsetAddr >= 0x100 && lm.offsetAddr < 0x200) {
int nodeIdx = lm.nodeIdx;
funcPtrs.append({i, lm.offsetAddr, lm.nodeKind,
nodeIdx >= 0 ? tree.nodes[nodeIdx].name : QString()});
}
}
}
QCOMPARE(funcPtrs.size(), 2);
// Verify composed addresses point to the vtable, NOT to the root struct
// func0 should be at 0x100 (vtable + 0)
QCOMPARE(funcPtrs[0].offsetAddr, (uint64_t)0x100);
// func1 should be at 0x108 (vtable + 8)
QCOMPARE(funcPtrs[1].offsetAddr, (uint64_t)0x108);
// Now simulate what the hover code should do:
// Read the function pointer VALUE from the correct provider address
for (const auto& fp : funcPtrs) {
// Provider reads at absolute address directly
uint64_t provAddr = fp.offsetAddr;
// Read the pointer value (the function address)
uint64_t ptrVal = prov.readU64(provAddr);
// Verify we got the right pointer values
if (fp.name == "func0") {
QCOMPARE(ptrVal, (uint64_t)0x200);
} else {
QCOMPARE(ptrVal, (uint64_t)0x300);
}
// Read code bytes at the pointer target (absolute address)
uint64_t codeProvAddr = ptrVal;
QByteArray codeBytes = prov.readBytes(codeProvAddr, 128);
// Disassemble and verify
QString asm_ = disassemble(codeBytes, ptrVal, 64, 128);
QVERIFY2(!asm_.isEmpty(), qPrintable("Empty disasm for " + fp.name));
QStringList lines = asm_.split('\n');
if (fp.name == "func0") {
// Should decode: push rbp; ret
QVERIFY2(lines.size() >= 2, qPrintable(QString("Expected >= 2 lines for func0, got %1: %2").arg(lines.size()).arg(asm_)));
QCOMPARE(mnemonic(lines[0]), QStringLiteral("push rbp"));
QCOMPARE(mnemonic(lines[1]), QStringLiteral("ret"));
// Verify address in output matches the real function address
QVERIFY2(lines[0].contains("200"),
qPrintable("func0 addr wrong: " + lines[0]));
} else {
// Should decode: xor eax, eax; ret
QVERIFY2(lines.size() >= 2, qPrintable(QString("Expected >= 2 lines for func1, got %1: %2").arg(lines.size()).arg(asm_)));
QCOMPARE(mnemonic(lines[0]), QStringLiteral("xor eax, eax"));
QCOMPARE(mnemonic(lines[1]), QStringLiteral("ret"));
QVERIFY2(lines[0].contains("300"),
qPrintable("func1 addr wrong: " + lines[0]));
}
}
// CRITICAL: Verify that reading from node.offset (the WRONG way) gives
// different/wrong results. node.offset for func0=0, func1=8, which are
// inside the ROOT struct, not the vtable.
uint64_t wrongVal0 = prov.readU64(0); // node.offset=0: reads __vptr value
uint64_t wrongVal1 = prov.readU64(8); // node.offset=8: reads garbage after __vptr
// wrongVal0 = 0x100 (the vptr itself, NOT a function address)
QCOMPARE(wrongVal0, (uint64_t)0x100);
// This is the vtable address, not a function — disassembling it would be wrong
QVERIFY2(wrongVal0 != (uint64_t)0x200,
"node.offset reads the vptr, not the function pointer");
QVERIFY2(wrongVal1 != (uint64_t)0x300,
"node.offset=8 reads past vptr, not the second function pointer");
}
void testVTableDisasm_wrongAddressGivesWrongCode() {
// Demonstrate that using node.offset instead of composed address
// gives completely wrong disassembly results
QByteArray mem(1024, '\0');
auto w64 = [&](int off, uint64_t val) { memcpy(mem.data()+off, &val, 8); };
// Root at 0: vptr -> 0x80
w64(0x00, (uint64_t)0x80);
// VTable at 0x80: one func ptr -> 0x100
w64(0x80, (uint64_t)0x100);
// Code at 0x100: sub rsp, 0x28; nop; ret
mem[0x100] = '\x48'; mem[0x101] = '\x83'; mem[0x102] = '\xec';
mem[0x103] = '\x28'; mem[0x104] = '\x90'; mem[0x105] = '\xc3';
BufferProvider prov(mem);
// WRONG: read from node.offset=0 (root's vptr value, not the func ptr)
uint64_t wrongPtrVal = prov.readU64(0);
QCOMPARE(wrongPtrVal, (uint64_t)0x80); // This is the vtable addr, not a function!
// RIGHT: read from composed address (vtable + 0)
uint64_t rightPtrVal = prov.readU64(0x80);
QCOMPARE(rightPtrVal, (uint64_t)0x100); // This IS the function address
// Disassemble the RIGHT target
QByteArray rightCode = prov.readBytes(0x100, 128);
QString rightAsm = disassemble(rightCode, 0x100, 64, 128);
QStringList rightLines = rightAsm.split('\n');
QVERIFY(rightLines.size() >= 3);
QCOMPARE(mnemonic(rightLines[0]), QStringLiteral("sub rsp, 0x28"));
QCOMPARE(mnemonic(rightLines[1]), QStringLiteral("nop"));
QCOMPARE(mnemonic(rightLines[2]), QStringLiteral("ret"));
// Disassemble the WRONG target (vtable data, not code!)
QByteArray wrongCode = prov.readBytes(0x80, 128);
QString wrongAsm = disassemble(wrongCode, 0x80, 64, 128);
// The wrong bytes are the vtable entries (pointer values),
// which decode as garbage instructions, not sub/nop/ret
QVERIFY2(!wrongAsm.contains("sub rsp"),
qPrintable("Wrong address should NOT produce sub rsp: " + wrongAsm));
}
void testHoverFlow_fullSimulation() {
// Full simulation of the hover flow as implemented in editor.cpp:
//
// 1. Compose the tree to get LineMeta with correct offsetAddr
// 2. For each FuncPtr64 line, read pointer value from provider
// using lm.offsetAddr (absolute address)
// 3. Read code bytes from the REAL provider using ptrVal directly
// (the real provider can read any process address; snapshot cannot)
// 4. Disassemble the code bytes
//
// The key distinction: step 2 reads from composed tree addresses (in
// the snapshot), step 3 reads from arbitrary code addresses (needs
// the real provider, not snapshot).
QByteArray mem(8192, '\0');
auto w64 = [&](int off, uint64_t val) {
memcpy(mem.data() + off, &val, 8);
};
// Layout:
// [0x000] Root struct: __vptr -> vtable at 0x100
// [0x100] VTable: func0 -> 0x1000, func1 -> 0x1800
// [0x1000] func0 code: push rbp; mov rbp, rsp; sub rsp, 0x20; ret
// [0x1800] func1 code: xor eax, eax; ret
w64(0x000, (uint64_t)0x100); // __vptr
w64(0x100, (uint64_t)0x1000); // vtable[0]
w64(0x108, (uint64_t)0x1800); // vtable[1]
// func0 code
memcpy(mem.data() + 0x1000, "\x55\x48\x89\xe5\x48\x83\xec\x20\xc3", 9);
// func1 code
memcpy(mem.data() + 0x1800, "\x31\xc0\xc3", 3);
// This provider represents the real process memory.
BufferProvider realProv(mem);
// Build a snapshot that only contains tree-data pages (like the
// async refresh does). The snapshot does NOT contain function code pages.
// This simulates the real scenario where SnapshotProvider only has
// pages for the root struct and pointer-expanded structs.
QByteArray snapData(0x200, '\0'); // only pages for root + vtable
memcpy(snapData.data(), mem.constData(), 0x200);
BufferProvider snapProv(snapData);
// Build node tree
NodeTree tree;
tree.baseAddress = 0;
Node root; root.kind = NodeKind::Struct; root.name = "Obj";
root.parentId = 0; root.offset = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node vtDef; vtDef.kind = NodeKind::Struct; vtDef.name = "VTable";
vtDef.parentId = 0; vtDef.offset = 0x2000;
int vti = tree.addNode(vtDef);
uint64_t vtId = tree.nodes[vti].id;
Node fp0; fp0.kind = NodeKind::FuncPtr64; fp0.name = "func0";
fp0.parentId = vtId; fp0.offset = 0;
tree.addNode(fp0);
Node fp1; fp1.kind = NodeKind::FuncPtr64; fp1.name = "func1";
fp1.parentId = vtId; fp1.offset = 8;
tree.addNode(fp1);
Node vptr; vptr.kind = NodeKind::Pointer64; vptr.name = "__vptr";
vptr.parentId = rootId; vptr.offset = 0; vptr.refId = vtId;
tree.addNode(vptr);
// Compose with the snapshot (like production: compose uses snapshot)
ComposeResult result = compose(tree, snapProv);
// Find expanded FuncPtr64 lines
for (int i = 0; i < result.meta.size(); i++) {
const LineMeta& lm = result.meta[i];
if (lm.nodeKind != NodeKind::FuncPtr64 || lm.lineKind != LineKind::Field)
continue;
if (lm.offsetAddr < 0x100 || lm.offsetAddr >= 0x200)
continue; // skip standalone VTable definition entries
// --- Hover step 1: read pointer value from snapshot ---
uint64_t provAddr = lm.offsetAddr;
// The snapshot has this data (vtable pages are in it)
QVERIFY2(snapProv.isReadable(provAddr, 8),
qPrintable(QString("Snapshot should have vtable page at %1")
.arg(provAddr, 0, 16)));
uint64_t ptrVal = snapProv.readU64(provAddr);
QVERIFY2(ptrVal != 0, "Function pointer should not be zero");
// --- Hover step 2: read code from REAL provider ---
// The snapshot does NOT have the code pages:
uint64_t codeAddr = ptrVal;
QVERIFY2(!snapProv.isReadable(codeAddr, 1),
"Snapshot should NOT have function code pages");
// But the real provider does:
QByteArray codeBytes(128, Qt::Uninitialized);
bool readOk = realProv.read(codeAddr, codeBytes.data(), 128);
QVERIFY2(readOk, "Real provider should be able to read code bytes");
// --- Hover step 3: disassemble ---
QString asm_ = disassemble(codeBytes, ptrVal, 64, 128);
QVERIFY2(!asm_.isEmpty(), qPrintable("Empty disasm for line " + QString::number(i)));
QStringList lines = asm_.split('\n');
const Node& node = tree.nodes[lm.nodeIdx];
if (node.name == "func0") {
QVERIFY(lines.size() >= 4);
QCOMPARE(mnemonic(lines[0]), QStringLiteral("push rbp"));
QCOMPARE(mnemonic(lines[1]), QStringLiteral("mov rbp, rsp"));
QCOMPARE(mnemonic(lines[2]), QStringLiteral("sub rsp, 0x20"));
QCOMPARE(mnemonic(lines[3]), QStringLiteral("ret"));
} else if (node.name == "func1") {
QVERIFY(lines.size() >= 2);
QCOMPARE(mnemonic(lines[0]), QStringLiteral("xor eax, eax"));
QCOMPARE(mnemonic(lines[1]), QStringLiteral("ret"));
}
}
}
};
QTEST_MAIN(TestDisasm)
#include "test_disasm.moc"

File diff suppressed because it is too large Load Diff

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

@@ -39,12 +39,21 @@ private slots:
}
void testFmtOffsetMargin_primary() {
QCOMPARE(fmt::fmtOffsetMargin(0x10, false), QString("10"));
QCOMPARE(fmt::fmtOffsetMargin(0, false), QString("0"));
QCOMPARE(fmt::fmtOffsetMargin(0x10, false), QString("00000010 "));
QCOMPARE(fmt::fmtOffsetMargin(0, false), QString("00000000 "));
}
void testFmtOffsetMargin_continuation() {
QCOMPARE(fmt::fmtOffsetMargin(0x10, true), QString(" \u00B7"));
QCOMPARE(fmt::fmtOffsetMargin(0x10, true), QString(" \u00B7 "));
}
void testFmtOffsetMargin_kernelAddr() {
QCOMPARE(fmt::fmtOffsetMargin(0xFFFFF80012345678ULL, false, 16),
QString("FFFFF80012345678 "));
QCOMPARE(fmt::fmtOffsetMargin(0x10, false, 16),
QString("0000000000000010 "));
QCOMPARE(fmt::fmtOffsetMargin(0x10, false, 4),
QString("0010 "));
}
void testFmtStructHeader() {

View File

@@ -54,18 +54,16 @@ private slots:
QString result = rcx::renderCpp(tree, rootId);
// Header
QVERIFY(result.contains("Generated by ReclassX"));
QVERIFY(result.contains("#pragma once"));
QVERIFY(result.contains("#include <cstdint>"));
QVERIFY(!result.contains("#include <cstdint>"));
QVERIFY(!result.contains("#pragma pack"));
// Struct definition
QVERIFY(result.contains("#pragma pack(push, 1)"));
QVERIFY(result.contains("struct Player {"));
QVERIFY(result.contains("int32_t health;"));
QVERIFY(result.contains("float speed;"));
QVERIFY(result.contains("uint64_t id;"));
QVERIFY(result.contains("};"));
QVERIFY(result.contains("#pragma pack(pop)"));
// static_assert - struct is 16 bytes (0+4 + 4+4 + 8+8 = 16)
QVERIFY(result.contains("static_assert(sizeof(Player) == 0x10"));
@@ -420,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() {
@@ -485,7 +459,6 @@ private slots:
QString result = rcx::renderCppAll(tree);
QVERIFY(result.contains("Full SDK export"));
QVERIFY(result.contains("struct StructA {"));
QVERIFY(result.contains("struct StructB {"));
QVERIFY(result.contains("uint32_t valueA;"));

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

@@ -1,14 +1,15 @@
#include <QTest>
#ifdef _WIN32
#include "providers/process_provider.h"
using namespace rcx;
#endif
class TestProcessProviderSymbol : public QObject {
Q_OBJECT
private slots:
#ifdef _WIN32
void getSymbol_selfProcess() {
// Attach to our own process for testing
HANDLE self = GetCurrentProcess();
@@ -87,19 +88,10 @@ private slots:
QString sym = prov.getSymbol(ntdllBase);
QVERIFY(sym.toLower().startsWith("ntdll.dll+0x"));
}
};
QTEST_MAIN(TestProcessProviderSymbol)
#include "test_provider_getSymbol.moc"
#else
// Non-Windows: empty test that passes
#include <QTest>
class TestProcessProviderSymbol : public QObject {
Q_OBJECT
private slots:
void skip() { QSKIP("ProcessProvider tests are Windows-only"); }
#endif
};
QTEST_MAIN(TestProcessProviderSymbol)
#include "test_provider_getSymbol.moc"
#endif

View File

@@ -0,0 +1,361 @@
#include <QtTest/QTest>
#include <QApplication>
#include <Qsci/qsciscintilla.h>
#include <Qsci/qsciscintillabase.h>
#include <Qsci/qscilexercpp.h>
#include <QColor>
#include <QFont>
#include "core.h"
#include "generator.h"
// Raw Scintilla message IDs not exposed by QsciScintillaBase wrapper
static constexpr int SCI_GETSELBACK = 2477;
static constexpr int SCI_GETSELFORE = 2476;
// ── Helper: extract BGR long from QColor (Scintilla stores colors as 0x00BBGGRR) ──
static long toBGR(const QColor& c) {
return (long)c.red() | ((long)c.green() << 8) | ((long)c.blue() << 16);
}
// ── Replicates MainWindow::setupRenderedSci so the test stays in sync ──
static void setupRenderedSci(QsciScintilla* sci) {
QFont f("Consolas", 12);
f.setFixedPitch(true);
sci->setFont(f);
sci->setReadOnly(false);
sci->setWrapMode(QsciScintilla::WrapNone);
sci->setTabWidth(4);
sci->setIndentationsUseTabs(false);
sci->SendScintilla(QsciScintillaBase::SCI_SETEXTRAASCENT, (long)2);
sci->SendScintilla(QsciScintillaBase::SCI_SETEXTRADESCENT, (long)2);
// Line number margin
sci->setMarginType(0, QsciScintilla::NumberMargin);
sci->setMarginWidth(0, "00000");
sci->setMarginsBackgroundColor(QColor("#252526"));
sci->setMarginsForegroundColor(QColor("#858585"));
sci->setMarginsFont(f);
sci->setMarginWidth(1, 0);
sci->setMarginWidth(2, 0);
// Lexer FIRST — setLexer() resets caret/selection/paper colors
auto* lexer = new QsciLexerCPP(sci);
lexer->setFont(f);
lexer->setColor(QColor("#569cd6"), QsciLexerCPP::Keyword);
lexer->setColor(QColor("#569cd6"), QsciLexerCPP::KeywordSet2);
lexer->setColor(QColor("#b5cea8"), QsciLexerCPP::Number);
lexer->setColor(QColor("#ce9178"), QsciLexerCPP::DoubleQuotedString);
lexer->setColor(QColor("#ce9178"), QsciLexerCPP::SingleQuotedString);
lexer->setColor(QColor("#6a9955"), QsciLexerCPP::Comment);
lexer->setColor(QColor("#6a9955"), QsciLexerCPP::CommentLine);
lexer->setColor(QColor("#6a9955"), QsciLexerCPP::CommentDoc);
lexer->setColor(QColor("#d4d4d4"), QsciLexerCPP::Default);
lexer->setColor(QColor("#d4d4d4"), QsciLexerCPP::Identifier);
lexer->setColor(QColor("#c586c0"), QsciLexerCPP::PreProcessor);
lexer->setColor(QColor("#d4d4d4"), QsciLexerCPP::Operator);
for (int i = 0; i <= 127; i++) {
lexer->setPaper(QColor("#1e1e1e"), i);
lexer->setFont(f, i);
}
sci->setLexer(lexer);
sci->setBraceMatching(QsciScintilla::NoBraceMatch);
// Colors AFTER setLexer() — the lexer resets these on attach
sci->setPaper(QColor("#1e1e1e"));
sci->setColor(QColor("#d4d4d4"));
sci->setCaretForegroundColor(QColor("#d4d4d4"));
sci->setCaretLineVisible(true);
sci->setCaretLineBackgroundColor(QColor(43, 43, 43));
sci->setSelectionBackgroundColor(QColor("#264f78"));
sci->setSelectionForegroundColor(QColor("#d4d4d4"));
}
// ── Test tree helper ──
static rcx::NodeTree makeTestTree() {
rcx::NodeTree tree;
rcx::Node root;
root.kind = rcx::NodeKind::Struct;
root.name = "TestStruct";
root.structTypeName = "TestStruct";
root.parentId = 0;
root.offset = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
rcx::Node f1;
f1.kind = rcx::NodeKind::Int32;
f1.name = "health";
f1.parentId = rootId;
f1.offset = 0;
tree.addNode(f1);
rcx::Node f2;
f2.kind = rcx::NodeKind::Float;
f2.name = "speed";
f2.parentId = rootId;
f2.offset = 4;
tree.addNode(f2);
return tree;
}
// ── Test class ──
class TestRenderedView : public QObject {
Q_OBJECT
private slots:
// ── Verify caret line background is NOT yellow after setup ──
void testCaretLineBackgroundNotYellow() {
QsciScintilla sci;
setupRenderedSci(&sci);
sci.show();
sci.setText("struct Foo {\n int x;\n};\n");
QTest::qWait(50);
long bgr = sci.SendScintilla(QsciScintillaBase::SCI_GETCARETLINEBACK);
long expected = toBGR(QColor(43, 43, 43));
// Yellow would be 0x00FFFF or similar high-value — ours should be dark
long yellow = toBGR(QColor(255, 255, 0));
QVERIFY2(bgr != yellow,
qPrintable(QString("Caret line is yellow (0x%1), expected dark (0x%2)")
.arg(bgr, 6, 16, QChar('0'))
.arg(expected, 6, 16, QChar('0'))));
QCOMPARE(bgr, expected);
}
// ── Verify caret line is enabled ──
void testCaretLineEnabled() {
QsciScintilla sci;
setupRenderedSci(&sci);
long visible = sci.SendScintilla(QsciScintillaBase::SCI_GETCARETLINEVISIBLE);
QCOMPARE(visible, (long)1);
}
// ── Verify editor background (paper) is dark ──
void testPaperColor() {
QsciScintilla sci;
setupRenderedSci(&sci);
// Query default style background via Scintilla
long bgr = sci.SendScintilla(QsciScintillaBase::SCI_STYLEGETBACK,
(unsigned long)0 /*STYLE_DEFAULT*/);
long expected = toBGR(QColor("#1e1e1e"));
QCOMPARE(bgr, expected);
}
// ── Verify caret (cursor) foreground color ──
void testCaretForegroundColor() {
QsciScintilla sci;
setupRenderedSci(&sci);
long bgr = sci.SendScintilla(QsciScintillaBase::SCI_GETCARETFORE);
long expected = toBGR(QColor("#d4d4d4"));
QCOMPARE(bgr, expected);
}
// ── Verify selection colors are set (no direct Scintilla getter, but we can
// verify they survive a round-trip through the SCI_SETSEL* messages by
// checking the element colour API introduced in Scintilla 5.x) ──
void testSelectionColorsApplied() {
QsciScintilla sci;
setupRenderedSci(&sci);
sci.show();
sci.setText("int x = 42;\n");
QTest::qWait(50);
// Select text and verify rendering doesn't crash
sci.SendScintilla(QsciScintillaBase::SCI_SETSEL, (unsigned long)0, (long)3);
QTest::qWait(50);
// SCI_GETELEMENTCOLOUR (element 10 = SC_ELEMENT_SELECTION_BACK) returns
// the selection back colour on Scintilla >= 5.2. If not available, fall
// back to verifying the calls didn't throw and caret line is still correct.
constexpr int SCI_GETELEMENTCOLOUR = 2753;
constexpr int SC_ELEMENT_SELECTION_BACK = 10;
long selBack = sci.SendScintilla(SCI_GETELEMENTCOLOUR,
(unsigned long)SC_ELEMENT_SELECTION_BACK);
if (selBack != 0) {
// Scintilla 5.x: colour stored as 0xAABBGGRR (with alpha in high byte)
long bgrMask = selBack & 0x00FFFFFF;
long expected = toBGR(QColor("#264f78"));
QCOMPARE(bgrMask, expected);
} else {
// Older Scintilla: just verify caret line is still correct as a proxy
long caretBg = sci.SendScintilla(QsciScintillaBase::SCI_GETCARETLINEBACK);
long expected = toBGR(QColor(43, 43, 43));
QCOMPARE(caretBg, expected);
}
}
// ── Verify lexer keyword color is VS Code blue, not default ──
void testKeywordColor() {
QsciScintilla sci;
setupRenderedSci(&sci);
auto* lexer = qobject_cast<QsciLexerCPP*>(sci.lexer());
QVERIFY(lexer != nullptr);
QColor kw = lexer->color(QsciLexerCPP::Keyword);
QCOMPARE(kw, QColor("#569cd6"));
}
// ── Verify comment color is VS Code green ──
void testCommentColor() {
QsciScintilla sci;
setupRenderedSci(&sci);
auto* lexer = qobject_cast<QsciLexerCPP*>(sci.lexer());
QVERIFY(lexer != nullptr);
QCOMPARE(lexer->color(QsciLexerCPP::Comment), QColor("#6a9955"));
QCOMPARE(lexer->color(QsciLexerCPP::CommentLine), QColor("#6a9955"));
}
// ── Verify number color is VS Code light green ──
void testNumberColor() {
QsciScintilla sci;
setupRenderedSci(&sci);
auto* lexer = qobject_cast<QsciLexerCPP*>(sci.lexer());
QVERIFY(lexer != nullptr);
QCOMPARE(lexer->color(QsciLexerCPP::Number), QColor("#b5cea8"));
}
// ── Verify string color is VS Code orange ──
void testStringColor() {
QsciScintilla sci;
setupRenderedSci(&sci);
auto* lexer = qobject_cast<QsciLexerCPP*>(sci.lexer());
QVERIFY(lexer != nullptr);
QCOMPARE(lexer->color(QsciLexerCPP::DoubleQuotedString), QColor("#ce9178"));
QCOMPARE(lexer->color(QsciLexerCPP::SingleQuotedString), QColor("#ce9178"));
}
// ── Verify preprocessor color is VS Code purple ──
void testPreprocessorColor() {
QsciScintilla sci;
setupRenderedSci(&sci);
auto* lexer = qobject_cast<QsciLexerCPP*>(sci.lexer());
QVERIFY(lexer != nullptr);
QCOMPARE(lexer->color(QsciLexerCPP::PreProcessor), QColor("#c586c0"));
}
// ── Verify default/identifier text color ──
void testDefaultTextColor() {
QsciScintilla sci;
setupRenderedSci(&sci);
auto* lexer = qobject_cast<QsciLexerCPP*>(sci.lexer());
QVERIFY(lexer != nullptr);
QCOMPARE(lexer->color(QsciLexerCPP::Default), QColor("#d4d4d4"));
QCOMPARE(lexer->color(QsciLexerCPP::Identifier), QColor("#d4d4d4"));
QCOMPARE(lexer->color(QsciLexerCPP::Operator), QColor("#d4d4d4"));
}
// ── Verify all 128 lexer styles have dark paper ──
void testAllStylesHaveDarkPaper() {
QsciScintilla sci;
setupRenderedSci(&sci);
auto* lexer = qobject_cast<QsciLexerCPP*>(sci.lexer());
QVERIFY(lexer != nullptr);
QColor expected("#1e1e1e");
for (int i = 0; i <= 127; i++) {
QColor paper = lexer->paper(i);
QVERIFY2(paper == expected,
qPrintable(QString("Style %1 paper is %2, expected %3")
.arg(i).arg(paper.name()).arg(expected.name())));
}
}
// ── Verify margin colors match dark theme ──
void testMarginColors() {
QsciScintilla sci;
setupRenderedSci(&sci);
// Query margin background via Scintilla (style 33 = STYLE_LINENUMBER)
long marginBg = sci.SendScintilla(QsciScintillaBase::SCI_STYLEGETBACK,
(unsigned long)33);
long expectedBg = toBGR(QColor("#252526"));
QCOMPARE(marginBg, expectedBg);
long marginFg = sci.SendScintilla(QsciScintillaBase::SCI_STYLEGETFORE,
(unsigned long)33);
long expectedFg = toBGR(QColor("#858585"));
QCOMPARE(marginFg, expectedFg);
}
// ── End-to-end: generate C++ and load into rendered view ──
void testGeneratedCodeInRenderedView() {
auto tree = makeTestTree();
uint64_t rootId = tree.nodes[0].id;
QString code = rcx::renderCpp(tree, rootId);
// Verify generated code has no pragma pack / cstdint
QVERIFY(!code.contains("#pragma pack"));
QVERIFY(!code.contains("#include <cstdint>"));
QVERIFY(code.contains("#pragma once"));
QVERIFY(code.contains("struct TestStruct {"));
// Load into rendered sci and verify colors survive
QsciScintilla sci;
setupRenderedSci(&sci);
sci.show();
sci.setText(code);
QTest::qWait(100);
// Caret line must still be dark after text load
long caretBg = sci.SendScintilla(QsciScintillaBase::SCI_GETCARETLINEBACK);
long expected = toBGR(QColor(43, 43, 43));
QCOMPARE(caretBg, expected);
// Paper must still be dark
long paperBg = sci.SendScintilla(QsciScintillaBase::SCI_STYLEGETBACK,
(unsigned long)0);
QCOMPARE(paperBg, toBGR(QColor("#1e1e1e")));
}
// ── Verify brace matching is disabled ──
void testBraceMatchDisabled() {
QsciScintilla sci;
setupRenderedSci(&sci);
QCOMPARE(sci.braceMatching(), QsciScintilla::NoBraceMatch);
}
};
QTEST_MAIN(TestRenderedView)
#include "test_rendered_view.moc"

View File

@@ -0,0 +1,246 @@
#include <QtTest/QTest>
#include <QApplication>
#include <QSplitter>
#include <QDir>
#include <QFile>
#include <Qsci/qsciscintilla.h>
#include "controller.h"
#include "core.h"
#include "providers/null_provider.h"
#include "providers/buffer_provider.h"
using namespace rcx;
static void buildTree(NodeTree& tree) {
tree.baseAddress = 0x1000;
Node root;
root.kind = NodeKind::Struct;
root.structTypeName = "TestClass";
root.name = "TestClass";
root.parentId = 0;
root.offset = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
Node f;
f.kind = NodeKind::Hex64;
f.name = "field_00";
f.parentId = rootId;
f.offset = 0;
tree.addNode(f);
}
class TestSourceManagement : public QObject {
Q_OBJECT
private:
RcxDocument* m_doc = nullptr;
RcxController* m_ctrl = nullptr;
QSplitter* m_splitter = nullptr;
// Helper: write a temp binary file and return its path
QString writeTempFile(const QString& name, const QByteArray& data) {
QString path = QDir::tempPath() + "/" + name;
QFile f(path);
f.open(QIODevice::WriteOnly);
f.write(data);
f.close();
return path;
}
// Helper: directly add a file source entry (bypasses QFileDialog)
void addFileSource(const QString& path, const QString& displayName) {
m_doc->loadData(path);
SavedSourceEntry entry;
entry.kind = QStringLiteral("File");
entry.displayName = displayName;
entry.filePath = path;
entry.baseAddress = m_doc->tree.baseAddress;
// Access saved sources through selectSource's internal mechanism
// We manually add since selectSource("File") opens a dialog
m_ctrl->document()->provider = std::make_shared<BufferProvider>(
QFile(path).readAll().isEmpty() ? QByteArray(64, '\0') : QByteArray(64, '\0'));
// Use the test accessor pattern from controller
}
private slots:
void init() {
m_doc = new RcxDocument();
buildTree(m_doc->tree);
m_splitter = new QSplitter();
m_ctrl = new RcxController(m_doc, nullptr);
m_ctrl->addSplitEditor(m_splitter);
m_splitter->resize(800, 600);
m_splitter->show();
QVERIFY(QTest::qWaitForWindowExposed(m_splitter));
QApplication::processEvents();
}
void cleanup() {
delete m_ctrl; m_ctrl = nullptr;
delete m_splitter; m_splitter = nullptr;
delete m_doc; m_doc = nullptr;
}
// ── Initial state: NullProvider, no saved sources ──
void testInitialProviderIsNull() {
QVERIFY(m_doc->provider != nullptr);
QCOMPARE(m_doc->provider->size(), 0);
QVERIFY(!m_doc->provider->isValid());
QCOMPARE(m_ctrl->savedSources().size(), 0);
QCOMPARE(m_ctrl->activeSourceIndex(), -1);
}
// ── Loading binary data creates a valid provider ──
void testLoadDataCreatesValidProvider() {
QByteArray data(128, '\xAB');
m_doc->loadData(data);
QApplication::processEvents();
QVERIFY(m_doc->provider->isValid());
QCOMPARE(m_doc->provider->size(), 128);
QCOMPARE(m_doc->provider->readU8(0), (uint8_t)0xAB);
}
// ── clearSources resets to NullProvider ──
void testClearSourcesResetsToNull() {
// Load some data first so provider is valid
QByteArray data(64, '\xFF');
m_doc->loadData(data);
QApplication::processEvents();
QVERIFY(m_doc->provider->isValid());
m_ctrl->clearSources();
QApplication::processEvents();
// Provider should be NullProvider
QVERIFY(!m_doc->provider->isValid());
QCOMPARE(m_doc->provider->size(), 0);
// Saved sources should be empty
QCOMPARE(m_ctrl->savedSources().size(), 0);
QCOMPARE(m_ctrl->activeSourceIndex(), -1);
}
// ── clearSources clears value history ──
void testClearSourcesClearsValueHistory() {
// The value history is cleared via resetSnapshot inside clearSources
m_ctrl->clearSources();
QApplication::processEvents();
QVERIFY(m_ctrl->valueHistory().isEmpty());
}
// ── clearSources clears dataPath ──
void testClearSourcesClearsDataPath() {
QString path = writeTempFile("rcx_test_src.bin", QByteArray(64, '\xCC'));
m_doc->loadData(path);
QVERIFY(!m_doc->dataPath.isEmpty());
m_ctrl->clearSources();
QApplication::processEvents();
QVERIFY(m_doc->dataPath.isEmpty());
QFile::remove(path);
}
// ── selectSource("#clear") calls clearSources ──
void testSelectSourceClearCommand() {
QByteArray data(64, '\xFF');
m_doc->loadData(data);
QVERIFY(m_doc->provider->isValid());
m_ctrl->selectSource(QStringLiteral("#clear"));
QApplication::processEvents();
QVERIFY(!m_doc->provider->isValid());
QCOMPARE(m_ctrl->savedSources().size(), 0);
QCOMPARE(m_ctrl->activeSourceIndex(), -1);
}
// ── clearSources then refresh still works (compose doesn't crash) ──
void testClearSourcesThenRefreshWorks() {
m_ctrl->clearSources();
QApplication::processEvents();
// refresh() is called internally by clearSources; verify it didn't crash
// and the editor still has content (the tree structure is intact)
auto* editor = m_ctrl->editors().first();
QVERIFY(editor != nullptr);
}
// ── Multiple clearSources calls are safe (idempotent) ──
void testMultipleClearSourcesIdempotent() {
m_ctrl->clearSources();
m_ctrl->clearSources();
m_ctrl->clearSources();
QApplication::processEvents();
QVERIFY(!m_doc->provider->isValid());
QCOMPARE(m_ctrl->savedSources().size(), 0);
QCOMPARE(m_ctrl->activeSourceIndex(), -1);
}
// ── switchToSavedSource with invalid index is no-op ──
void testSwitchInvalidIndexNoOp() {
m_ctrl->switchSource(-1);
m_ctrl->switchSource(999);
QApplication::processEvents();
// Should still be in initial state
QCOMPARE(m_ctrl->activeSourceIndex(), -1);
}
// ── Provider read fails after clear (all zeros) ──
void testProviderReadFailsAfterClear() {
QByteArray data(64, '\xAB');
m_doc->loadData(data);
QCOMPARE(m_doc->provider->readU8(0), (uint8_t)0xAB);
m_ctrl->clearSources();
QApplication::processEvents();
// NullProvider: read returns false, readU8 returns 0
uint8_t buf = 0xFF;
QVERIFY(!m_doc->provider->read(0, &buf, 1));
QCOMPARE(m_doc->provider->readU8(0), (uint8_t)0);
}
// ── clearSources resets snapshot state ──
void testClearSourcesResetsSnapshot() {
QByteArray data(64, '\x00');
m_doc->loadData(data);
QApplication::processEvents();
m_ctrl->clearSources();
QApplication::processEvents();
// After clear, the value history should be empty (resetSnapshot was called)
QVERIFY(m_ctrl->valueHistory().isEmpty());
}
// ── NullProvider name is empty (triggers "source" placeholder in command row) ──
void testNullProviderNameEmpty() {
m_ctrl->clearSources();
QApplication::processEvents();
QVERIFY(m_doc->provider->name().isEmpty());
}
};
QTEST_MAIN(TestSourceManagement)
#include "test_source_management.moc"

Some files were not shown because too many files have changed in this diff Show More