Compare commits

...

22 Commits

Author SHA1 Message Date
IChooseYou
5944dbdc81 fix: cast char16_t to uint for QString::arg on macOS 2026-03-04 10:37:18 -07:00
IChooseYou
b3425aec9e clean up README: move screenshots above features, trim sections 2026-03-04 10:34:39 -07:00
IChooseYou
2a8cfee719 docs: update README screenshots (Windows, macOS, scanner) 2026-03-04 10:22:58 -07:00
IChooseYou
e999c664b8 feat: tree lines, scanner improvements, themes, tooltips, README overhaul
- Tree line connectors (Unicode box-drawing ├─ └─ │) at arbitrary depth
- Fix editor overwriting tree chars at depth 2+ (applyMarginText Pass 2)
- Scanner: unknown value scan, comparison rescan modes (Changed/Unchanged/Increased/Decreased)
- New Tailwind theme (tw.json), WCAG contrast fixes for warm/mid themes
- Tooltip system (rcxtooltip.h)
- Comprehensive README rewrite with full feature inventory
- New tests for compose tree lines, scanner, tooltips

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 09:21:09 -07:00
Lab
0dc4af6b1d Merge pull request #7 from IChooseYou/bundle-mcp-bridge
Bundle ReclassMcpBridge into macOS .app
2026-03-03 15:45:07 -08:00
Lab
376aad2169 Bundle ReclassMcpBridge into macOS .app
Copy the MCP stdio bridge executable into Reclass.app/Contents/MacOS/
via a POST_BUILD step so Claude Desktop can find it when the app is
distributed as a bundle.
2026-03-03 15:43:37 -08:00
Matty
4937c58062 fix: grey out value input instead of hiding, raise unknown scan cap to 10M 2026-03-03 12:16:14 -07:00
Matty
9c72265901 feat: scanner unknown value + comparison rescan modes, find bar height fix
Add Cheat Engine-style scan conditions: Unknown Value captures all
aligned addresses as baseline, then Changed/Unchanged/Increased/Decreased
narrow results by comparing current vs previous values. Exact Value
mode unchanged. Also fix find bar search box height to match buttons
and improve MCP bridge instructions.
2026-03-03 11:32:13 -07:00
IChooseYou
86499e58ee fix: remove value history cooldown hack, dismiss popup on clear
The cooldown suppressed tracking for ~1s but the popup persisted showing
stale "1h ago" values because applyDocument skips popup dismissal.
Replaced with explicit dismissHistoryPopup() after clear+refresh so the
popup is gone immediately. Value tracking resumes on the next async cycle
with a clean baseline (m_refreshGen++ discards in-flight reads,
m_prevPages.clear() prevents phantom diffs).
2026-03-03 08:38:08 -07:00
IChooseYou
b2ae8d5a5d fix: insert above node, clear value history cooldown, search context menu
- Insert 4/8 now inserts above the right-clicked node and shifts siblings
  down instead of appending at end. Insert key shortcut (Shift+Ins = 4,
  Ins = 8). Falls back to append when clicking empty space.
- Clear Value History uses a 5-cycle cooldown counter so heat stays gone
  for ~1s instead of returning on the next async refresh.
- Right-click Search defers showFindBar via QTimer::singleShot so focus
  isn't stolen by the closing context menu.
2026-03-03 08:31:49 -07:00
IChooseYou
6768f04e9a Merge pull request #6 from LabGuy94/add-macos-support
Fix file opening on macOS
2026-03-03 08:31:25 -07:00
Lab
c6e5f6508f Fix file opening on macOS 2026-03-02 15:25:57 -08:00
IChooseYou
e6529052b3 fix: clear value history clears subtree, add Copy Line and Search to context menu
- Clear Value History now removes history for all descendant nodes too
- Add "Copy Line" right-click menu item
- Add "Search..." right-click menu item (opens Ctrl+F find bar)
- Move showFindBar() to public in editor.h
2026-03-02 15:34:37 -07:00
IChooseYou
d43e989992 Merge pull request #5 from LabGuy94/add-macos-support
Add macOS support and CI
2026-03-02 14:57:55 -07:00
IChooseYou
879e9f4047 fix: global blue highlight, Ctrl+F find bar with prev/next/close buttons
- Change QPalette::Highlight from theme.selection to theme.hover globally
- RcxEditor find: use SCI_SEARCHINTARGET + INDIC_COMPOSITIONTHICK indicator
  (selection rendering is disabled, so findFirst was invisible)
- Re-apply find indicators after applyDocument() refresh cycle
- Add prev/next/close buttons to find bars in both Reclass and C/C++ modes
- Buttons styled with hover/pressed states matching tab styling
2026-03-02 14:53:14 -07:00
Lab
e0d5a799b4 Add macOS support and CI 2026-03-02 11:34:22 -08:00
IChooseYou
efae193520 feat: value history timestamps, Ctrl+F search, base address fixes
- Add timestamps to ValueHistory ring buffer, expose via new MCP tool
  node.history, show relative time in popup ("26s ago", "2m ago")
- Add "Clear Value History" right-click menu for single and multi-select
- Add Ctrl+F find bar to RcxEditor with live search, Enter-to-next, wrap
- Fix Ctrl+F in workspace dock to auto-focus search field
- Add "Change to float" quick-convert for Hex32 right-click menu
- Sort workspace explorer by children count descending (most fields first)
- Fix provider->base() overwriting saved base address from .rcx files
- Add formula support to MCP change_base operation
- Re-evaluate baseAddressFormula on provider attach in selectSource()

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 10:00:17 -07:00
IChooseYou
ba1c2f8e5a refactor: process picker themed styling, context menu, auto-select
Extract shared init into initUi(). Apply dark theme styling from global
palette to table, header, filter, and buttons. Add right-click context
menu with Copy PID/Name/Path. Auto-select last attached process on open.
Remove duplicate attach->accept() connection from .ui (handled in code).
2026-03-02 08:24:39 -07:00
IChooseYou
5a0a4d1802 feat: recent files menu, remove split visibility, clean up demo data
Add Recent Files submenu under File menu (persists last 10 opened/saved
files in QSettings). Hide Remove Split action until a split actually
exists. Remove _SAMPLE_OBJECT demo class from both buildEmptyStruct and
buildEditorDemo. Create a second empty class tab on selfTest so the user
starts with a clean workspace.
2026-03-02 07:50:46 -07:00
Sen66
030eb34510 fix: include shim also on linux 2026-03-02 00:11:37 +01:00
Sen66
2939b25895 fix: build instructions for fadec on cmake build 2026-03-02 00:08:11 +01:00
Sen66
d38cb02fa2 fix: mingw build 2026-03-01 23:58:06 +01:00
46 changed files with 14587 additions and 488 deletions

View File

@@ -2,7 +2,8 @@ name: Build
on:
push:
branches: [main]
branches:
- "**"
pull_request:
branches: [main]
@@ -21,9 +22,9 @@ jobs:
- name: Install Qt6 and MinGW
uses: jurplel/install-qt-action@v4
with:
version: '6.8.1'
arch: 'win64_mingw'
tools: 'tools_mingw1310,qt.tools.win64_mingw1310'
version: "6.8.1"
arch: "win64_mingw"
tools: "tools_mingw1310,qt.tools.win64_mingw1310"
cache: true
- name: Configure
@@ -83,7 +84,7 @@ jobs:
- name: Install Qt6
uses: jurplel/install-qt-action@v4
with:
version: '6.8.1'
version: "6.8.1"
cache: true
- name: Install dependencies
@@ -140,9 +141,66 @@ jobs:
name: Reclass-linux64-qt6
path: Reclass-linux64-qt6.AppImage
macos:
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: macos-15
qt_arch: clang_arm64
artifact_name: Reclass-macos-arm64-qt6
zip_name: Reclass-macos-arm64-qt6.zip
- os: macos-15-intel
qt_arch: clang_64
artifact_name: Reclass-macos-x86_64-qt6
zip_name: Reclass-macos-x86_64-qt6.zip
steps:
- uses: actions/checkout@v4
with:
submodules: recursive
- name: Install dependencies
run: |
brew update
brew install cmake ninja qt
- name: Configure Qt paths
run: |
QT_PREFIX="$(brew --prefix qt)"
echo "QT_PREFIX=$QT_PREFIX" >> "$GITHUB_ENV"
echo "PATH=$QT_PREFIX/bin:$PATH" >> "$GITHUB_ENV"
- name: Configure
run: cmake -B build -G Ninja -DCMAKE_BUILD_TYPE=Release -DBUILD_UI_TESTS=OFF -DCMAKE_PREFIX_PATH="$QT_PREFIX"
- name: Build
run: cmake --build build
- name: Test
run: ctest --test-dir build --output-on-failure
- name: Package app zip
run: |
MACDEPLOYQT_BIN="$QT_PREFIX/bin/macdeployqt"
if [ ! -x "$MACDEPLOYQT_BIN" ]; then
MACDEPLOYQT_BIN=$(which macdeployqt 2>/dev/null || find "$RUNNER_WORKSPACE" -name macdeployqt -path "*/bin/*" | head -1)
fi
echo "Found macdeployqt at: $MACDEPLOYQT_BIN"
"$MACDEPLOYQT_BIN" build/Reclass.app -always-overwrite
codesign --force --deep --sign - build/Reclass.app
ditto -c -k --sequesterRsrc --keepParent build/Reclass.app "${{ matrix.zip_name }}"
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact_name }}
path: ${{ matrix.zip_name }}
release:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [windows, linux]
needs: [windows, linux, macos]
runs-on: ubuntu-latest
steps:
@@ -167,5 +225,7 @@ jobs:
files: |
artifacts/Reclass-win64-qt6/Reclass-win64-qt6.zip
artifacts/Reclass-linux64-qt6/Reclass-linux64-qt6.AppImage
artifacts/Reclass-macos-arm64-qt6/Reclass-macos-arm64-qt6.zip
artifacts/Reclass-macos-x86_64-qt6/Reclass-macos-x86_64-qt6.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

1
.gitignore vendored
View File

@@ -14,3 +14,4 @@ CMakeUserPresets.json
plugins/RcNetPluginCompatLayer/bridge/obj
plugins/RcNetPluginCompatLayer/bridge/bin
.cache
*.DS_Store

View File

@@ -36,10 +36,35 @@ file(GLOB RAW_PDB_SRCS third_party/raw_pdb/src/*.cpp)
add_library(raw_pdb STATIC ${RAW_PDB_SRCS})
target_include_directories(raw_pdb PUBLIC third_party/raw_pdb/src)
target_compile_features(raw_pdb PRIVATE cxx_std_11)
# PDB_CRT.h forward-declares printf/memcmp/etc with __cdecl which conflicts
# with non-MSVC compilers (GCC, Clang, MinGW). Force-include a prefix header
# that pulls in the real CRT headers and strips __cdecl.
if(NOT MSVC)
target_compile_options(raw_pdb PUBLIC
-include "${CMAKE_CURRENT_SOURCE_DIR}/cmake/raw_pdb_prefix.h")
endif()
if(WIN32)
target_link_libraries(raw_pdb PRIVATE rpcrt4)
endif()
# Fadec — generate decode tables (.inc files) from instrs.txt at configure time
find_package(Python3 3.9 REQUIRED)
set(FADEC_DIR "${CMAKE_SOURCE_DIR}/third_party/fadec")
if(NOT EXISTS "${FADEC_DIR}/fadec-decode-public.inc")
message(STATUS "Generating fadec decode tables...")
execute_process(
COMMAND ${Python3_EXECUTABLE} "${FADEC_DIR}/parseinstrs.py" decode
"${FADEC_DIR}/instrs.txt"
"${FADEC_DIR}/fadec-decode-public.inc"
"${FADEC_DIR}/fadec-decode-private.inc"
--32 --64
RESULT_VARIABLE _fadec_result
)
if(NOT _fadec_result EQUAL 0)
message(FATAL_ERROR "Failed to generate fadec decode tables")
endif()
endif()
add_executable(Reclass
src/main.cpp
src/editor.h
@@ -88,6 +113,8 @@ add_executable(Reclass
src/optionsdialog.cpp
src/titlebar.h
src/titlebar.cpp
src/macos_titlebar.h
$<$<PLATFORM_ID:Darwin>:src/macos_titlebar.mm>
src/mcp/mcp_bridge.h
src/mcp/mcp_bridge.cpp
src/addressparser.h
@@ -99,6 +126,16 @@ add_executable(Reclass
$<$<PLATFORM_ID:Windows>:src/app.rc>
)
if(APPLE)
set_target_properties(Reclass PROPERTIES
MACOSX_BUNDLE TRUE
MACOSX_BUNDLE_ICON_FILE "class.icns"
)
target_sources(Reclass PRIVATE src/icons/class.icns)
set_source_files_properties(src/icons/class.icns
PROPERTIES MACOSX_PACKAGE_LOCATION "Resources")
endif()
target_include_directories(Reclass PRIVATE src third_party/fadec)
target_link_libraries(Reclass PRIVATE
@@ -116,6 +153,14 @@ endif()
add_executable(ReclassMcpBridge tools/rcx-mcp-stdio.cpp)
target_link_libraries(ReclassMcpBridge PRIVATE ${QT}::Core ${QT}::Network)
if(APPLE)
add_custom_command(TARGET ReclassMcpBridge POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy
$<TARGET_FILE:ReclassMcpBridge>
$<TARGET_FILE_DIR:Reclass>/ReclassMcpBridge
COMMENT "Bundling ReclassMcpBridge into Reclass.app"
)
endif()
# Copy built-in theme JSON files to build directory
file(GLOB _theme_files "${CMAKE_SOURCE_DIR}/src/themes/defaults/*.json")
@@ -125,6 +170,12 @@ foreach(_tf ${_theme_files})
configure_file(${_tf} "${CMAKE_BINARY_DIR}/themes/${_name}" COPYONLY)
endforeach()
if(APPLE)
target_sources(Reclass PRIVATE ${_theme_files})
set_source_files_properties(${_theme_files}
PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/themes")
endif()
# Copy example .rcx files to build directory
file(GLOB _example_files "${CMAKE_SOURCE_DIR}/src/examples/*.rcx")
file(MAKE_DIRECTORY "${CMAKE_BINARY_DIR}/examples")
@@ -133,6 +184,12 @@ foreach(_ef ${_example_files})
configure_file(${_ef} "${CMAKE_BINARY_DIR}/examples/${_name}" COPYONLY)
endforeach()
if(APPLE)
target_sources(Reclass PRIVATE ${_example_files})
set_source_files_properties(${_example_files}
PROPERTIES MACOSX_PACKAGE_LOCATION "Resources/examples")
endif()
include(deploy)
@@ -273,178 +330,178 @@ if(BUILD_TESTING)
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
add_executable(test_controller tests/test_controller.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.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
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)
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
add_executable(test_validation tests/test_validation.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.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
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)
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
add_executable(test_context_menu tests/test_context_menu.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.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_context_menu PRIVATE src third_party/fadec)
target_link_libraries(test_context_menu PRIVATE
target_include_directories(test_context_menu PRIVATE src third_party/fadec)
target_link_libraries(test_context_menu PRIVATE
${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)
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
add_executable(test_source_management tests/test_source_management.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.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
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)
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
add_executable(test_editor tests/test_editor.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.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
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_test(NAME test_editor COMMAND test_editor)
add_executable(test_rendered_view tests/test_rendered_view.cpp
add_executable(test_rendered_view tests/test_rendered_view.cpp
src/generator.cpp src/compose.cpp src/format.cpp src/addressparser.cpp)
target_include_directories(test_rendered_view PRIVATE src)
target_link_libraries(test_rendered_view PRIVATE
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_test(NAME test_rendered_view COMMAND test_rendered_view)
add_executable(test_new_features tests/test_new_features.cpp
add_executable(test_new_features tests/test_new_features.cpp
src/generator.cpp src/compose.cpp src/format.cpp src/addressparser.cpp src/controller.cpp
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
target_include_directories(test_new_features PRIVATE src third_party/fadec)
target_link_libraries(test_new_features PRIVATE
${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)
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
add_executable(test_type_selector tests/test_type_selector.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.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
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)
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
add_executable(test_type_visibility tests/test_type_visibility.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.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
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)
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
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)
target_include_directories(test_options_dialog PRIVATE src)
target_link_libraries(test_options_dialog PRIVATE ${QT}::Widgets ${QT}::Test)
add_test(NAME test_options_dialog COMMAND test_options_dialog)
add_executable(test_source_provider tests/test_source_provider.cpp
add_executable(test_source_provider tests/test_source_provider.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.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}
src/resources.qrc)
target_include_directories(test_source_provider PRIVATE src third_party/fadec)
target_link_libraries(test_source_provider PRIVATE
target_include_directories(test_source_provider PRIVATE src third_party/fadec)
target_link_libraries(test_source_provider PRIVATE
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test ${QT}::Svg
QScintilla::QScintilla)
if(WIN32)
target_link_libraries(test_source_provider PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
endif()
add_test(NAME test_source_provider COMMAND test_source_provider)
if(WIN32)
target_link_libraries(test_source_provider PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
endif()
add_test(NAME test_source_provider COMMAND test_source_provider)
add_executable(test_scanner_ui tests/test_scanner_ui.cpp
add_executable(test_scanner_ui tests/test_scanner_ui.cpp
src/scanner.cpp src/scannerpanel.cpp src/addressparser.cpp
src/themes/theme.cpp src/themes/thememanager.cpp)
target_include_directories(test_scanner_ui PRIVATE src)
target_link_libraries(test_scanner_ui PRIVATE
target_include_directories(test_scanner_ui PRIVATE src)
target_link_libraries(test_scanner_ui PRIVATE
${QT}::Widgets ${QT}::Concurrent ${QT}::Test)
add_test(NAME test_scanner_ui COMMAND test_scanner_ui)
add_test(NAME test_scanner_ui COMMAND test_scanner_ui)
if(WIN32)
add_executable(test_windbg_provider tests/test_windbg_provider.cpp
if(WIN32)
add_executable(test_windbg_provider tests/test_windbg_provider.cpp
plugins/WinDbgMemory/WinDbgMemoryPlugin.cpp
src/scanner.cpp)
target_include_directories(test_windbg_provider PRIVATE src plugins/WinDbgMemory)
target_link_libraries(test_windbg_provider PRIVATE
target_include_directories(test_windbg_provider PRIVATE src plugins/WinDbgMemory)
target_link_libraries(test_windbg_provider PRIVATE
${QT}::Widgets ${QT}::Concurrent ${QT}::Test dbgeng ole32)
add_test(NAME test_windbg_provider COMMAND test_windbg_provider)
endif()
add_test(NAME test_windbg_provider COMMAND test_windbg_provider)
endif()
add_executable(bench_large_class tests/bench_large_class.cpp
add_executable(bench_large_class tests/bench_large_class.cpp
src/editor.cpp src/compose.cpp src/format.cpp src/addressparser.cpp
src/providerregistry.cpp
src/themes/theme.cpp src/themes/thememanager.cpp ${DISASM_SRCS})
target_include_directories(bench_large_class PRIVATE src third_party/fadec)
target_link_libraries(bench_large_class PRIVATE
target_include_directories(bench_large_class PRIVATE src third_party/fadec)
target_link_libraries(bench_large_class PRIVATE
${QT}::Widgets ${QT}::PrintSupport ${QT}::Concurrent ${QT}::Test
QScintilla::QScintilla)
if(WIN32)
target_link_libraries(bench_large_class PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
endif()
add_test(NAME bench_large_class COMMAND bench_large_class)
if(WIN32)
target_link_libraries(bench_large_class PRIVATE dbghelp psapi ${_QT_WINEXTRAS})
endif()
add_test(NAME bench_large_class COMMAND bench_large_class)
# 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
# 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
@@ -452,12 +509,14 @@ if(BUILD_TESTING)
DEPENDS test_controller
COMMENT "Deploying Qt runtime DLLs for tests..."
)
endif()
endif()
endif() # BUILD_UI_TESTS
endif()
add_subdirectory(plugins/ProcessMemory)
add_subdirectory(plugins/RemoteProcessMemory)
if(NOT APPLE)
add_subdirectory(plugins/ProcessMemory)
add_subdirectory(plugins/RemoteProcessMemory)
endif()
if(WIN32)
add_subdirectory(plugins/WinDbgMemory)
add_subdirectory(plugins/RcNetPluginCompatLayer)

129
README.md
View File

@@ -12,64 +12,109 @@
[![Build](https://github.com/IChooseYou/Reclass/actions/workflows/build.yml/badge.svg)](https://github.com/IChooseYou/Reclass/actions/workflows/build.yml)
[![License](https://img.shields.io/github/license/IChooseYou/Reclass)](LICENSE)
[![Release](https://img.shields.io/github/v/release/IChooseYou/Reclass?label=snapshot)](https://github.com/IChooseYou/Reclass/releases)
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux-blue)]()
[![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20Linux%20%7C%20macOS-blue)]()
</div>
Reclass 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 at runtime from a live process, or from a static source like a binary file or crash dump.
Reclass helps you inspect raw bytes and interpret them as types (structs, arrays, primitives, pointers, padding) instead of just hex. It is a debugging tool for figuring out unknown data structures — either at runtime from a live process, or from a static source like a binary file or crash dump.
Built with C++17, Qt 6 (Qt 5 also supported), and QScintilla. The entire editor surface is rendered as formatted plain text with inline editing, fold markers, and hex/ASCII previews.
## Screenshots
![Windows — VTable with value history popup](docs/README_PIC1.png)
![macOS — project tree with kernel struct inspection](docs/README_PIC2.png)
![Memory scanner](docs/README_PIC3.png)
## Features
- **Structured binary view** — render raw bytes as typed fields (integers, floats, pointers, vectors, matrices, strings, booleans, padding)
- **Struct & array nesting** — define nested structs and arrays with collapsible fold regions
- **Enums & bitfields** — define enums and bitfield types with named members, inline editing, and auto-sort
- **Inline editing** — click to edit type names, field names, values, and base addresses directly in the editor
- **Undo/redo** — full undo history for all mutations via command stack
- **Multi-document tabs** — open multiple projects simultaneously in MDI sub-windows
### Editor
- **Structured binary view** — render raw bytes as typed fields with columnar alignment
- **Inline editing** — click to edit type names, field names, values, base addresses, array metadata, pointer targets, enum members, bitfield members, static expressions, and comments — all with real-time validation
- **Tab-cycling** — tab through editable fields within a line
- **Type autocomplete** — cached popup type picker with search/filter for struct targets
- **Multi-select** — Ctrl+click individual nodes or Shift+click for range selection
- **Split views** — multiple synchronized editor panes over the same document
- **Type autocomplete** — popup type picker when changing field kinds
- **Hex + ASCII margins** — raw byte previews alongside the structured view
- **Value history & heatmap** — track value changes over time with color-coded heat indicators
- **Disassembly preview** — hover over code pointers to see decoded instructions
- **C/C++ code generation** — export structs as compilable C/C++ headers
- **Import / export** — PDB import (Windows), ReClass XML import/export, C/C++ source import
- **Themes** — built-in theme editor with multiple presets
- **MCP bridge** — expose all tool functionality to AI clients via Model Context Protocol
- **Plugin system** — extend with custom data source providers via DLL plugins; the following ship by default:
- **Process plugin** — access memory of live processes on Windows and Linux
- **WinDbg plugin** — access data sources live in WinDbg debugging sessions
- **ReClass.NET compatibility layer** — load existing .NET and native ReClass.NET plugins
- **Find bar** — Ctrl+F in-editor search with indicator highlighting
- **Fold/collapse** — expand and collapse structs, arrays, and pointer expansions with embedded fold indicators
- **Hex + ASCII columns** — raw byte previews alongside the structured view with per-byte change highlighting
## Roadmap
### Live Memory Analysis
- [ ] Process memory section enumeration
- [ ] Address parser auto-complete
- [ ] Safe mode
- [ ] File import for other Reclass instances
- [ ] Expose UI functionality to plugins
- [ ] iOS/macOS support
- [ ] Display RTTI information
- **Auto-refresh** — configurable interval (default 660ms) with async page-based reads for non-blocking UI
- **Value history & heatmap** — per-node ring buffer (10 samples with timestamps), color-coded heat indicators (static/cold/warm/hot) based on change frequency
- **Changed-byte highlighting** — per-byte change indicators within hex preview lines
- **Memory write-back** — edit values inline, writes propagate through the provider to live process memory
- **Pointer chasing** — automatic reads of dereferenced memory regions across pointer chains
- **Address parser** — formula expressions like `<module.exe>+0x1A0`, pointer dereference chains, symbol resolution
### Undo / Redo
Full command stack with 15 undoable operations: ChangeKind, Rename, Collapse, Insert, Remove, ChangeBase, WriteBytes, ChangeArrayMeta, ChangePointerRef, ChangeStructTypeName, ChangeClassKeyword, ChangeOffset, ChangeEnumMembers, ChangeOffsetExpr, ToggleStatic. Batch macro support for multi-node operations.
### Import / Export
| Format | Import | Export |
|--------|:------:|:------:|
| **Native JSON (.rcx)** | Full tree + metadata | Full tree + metadata |
| **C/C++ source** | Struct/class/union/enum parsing with offset comments | Header generation with optional static asserts |
| **ReClass XML** | Full compatibility with ReClass Classic | Full compatibility |
| **PDB symbols (Windows)** | UDT enumeration with selective recursive import via raw_pdb — no DIA SDK dependency | |
### Workspace & Navigation
- **Multi-document tabs** — MDI interface, one document per tab
- **Workspace dock** — project explorer tree with struct/enum/union icons, sorted by field count, quick navigation to members
- **Scanner dock** — integrated memory search panel
- **Dual view mode** — switch between ReClass tree view and rendered C/C++ output per tab
- **View root** — focus on a specific struct, hiding all others
- **Scroll to node** — programmatic navigation to any node by ID
## 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
- **Remote Process** — read another process's memory via shared memory
- **WinDbg** — load `.dmp` crash dump files or connect to live debugging sessions
- **Process** — attach to a live process and read its memory in real time (Windows/Linux)
- **Remote Process** — read another process's memory over TCP with cross-architecture 32/64-bit support
- **WinDbg** — connect to live WinDbg debugging sessions or load crash dumps
- **Saved sources** — quick-switch between recently used data sources per tab
## Screenshots
## Plugin System
![Type chooser and struct inspection](docs/README_PIC1.png)
Extensible provider architecture via DLL plugins with `IPlugin` interface, factory function discovery, and auto/manual loading from a Plugins folder.
![VTable pointer expansion with disassembly preview](docs/README_PIC2.png)
**Bundled plugins:**
![Split view with rendered C/C++ output](docs/README_PIC3.png)
| Plugin | Description |
|--------|-------------|
| **Process memory** | Attach to local processes on Windows and Linux — PID-based, with symbol resolution and module/region enumeration |
| **WinDbg** | Access data from live WinDbg debugging sessions |
| **Remote process memory** | TCP RPC-based remote process access with cross-architecture support |
| **ReClass.NET compatibility** | Load existing ReClass.NET native DLL plugins directly; optional .NET CLR hosting for managed plugins |
## MCP Integration
Built-in [Model Context Protocol](https://modelcontextprotocol.io/) bridge via `ReclassMcpBridge`. The server starts automatically on launch and can be toggled from the File menu. It exposes all tool functionality to any MCP-compatible client (e.g. Claude Code). A standalone stdio-to-pipe bridge binary is built alongside the main application. To connect, add this to your MCP client config (e.g. `.mcp.json`):
Built-in [Model Context Protocol](https://modelcontextprotocol.io/) bridge via `ReclassMcpBridge` — the first reverse engineering tool with native AI/LLM integration. The server uses JSON-RPC 2.0 over named pipes and can be toggled from the Tools menu or auto-started on launch.
**Available tools:**
| Tool | Description |
|------|-------------|
| `projectState` | Read current tree structure, base address, tab state |
| `treeApply` | Apply structural command deltas to the node tree |
| `sourceSwitch` | Switch the active data source |
| `hexRead` | Read bytes at an address |
| `hexWrite` | Write bytes at an address |
| `statusSet` | Update the status bar text |
| `uiAction` | Trigger menu actions programmatically |
| `treeSearch` | Search nodes by name or type |
| `nodeHistory` | Query value change history for a node |
**Notifications:** `notifyTreeChanged`, `notifyDataChanged`
A standalone stdio-to-pipe bridge binary is built alongside the main application. To connect, add this to your MCP client config (e.g. `.mcp.json`):
```json
{
@@ -101,6 +146,16 @@ cd Reclass
The build script auto-detects your Qt install location.
### macOS Build
```bash
./scripts/build_macos.sh --qt-dir /opt/homebrew/opt/qt --build-type Release --package
```
If you installed Qt via Homebrew, `--qt-dir /opt/homebrew/opt/qt` is typical on Apple Silicon. You can also set `QTDIR` or `Qt6_DIR` instead of passing `--qt-dir`.
Note: macOS Gatekeeper may block unsigned apps. If the app won't open, go to **System Settings > Privacy & Security** and click **Open Anyway**.
### Manual Build (MinGW)
1. Clone with `--recurse-submodules` (or run `git submodule update --init --recursive` after cloning)
@@ -122,6 +177,8 @@ The `msvc/` folder contains a ready-made solution (`Reclass.slnx`) with projects
ctest --test-dir build --output-on-failure
```
30 tests covering composition, serialization, undo/redo, import/export, provider switching, type visibility, validation, scanning, and rendering.
## Alternatives
- [ReClass.NET](https://github.com/ReClassNET/ReClass.NET)

29
cmake/raw_pdb_prefix.h Normal file
View File

@@ -0,0 +1,29 @@
// Force-included before every raw_pdb translation unit (and consumers).
// PDB_CRT.h forward-declares printf/memcmp/etc with extern "C" __cdecl,
// which conflicts with MinGW's CRT headers (C++ linkage, no __cdecl).
//
// Fix: include the real CRT headers, then include PDB_CRT.h with function
// names macro-renamed to harmless dummies. This triggers #pragma once so
// no raw_pdb source file ever processes PDB_CRT.h's conflicting declarations.
//
// Guarded with __cplusplus because PUBLIC propagation applies this to C
// sources (fadec) where PDB_CRT.h is irrelevant and <cstdio> doesn't exist.
#ifdef __cplusplus
#include <cstdio>
#include <cstring>
#undef __cdecl
#define __cdecl
#define printf _pdb_crt_unused_printf
#define memcmp _pdb_crt_unused_memcmp
#define memcpy _pdb_crt_unused_memcpy
#define strlen _pdb_crt_unused_strlen
#define strcmp _pdb_crt_unused_strcmp
#include "Foundation/PDB_CRT.h"
#undef printf
#undef memcmp
#undef memcpy
#undef strlen
#undef strcmp
#endif

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 150 KiB

After

Width:  |  Height:  |  Size: 113 KiB

168
scripts/build_macos.sh Executable file
View File

@@ -0,0 +1,168 @@
#!/usr/bin/env bash
set -euo pipefail
print_help() {
cat <<'EOF'
Reclass macOS Build Script
Usage:
./scripts/build_macos.sh [options]
Options:
--qt-dir <path> Qt installation prefix (e.g. /opt/homebrew/opt/qt)
--build-type <type> Release | Debug | RelWithDebInfo | MinSizeRel (default: Release)
--build-dir <path> Build directory (default: <repo>/build)
--generator <name> CMake generator (default: Ninja if available)
--clean Remove build directory before configuring
--rebuild Clean then build
--package Run macdeployqt and create a zip
--tests Run ctest after build
-h, --help Show this help
Notes:
- You can set QTDIR or Qt6_DIR in your environment instead of --qt-dir.
- If Qt is installed via Homebrew, the script will try to detect it.
EOF
}
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
project_root="$(cd "${script_dir}/.." && pwd)"
qt_dir=""
build_type="Release"
build_dir="${project_root}/build"
generator=""
do_clean="false"
do_package="false"
do_tests="false"
while [[ $# -gt 0 ]]; do
case "$1" in
--qt-dir)
qt_dir="${2:-}"
shift 2
;;
--build-type)
build_type="${2:-}"
shift 2
;;
--build-dir)
build_dir="${2:-}"
shift 2
;;
--generator)
generator="${2:-}"
shift 2
;;
--clean)
do_clean="true"
shift
;;
--rebuild)
do_clean="true"
shift
;;
--package)
do_package="true"
shift
;;
--tests)
do_tests="true"
shift
;;
-h|--help)
print_help
exit 0
;;
*)
echo "Unknown argument: $1" >&2
print_help
exit 1
;;
esac
done
if [[ -z "${qt_dir}" ]]; then
if [[ -n "${QTDIR:-}" ]]; then
qt_dir="${QTDIR}"
elif [[ -n "${Qt6_DIR:-}" ]]; then
qt_dir="${Qt6_DIR}"
elif command -v brew >/dev/null 2>&1; then
if brew --prefix qt >/dev/null 2>&1; then
qt_dir="$(brew --prefix qt)"
fi
fi
fi
if ! command -v cmake >/dev/null 2>&1; then
echo "ERROR: cmake not found. Install CMake and try again." >&2
exit 1
fi
if [[ -z "${generator}" ]]; then
if command -v ninja >/dev/null 2>&1; then
generator="Ninja"
fi
fi
if [[ "${do_clean}" == "true" && -d "${build_dir}" ]]; then
echo "Cleaning build directory: ${build_dir}"
rm -rf "${build_dir}"
fi
mkdir -p "${build_dir}"
cmake_args=(
-S "${project_root}"
-B "${build_dir}"
-DCMAKE_BUILD_TYPE="${build_type}"
)
if [[ -n "${generator}" ]]; then
cmake_args+=(-G "${generator}")
fi
if [[ -n "${qt_dir}" ]]; then
export PATH="${qt_dir}/bin:${PATH}"
cmake_args+=(-DCMAKE_PREFIX_PATH="${qt_dir}")
fi
echo "Configuring..."
cmake "${cmake_args[@]}"
echo "Building..."
cmake --build "${build_dir}" --config "${build_type}"
if [[ "${do_tests}" == "true" ]]; then
echo "Running tests..."
ctest --test-dir "${build_dir}" --output-on-failure -C "${build_type}"
fi
if [[ "${do_package}" == "true" ]]; then
app_path="${build_dir}/Reclass.app"
if [[ ! -d "${app_path}" ]]; then
echo "ERROR: ${app_path} not found. Build may have failed." >&2
exit 1
fi
macdeployqt_bin=""
if [[ -n "${qt_dir}" && -x "${qt_dir}/bin/macdeployqt" ]]; then
macdeployqt_bin="${qt_dir}/bin/macdeployqt"
elif command -v macdeployqt >/dev/null 2>&1; then
macdeployqt_bin="$(command -v macdeployqt)"
fi
if [[ -z "${macdeployqt_bin}" ]]; then
echo "ERROR: macdeployqt not found. Ensure Qt is installed and in PATH." >&2
exit 1
fi
echo "Running macdeployqt..."
"${macdeployqt_bin}" "${app_path}" -always-overwrite
arch="$(uname -m)"
zip_name="Reclass-macos-${arch}-qt6.zip"
echo "Creating zip: ${zip_name}"
ditto -c -k --sequesterRsrc --keepParent "${app_path}" "${build_dir}/${zip_name}"
echo "Packaged: ${build_dir}/${zip_name}"
fi

View File

@@ -24,6 +24,8 @@ struct ComposeState {
int offsetHexDigits = 8; // hex digit tier for offset margin
bool baseEmitted = false; // only first root struct shows base address
bool compactColumns = false; // compact column mode: cap type width, overflow long types
bool treeLines = false; // draw Unicode tree connectors in indentation
QVector<bool> siblingStack; // per-depth: true = more siblings follow at this level
uint64_t currentPtrBase = 0; // absolute addr of current pointer expansion target
// Precomputed for O(1) lookups
@@ -41,6 +43,15 @@ struct ComposeState {
return scopeNameW.value(scopeId, nameW);
}
// Set sibling-continuation flag for children at the given depth.
// childDepth is the depth of the children being iterated.
void setTreeSibling(int childDepth, bool hasMoreSiblings) {
if (!treeLines) return;
int d = childDepth - 1;
while (siblingStack.size() <= d) siblingStack.append(false);
siblingStack[d] = hasMoreSiblings;
}
void emitLine(const QString& lineText, LineMeta lm) {
if (currentLine > 0) text += '\n';
// 3-char fold indicator column: " - " expanded, " + " collapsed, " " other
@@ -52,7 +63,29 @@ struct ComposeState {
text += lm.foldCollapsed ? QStringLiteral(" \u25B8 ") : QStringLiteral(" \u25BE ");
else
text += QStringLiteral(" ");
text += lineText;
// Replace leading indent spaces with Unicode tree connectors
if (treeLines && lm.depth > 0) {
QString treeIndent;
int D = lm.depth;
bool isFooter = (lm.lineKind == LineKind::Footer);
for (int d = 0; d < D; d++) {
bool active = (d < siblingStack.size() && siblingStack[d]);
if (isFooter || d < D - 1) {
// Ancestor continuation or footer's own level
treeIndent += active ? QStringLiteral("\u2502 ")
: QStringLiteral(" ");
} else {
// This node's own connector (non-footer only)
treeIndent += active ? QStringLiteral("\u251C\u2500 ")
: QStringLiteral("\u2514\u2500 ");
}
}
text += treeIndent + lineText.mid(D * 3);
} else {
text += lineText;
}
meta.append(lm);
currentLine++;
}
@@ -305,6 +338,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
});
for (int oi = 0; oi < order.size(); oi++) {
state.setTreeSibling(childDepth, oi < order.size() - 1);
int mi = order[oi];
const auto& m = node.enumMembers[mi];
LineMeta lm;
@@ -353,6 +387,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
maxNameLen = qMax(maxNameLen, (int)m.name.size());
for (int mi = 0; mi < node.bitfieldMembers.size(); mi++) {
state.setTreeSibling(childDepth, mi < node.bitfieldMembers.size() - 1);
const auto& m = node.bitfieldMembers[mi];
uint64_t bitVal = fmt::extractBits(prov, absAddr, node.elementKind,
m.bitOffset, m.bitWidth);
@@ -415,6 +450,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
int eTW = state.effectiveTypeW(node.id);
int eNW = state.effectiveNameW(node.id);
for (int i = 0; i < node.arrayLen; i++) {
state.setTreeSibling(childDepth, i < node.arrayLen - 1);
uint64_t elemAddr = absAddr + i * elemSize;
// Type override: "float[0]", "uint32_t[1]", etc.
@@ -460,6 +496,7 @@ void composeParent(ComposeState& state, const NodeTree& tree,
int elemSize = tree.structSpan(node.refId, &state.childMap);
if (elemSize <= 0) elemSize = 1;
for (int i = 0; i < node.arrayLen; i++) {
state.setTreeSibling(childDepth, i < node.arrayLen - 1);
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,
@@ -476,7 +513,9 @@ void composeParent(ComposeState& state, const NodeTree& tree,
const QVector<int>& refChildren = childIndices(state, node.refId);
// Use the referenced struct's scope widths (children come from there)
uint64_t refScopeId = node.refId;
for (int childIdx : refChildren) {
for (int rci = 0; rci < refChildren.size(); rci++) {
int childIdx = refChildren[rci];
state.setTreeSibling(childDepth, rci < refChildren.size() - 1);
const Node& child = tree.nodes[childIdx];
// Self-referential child → show as collapsed struct (non-expandable)
if (state.visiting.contains(child.id)) {
@@ -514,7 +553,13 @@ void composeParent(ComposeState& state, const NodeTree& tree,
// For arrays, render children as condensed (no header/footer for struct elements)
bool childrenAreArrayElements = (node.kind == NodeKind::Array);
int elementIdx = 0;
for (int childIdx : regular) {
for (int ri = 0; ri < regular.size(); ri++) {
int childIdx = regular[ri];
// A regular child has more siblings if there are more regular children
// or if static fields follow after all regular children
bool hasMore = (ri < regular.size() - 1)
|| (!staticIdxs.isEmpty() && !node.collapsed);
state.setTreeSibling(childDepth, hasMore);
// Pass this container's id as the scope for children (for per-scope widths)
// For array elements, also pass the element index for [N] separator
composeNode(state, tree, prov, childIdx, childDepth, base, rootId,
@@ -569,7 +614,9 @@ void composeParent(ComposeState& state, const NodeTree& tree,
auto cbs = makeResolver(absAddr);
for (int si : staticIdxs) {
for (int sii = 0; sii < staticIdxs.size(); sii++) {
int si = staticIdxs[sii];
state.setTreeSibling(childDepth, sii < staticIdxs.size() - 1);
const Node& sf = tree.nodes[si];
// Evaluate expression → absolute address
@@ -639,8 +686,18 @@ void composeParent(ComposeState& state, const NodeTree& tree,
// ── Body + children (only when expanded) ──
if (!isCollapsed) {
// Determine if struct children follow the body line
bool hasStructKids = exprOk
&& (sf.kind == NodeKind::Struct || sf.kind == NodeKind::Array);
const QVector<int> staticKids = hasStructKids
? childIndices(state, sf.id) : QVector<int>();
hasStructKids = hasStructKids && !staticKids.isEmpty();
// Body line: " return <expr> → 0xADDR"
{
// Body has more siblings if struct children follow
state.setTreeSibling(childDepth + 1, hasStructKids);
QString bodyLine;
if (!sf.offsetExpr.isEmpty()) {
if (exprOk)
@@ -676,10 +733,10 @@ void composeParent(ComposeState& state, const NodeTree& tree,
}
// If struct/array, compose children at evaluated address
if (exprOk && (sf.kind == NodeKind::Struct || sf.kind == NodeKind::Array)) {
const QVector<int>& staticKids = childIndices(state, sf.id);
for (int sci : staticKids) {
composeNode(state, tree, prov, sci, childDepth + 1,
if (hasStructKids) {
for (int ski = 0; ski < staticKids.size(); ski++) {
state.setTreeSibling(childDepth + 1, ski < staticKids.size() - 1);
composeNode(state, tree, prov, staticKids[ski], childDepth + 1,
staticAddr, sf.id, false, sf.id);
}
}
@@ -818,8 +875,9 @@ void composeNode(ComposeState& state, const NodeTree& tree,
// 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.
for (int childIdx : ptrChildren) {
composeNode(state, tree, childProv, childIdx, depth + 1,
for (int pci = 0; pci < ptrChildren.size(); pci++) {
state.setTreeSibling(depth + 1, pci < ptrChildren.size() - 1);
composeNode(state, tree, childProv, ptrChildren[pci], depth + 1,
pBase, node.id, false, node.id);
}
} else {
@@ -878,9 +936,10 @@ void composeNode(ComposeState& state, const NodeTree& tree,
} // anonymous namespace
ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId,
bool compactColumns) {
bool compactColumns, bool treeLines) {
ComposeState state;
state.compactColumns = compactColumns;
state.treeLines = treeLines;
// Precompute parent→children map
for (int i = 0; i < tree.nodes.size(); i++)
@@ -1026,7 +1085,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, state.offsetHexDigits, tree.baseAddress} };
return { state.text, state.meta, LayoutInfo{state.typeW, state.nameW, state.offsetHexDigits, tree.baseAddress, treeLines} };
}
QSet<uint64_t> NodeTree::normalizePreferAncestors(const QSet<uint64_t>& ids) const {

View File

@@ -72,8 +72,9 @@ RcxDocument::RcxDocument(QObject* parent)
});
}
ComposeResult RcxDocument::compose(uint64_t viewRootId, bool compactColumns) const {
return rcx::compose(tree, *provider, viewRootId, compactColumns);
ComposeResult RcxDocument::compose(uint64_t viewRootId, bool compactColumns,
bool treeLines) const {
return rcx::compose(tree, *provider, viewRootId, compactColumns, treeLines);
}
bool RcxDocument::save(const QString& path) {
@@ -244,6 +245,17 @@ void RcxController::connectEditor(RcxEditor* editor) {
showTypePopup(editor, mode, nodeIdx, globalPos);
});
// Insert key shortcut
connect(editor, &RcxEditor::insertAboveRequested,
this, [this](int nodeIdx, NodeKind kind) {
if (nodeIdx >= 0)
insertNodeAbove(nodeIdx, kind, QStringLiteral("field"));
else {
uint64_t target = m_viewRootId ? m_viewRootId : 0;
insertNode(target, -1, kind, QStringLiteral("field"));
}
});
// Inline editing signals
connect(editor, &RcxEditor::inlineEditCommitted,
this, [this](int nodeIdx, int subLine, EditTarget target, const QString& text,
@@ -308,7 +320,7 @@ void RcxController::connectEditor(RcxEditor* editor) {
// Regular type change
bool ok;
NodeKind k = kindFromTypeName(text, &ok);
if (ok) {
if (ok && k != NodeKind::Struct && k != NodeKind::Array) {
changeNodeKind(nodeIdx, k);
} else if (nodeIdx < m_doc->tree.nodes.size()) {
// Check if it's a defined struct type name
@@ -535,6 +547,7 @@ void RcxController::resetChangeTracking() {
m_changedOffsets.clear();
m_valueHistory.clear();
m_prevPages.clear();
m_valueTrackCooldown = 5; // suppress tracking for ~1s
for (auto& lm : m_lastResult.meta)
lm.heatLevel = 0;
}
@@ -545,9 +558,9 @@ void RcxController::refresh() {
// Compose against snapshot provider if active, otherwise real provider
if (m_snapshotProv)
m_lastResult = rcx::compose(m_doc->tree, *m_snapshotProv, m_viewRootId, m_compactColumns);
m_lastResult = rcx::compose(m_doc->tree, *m_snapshotProv, m_viewRootId, m_compactColumns, m_treeLines);
else
m_lastResult = m_doc->compose(m_viewRootId, m_compactColumns);
m_lastResult = m_doc->compose(m_viewRootId, m_compactColumns, m_treeLines);
s_composeDoc = nullptr;
@@ -591,7 +604,8 @@ void RcxController::refresh() {
else if (m_doc->provider && m_doc->provider->isValid() && m_doc->provider->isLive())
prov = m_doc->provider.get();
if (m_trackValues && prov) {
if (m_valueTrackCooldown > 0) --m_valueTrackCooldown;
if (m_trackValues && prov && m_valueTrackCooldown <= 0) {
for (auto& lm : m_lastResult.meta) {
if (lm.nodeIdx < 0 || lm.nodeIdx >= m_doc->tree.nodes.size()) continue;
if (isSyntheticLine(lm) || lm.isContinuation) continue;
@@ -708,6 +722,15 @@ void RcxController::changeNodeKind(int nodeIdx, NodeKind newKind) {
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeKind{node.id, node.kind, newKind, {}}));
// Hex nodes don't display names (ASCII preview instead), so the stored
// name may be empty or stale. Give it a sensible default.
if (isHexNode(node.kind) && !isHexNode(newKind)) {
QString autoName = QStringLiteral("field_%1")
.arg(node.offset, 4, 16, QChar('0'));
m_doc->undoStack.push(new RcxCommand(this,
cmd::Rename{node.id, node.name, autoName}));
}
// Insert hex nodes to fill the gap (largest first for alignment)
int padOffset = baseOffset;
while (gap > 0) {
@@ -741,8 +764,19 @@ void RcxController::changeNodeKind(int nodeIdx, NodeKind newKind) {
adjs.append({sib.id, sib.offset, sib.offset + delta});
}
}
bool needsRename = isHexNode(node.kind) && !isHexNode(newKind);
if (needsRename) {
m_doc->undoStack.beginMacro(QStringLiteral("Change type"));
}
m_doc->undoStack.push(new RcxCommand(this,
cmd::ChangeKind{node.id, node.kind, newKind, adjs}));
if (needsRename) {
QString autoName = QStringLiteral("field_%1")
.arg(node.offset, 4, 16, QChar('0'));
m_doc->undoStack.push(new RcxCommand(this,
cmd::Rename{node.id, node.name, autoName}));
m_doc->undoStack.endMacro();
}
}
}
@@ -782,6 +816,31 @@ void RcxController::insertNode(uint64_t parentId, int offset, NodeKind kind, con
m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{n}));
}
void RcxController::insertNodeAbove(int beforeIdx, NodeKind kind, const QString& name) {
if (beforeIdx < 0 || beforeIdx >= m_doc->tree.nodes.size()) return;
const Node& before = m_doc->tree.nodes[beforeIdx];
Node n;
n.kind = kind;
n.name = name;
n.parentId = before.parentId;
n.offset = before.offset;
n.id = m_doc->tree.reserveId();
int insertSize = sizeForKind(kind);
// Shift siblings at or after the insertion offset down
QVector<cmd::OffsetAdj> adjs;
auto siblings = m_doc->tree.childrenOf(before.parentId);
for (int si : siblings) {
auto& sib = m_doc->tree.nodes[si];
if (sib.offset >= before.offset)
adjs.append({sib.id, sib.offset, sib.offset + insertSize});
}
m_doc->undoStack.push(new RcxCommand(this, cmd::Insert{n, adjs}));
}
void RcxController::removeNode(int nodeIdx) {
if (nodeIdx < 0 || nodeIdx >= m_doc->tree.nodes.size()) return;
const Node& node = m_doc->tree.nodes[nodeIdx];
@@ -1558,6 +1617,17 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
return indices;
};
// ── Insert shortcuts (always available) ──
menu.addAction(icon("diff-added.svg"), "Insert 4", [this]() {
uint64_t target = m_viewRootId ? m_viewRootId : 0;
insertNode(target, -1, NodeKind::Hex32, QStringLiteral("field"));
});
menu.addAction(icon("diff-added.svg"), "Insert 8", [this]() {
uint64_t target = m_viewRootId ? m_viewRootId : 0;
insertNode(target, -1, NodeKind::Hex64, QStringLiteral("field"));
});
menu.addSeparator();
// Quick-convert shortcuts when all selected nodes share the same kind
NodeKind commonKind = NodeKind::Hex64;
bool allSame = true;
@@ -1581,6 +1651,8 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
} else if (commonKind == NodeKind::Hex32) {
menu.addAction("Change to uint32_t", [this, collectIndices]() {
batchChangeKind(collectIndices(), NodeKind::UInt32); });
menu.addAction("Change to float", [this, collectIndices]() {
batchChangeKind(collectIndices(), NodeKind::Float); });
addedQuickConvert = true;
} else if (commonKind == NodeKind::Hex16) {
menu.addAction("Change to int16_t", [this, collectIndices]() {
@@ -1629,6 +1701,24 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
act->setChecked(m_trackValues);
connect(act, &QAction::toggled, this, &RcxController::setTrackValues);
}
{
auto* act = menu.addAction("Clear Value History");
act->setToolTip(QStringLiteral("Reset change tracking for selected nodes"));
connect(act, &QAction::triggered, this, [this, ids]() {
for (uint64_t id : ids) {
m_valueHistory.remove(id);
for (int ci : m_doc->tree.subtreeIndices(id))
m_valueHistory.remove(m_doc->tree.nodes[ci].id);
}
m_refreshGen++; // discard in-flight async reads
m_prevPages.clear(); // clean baseline for next read cycle
m_changedOffsets.clear(); // no phantom change indicators
m_valueTrackCooldown = 5; // suppress tracking for ~1s
refresh();
for (auto* editor : m_editors)
editor->dismissHistoryPopup();
});
}
menu.addSeparator();
// Check if all selected nodes share the same parent (required for grouping)
@@ -1658,7 +1748,8 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
menu.addSeparator();
menu.addAction(icon("link.svg"), "Copy &Address", [this, ids]() {
QMenu* copyMenu = menu.addMenu(icon("clippy.svg"), "Copy");
copyMenu->addAction(icon("link.svg"), "Copy &Address", [this, ids]() {
QStringList addrs;
for (uint64_t id : ids) {
int ni = m_doc->tree.indexOfId(id);
@@ -1675,6 +1766,28 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
QMenu menu;
// ── Insert shortcuts (at very top) ──
if (hasNode) {
menu.addAction(icon("diff-added.svg"), "Insert 4 Above\tShift+Ins",
[this, nodeIdx]() {
insertNodeAbove(nodeIdx, NodeKind::Hex32, QStringLiteral("field"));
});
menu.addAction(icon("diff-added.svg"), "Insert 8 Above\tIns",
[this, nodeIdx]() {
insertNodeAbove(nodeIdx, NodeKind::Hex64, QStringLiteral("field"));
});
} else {
menu.addAction(icon("diff-added.svg"), "Insert 4", [this]() {
uint64_t target = m_viewRootId ? m_viewRootId : 0;
insertNode(target, -1, NodeKind::Hex32, QStringLiteral("field"));
});
menu.addAction(icon("diff-added.svg"), "Insert 8", [this]() {
uint64_t target = m_viewRootId ? m_viewRootId : 0;
insertNode(target, -1, NodeKind::Hex64, QStringLiteral("field"));
});
}
menu.addSeparator();
// ── Node-specific actions (only when clicking on a node) ──
if (hasNode) {
const Node& node = m_doc->tree.nodes[nodeIdx];
@@ -1723,6 +1836,10 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) changeNodeKind(ni, NodeKind::UInt32);
});
menu.addAction("Change to float", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni >= 0) changeNodeKind(ni, NodeKind::Float);
});
addedQuickConvert = true;
} else if (node.kind == NodeKind::Hex16) {
menu.addAction("Change to int16_t", [this, nodeId]() {
@@ -1812,6 +1929,22 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
act->setChecked(m_trackValues);
connect(act, &QAction::toggled, this, &RcxController::setTrackValues);
}
{
auto* act = menu.addAction("Clear Value History");
act->setToolTip(QStringLiteral("Reset change tracking for this node"));
connect(act, &QAction::triggered, this, [this, nodeId]() {
m_valueHistory.remove(nodeId);
for (int ci : m_doc->tree.subtreeIndices(nodeId))
m_valueHistory.remove(m_doc->tree.nodes[ci].id);
m_refreshGen++; // discard in-flight async reads
m_prevPages.clear(); // clean baseline for next read cycle
m_changedOffsets.clear(); // no phantom change indicators
m_valueTrackCooldown = 5; // suppress tracking for ~1s
refresh();
for (auto* editor : m_editors)
editor->dismissHistoryPopup();
});
}
menu.addSeparator();
// Convert to Hex nodes (decompose non-hex types into Hex64/32/16/8)
@@ -1961,24 +2094,6 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
if (ni >= 0) removeNode(ni);
});
menu.addSeparator();
menu.addAction(icon("link.svg"), "Copy &Address", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni < 0) return;
uint64_t addr = m_doc->tree.baseAddress + m_doc->tree.computeOffset(ni);
QApplication::clipboard()->setText(
QStringLiteral("0x") + QString::number(addr, 16).toUpper());
});
menu.addAction(icon("whole-word.svg"), "Copy &Offset", [this, nodeId]() {
int ni = m_doc->tree.indexOfId(nodeId);
if (ni < 0) return;
int off = m_doc->tree.nodes[ni].offset;
QApplication::clipboard()->setText(
QStringLiteral("+0x") + QString::number(off, 16).toUpper().rightJustified(4, '0'));
});
menu.addSeparator();
} // else (non-member node actions)
}
@@ -2059,10 +2174,46 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
menu.addSeparator();
menu.addAction(icon("clippy.svg"), "Copy All as Text", [editor]() {
QMenu* copyMenu = menu.addMenu(icon("clippy.svg"), "Copy");
if (hasNode) {
uint64_t copyNodeId = m_doc->tree.nodes[nodeIdx].id;
copyMenu->addAction(icon("link.svg"), "Copy &Address", [this, copyNodeId]() {
int ni = m_doc->tree.indexOfId(copyNodeId);
if (ni < 0) return;
uint64_t addr = m_doc->tree.baseAddress + m_doc->tree.computeOffset(ni);
QApplication::clipboard()->setText(
QStringLiteral("0x") + QString::number(addr, 16).toUpper());
});
copyMenu->addAction(icon("whole-word.svg"), "Copy &Offset", [this, copyNodeId]() {
int ni = m_doc->tree.indexOfId(copyNodeId);
if (ni < 0) return;
int off = m_doc->tree.nodes[ni].offset;
QApplication::clipboard()->setText(
QStringLiteral("+0x") + QString::number(off, 16).toUpper().rightJustified(4, '0'));
});
copyMenu->addSeparator();
}
copyMenu->addAction("Copy Line", [editor, line]() {
auto* sci = editor->scintilla();
int len = (int)sci->SendScintilla(QsciScintillaBase::SCI_LINELENGTH, (unsigned long)line);
if (len > 0) {
QByteArray buf(len + 1, '\0');
sci->SendScintilla(QsciScintillaBase::SCI_GETLINE, (unsigned long)line, (void*)buf.data());
QString text = QString::fromUtf8(buf.data(), len).trimmed();
if (!text.isEmpty())
QApplication::clipboard()->setText(text);
}
});
copyMenu->addAction("Copy All as Text", [editor]() {
QApplication::clipboard()->setText(editor->textWithMargins());
});
menu.addSeparator();
menu.addAction(icon("search.svg"), "Search...\tCtrl+F", [editor]() {
QTimer::singleShot(0, editor, &RcxEditor::showFindBar);
});
menu.exec(globalPos);
}
@@ -2462,7 +2613,7 @@ void RcxController::showTypePopup(RcxEditor* editor, TypePopupMode mode,
break;
case TypePopupMode::FieldType: {
addPrimitives(/*enabled=*/true, /*excludeStructArrayPad=*/false);
addPrimitives(/*enabled=*/true, /*excludeStructArrayPad=*/true);
bool isPtr = node
&& (node->kind == NodeKind::Pointer32 || node->kind == NodeKind::Pointer64);
bool isTypedPtr = isPtr && node->refId != 0;
@@ -2934,7 +3085,32 @@ void RcxController::selectSource(const QString& text) {
m_doc->undoStack.clear();
m_doc->provider = std::move(provider);
m_doc->dataPath.clear();
m_doc->tree.baseAddress = (newBase != 0) ? newBase : m_doc->tree.baseAddress;
m_doc->tree.pointerSize = m_doc->provider->pointerSize();
// Re-evaluate formula if present (mirrors attachViaPlugin)
if (!m_doc->tree.baseAddressFormula.isEmpty()) {
AddressParserCallbacks cbs;
auto* prov = m_doc->provider.get();
cbs.resolveModule = [prov](const QString& name, bool* ok) -> uint64_t {
uint64_t base = prov->symbolToAddress(name);
*ok = (base != 0);
return base;
};
int ptrSz = m_doc->tree.pointerSize;
cbs.readPointer = [prov, ptrSz](uint64_t addr, bool* ok) -> uint64_t {
uint64_t val = 0;
*ok = prov->read(addr, &val, ptrSz);
return val;
};
auto result = AddressParser::evaluate(
m_doc->tree.baseAddressFormula, ptrSz, &cbs);
if (result.ok)
m_doc->tree.baseAddress = result.value;
} else if (newBase != 0 && m_doc->tree.baseAddress == 0x00400000) {
// Only apply provider base for fresh/default projects.
// If user loaded an .rcx with a custom base, preserve it.
m_doc->tree.baseAddress = newBase;
}
resetSnapshot();
emit m_doc->documentChanged();
@@ -3010,6 +3186,11 @@ void RcxController::setCompactColumns(bool v) {
refresh();
}
void RcxController::setTreeLines(bool v) {
m_treeLines = v;
refresh();
}
void RcxController::setupAutoRefresh() {
int ms = QSettings("Reclass", "Reclass").value("refreshMs", 660).toInt();
m_refreshTimer = new QTimer(this);

View File

@@ -40,7 +40,8 @@ public:
return m ? QString::fromLatin1(m->typeName) : QStringLiteral("???");
}
ComposeResult compose(uint64_t viewRootId = 0, bool compactColumns = false) const;
ComposeResult compose(uint64_t viewRootId = 0, bool compactColumns = false,
bool treeLines = false) const;
bool save(const QString& path);
bool load(const QString& path);
void loadData(const QString& binaryPath);
@@ -90,6 +91,7 @@ public:
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 insertNodeAbove(int beforeIdx, NodeKind kind, const QString& name);
void removeNode(int nodeIdx);
void toggleCollapse(int nodeIdx);
void materializeRefChildren(int nodeIdx);
@@ -127,6 +129,7 @@ public:
void setEditorFont(const QString& fontName);
void setRefreshInterval(int ms);
void setCompactColumns(bool v);
void setTreeLines(bool v);
void resetProvider();
// MCP bridge accessors
@@ -147,8 +150,10 @@ public:
// Cross-tab type visibility: point at the project's full document list
void setProjectDocuments(QVector<RcxDocument*>* docs) { m_projectDocs = docs; }
// Test accessor
// Test accessors
const QHash<uint64_t, ValueHistory>& valueHistory() const { return m_valueHistory; }
const ComposeResult& lastResult() const { return m_lastResult; }
int dataExtent() const { return computeDataExtent(); }
signals:
void nodeSelected(int nodeIdx);
@@ -162,6 +167,7 @@ private:
int m_anchorLine = -1;
bool m_suppressRefresh = false;
bool m_compactColumns = false;
bool m_treeLines = false;
uint64_t m_viewRootId = 0;
// ── Saved sources for quick-switch ──
@@ -181,6 +187,7 @@ private:
QSet<int64_t> m_changedOffsets;
QHash<uint64_t, ValueHistory> m_valueHistory;
bool m_trackValues = true;
int m_valueTrackCooldown = 0; // suppress value recording for N refresh cycles after clear
uint64_t m_refreshGen = 0;
uint64_t m_readGen = 0;
bool m_readInFlight = false;

View File

@@ -11,6 +11,7 @@
#include <array>
#include <memory>
#include <variant>
#include <QDateTime>
#include "providers/provider.h"
#include "providers/buffer_provider.h"
@@ -85,8 +86,8 @@ inline constexpr KindMeta kKindMeta[] = {
{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::UTF8, "UTF8", "str", 1, 1, 1, KF_String},
{NodeKind::UTF16, "UTF16", "wstr", 2, 1, 2, KF_String},
{NodeKind::Struct, "Struct", "struct", 0, 1, 1, KF_Container},
{NodeKind::Array, "Array", "array", 0, 1, 1, KF_Container},
};
@@ -152,14 +153,11 @@ inline constexpr bool isValidPrimitivePtrTarget(NodeKind k) {
return true;
}
inline QStringList allTypeNamesForUI(bool stripBrackets = false) {
inline QStringList allTypeNamesForUI(bool /*stripBrackets*/ = false) {
QStringList out;
out.reserve(std::size(kKindMeta));
for (const auto& m : kKindMeta) {
QString t = QString::fromLatin1(m.typeName);
if (stripBrackets) t.remove(QStringLiteral("[]"));
out << t;
}
for (const auto& m : kKindMeta)
out << QString::fromLatin1(m.typeName);
out.sort(Qt::CaseInsensitive);
out.removeDuplicates();
return out;
@@ -500,6 +498,7 @@ struct NodeTree {
struct ValueHistory {
static constexpr int kCapacity = 10;
std::array<QString, kCapacity> values;
std::array<qint64, kCapacity> timestamps{}; // msec since epoch
int count = 0; // total unique values recorded
int head = 0; // next write position in ring
@@ -509,10 +508,16 @@ struct ValueHistory {
if (values[last] == v) return; // no change
}
values[head] = v;
timestamps[head] = QDateTime::currentMSecsSinceEpoch();
head = (head + 1) % kCapacity;
if (count < INT_MAX) count++;
}
void clear() {
count = 0;
head = 0;
}
int uniqueCount() const { return qMin(count, kCapacity); }
// 0=static, 1=cold(2 unique), 2=warm(3-4), 3=hot(5+)
@@ -536,6 +541,16 @@ struct ValueHistory {
for (int i = 0; i < n; i++)
fn(values[(start + i) % kCapacity]);
}
// Iterate with timestamps from newest to oldest
template<typename Fn>
void forEachWithTime(Fn&& fn) const {
int n = uniqueCount();
for (int i = 0; i < n; i++) {
int idx = (head + kCapacity - 1 - i) % kCapacity;
fn(values[idx], timestamps[idx]);
}
}
};
// ── LineMeta ──
@@ -618,6 +633,7 @@ struct LayoutInfo {
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
bool treeLines = false; // Whether tree line connectors are embedded in the text
};
// ── ComposeResult ──
@@ -1015,6 +1031,6 @@ namespace fmt {
// ── Compose function forward declaration ──
ComposeResult compose(const NodeTree& tree, const Provider& prov, uint64_t viewRootId = 0,
bool compactColumns = false);
bool compactColumns = false, bool treeLines = false);
} // namespace rcx

View File

@@ -19,8 +19,10 @@
#include <QClipboard>
#include <QLabel>
#include <QToolButton>
#include <QLineEdit>
#include <QScreen>
#include <QScrollBar>
#include <QDateTime>
#include <functional>
#include "themes/thememanager.h"
@@ -102,7 +104,8 @@ public:
sep->setPalette(sp);
vbox->addWidget(sep);
for (const QString& v : vals) {
qint64 now = QDateTime::currentMSecsSinceEpoch();
hist.forEachWithTime([&](const QString& v, qint64 msec) {
auto* row = new QHBoxLayout;
row->setContentsMargins(0, 1, 0, 1);
row->setSpacing(8);
@@ -113,6 +116,24 @@ public:
row->addWidget(label, 1);
m_labels.append(label);
if (msec > 0) {
qint64 elapsed = now - msec;
QString timeStr;
if (elapsed < 1000)
timeStr = QStringLiteral("now");
else if (elapsed < 60000)
timeStr = QStringLiteral("%1s ago").arg(elapsed / 1000);
else if (elapsed < 3600000)
timeStr = QStringLiteral("%1m ago").arg(elapsed / 60000);
else
timeStr = QStringLiteral("%1h ago").arg(elapsed / 3600000);
auto* timeLabel = new QLabel(timeStr);
timeLabel->setFont(font);
timeLabel->setStyleSheet(QStringLiteral("color: %1;").arg(theme.textDim.name()));
row->addWidget(timeLabel);
}
if (showButtons) {
auto* setBtn = new QToolButton;
setBtn->setText(QStringLiteral("Set"));
@@ -130,12 +151,12 @@ public:
row->addWidget(setBtn);
}
vbox->addLayout(row);
}
});
adjustSize();
}
void showAt(const QPoint& globalPos) {
void showAt(const QPoint& globalPos, int lineHeight = 0) {
QSize sz = sizeHint();
QRect screen = QApplication::screenAt(globalPos)
? QApplication::screenAt(globalPos)->availableGeometry()
@@ -143,7 +164,7 @@ public:
int x = qMin(globalPos.x(), screen.right() - sz.width());
int y = globalPos.y();
if (y + sz.height() > screen.bottom())
y = globalPos.y() - sz.height() - 4;
y = globalPos.y() - sz.height() - lineHeight - 4;
move(x, y);
if (!isVisible()) show();
}
@@ -236,7 +257,7 @@ public:
adjustSize();
}
void showAt(const QPoint& globalPos) {
void showAt(const QPoint& globalPos, int lineHeight = 0) {
QSize sz = sizeHint();
QRect screen = QApplication::screenAt(globalPos)
? QApplication::screenAt(globalPos)->availableGeometry()
@@ -244,7 +265,7 @@ public:
int x = qMin(globalPos.x(), screen.right() - sz.width());
int y = globalPos.y();
if (y + sz.height() > screen.bottom())
y = globalPos.y() - sz.height() - 4;
y = globalPos.y() - sz.height() - lineHeight - 4;
move(x, y);
if (!isVisible()) show();
}
@@ -333,7 +354,7 @@ public:
adjustSize();
}
void showAt(const QPoint& globalPos) {
void showAt(const QPoint& globalPos, int lineHeight = 0) {
QSize sz = sizeHint();
QRect screen = QApplication::screenAt(globalPos)
? QApplication::screenAt(globalPos)->availableGeometry()
@@ -341,7 +362,7 @@ public:
int x = qMin(globalPos.x(), screen.right() - sz.width());
int y = globalPos.y();
if (y + sz.height() > screen.bottom())
y = globalPos.y() - sz.height() - 4;
y = globalPos.y() - sz.height() - lineHeight - 4;
move(x, y);
if (!isVisible()) show();
}
@@ -364,6 +385,7 @@ static constexpr int IND_HINT_GREEN = 15; // Green text for hint/comment text
static constexpr int IND_LOCAL_OFF = 16; // Dim text for inline local offset in relative mode
static constexpr int IND_HEAT_WARM = 17; // Heatmap level 2 (moderate changes)
static constexpr int IND_HEAT_HOT = 18; // Heatmap level 3 (frequent changes)
static constexpr int IND_FIND = 19; // Search match highlight
static QString g_fontName = "JetBrains Mono";
@@ -380,6 +402,30 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
m_sci = new QsciScintilla(this);
layout->addWidget(m_sci);
// Find bar (hidden by default, shown with Ctrl+F)
m_findBarContainer = new QWidget(this);
auto* fbLayout = new QHBoxLayout(m_findBarContainer);
fbLayout->setContentsMargins(4, 1, 4, 1);
fbLayout->setSpacing(2);
auto* findPrevBtn = new QToolButton(m_findBarContainer);
findPrevBtn->setText(QStringLiteral("\u25C0"));
findPrevBtn->setFixedSize(24, 24);
auto* findNextBtn = new QToolButton(m_findBarContainer);
findNextBtn->setText(QStringLiteral("\u25B6"));
findNextBtn->setFixedSize(24, 24);
auto* findCloseBtn = new QToolButton(m_findBarContainer);
findCloseBtn->setText(QStringLiteral("\u2715"));
findCloseBtn->setFixedSize(24, 24);
m_findBar = new QLineEdit(m_findBarContainer);
m_findBar->setPlaceholderText(QStringLiteral("Find..."));
m_findBar->setFixedHeight(24);
fbLayout->addWidget(findPrevBtn);
fbLayout->addWidget(findNextBtn);
fbLayout->addWidget(findCloseBtn);
fbLayout->addWidget(m_findBar);
m_findBarContainer->setVisible(false);
layout->addWidget(m_findBarContainer);
setupScintilla();
setupLexer();
setupMargins();
@@ -395,6 +441,55 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
m_sci->viewport()->installEventFilter(this);
m_sci->viewport()->setMouseTracking(true);
// Find bar: indicator-based search (selection is disabled in our Scintilla)
auto doFind = [this](bool forward) {
long docLen = m_sci->SendScintilla(QsciScintillaBase::SCI_GETLENGTH);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, (long)IND_FIND);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORCLEARRANGE, (long)0, docLen);
QString text = m_findBar->text();
if (text.isEmpty()) return;
QByteArray needle = text.toUtf8();
long startPos = forward ? m_findPos : (m_findPos > 0 ? m_findPos - 1 : docLen);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART, startPos);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND,
forward ? docLen : (long)0);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSEARCHFLAGS, (long)0);
long pos = m_sci->SendScintilla(QsciScintillaBase::SCI_SEARCHINTARGET,
(uintptr_t)needle.size(), needle.constData());
if (pos == -1) { // wrap
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART,
forward ? (long)0 : docLen);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND,
forward ? startPos : (long)0);
pos = m_sci->SendScintilla(QsciScintillaBase::SCI_SEARCHINTARGET,
(uintptr_t)needle.size(), needle.constData());
}
if (pos >= 0) {
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, (long)IND_FIND);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORFILLRANGE, pos, (long)needle.size());
int line = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_LINEFROMPOSITION, pos);
m_sci->ensureLineVisible(line);
m_sci->SendScintilla(QsciScintillaBase::SCI_GOTOPOS, pos);
m_findPos = pos + (forward ? needle.size() : 0);
}
};
connect(m_findBar, &QLineEdit::textChanged, this, [doFind]() { doFind(true); });
connect(m_findBar, &QLineEdit::returnPressed, this, [doFind]() { doFind(true); });
connect(findNextBtn, &QToolButton::clicked, this, [doFind]() { doFind(true); });
connect(findPrevBtn, &QToolButton::clicked, this, [doFind]() { doFind(false); });
connect(findCloseBtn, &QToolButton::clicked, this, [this]() { hideFindBar(); });
// Escape hides find bar
{
auto* escAction = new QAction(m_findBar);
escAction->setShortcut(QKeySequence(Qt::Key_Escape));
escAction->setShortcutContext(Qt::WidgetShortcut);
m_findBar->addAction(escAction);
connect(escAction, &QAction::triggered, this, [this]() { hideFindBar(); });
}
// Recalculate hover when the viewport scrolls (scrollbar drag, wheel
// deceleration, etc.) so the highlight tracks whatever is under the cursor.
connect(m_sci->verticalScrollBar(), &QScrollBar::valueChanged,
@@ -598,6 +693,12 @@ void RcxEditor::setupScintilla() {
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_LOCAL_OFF, 17 /*INDIC_TEXTFORE*/);
// Find match highlight — thick underline (avoids box rendering artifacts)
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_FIND, 14 /*INDIC_COMPOSITIONTHICK*/);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETUNDER,
IND_FIND, (long)1);
}
void RcxEditor::setupLexer() {
@@ -734,6 +835,8 @@ void RcxEditor::applyTheme(const Theme& theme) {
IND_HINT_GREEN, theme.indHintGreen);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_LOCAL_OFF, theme.textFaint);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_FIND, theme.borderFocused);
// Lexer colors
m_lexer->setColor(theme.syntaxKeyword, QsciLexerCPP::Keyword);
@@ -763,7 +866,7 @@ void RcxEditor::applyTheme(const Theme& theme) {
m_sci->setMarkerBackgroundColor(theme.background, M_CYCLE);
m_sci->setMarkerForegroundColor(theme.background, M_CYCLE);
m_sci->setMarkerBackgroundColor(theme.markerError, M_ERR);
m_sci->setMarkerForegroundColor(QColor("#ffffff"), M_ERR);
m_sci->setMarkerForegroundColor(theme.text, M_ERR);
m_sci->setMarkerBackgroundColor(theme.background, M_STRUCT_BG);
m_sci->setMarkerForegroundColor(theme.text, M_STRUCT_BG);
m_sci->setMarkerBackgroundColor(theme.hover, M_HOVER);
@@ -782,6 +885,20 @@ void RcxEditor::applyTheme(const Theme& theme) {
abs, theme.background);
}
}
// Find bar
if (m_findBarContainer) {
m_findBar->setStyleSheet(
QStringLiteral("QLineEdit { background: %1; color: %2; border: 1px solid %3;"
" padding: 2px 6px; font-size: 13px; }")
.arg(theme.backgroundAlt.name(), theme.text.name(), theme.border.name()));
m_findBarContainer->setStyleSheet(
QStringLiteral("QToolButton { background: %1; color: %2; border: 1px solid %3; border-radius: 2px; }"
"QToolButton:hover { background: %4; }"
"QToolButton:pressed { background: %5; }")
.arg(theme.background.name(), theme.text.name(), theme.border.name(),
theme.hover.name(), theme.backgroundAlt.name()));
}
}
void RcxEditor::applyDocument(const ComposeResult& result) {
@@ -863,6 +980,27 @@ void RcxEditor::applyDocument(const ComposeResult& result) {
m_prevHoveredNodeId = 0;
m_prevHoveredLine = -1;
applyHoverHighlight();
// Re-apply find indicator (setText() clears all indicators)
if (m_findBarContainer && m_findBarContainer->isVisible()) {
QString needle = m_findBar->text();
if (!needle.isEmpty()) {
QByteArray nb = needle.toUtf8();
long docLen = m_sci->SendScintilla(QsciScintillaBase::SCI_GETLENGTH);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETSEARCHFLAGS, (long)0);
long pos = 0;
while (pos < docLen) {
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETSTART, pos);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETTARGETEND, docLen);
long found = m_sci->SendScintilla(QsciScintillaBase::SCI_SEARCHINTARGET,
(uintptr_t)nb.size(), nb.constData());
if (found < 0) break;
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, (long)IND_FIND);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORFILLRANGE, found, (long)nb.size());
pos = found + nb.size();
}
}
}
}
void RcxEditor::applyMarginText(const QVector<LineMeta>& meta) {
@@ -923,6 +1061,11 @@ void RcxEditor::reformatMargins() {
}
// ── Pass 2: inline local offsets in the text indent area ──
// Skip when tree lines are active — the compose step already placed
// Unicode tree connectors in the indent area; overwriting with spaces
// or offsets would destroy them.
if (m_layout.treeLines)
return;
m_sci->setReadOnly(false);
for (int i = 0; i < m_meta.size(); i++) {
const auto& lm = m_meta[i];
@@ -1243,6 +1386,27 @@ int RcxEditor::currentNodeIndex() const {
return lm ? lm->nodeIdx : -1;
}
void RcxEditor::showFindBar() {
m_findBarContainer->setVisible(true);
m_findBar->setFocus();
m_findBar->selectAll();
m_findPos = 0;
}
void RcxEditor::dismissHistoryPopup() {
if (m_historyPopup)
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();
}
void RcxEditor::hideFindBar() {
m_findBarContainer->setVisible(false);
long docLen = m_sci->SendScintilla(QsciScintillaBase::SCI_GETLENGTH);
m_sci->SendScintilla(QsciScintillaBase::SCI_SETINDICATORCURRENT, (long)IND_FIND);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICATORCLEARRANGE, (long)0, docLen);
m_findPos = 0;
m_sci->setFocus();
}
void RcxEditor::scrollToNodeId(uint64_t nodeId) {
for (int i = 0; i < m_meta.size(); i++) {
if (m_meta[i].nodeId == nodeId && m_meta[i].lineKind != LineKind::Footer) {
@@ -1318,7 +1482,12 @@ void RcxEditor::applyHeatmapHighlight(const QVector<LineMeta>& meta) {
int typeW = lm.effectiveTypeW;
int nameW = lm.effectiveNameW;
if (heat <= 0) continue;
if (heat <= 0) {
// Clear any stale heat indicators from a previous frame
for (int hi : heatIndicators)
clearIndicatorLine(hi, i);
continue;
}
// Pick the right indicator for this heat level (1→cold, 2→warm, 3→hot)
int activeInd = heatIndicators[qBound(0, heat - 1, 2)];
@@ -1810,6 +1979,10 @@ static bool hitTestTarget(QsciScintilla* sci,
bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
if (obj == m_sci && event->type() == QEvent::KeyPress) {
auto* ke = static_cast<QKeyEvent*>(event);
if (ke->matches(QKeySequence::Find)) {
showFindBar();
return true;
}
bool handled = m_editState.active ? handleEditKey(ke) : handleNormalKey(ke);
if (!handled && !m_editState.active) {
// Clear hover on keyboard navigation (stale after scroll)
@@ -2036,11 +2209,25 @@ bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
m_lastHoverPos = static_cast<QMouseEvent*>(event)->pos();
m_hoverInside = true;
} else if (event->type() == QEvent::Leave) {
m_hoverInside = false;
if (!m_editState.active) {
m_hoveredNodeId = 0;
m_hoveredLine = -1;
applyHoverHighlight();
// Don't dismiss if cursor moved onto one of our own popups
QPoint globalCursor = QCursor::pos();
bool onPopup = false;
if (m_historyPopup && m_historyPopup->isVisible()
&& m_historyPopup->geometry().contains(globalCursor))
onPopup = true;
if (m_disasmPopup && m_disasmPopup->isVisible()
&& m_disasmPopup->geometry().contains(globalCursor))
onPopup = true;
if (m_structPreviewPopup && m_structPreviewPopup->isVisible()
&& m_structPreviewPopup->geometry().contains(globalCursor))
onPopup = true;
if (!onPopup) {
m_hoverInside = false;
if (!m_editState.active) {
m_hoveredNodeId = 0;
m_hoveredLine = -1;
applyHoverHighlight();
}
}
} else if (event->type() == QEvent::Wheel) {
m_lastHoverPos = m_sci->viewport()->mapFromGlobal(QCursor::pos());
@@ -2085,6 +2272,12 @@ bool RcxEditor::handleNormalKey(QKeyEvent* ke) {
case Qt::Key_Return:
case Qt::Key_Enter:
return beginInlineEdit(EditTarget::Value);
case Qt::Key_Insert:
if (ke->modifiers() & Qt::ShiftModifier)
emit insertAboveRequested(currentNodeIndex(), NodeKind::Hex32);
else
emit insertAboveRequested(currentNodeIndex(), NodeKind::Hex64);
return true;
case Qt::Key_Tab: {
EditTarget order[] = {EditTarget::Name, EditTarget::Type, EditTarget::Value,
EditTarget::ArrayElementType, EditTarget::ArrayElementCount,
@@ -2818,7 +3011,7 @@ void RcxEditor::applyHoverCursor() {
int lh = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT,
(unsigned long)m_editState.line);
QPoint anchor = m_sci->viewport()->mapToGlobal(QPoint(px, py + lh));
popup->showAt(anchor);
popup->showAt(anchor, lh);
showPopup = true;
}
}
@@ -2973,7 +3166,7 @@ void RcxEditor::applyHoverCursor() {
int lh = (int)m_sci->SendScintilla(QsciScintillaBase::SCI_TEXTHEIGHT,
(unsigned long)h.line);
QPoint anchor = m_sci->viewport()->mapToGlobal(QPoint(px, py + lh));
popup->showAt(anchor);
popup->showAt(anchor, lh);
showPopup = true;
}
}
@@ -3066,7 +3259,7 @@ void RcxEditor::applyHoverCursor() {
(unsigned long)h.line);
QPoint anchor = m_sci->viewport()->mapToGlobal(
QPoint(px, py + lh));
popup->showAt(anchor);
popup->showAt(anchor, lh);
showDisasm = true;
// Dismiss value history popup to avoid fighting
if (m_historyPopup && m_historyPopup->isVisible())
@@ -3133,7 +3326,7 @@ void RcxEditor::applyHoverCursor() {
(unsigned long)h.line);
QPoint anchor = m_sci->viewport()->mapToGlobal(
QPoint(px, py + lh));
popup->showAt(anchor);
popup->showAt(anchor, lh);
showPreview = true;
if (m_historyPopup && m_historyPopup->isVisible())
static_cast<ValueHistoryPopup*>(m_historyPopup)->dismiss();

View File

@@ -6,6 +6,7 @@
#include <QPoint>
#include <QHash>
class QLineEdit;
class QsciScintilla;
class QsciLexerCPP;
@@ -32,6 +33,8 @@ public:
const LineMeta* metaForLine(int line) const;
int currentNodeIndex() const;
void scrollToNodeId(uint64_t nodeId);
void showFindBar();
void dismissHistoryPopup();
// ── Column span computation ──
static ColumnSpan typeSpan(const LineMeta& lm, int typeW = kColType);
@@ -78,6 +81,7 @@ signals:
void inlineEditCancelled();
void typeSelectorRequested();
void typePickerRequested(EditTarget target, int nodeIdx, QPoint globalPos);
void insertAboveRequested(int nodeIdx, NodeKind kind);
protected:
bool eventFilter(QObject* obj, QEvent* event) override;
@@ -154,6 +158,12 @@ private:
const Provider* m_disasmRealProv = nullptr; // real process provider — for reading code at arbitrary addresses
const NodeTree* m_disasmTree = nullptr;
// ── Find bar ──
QWidget* m_findBarContainer = nullptr;
QLineEdit* m_findBar = nullptr;
long m_findPos = 0;
void hideFindBar();
// ── Reentrancy guards ──
bool m_applyingDocument = false;
bool m_clampingSelection = false;

143
src/examples/Demo.rcx Normal file
View File

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

10755
src/examples/t6zm.rcx Normal file

File diff suppressed because it is too large Load Diff

BIN
src/icons/class.icns Normal file

Binary file not shown.

13
src/macos_titlebar.h Normal file
View File

@@ -0,0 +1,13 @@
#pragma once
#include <QWidget>
namespace rcx {
struct Theme;
// Apply macOS native title bar color to match the theme.
// No-op on non-macOS platforms (implementation is platform-specific).
void applyMacTitleBarTheme(QWidget* window, const Theme& theme);
} // namespace rcx

43
src/macos_titlebar.mm Normal file
View File

@@ -0,0 +1,43 @@
#include "macos_titlebar.h"
#include "themes/theme.h"
#import <Cocoa/Cocoa.h>
#include <QColor>
#include <QWidget>
namespace rcx {
static NSColor* toNSColor(const QColor& color) {
return [NSColor colorWithCalibratedRed:color.redF()
green:color.greenF()
blue:color.blueF()
alpha:color.alphaF()];
}
void applyMacTitleBarTheme(QWidget* window, const Theme& theme) {
if (!window) return;
// Ensure native window is created.
window->winId();
auto* nsView = reinterpret_cast<NSView*>(window->winId());
if (!nsView) return;
NSWindow* nsWindow = [nsView window];
if (!nsWindow) return;
// Keep native traffic lights while tinting the title bar to the theme.
// Match the title text contrast by selecting the appropriate system appearance.
const qreal luminance =
0.2126 * theme.background.redF() +
0.7152 * theme.background.greenF() +
0.0722 * theme.background.blueF();
const bool isLight = luminance >= 0.5;
[nsWindow setAppearance:[NSAppearance appearanceNamed:
(isLight ? NSAppearanceNameAqua : NSAppearanceNameDarkAqua)]];
[nsWindow setTitlebarAppearsTransparent:YES];
[nsWindow setTitleVisibility:NSWindowTitleVisible];
[nsWindow setBackgroundColor:toNSColor(theme.background)];
}
} // namespace rcx

View File

@@ -53,7 +53,6 @@
#include "themes/thememanager.h"
#include "themes/themeeditor.h"
#include "optionsdialog.h"
#ifdef _WIN32
#include <windows.h>
#include <windowsx.h>
@@ -237,6 +236,14 @@ public:
class MenuBarStyle : public QProxyStyle {
public:
using QProxyStyle::QProxyStyle;
void polish(QWidget* w) override {
// Strip OS window border/shadow from QMenu popups — we draw our own
// 1px border in PE_FrameMenu. Same pattern as TypeSelectorPopup.
if (qobject_cast<QMenu*>(w))
w->setWindowFlag(Qt::FramelessWindowHint, true);
QProxyStyle::polish(w);
}
using QProxyStyle::polish;
QSize sizeFromContents(ContentsType type, const QStyleOption* opt,
const QSize& sz, const QWidget* w) const override {
QSize s = QProxyStyle::sizeFromContents(type, opt, sz, w);
@@ -248,9 +255,12 @@ public:
}
int pixelMetric(PixelMetric metric, const QStyleOption* opt,
const QWidget* w) const override {
// Kill the 1px frame margin Fusion reserves around QMenu contents
// Reserve 1px for our own menu border (drawn in PE_FrameMenu)
if (metric == PM_MenuPanelWidth)
return 0;
return 1;
// Inset menu items from border so hover rect doesn't touch edges
if (metric == PM_MenuHMargin)
return 3;
// Thin draggable separator between dock widgets / central widget
if (metric == PM_DockWidgetSeparatorExtent)
return 1;
@@ -258,9 +268,13 @@ public:
}
void drawPrimitive(PrimitiveElement elem, const QStyleOption* opt,
QPainter* p, const QWidget* w) const override {
// Kill Fusion's 3D bevel on QMenu — the OS drop shadow is enough
if (elem == PE_FrameMenu)
// Clean 1px border on QMenu (replaces Fusion's 3D bevel + OS shadow)
if (elem == PE_FrameMenu) {
p->setPen(opt->palette.color(QPalette::Dark));
p->setBrush(Qt::NoBrush);
p->drawRect(opt->rect.adjusted(0, 0, -1, -1));
return;
}
// Kill the status bar item frame and panel border
if (elem == PE_FrameStatusBarItem || elem == PE_PanelStatusBar)
return;
@@ -351,12 +365,12 @@ static void applyGlobalTheme(const rcx::Theme& theme) {
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::Highlight, theme.hover);
pal.setColor(QPalette::HighlightedText, theme.text);
pal.setColor(QPalette::ToolTipBase, theme.backgroundAlt);
pal.setColor(QPalette::ToolTipText, theme.text);
pal.setColor(QPalette::Mid, theme.hover);
pal.setColor(QPalette::Dark, theme.background);
pal.setColor(QPalette::Dark, theme.border);
pal.setColor(QPalette::Light, theme.textFaint);
pal.setColor(QPalette::Link, theme.indHoverSpan);
@@ -389,13 +403,18 @@ public:
namespace rcx {
#ifdef __APPLE__
void applyMacTitleBarTheme(QWidget* window, const Theme& theme);
#endif
// MainWindow class declaration is in mainwindow.h
MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
setWindowTitle("Reclass");
resize(1200, 800);
// Frameless window with system menu (Alt+Space) and min/max/close support
#ifndef __APPLE__
// Frameless window with system menu (Alt+Space) and min/max/close support.
setWindowFlags(Qt::FramelessWindowHint | Qt::WindowSystemMenuHint
| Qt::WindowMinMaxButtonsHint);
@@ -403,6 +422,14 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
m_titleBar = new TitleBarWidget(this);
m_titleBar->applyTheme(ThemeManager::instance().current());
setMenuWidget(m_titleBar);
m_menuBar = m_titleBar->menuBar();
#else
setWindowTitle(QStringLiteral("Reclass"));
setUnifiedTitleAndToolBarOnMac(true);
m_menuBar = menuBar();
m_menuBar->setNativeMenuBar(true);
applyMacTitleBarTheme(this, ThemeManager::instance().current());
#endif
#ifdef _WIN32
// 1px top margin preserves DWM drop shadow on the frameless window
@@ -454,8 +481,9 @@ MainWindow::MainWindow(QWidget* parent) : QMainWindow(parent) {
// Restore menu bar title case setting (after menus are created)
{
QSettings s("Reclass", "Reclass");
m_titleBar->setMenuBarTitleCase(s.value("menuBarTitleCase", false).toBool());
if (s.value("showIcon", false).toBool())
m_menuBarTitleCase = s.value("menuBarTitleCase", false).toBool();
applyMenuBarTitleCase(m_menuBarTitleCase);
if (m_titleBar && s.value("showIcon", false).toBool())
m_titleBar->setShowIcon(true);
}
@@ -507,13 +535,48 @@ inline QAction* Qt5Qt6AddAction(QMenu* menu, const QString &text, const QKeySequ
return result;
}
void MainWindow::applyMenuBarTitleCase(bool titleCase) {
m_menuBarTitleCase = titleCase;
if (m_titleBar) {
m_titleBar->setMenuBarTitleCase(titleCase);
return;
}
if (!m_menuBar) return;
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 MainWindow::createMenus() {
// File
auto* file = m_titleBar->menuBar()->addMenu("&File");
auto* file = m_menuBar->addMenu("&File");
Qt5Qt6AddAction(file, "New &Class", QKeySequence::New, QIcon(), this, &MainWindow::newClass);
Qt5Qt6AddAction(file, "New &Struct", QKeySequence(Qt::CTRL | Qt::Key_T), QIcon(), this, &MainWindow::newStruct);
Qt5Qt6AddAction(file, "New &Enum", QKeySequence(Qt::CTRL | Qt::Key_E), QIcon(), this, &MainWindow::newEnum);
Qt5Qt6AddAction(file, "&Open...", QKeySequence::Open, makeIcon(":/vsicons/folder-opened.svg"), this, &MainWindow::openFile);
m_recentFilesMenu = file->addMenu("Recent &Files");
updateRecentFilesMenu();
file->addSeparator();
Qt5Qt6AddAction(file, "&Save", QKeySequence::Save, makeIcon(":/vsicons/save.svg"), this, &MainWindow::saveFile);
Qt5Qt6AddAction(file, "Save &As...", QKeySequence::SaveAs, makeIcon(":/vsicons/save-as.svg"), this, &MainWindow::saveFileAs);
@@ -527,7 +590,11 @@ void MainWindow::createMenus() {
Qt5Qt6AddAction(exportMenu, "ReClass &XML...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::exportReclassXmlAction);
// Examples submenu — scan once at init
{
#ifdef __APPLE__
QDir exDir(QDir::cleanPath(QCoreApplication::applicationDirPath() + "/../Resources/examples"));
#else
QDir exDir(QCoreApplication::applicationDirPath() + "/examples");
#endif
QStringList rcxFiles = exDir.entryList({"*.rcx"}, QDir::Files, QDir::Name);
if (!rcxFiles.isEmpty()) {
auto* examples = file->addMenu("E&xamples");
@@ -543,15 +610,20 @@ void MainWindow::createMenus() {
Qt5Qt6AddAction(file, "E&xit", QKeySequence(Qt::Key_Close), makeIcon(":/vsicons/close.svg"), this, &QMainWindow::close);
// Edit
auto* edit = m_titleBar->menuBar()->addMenu("&Edit");
auto* edit = m_menuBar->addMenu("&Edit");
Qt5Qt6AddAction(edit, "&Undo", QKeySequence::Undo, makeIcon(":/vsicons/arrow-left.svg"), this, &MainWindow::undo);
Qt5Qt6AddAction(edit, "&Redo", QKeySequence::Redo, makeIcon(":/vsicons/arrow-right.svg"), this, &MainWindow::redo);
// View
auto* view = m_titleBar->menuBar()->addMenu("&View");
auto* view = m_menuBar->addMenu("&View");
Qt5Qt6AddAction(view, "Split &Horizontal", QKeySequence::UnknownKey, makeIcon(":/vsicons/split-horizontal.svg"), this, &MainWindow::splitView);
Qt5Qt6AddAction(view, "&Remove Split", QKeySequence::UnknownKey, makeIcon(":/vsicons/chrome-close.svg"), this, &MainWindow::unsplitView);
m_removeSplitAction = Qt5Qt6AddAction(view, "&Remove Split", QKeySequence::UnknownKey, makeIcon(":/vsicons/chrome-close.svg"), this, &MainWindow::unsplitView);
m_removeSplitAction->setVisible(false);
view->addSeparator();
connect(view, &QMenu::aboutToShow, this, [this]() {
auto* tab = activeTab();
m_removeSplitAction->setVisible(tab && tab->panes.size() > 1);
});
m_sourceMenu = view->addMenu("&Data Source");
connect(m_sourceMenu, &QMenu::aboutToShow, this, &MainWindow::populateSourceMenu);
view->addSeparator();
@@ -600,6 +672,15 @@ void MainWindow::createMenus() {
tab.ctrl->setCompactColumns(checked);
});
auto* actTreeLines = view->addAction("&Tree Lines");
actTreeLines->setCheckable(true);
actTreeLines->setChecked(settings.value("treeLines", false).toBool());
connect(actTreeLines, &QAction::triggered, this, [this](bool checked) {
QSettings("Reclass", "Reclass").setValue("treeLines", checked);
for (auto& tab : m_tabs)
tab.ctrl->setTreeLines(checked);
});
auto* actRelOfs = view->addAction("R&elative Offsets");
actRelOfs->setCheckable(true);
actRelOfs->setChecked(settings.value("relativeOffsets", true).toBool());
@@ -619,7 +700,7 @@ void MainWindow::createMenus() {
}
// Tools
auto* tools = m_titleBar->menuBar()->addMenu("&Tools");
auto* tools = m_menuBar->addMenu("&Tools");
Qt5Qt6AddAction(tools, "&Type Aliases...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::showTypeAliasesDialog);
tools->addSeparator();
const auto mcpName = QSettings("Reclass", "Reclass").value("autoStartMcp", true).toBool() ? "Stop &MCP Server" : "Start &MCP Server";
@@ -628,11 +709,11 @@ void MainWindow::createMenus() {
Qt5Qt6AddAction(tools, "&Options...", QKeySequence::UnknownKey, makeIcon(":/vsicons/settings-gear.svg"), this, &MainWindow::showOptionsDialog);
// Plugins
auto* plugins = m_titleBar->menuBar()->addMenu("&Plugins");
auto* plugins = m_menuBar->addMenu("&Plugins");
Qt5Qt6AddAction(plugins, "&Manage Plugins...", QKeySequence::UnknownKey, QIcon(), this, &MainWindow::showPluginsDialog);
// Help
auto* help = m_titleBar->menuBar()->addMenu("&Help");
auto* help = m_menuBar->addMenu("&Help");
Qt5Qt6AddAction(help, "&About Reclass", QKeySequence::UnknownKey, makeIcon(":/vsicons/question.svg"), this, &MainWindow::about);
}
@@ -1065,28 +1146,54 @@ MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) {
setupRenderedSci(pane.rendered);
rvLayout->addWidget(pane.rendered);
// Find bar (hidden by default)
// Find bar with prev/next buttons (hidden by default)
pane.findContainer = new QWidget;
auto* fcLayout = new QHBoxLayout(pane.findContainer);
fcLayout->setContentsMargins(4, 1, 4, 1);
fcLayout->setSpacing(2);
const auto& fbTheme = ThemeManager::instance().current();
auto* ccPrevBtn = new QToolButton;
ccPrevBtn->setText(QStringLiteral("\u25C0"));
ccPrevBtn->setFixedSize(24, 24);
auto* ccNextBtn = new QToolButton;
ccNextBtn->setText(QStringLiteral("\u25B6"));
ccNextBtn->setFixedSize(24, 24);
auto* ccCloseBtn = new QToolButton;
ccCloseBtn->setText(QStringLiteral("\u2715"));
ccCloseBtn->setFixedSize(24, 24);
QString btnCss = QStringLiteral(
"QToolButton { background: %1; color: %2; border: 1px solid %3; border-radius: 2px; }"
"QToolButton:hover { background: %4; }"
"QToolButton:pressed { background: %5; }")
.arg(fbTheme.background.name(), fbTheme.text.name(), fbTheme.border.name(),
fbTheme.hover.name(), fbTheme.backgroundAlt.name());
ccPrevBtn->setStyleSheet(btnCss);
ccNextBtn->setStyleSheet(btnCss);
ccCloseBtn->setStyleSheet(btnCss);
pane.findBar = new QLineEdit;
pane.findBar->setPlaceholderText("Find...");
pane.findBar->setVisible(false);
const auto& fbTheme = ThemeManager::instance().current();
pane.findBar->setFixedHeight(24);
pane.findBar->setStyleSheet(
QStringLiteral("QLineEdit { background: %1; color: %2; border: 1px solid %3;"
" padding: 4px 8px; font-size: 13px; }")
.arg(fbTheme.backgroundAlt.name())
.arg(fbTheme.text.name())
.arg(fbTheme.border.name()));
rvLayout->addWidget(pane.findBar);
" padding: 2px 6px; font-size: 13px; }")
.arg(fbTheme.backgroundAlt.name(), fbTheme.text.name(), fbTheme.border.name()));
fcLayout->addWidget(ccPrevBtn);
fcLayout->addWidget(ccNextBtn);
fcLayout->addWidget(ccCloseBtn);
fcLayout->addWidget(pane.findBar);
pane.findContainer->setVisible(false);
rvLayout->addWidget(pane.findContainer);
// Ctrl+F to show find bar
QsciScintilla* sci = pane.rendered;
QLineEdit* fb = pane.findBar;
QWidget* fc = pane.findContainer;
auto* findAction = new QAction(pane.renderedContainer);
findAction->setShortcut(QKeySequence::Find);
findAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
pane.renderedContainer->addAction(findAction);
connect(findAction, &QAction::triggered, fb, [fb, sci]() {
fb->setVisible(true);
connect(findAction, &QAction::triggered, fb, [fb, fc]() {
fc->setVisible(true);
fb->setFocus();
fb->selectAll();
});
@@ -1096,8 +1203,8 @@ MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) {
escAction->setShortcut(QKeySequence(Qt::Key_Escape));
escAction->setShortcutContext(Qt::WidgetShortcut);
fb->addAction(escAction);
connect(escAction, &QAction::triggered, fb, [fb, sci]() {
fb->setVisible(false);
connect(escAction, &QAction::triggered, fb, [fc, sci]() {
fc->setVisible(false);
sci->setFocus();
});
@@ -1112,6 +1219,21 @@ MainWindow::SplitPane MainWindow::createSplitPane(TabState& tab) {
if (!sci->findNext())
sci->findFirst(text, false, false, false, true, true, 0, 0);
});
connect(ccNextBtn, &QToolButton::clicked, sci, [sci, fb]() {
if (!sci->findNext())
sci->findFirst(fb->text(), false, false, false, true, true, 0, 0);
});
connect(ccPrevBtn, &QToolButton::clicked, sci, [sci, fb]() {
QString text = fb->text();
if (text.isEmpty()) return;
int line, col;
sci->getCursorPosition(&line, &col);
sci->findFirst(text, false, false, false, true, false, line, col);
});
connect(ccCloseBtn, &QToolButton::clicked, sci, [fc, sci]() {
fc->setVisible(false);
sci->setFocus();
});
pane.tabWidget->addTab(pane.renderedContainer, "C/C++"); // index 1
@@ -1209,6 +1331,7 @@ QMdiSubWindow* MainWindow::createTab(RcxDocument* doc) {
// Apply global compact columns setting to new tab
ctrl->setCompactColumns(QSettings("Reclass", "Reclass").value("compactColumns", true).toBool());
ctrl->setTreeLines(QSettings("Reclass", "Reclass").value("treeLines", false).toBool());
// Give every controller the shared document list for cross-tab type visibility
ctrl->setProjectDocuments(&m_allDocs);
@@ -1342,52 +1465,6 @@ static void buildEmptyStruct(NodeTree& tree, const QString& classKeyword = QStri
tree.addNode(e);
}
// ── Example class with a union: _SAMPLE_OBJECT ──
{
Node cls;
cls.kind = NodeKind::Struct;
cls.name = QStringLiteral("sample");
cls.structTypeName = QStringLiteral("_SAMPLE_OBJECT");
cls.classKeyword = QStringLiteral("class");
cls.parentId = 0;
cls.offset = 0;
int ci = tree.addNode(cls);
uint64_t clsId = tree.nodes[ci].id;
// Field: uint32_t Type at offset 0
{ Node n; n.kind = NodeKind::UInt32; n.name = QStringLiteral("Type");
n.parentId = clsId; n.offset = 0; tree.addNode(n); }
// Field: uint32_t Size at offset 4
{ Node n; n.kind = NodeKind::UInt32; n.name = QStringLiteral("Size");
n.parentId = clsId; n.offset = 4; tree.addNode(n); }
// Union at offset 8
{
Node u;
u.kind = NodeKind::Struct;
u.name = QStringLiteral("Data");
u.structTypeName = QStringLiteral("Data");
u.classKeyword = QStringLiteral("union");
u.parentId = clsId;
u.offset = 8;
int ui = tree.addNode(u);
uint64_t uId = tree.nodes[ui].id;
// Union member: uint64_t AsLong
{ Node n; n.kind = NodeKind::UInt64; n.name = QStringLiteral("AsLong");
n.parentId = uId; n.offset = 0; tree.addNode(n); }
// Union member: void* AsPointer
{ Node n; n.kind = NodeKind::Pointer64; n.name = QStringLiteral("AsPointer");
n.parentId = uId; n.offset = 0; n.collapsed = true; tree.addNode(n); }
// Union member: float[2] AsFloat2
{ Node n; n.kind = NodeKind::Vec2; n.name = QStringLiteral("AsFloat2");
n.parentId = uId; n.offset = 0; tree.addNode(n); }
}
// Field: void* Next at offset 16
{ Node n; n.kind = NodeKind::Pointer64; n.name = QStringLiteral("Next");
n.parentId = clsId; n.offset = 16; n.collapsed = true; tree.addNode(n); }
}
}
}
@@ -1516,57 +1593,11 @@ static void buildEditorDemo(NodeTree& tree, uintptr_t editorAddr) {
tree.addNode(e);
}
// ── Example class with a union: _SAMPLE_OBJECT ──
{
Node cls;
cls.kind = NodeKind::Struct;
cls.name = QStringLiteral("sample");
cls.structTypeName = QStringLiteral("_SAMPLE_OBJECT");
cls.classKeyword = QStringLiteral("class");
cls.parentId = 0;
cls.offset = 0;
int ci = tree.addNode(cls);
uint64_t clsId = tree.nodes[ci].id;
// Field: uint32_t Type at offset 0
{ Node n; n.kind = NodeKind::UInt32; n.name = QStringLiteral("Type");
n.parentId = clsId; n.offset = 0; tree.addNode(n); }
// Field: uint32_t Size at offset 4
{ Node n; n.kind = NodeKind::UInt32; n.name = QStringLiteral("Size");
n.parentId = clsId; n.offset = 4; tree.addNode(n); }
// Union at offset 8
{
Node u;
u.kind = NodeKind::Struct;
u.name = QStringLiteral("Data");
u.structTypeName = QStringLiteral("Data");
u.classKeyword = QStringLiteral("union");
u.parentId = clsId;
u.offset = 8;
int ui = tree.addNode(u);
uint64_t uId = tree.nodes[ui].id;
// Union member: uint64_t AsLong
{ Node n; n.kind = NodeKind::UInt64; n.name = QStringLiteral("AsLong");
n.parentId = uId; n.offset = 0; tree.addNode(n); }
// Union member: void* AsPointer
{ Node n; n.kind = NodeKind::Pointer64; n.name = QStringLiteral("AsPointer");
n.parentId = uId; n.offset = 0; n.collapsed = true; tree.addNode(n); }
// Union member: float[2] AsFloat2
{ Node n; n.kind = NodeKind::Vec2; n.name = QStringLiteral("AsFloat2");
n.parentId = uId; n.offset = 0; tree.addNode(n); }
}
// Field: void* Next at offset 16
{ Node n; n.kind = NodeKind::Pointer64; n.name = QStringLiteral("Next");
n.parentId = clsId; n.offset = 16; n.collapsed = true; tree.addNode(n); }
}
}
void MainWindow::selfTest() {
#ifdef Q_OS_WIN
// Create a new project, then point it at the live editor object
// Tab 2: Editor demo with live process memory (created first)
project_new();
auto* ctrl = activeController();
@@ -1583,8 +1614,14 @@ void MainWindow::selfTest() {
QString target = QString("%1:Reclass.exe").arg(pid);
ctrl->attachViaPlugin(QStringLiteral("processmemory"), target);
// Tab 1: Empty class for user work (created second, becomes active)
auto* userTab = project_new(QStringLiteral("class"));
m_mdiArea->setActiveSubWindow(userTab);
#else
project_new();
auto* userTab = project_new(QStringLiteral("class"));
m_mdiArea->setActiveSubWindow(userTab);
#endif
}
@@ -1750,10 +1787,15 @@ void MainWindow::toggleMcp() {
void MainWindow::applyTheme(const Theme& theme) {
applyGlobalTheme(theme);
#ifdef __APPLE__
applyMacTitleBarTheme(this, theme);
#endif
// Dock separator is 1px via PM_DockWidgetSeparatorExtent in MenuBarStyle
// Custom title bar
m_titleBar->applyTheme(theme);
if (m_titleBar)
m_titleBar->applyTheme(theme);
// Update border overlay color
updateBorderColor(isActiveWindow() ? theme.borderFocused : theme.border);
@@ -1910,8 +1952,10 @@ void MainWindow::showOptionsDialog() {
OptionsResult current;
current.themeIndex = tm.currentIndex();
current.fontName = QSettings("Reclass", "Reclass").value("font", "JetBrains Mono").toString();
current.menuBarTitleCase = m_titleBar->menuBarTitleCase();
current.showIcon = QSettings("Reclass", "Reclass").value("showIcon", false).toBool();
current.menuBarTitleCase = m_menuBarTitleCase;
current.showIcon = m_titleBar
? QSettings("Reclass", "Reclass").value("showIcon", false).toBool()
: false;
current.safeMode = QSettings("Reclass", "Reclass").value("safeMode", false).toBool();
current.autoStartMcp = QSettings("Reclass", "Reclass").value("autoStartMcp", true).toBool();
current.refreshMs = QSettings("Reclass", "Reclass").value("refreshMs", 660).toInt();
@@ -1929,12 +1973,13 @@ void MainWindow::showOptionsDialog() {
setEditorFont(r.fontName);
if (r.menuBarTitleCase != current.menuBarTitleCase) {
m_titleBar->setMenuBarTitleCase(r.menuBarTitleCase);
applyMenuBarTitleCase(r.menuBarTitleCase);
QSettings("Reclass", "Reclass").setValue("menuBarTitleCase", r.menuBarTitleCase);
}
if (r.showIcon != current.showIcon) {
m_titleBar->setShowIcon(r.showIcon);
if (m_titleBar)
m_titleBar->setShowIcon(r.showIcon);
QSettings("Reclass", "Reclass").setValue("showIcon", r.showIcon);
}
@@ -2011,6 +2056,9 @@ MainWindow::TabState* MainWindow::tabByIndex(int index) {
}
void MainWindow::updateWindowTitle() {
#ifdef __APPLE__
setWindowTitle(QStringLiteral("Reclass"));
#else
QString title;
auto* sub = m_mdiArea->activeSubWindow();
if (sub && m_tabs.contains(sub)) {
@@ -2024,6 +2072,7 @@ void MainWindow::updateWindowTitle() {
title = "Reclass";
}
setWindowTitle(title);
#endif
}
// ── Rendered view setup ──
@@ -2566,6 +2615,7 @@ QMdiSubWindow* MainWindow::project_open(const QString& path) {
if (n.parentId == 0 && n.kind == NodeKind::Struct) classCount++;
setAppStatus(QStringLiteral("Imported %1 classes from %2")
.arg(classCount).arg(QFileInfo(filePath).fileName()));
addRecentFile(filePath);
return sub;
}
@@ -2582,6 +2632,7 @@ QMdiSubWindow* MainWindow::project_open(const QString& path) {
auto* sub = createTab(doc);
rebuildWorkspaceModel();
m_workspaceDock->show();
addRecentFile(filePath);
return sub;
}
@@ -2595,8 +2646,10 @@ bool MainWindow::project_save(QMdiSubWindow* sub, bool saveAs) {
"Save Definition", {}, "Reclass (*.rcx);;JSON (*.json)");
if (path.isEmpty()) return false;
tab.doc->save(path);
addRecentFile(path);
} else {
tab.doc->save(tab.doc->filePath);
addRecentFile(tab.doc->filePath);
}
updateWindowTitle();
return true;
@@ -2812,6 +2865,18 @@ void MainWindow::createWorkspaceDock() {
}
});
// Ctrl+F focuses the workspace search field
{
auto* findAction = new QAction(dockContainer);
findAction->setShortcut(QKeySequence::Find);
findAction->setShortcutContext(Qt::WidgetWithChildrenShortcut);
dockContainer->addAction(findAction);
connect(findAction, &QAction::triggered, this, [this]() {
m_workspaceSearch->setFocus();
m_workspaceSearch->selectAll();
});
}
m_workspaceDock->setWidget(dockContainer);
addDockWidget(Qt::LeftDockWidgetArea, m_workspaceDock);
m_workspaceDock->hide();
@@ -2937,6 +3002,30 @@ void MainWindow::createScannerDock() {
return ctrl ? ctrl->document()->provider : nullptr;
});
// Wire bounds getter: struct base + size for "Current Struct" filter
m_scannerPanel->setBoundsGetter([this]() -> rcx::ScannerPanel::StructBounds {
auto* ctrl = activeController();
if (!ctrl) return {};
auto& tree = ctrl->document()->tree;
uint64_t base = tree.baseAddress;
uint64_t viewRoot = ctrl->viewRootId();
int span = 0;
if (viewRoot != 0) {
span = tree.structSpan(viewRoot);
} else {
// Compute extent from all top-level nodes
for (int i = 0; i < tree.nodes.size(); i++) {
const auto& n = tree.nodes[i];
int64_t off = tree.computeOffset(i);
int sz = (n.kind == rcx::NodeKind::Struct || n.kind == rcx::NodeKind::Array)
? tree.structSpan(n.id) : n.byteSize();
int64_t end = off + sz;
if (end > span) span = static_cast<int>(end);
}
}
return { base, static_cast<uint64_t>(span) };
});
// Wire "Go to Address" to rebase the active tab
connect(m_scannerPanel, &ScannerPanel::goToAddress, this, [this](uint64_t addr) {
auto* ctrl = activeController();
@@ -2967,6 +3056,43 @@ void MainWindow::rebuildWorkspaceModel() {
m_workspaceTree->expandToDepth(0);
}
void MainWindow::addRecentFile(const QString& path) {
if (path.isEmpty()) return;
QString absPath = QFileInfo(path).absoluteFilePath();
QSettings s("Reclass", "Reclass");
QStringList recent = s.value("recentFiles").toStringList();
recent.removeAll(absPath);
recent.prepend(absPath);
while (recent.size() > 10)
recent.removeLast();
s.setValue("recentFiles", recent);
updateRecentFilesMenu();
}
void MainWindow::updateRecentFilesMenu() {
if (!m_recentFilesMenu) return;
m_recentFilesMenu->clear();
QSettings s("Reclass", "Reclass");
QStringList recent = s.value("recentFiles").toStringList();
int added = 0;
for (const QString& path : recent) {
if (!QFile::exists(path)) continue;
QString label = QStringLiteral("&%1 %2").arg(added + 1).arg(QFileInfo(path).fileName());
m_recentFilesMenu->addAction(label, this, [this, path]() {
project_open(path);
})->setToolTip(path);
if (++added >= 10) break;
}
if (added == 0) {
auto* empty = m_recentFilesMenu->addAction(QStringLiteral("(empty)"));
empty->setEnabled(false);
}
}
void MainWindow::populateSourceMenu() {
m_sourceMenu->clear();
auto* ctrl = activeController();
@@ -3136,7 +3262,7 @@ void MainWindow::changeEvent(QEvent* event) {
const auto& t = ThemeManager::instance().current();
updateBorderColor(isActiveWindow() ? t.borderFocused : t.border);
}
if (event->type() == QEvent::WindowStateChange)
if (event->type() == QEvent::WindowStateChange && m_titleBar)
m_titleBar->updateMaximizeIcon();
}
@@ -3166,6 +3292,9 @@ int main(int argc, char* argv[]) {
#ifdef _WIN32
SetUnhandledExceptionFilter(crashHandler);
#endif
#ifdef Q_OS_MACOS
QCoreApplication::setAttribute(Qt::AA_DontUseNativeDialogs);
#endif
DarkApp app(argc, argv);
app.setApplicationName("Reclass");

View File

@@ -88,17 +88,22 @@ private:
QPushButton* m_btnReclass = nullptr;
QPushButton* m_btnRendered = nullptr;
TitleBarWidget* m_titleBar = nullptr;
QMenuBar* m_menuBar = nullptr;
bool m_menuBarTitleCase = false;
QWidget* m_borderOverlay = nullptr;
PluginManager m_pluginManager;
McpBridge* m_mcp = nullptr;
QAction* m_mcpAction = nullptr;
QAction* m_removeSplitAction = nullptr;
QMenu* m_sourceMenu = nullptr;
QMenu* m_recentFilesMenu = nullptr;
struct SplitPane {
QTabWidget* tabWidget = nullptr;
RcxEditor* editor = nullptr;
QsciScintilla* rendered = nullptr;
QLineEdit* findBar = nullptr;
QWidget* findContainer = nullptr;
QWidget* renderedContainer = nullptr;
ViewMode viewMode = VM_Reclass;
uint64_t lastRenderedRootId = 0;
@@ -116,9 +121,12 @@ private:
void rebuildAllDocs();
void createMenus();
void applyMenuBarTitleCase(bool titleCase);
void createStatusBar();
void showPluginsDialog();
void populateSourceMenu();
void addRecentFile(const QString& path);
void updateRecentFilesMenu();
QIcon makeIcon(const QString& svgPath);
RcxController* activeController() const;

View File

@@ -203,7 +203,28 @@ QJsonObject McpBridge::handleInitialize(const QJsonValue& id, const QJsonObject&
{"serverInfo", QJsonObject{
{"name", "reclass-mcp"},
{"version", "1.0.0"}
}}
}},
{"instructions",
"You are connected to ReClass, a live memory structure editor for reverse engineering. "
"You have two types of data available:\n"
"1. STRUCTURE: The node tree defines typed fields (project.state, tree.search, tree.apply). "
"Each node has a kind (the data type: UInt32, Float, Hex64, etc.) and a name.\n"
"2. LIVE DATA: The provider reads real memory from an attached process (hex.read, hex.write). "
"node.history returns timestamped value changes with heat levels (0=static, 1=cold, 2=warm, 3=hot).\n\n"
"CRITICAL RULES:\n"
"- When labeling/identifying a field, ALWAYS change BOTH name AND kind in one tree.apply call. "
"Example: [{op:'rename',nodeId:'X',name:'health'},{op:'change_kind',nodeId:'X',kind:'Int32'}]. "
"A node named 'health' with kind Hex64 is WRONG — the kind must match the actual data type.\n"
"- To detect what changed after an in-game event: call ui.action with action:'reset_tracking', "
"then have the user perform the action, then call node.history on the relevant nodes "
"to see which ones have new timestamped entries.\n"
"- hex.read offset is relative to the struct base address by default. "
"Use baseRelative=true for absolute virtual addresses in the process.\n"
"- tree.apply operations are atomic (undo macro). Batch related changes into one call.\n"
"- Use tree.search to quickly find nodes by name instead of paging through project.state.\n"
"- project.state returns structure metadata only (kinds, names, offsets), NOT live values. "
"Use hex.read for actual memory values and node.history for tracking changes over time."
}
};
return okReply(id, result);
}
@@ -219,6 +240,8 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
tools.append(QJsonObject{
{"name", "project.state"},
{"description", "Returns project state with paginated node tree. "
"NOTE: This returns structure metadata only (kinds, names, offsets), NOT live memory values. "
"Use hex.read to read actual values and node.history to track value changes over time. "
"Responses return max 'limit' nodes (default 50). "
"Use depth:1 first, then parentId to drill into a struct. "
"Enum/bitfield member arrays are omitted by default (counts shown instead); "
@@ -249,6 +272,10 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
tools.append(QJsonObject{
{"name", "tree.apply"},
{"description", "Apply batch of tree operations atomically (undo macro). "
"IMPORTANT: When identifying/labeling a field, you MUST use BOTH rename AND change_kind "
"in the same batch. A renamed node still has its original kind (e.g. Hex64) unless you "
"explicitly change it. Example: "
"[{op:'rename',nodeId:'ID',name:'health'},{op:'change_kind',nodeId:'ID',kind:'Int32'}]. "
"Each op is a JSON object with an 'op' field for the operation type and 'nodeId' (string) for the target node. "
"Operations: "
"remove: {op:'remove', nodeId:'ID'}. "
@@ -256,7 +283,7 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
"insert: {op:'insert', kind:'Hex64', name:'field', parentId:'ID', offset:0}. "
"change_kind: {op:'change_kind', nodeId:'ID', kind:'UInt32'}. "
"change_offset: {op:'change_offset', nodeId:'ID', offset:16}. "
"change_base: {op:'change_base', baseAddress:'0x400000'}. "
"change_base: {op:'change_base', baseAddress:'0x400000', formula:'[0x233CA80]'} — formula is optional, enables auto-resolve on provider attach. "
"change_struct_type: {op:'change_struct_type', nodeId:'ID', structTypeName:'Name'}. "
"change_class_keyword: {op:'change_class_keyword', nodeId:'ID', classKeyword:'class'}. "
"change_pointer_ref: {op:'change_pointer_ref', nodeId:'ID', refId:'targetID'}. "
@@ -301,10 +328,11 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
// 4. hex.read
tools.append(QJsonObject{
{"name", "hex.read"},
{"description", "Read raw bytes from provider. Returns hex dump, ASCII, and multi-type "
{"description", "Read raw bytes from provider (live process memory). Returns hex dump, ASCII, and multi-type "
"interpretations (u8/u16/u32/u64/i32/f32/f64/ptr/string heuristics). "
"Use this to see what actual values are in memory at any offset. "
"Offset is tree-relative (0-based, baseAddress added automatically) "
"unless baseRelative=true (offset is absolute)."},
"unless baseRelative=true (offset is absolute virtual address in the process)."},
{"inputSchema", QJsonObject{
{"type", "object"},
{"properties", QJsonObject{
@@ -359,8 +387,10 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
{"description", "Trigger a UI action. Fallback for operations without dedicated tools. "
"Actions: undo, redo, new_file, open_file, save_file, save_file_as, "
"export_cpp, set_view_root, scroll_to_node, collapse_node, expand_node, "
"select_node, refresh. "
"export_cpp accepts optional nodeId to export a single struct (recommended for large projects)."},
"select_node, refresh, reset_tracking. "
"export_cpp accepts optional nodeId to export a single struct (recommended for large projects). "
"reset_tracking clears all value change histories — use before an in-game event, "
"then check node.history afterward to see what changed."},
{"inputSchema", QJsonObject{
{"type", "object"},
{"properties", QJsonObject{
@@ -396,6 +426,27 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
}}
});
// 9. node.history
tools.append(QJsonObject{
{"name", "node.history"},
{"description", "Returns timestamped value change history (up to 10 entries) for specified nodes. "
"Use this to detect what changed after an in-game event — no need to manually snapshot memory. "
"Each node returns: entries[] with {value, timestamp}, heatLevel (0=static to 3=hot), "
"and uniqueCount. Heat level 3 means the field is actively changing. "
"Requires live provider with value tracking enabled."},
{"inputSchema", QJsonObject{
{"type", "object"},
{"properties", QJsonObject{
{"nodeIds", QJsonObject{{"type", "array"},
{"items", QJsonObject{{"type", "string"}}},
{"description", "Array of node IDs to get history for."}}},
{"tabIndex", QJsonObject{{"type", "integer"},
{"description", "MDI tab index. Omit for active tab."}}}
}},
{"required", QJsonArray{"nodeIds"}}
}}
});
return okReply(id, QJsonObject{{"tools", tools}});
}
@@ -420,6 +471,7 @@ QJsonObject McpBridge::handleToolsCall(const QJsonValue& id, const QJsonObject&
else if (toolName == "status.set") result = toolStatusSet(args);
else if (toolName == "ui.action") result = toolUiAction(args);
else if (toolName == "tree.search") result = toolTreeSearch(args);
else if (toolName == "node.history") result = toolNodeHistory(args);
else return errReply(id, -32601, "Unknown tool: " + toolName);
m_mainWindow->clearMcpStatus();
@@ -751,8 +803,10 @@ QJsonObject McpBridge::toolTreeApply(const QJsonObject& args) {
}
else if (opType == "change_base") {
uint64_t newBase = op.value("baseAddress").toString().toULongLong(nullptr, 16);
QString oldFormula = tree.baseAddressFormula;
QString newFormula = op.value("formula").toString();
doc->undoStack.push(new RcxCommand(ctrl,
cmd::ChangeBase{tree.baseAddress, newBase}));
cmd::ChangeBase{tree.baseAddress, newBase, oldFormula, newFormula}));
applied++;
}
else if (opType == "change_struct_type") {
@@ -1159,6 +1213,16 @@ QJsonObject McpBridge::toolUiAction(const QJsonObject& args) {
return makeTextResult("Selected node " + nodeIdStr);
}
if (action == "reset_tracking") {
int count = m_mainWindow->tabCount();
for (int i = 0; i < count; ++i) {
auto* t = m_mainWindow->tabByIndex(i);
if (t && t->ctrl)
t->ctrl->resetChangeTracking();
}
return makeTextResult(QStringLiteral("Value tracking reset on all %1 tabs.").arg(count));
}
return makeTextResult("Unknown action: " + action, true);
}
@@ -1226,6 +1290,43 @@ QJsonObject McpBridge::toolTreeSearch(const QJsonObject& args) {
QJsonDocument(out).toJson(QJsonDocument::Indented)));
}
// ════════════════════════════════════════════════════════════════════
// Tool: node.history — return timestamped value history for nodes
// ════════════════════════════════════════════════════════════════════
QJsonObject McpBridge::toolNodeHistory(const QJsonObject& args) {
auto* tab = resolveTab(args);
if (!tab) return makeTextResult("No active tab.", true);
const auto& histMap = tab->ctrl->valueHistory();
QJsonArray requestedIds = args.value("nodeIds").toArray();
if (requestedIds.isEmpty())
return makeTextResult("nodeIds array is required.", true);
QJsonObject result;
for (const auto& idVal : requestedIds) {
QString idStr = idVal.toString();
uint64_t nodeId = idStr.toULongLong();
auto it = histMap.find(nodeId);
QJsonArray entries;
if (it != histMap.end()) {
it->forEachWithTime([&](const QString& val, qint64 msec) {
QJsonObject entry;
entry.insert(QStringLiteral("value"), val);
entry.insert(QStringLiteral("timestamp"), msec);
entries.append(entry);
});
}
QJsonObject nodeResult;
nodeResult.insert(QStringLiteral("entries"), entries);
nodeResult.insert(QStringLiteral("heatLevel"), it != histMap.end() ? it->heatLevel() : 0);
nodeResult.insert(QStringLiteral("uniqueCount"), it != histMap.end() ? it->uniqueCount() : 0);
result.insert(idStr, nodeResult);
}
return makeTextResult(QString::fromUtf8(
QJsonDocument(result).toJson(QJsonDocument::Compact)));
}
// ════════════════════════════════════════════════════════════════════
// Notifications (call from MainWindow/Controller hooks)
// ════════════════════════════════════════════════════════════════════

View File

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

View File

@@ -5,6 +5,10 @@
#include <QMessageBox>
#include <QFileInfo>
#include <QPixmap>
#include <QSettings>
#include <QApplication>
#include <QClipboard>
#include <QMenu>
#ifdef _WIN32
#include <windows.h>
@@ -27,22 +31,9 @@ ProcessPicker::ProcessPicker(QWidget *parent)
, m_useCustomList(false)
{
ui->setupUi(this);
// Configure table
ui->processTable->setColumnWidth(0, 80); // PID column - fixed width
ui->processTable->setColumnWidth(1, 200); // Name column - fixed width
ui->processTable->horizontalHeader()->setStretchLastSection(true); // Path column - fills remaining space
ui->processTable->setWordWrap(false); // Disable word wrap for single-line display
ui->processTable->setTextElideMode(Qt::ElideLeft); // Elide from left (show end of path)
// Connect signals
connect(ui->refreshButton, &QPushButton::clicked, this, &ProcessPicker::refreshProcessList);
connect(ui->processTable, &QTableWidget::itemDoubleClicked, this, &ProcessPicker::onProcessSelected);
connect(ui->filterEdit, &QLineEdit::textChanged, this, &ProcessPicker::filterProcesses);
connect(ui->attachButton, &QPushButton::clicked, this, &ProcessPicker::onProcessSelected);
// Initial process enumeration
initUi();
refreshProcessList();
selectPreferredProcess();
}
ProcessPicker::ProcessPicker(const QList<ProcessInfo>& customProcesses, QWidget *parent)
@@ -51,23 +42,102 @@ ProcessPicker::ProcessPicker(const QList<ProcessInfo>& customProcesses, QWidget
, m_useCustomList(true)
{
ui->setupUi(this);
// Configure table
ui->processTable->setColumnWidth(0, 80);
ui->processTable->setColumnWidth(1, 200);
initUi();
ui->refreshButton->setVisible(false);
m_allProcesses = customProcesses;
applyFilter();
selectPreferredProcess();
}
void ProcessPicker::initUi()
{
// Table configuration
ui->processTable->setColumnWidth(0, 80); // PID column
ui->processTable->setColumnWidth(1, 200); // Name column
ui->processTable->horizontalHeader()->setStretchLastSection(true);
ui->processTable->setWordWrap(false);
ui->processTable->setTextElideMode(Qt::ElideLeft);
// Connect signals (no refresh button for custom lists)
ui->refreshButton->setVisible(false);
ui->processTable->setShowGrid(false);
ui->processTable->verticalHeader()->setDefaultSectionSize(fontMetrics().height() + 6);
// Signal connections
connect(ui->refreshButton, &QPushButton::clicked, this, &ProcessPicker::refreshProcessList);
connect(ui->processTable, &QTableWidget::itemDoubleClicked, this, &ProcessPicker::onProcessSelected);
connect(ui->filterEdit, &QLineEdit::textChanged, this, &ProcessPicker::filterProcesses);
connect(ui->attachButton, &QPushButton::clicked, this, &ProcessPicker::onProcessSelected);
// Use custom process list
m_allProcesses = customProcesses;
applyFilter();
// Derive theme colors from the global palette (set by applyGlobalTheme)
QPalette pal = qApp->palette();
QString bg = pal.color(QPalette::Base).name();
QString text = pal.color(QPalette::Text).name();
QString hover = pal.color(QPalette::Mid).name();
QString surface = pal.color(QPalette::AlternateBase).name();
QString button = pal.color(QPalette::Button).name();
QString highlight= pal.color(QPalette::Highlight).name();
QString border = pal.color(QPalette::Mid).darker(120).name();
QString mutedText= pal.color(QPalette::Disabled, QPalette::WindowText).name();
QString hoverDk = pal.color(QPalette::Mid).darker(130).name();
ui->processTable->setStyleSheet(QStringLiteral(
"QTableWidget { background: %1; color: %2; border: none; }"
"QTableWidget::item { padding: 2px 6px; border: none; }"
"QTableWidget::item:hover { background: %3; padding: 2px 6px; border: none; }"
"QTableWidget::item:selected { background: %3; color: %2; padding: 2px 6px; border: none; }")
.arg(bg, text, hover));
ui->processTable->horizontalHeader()->setStyleSheet(QStringLiteral(
"QHeaderView::section { background: %1; color: %2; border: none;"
" padding: 4px 6px; text-align: left; }")
.arg(surface, text));
ui->processTable->horizontalHeader()->setDefaultAlignment(Qt::AlignLeft | Qt::AlignVCenter);
ui->filterEdit->setStyleSheet(QStringLiteral(
"QLineEdit { background: %1; color: %2; border: 1px solid %3; padding: 2px 4px; }"
"QLineEdit:focus { border-color: %4; }")
.arg(bg, text, border, highlight));
QString btnStyle = QStringLiteral(
"QPushButton { background: %1; color: %2; border: 1px solid %3; padding: 4px 12px; }"
"QPushButton:hover { background: %4; }"
"QPushButton:pressed { background: %5; }"
"QPushButton:disabled { color: %6; }")
.arg(button, text, border, hover, hoverDk, mutedText);
ui->refreshButton->setStyleSheet(btnStyle);
ui->attachButton->setStyleSheet(btnStyle);
ui->cancelButton->setStyleSheet(btnStyle);
// Right-click context menu
ui->processTable->setContextMenuPolicy(Qt::CustomContextMenu);
connect(ui->processTable, &QWidget::customContextMenuRequested, this, [this](const QPoint& pos) {
int row = ui->processTable->rowAt(pos.y());
if (row < 0) return;
auto* pidItem = ui->processTable->item(row, 0);
auto* nameItem = ui->processTable->item(row, 1);
auto* pathItem = ui->processTable->item(row, 2);
if (!pidItem || !nameItem) return;
QString pid = QString::number(pidItem->data(Qt::EditRole).toUInt());
QString name = nameItem->data(Qt::UserRole).toString();
QString path = pathItem ? pathItem->text() : QString();
QMenu menu;
auto* copyPid = menu.addAction(QStringLiteral("Copy PID"));
auto* copyName = menu.addAction(QStringLiteral("Copy Name"));
QAction* copyPath = nullptr;
if (!path.isEmpty())
copyPath = menu.addAction(QStringLiteral("Copy Path"));
auto* chosen = menu.exec(ui->processTable->viewport()->mapToGlobal(pos));
if (chosen == copyPid)
QApplication::clipboard()->setText(pid);
else if (chosen == copyName)
QApplication::clipboard()->setText(name);
else if (copyPath && chosen == copyPath)
QApplication::clipboard()->setText(path);
});
// Auto-focus filter for immediate typing
ui->filterEdit->setFocus();
}
ProcessPicker::~ProcessPicker()
@@ -97,31 +167,31 @@ void ProcessPicker::onProcessSelected()
{
auto* item = ui->processTable->currentItem();
if (!item) return;
int row = item->row();
m_selectedPid = ui->processTable->item(row, 0)->data(Qt::EditRole).toUInt();
// Use original name stored in UserRole (without architecture suffix)
QVariant origName = ui->processTable->item(row, 1)->data(Qt::UserRole);
m_selectedName = origName.isValid() ? origName.toString()
: ui->processTable->item(row, 1)->text();
accept();
}
void ProcessPicker::enumerateProcesses()
{
QList<ProcessInfo> processes;
#ifdef _WIN32
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (snapshot == INVALID_HANDLE_VALUE) {
QMessageBox::warning(this, "Error", "Failed to enumerate processes.");
return;
}
PROCESSENTRY32W pe32;
pe32.dwSize = sizeof(PROCESSENTRY32W);
if (Process32FirstW(snapshot, &pe32))
{
do
@@ -129,10 +199,7 @@ void ProcessPicker::enumerateProcesses()
ProcessInfo info;
info.pid = pe32.th32ProcessID;
info.name = QString::fromWCharArray(pe32.szExeFile);
// Try to get full path and extract icon
// If we can't open a process with PROCESS_QUERY_LIMITED_INFORMATION then
// we for sure can't access their memory. - Skip in this case
HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pe32.th32ProcessID);
if (hProcess)
{
@@ -143,7 +210,7 @@ void ProcessPicker::enumerateProcesses()
GetModuleFileNameExW(hProcess, nullptr, path, pathLen))
{
info.path = QString::fromWCharArray(path);
// Extract icon from executable
SHFILEINFOW sfi = {};
if (SHGetFileInfoW(path, 0, &sfi, sizeof(sfi), SHGFI_ICON | SHGFI_SMALLICON)) {
@@ -292,3 +359,22 @@ void ProcessPicker::applyFilter()
populateTable(filtered);
}
void ProcessPicker::selectPreferredProcess()
{
// Try to select the last-attached process if it's in the list
QSettings s("Reclass", "Reclass");
QString lastProc = s.value("lastAttachedProcess").toString();
if (lastProc.isEmpty()) return;
for (int row = 0; row < ui->processTable->rowCount(); ++row) {
auto* nameItem = ui->processTable->item(row, 1);
if (!nameItem) continue;
QString name = nameItem->data(Qt::UserRole).toString();
if (name.compare(lastProc, Qt::CaseInsensitive) == 0) {
ui->processTable->selectRow(row);
ui->processTable->scrollToItem(nameItem);
break;
}
}
}

View File

@@ -35,9 +35,11 @@ private slots:
void filterProcesses(const QString& text);
private:
void initUi();
void enumerateProcesses();
void populateTable(const QList<ProcessInfo>& processes);
void applyFilter();
void selectPreferredProcess();
Ui::ProcessPicker *ui;
uint32_t m_selectedPid = 0;

View File

@@ -127,22 +127,6 @@
</widget>
<resources/>
<connections>
<connection>
<sender>attachButton</sender>
<signal>clicked()</signal>
<receiver>ProcessPicker</receiver>
<slot>accept()</slot>
<hints>
<hint type="sourcelabel">
<x>600</x>
<y>470</y>
</hint>
<hint type="destinationlabel">
<x>350</x>
<y>250</y>
</hint>
</hints>
</connection>
<connection>
<sender>cancelButton</sender>
<signal>clicked()</signal>

241
src/rcxtooltip.h Normal file
View File

@@ -0,0 +1,241 @@
#pragma once
#include "themes/thememanager.h"
#include <QWidget>
#include <QLabel>
#include <QPainter>
#include <QPainterPath>
#include <QApplication>
#include <QScreen>
#include <QTimer>
#include <QPropertyAnimation>
#include <QCursor>
#include <cstdio>
#define TIP_LOG(...) do { \
FILE* _f = fopen("E:/game_dev/util/reclass2027-main/build/tip_trace.log", "a"); \
if (_f) { fprintf(_f, __VA_ARGS__); fclose(_f); } \
} while(0)
namespace rcx {
class RcxTooltip : public QWidget {
public:
static RcxTooltip* instance() {
static RcxTooltip* s = nullptr;
if (!s) {
s = new RcxTooltip;
QObject::connect(&ThemeManager::instance(), &ThemeManager::themeChanged,
s, [](const rcx::Theme&) { /* colors read live in paintEvent */ });
}
return s;
}
void showFor(QWidget* trigger, const QString& text) {
if (!trigger || text.isEmpty()) {
TIP_LOG("[TIP] showFor: null trigger or empty text -- dismiss\n");
dismiss(); return;
}
// Same widget+text already showing — do nothing (prevents teleport)
if (m_trigger == trigger && m_text == text && isVisible()) {
TIP_LOG("[TIP] showFor: same widget+text, already visible -- skip\n");
return;
}
TIP_LOG("[TIP] showFor: text='%s' trigger=%p class=%s\n",
qPrintable(text), (void*)trigger, trigger->metaObject()->className());
// Cancel pending dismiss
if (m_dismissTimer) m_dismissTimer->stop();
m_trigger = trigger;
m_text = text;
m_label->setText(text);
m_label->adjustSize();
// ── Size: label + padding + arrow ──
const int pad = 8;
const int vpad = 4;
int bodyW = m_label->sizeHint().width() + pad * 2;
int bodyH = m_label->sizeHint().height() + vpad * 2;
int totalW = bodyW;
int totalH = bodyH + kArrowH;
// ── Position relative to trigger widget ──
QRect trigGlobal = QRect(trigger->mapToGlobal(QPoint(0, 0)), trigger->size());
int trigCenterX = trigGlobal.center().x();
QScreen* screen = QApplication::screenAt(trigGlobal.center());
QRect scr = screen ? screen->availableGeometry() : QRect(0, 0, 1920, 1080);
// Default: above the trigger
m_arrowDown = true;
int x = trigCenterX - totalW / 2;
int y = trigGlobal.top() - totalH - kGap;
// Flip below if not enough room above
if (y < scr.top()) {
m_arrowDown = false;
y = trigGlobal.bottom() + kGap;
}
// Clamp horizontally
if (x < scr.left()) x = scr.left() + 2;
if (x + totalW > scr.right()) x = scr.right() - totalW - 2;
// Arrow X in local coords
m_arrowLocalX = trigCenterX - x;
m_arrowLocalX = qBound(kArrowHalfW + 4, m_arrowLocalX, totalW - kArrowHalfW - 4);
// Position label inside the body
if (m_arrowDown)
m_label->move(pad, vpad);
else
m_label->move(pad, kArrowH + vpad);
m_bodyRect = m_arrowDown
? QRect(0, 0, bodyW, bodyH)
: QRect(0, kArrowH, bodyW, bodyH);
setFixedSize(totalW, totalH);
move(x, y);
if (!isVisible()) {
TIP_LOG("[TIP] showFor: showing at (%d,%d) size=%dx%d arrowDown=%d arrowX=%d\n",
x, y, totalW, totalH, m_arrowDown, m_arrowLocalX);
setWindowOpacity(0.0);
show();
raise();
// Fade in
auto* anim = new QPropertyAnimation(this, "windowOpacity", this);
anim->setDuration(80);
anim->setStartValue(0.0);
anim->setEndValue(1.0);
anim->setEasingCurve(QEasingCurve::OutCubic);
anim->start(QAbstractAnimation::DeleteWhenStopped);
} else {
TIP_LOG("[TIP] showFor: already visible, updating\n");
update();
}
}
void dismiss() {
TIP_LOG("[TIP] dismiss: wasVisible=%d\n", isVisible());
if (m_dismissTimer) m_dismissTimer->stop();
if (isVisible()) hide();
m_trigger = nullptr;
}
// Schedule dismiss with a delay — but only if the cursor has truly
// left the trigger+tooltip zone. Qt fires synthetic Leave events
// when a tooltip window appears above the trigger; we must ignore those.
void scheduleDismiss() {
if (m_trigger) {
QPoint cursor = QCursor::pos();
QRect trigRect(m_trigger->mapToGlobal(QPoint(0, 0)), m_trigger->size());
QRect tipRect(pos(), size());
QRect zone = trigRect.united(tipRect).adjusted(-4, -4, 4, 4);
bool inside = zone.contains(cursor);
TIP_LOG("[TIP] scheduleDismiss: cursor=(%d,%d) zone=(%d,%d %dx%d) inside=%d\n",
cursor.x(), cursor.y(),
zone.x(), zone.y(), zone.width(), zone.height(), inside);
if (inside)
return; // cursor still inside — ignore spurious Leave
}
if (!m_dismissTimer) {
m_dismissTimer = new QTimer(this);
m_dismissTimer->setSingleShot(true);
connect(m_dismissTimer, &QTimer::timeout, this, &RcxTooltip::dismiss);
}
m_dismissTimer->start(100);
}
QWidget* currentTrigger() const { return m_trigger; }
// ── Geometry accessors (for testing) ──
bool arrowPointsDown() const { return m_arrowDown; }
int arrowLocalX() const { return m_arrowLocalX; }
QRect bodyRect() const { return m_bodyRect; }
QString currentText() const { return m_text; }
// Constants exposed for testing
static constexpr int kArrowH = 6;
static constexpr int kArrowHalfW = 6;
static constexpr int kGap = 2;
protected:
void paintEvent(QPaintEvent*) override {
TIP_LOG("[TIP] paintEvent: size=%dx%d bodyRect=(%d,%d %dx%d)\n",
width(), height(),
m_bodyRect.x(), m_bodyRect.y(), m_bodyRect.width(), m_bodyRect.height());
const auto& theme = ThemeManager::instance().current();
QPainter p(this);
p.setRenderHint(QPainter::Antialiasing);
// Fill entire widget with the tooltip background first
// (no WA_TranslucentBackground, so unpainted areas would be opaque garbage)
p.fillRect(rect(), theme.backgroundAlt);
// Build path: rounded body + triangle arrow
QPainterPath path;
path.addRoundedRect(QRectF(m_bodyRect), 4.0, 4.0);
// Triangle arrow
QPolygonF arrow;
if (m_arrowDown) {
int ay = m_bodyRect.bottom();
arrow << QPointF(m_arrowLocalX - kArrowHalfW, ay)
<< QPointF(m_arrowLocalX, ay + kArrowH)
<< QPointF(m_arrowLocalX + kArrowHalfW, ay);
} else {
int ay = kArrowH;
arrow << QPointF(m_arrowLocalX - kArrowHalfW, ay)
<< QPointF(m_arrowLocalX, 0)
<< QPointF(m_arrowLocalX + kArrowHalfW, ay);
}
QPainterPath arrowPath;
arrowPath.addPolygon(arrow);
arrowPath.closeSubpath();
path = path.united(arrowPath);
// Stroke the shape border
p.setPen(QPen(theme.border, 1.0));
p.setBrush(theme.backgroundAlt);
p.drawPath(path);
}
private:
explicit RcxTooltip()
: QWidget(nullptr, Qt::ToolTip | Qt::FramelessWindowHint)
{
// NOTE: WA_TranslucentBackground removed — it breaks under DWM dark mode
// (DwmSetWindowAttribute DWMWA_USE_IMMERSIVE_DARK_MODE kills layered compositing)
setAttribute(Qt::WA_ShowWithoutActivating);
setAutoFillBackground(false); // we paint everything ourselves in paintEvent
m_label = new QLabel(this);
m_label->setAlignment(Qt::AlignCenter);
updateLabelStyle();
connect(&ThemeManager::instance(), &ThemeManager::themeChanged,
this, [this](const rcx::Theme&) { updateLabelStyle(); });
}
void updateLabelStyle() {
const auto& theme = ThemeManager::instance().current();
m_label->setStyleSheet(
QStringLiteral("QLabel { color: %1; background: transparent; padding: 0; }")
.arg(theme.text.name()));
}
QLabel* m_label = nullptr;
QWidget* m_trigger = nullptr;
QString m_text;
QTimer* m_dismissTimer = nullptr;
bool m_arrowDown = true;
int m_arrowLocalX = 0;
QRect m_bodyRect;
};
} // namespace rcx

View File

@@ -347,6 +347,64 @@ int naturalAlignment(ValueType type) {
return 1;
}
int valueSizeForType(ValueType type) {
switch (type) {
case ValueType::Int8: case ValueType::UInt8: return 1;
case ValueType::Int16: case ValueType::UInt16: return 2;
case ValueType::Int32: case ValueType::UInt32: case ValueType::Float: return 4;
case ValueType::Int64: case ValueType::UInt64: case ValueType::Double: return 8;
case ValueType::Vec2: return 8;
case ValueType::Vec3: return 12;
case ValueType::Vec4: return 16;
default: return 4;
}
}
// ── Typed comparison for rescan conditions ──
static int compareTyped(const QByteArray& a, const QByteArray& b, ValueType vt) {
const char* da = a.constData();
const char* db = b.constData();
int sz = qMin(a.size(), b.size());
switch (vt) {
case ValueType::Int8:
if (sz >= 1) { int8_t va, vb; memcpy(&va, da, 1); memcpy(&vb, db, 1); return (va > vb) - (va < vb); }
break;
case ValueType::UInt8:
if (sz >= 1) { uint8_t va, vb; memcpy(&va, da, 1); memcpy(&vb, db, 1); return (va > vb) - (va < vb); }
break;
case ValueType::Int16:
if (sz >= 2) { int16_t va, vb; memcpy(&va, da, 2); memcpy(&vb, db, 2); return (va > vb) - (va < vb); }
break;
case ValueType::UInt16:
if (sz >= 2) { uint16_t va, vb; memcpy(&va, da, 2); memcpy(&vb, db, 2); return (va > vb) - (va < vb); }
break;
case ValueType::Int32:
if (sz >= 4) { int32_t va, vb; memcpy(&va, da, 4); memcpy(&vb, db, 4); return (va > vb) - (va < vb); }
break;
case ValueType::UInt32:
if (sz >= 4) { uint32_t va, vb; memcpy(&va, da, 4); memcpy(&vb, db, 4); return (va > vb) - (va < vb); }
break;
case ValueType::Int64:
if (sz >= 8) { int64_t va, vb; memcpy(&va, da, 8); memcpy(&vb, db, 8); return (va > vb) - (va < vb); }
break;
case ValueType::UInt64:
if (sz >= 8) { uint64_t va, vb; memcpy(&va, da, 8); memcpy(&vb, db, 8); return (va > vb) - (va < vb); }
break;
case ValueType::Float:
if (sz >= 4) { float va, vb; memcpy(&va, da, 4); memcpy(&vb, db, 4); return (va > vb) - (va < vb); }
break;
case ValueType::Double:
if (sz >= 8) { double va, vb; memcpy(&va, da, 8); memcpy(&vb, db, 8); return (va > vb) - (va < vb); }
break;
default:
break;
}
// Fallback: byte comparison
return memcmp(da, db, sz);
}
// ── Scan engine ──
ScanEngine::ScanEngine(QObject* parent)
@@ -366,13 +424,15 @@ void ScanEngine::abort() {
void ScanEngine::start(std::shared_ptr<Provider> provider, const ScanRequest& req) {
if (isRunning()) return;
if (req.pattern.isEmpty()) {
emit error(QStringLiteral("Empty pattern"));
return;
}
if (req.pattern.size() != req.mask.size()) {
emit error(QStringLiteral("Pattern and mask size mismatch"));
return;
if (req.condition != ScanCondition::UnknownValue) {
if (req.pattern.isEmpty()) {
emit error(QStringLiteral("Empty pattern"));
return;
}
if (req.pattern.size() != req.mask.size()) {
emit error(QStringLiteral("Pattern and mask size mismatch"));
return;
}
}
m_abort.store(false);
@@ -400,14 +460,16 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
timer.start();
QVector<ScanResult> results;
const bool isUnknown = (req.condition == ScanCondition::UnknownValue);
if (!prov || req.pattern.isEmpty())
if (!prov || (!isUnknown && req.pattern.isEmpty()))
return results;
auto regions = prov->enumerateRegions();
qDebug() << "[scan] regions:" << regions.size()
<< " pattern:" << req.pattern.size() << "bytes"
<< " align:" << req.alignment
<< " condition:" << (int)req.condition
<< " filterExec:" << req.filterExecutable
<< " filterWrite:" << req.filterWritable;
@@ -422,17 +484,26 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
regions.append(fallback);
}
const int patternLen = req.pattern.size();
const char* pat = req.pattern.constData();
const char* msk = req.mask.constData();
const int patternLen = isUnknown ? req.valueSize : req.pattern.size();
const char* pat = isUnknown ? nullptr : req.pattern.constData();
const char* msk = isUnknown ? nullptr : req.mask.constData();
const int alignment = qMax(1, req.alignment);
const int valSize = isUnknown ? req.valueSize : patternLen;
const bool hasRange = (req.startAddress != 0 || req.endAddress != 0) &&
req.endAddress > req.startAddress;
// Pre-compute total bytes for progress
uint64_t totalBytes = 0;
for (const auto& r : regions) {
if (req.filterExecutable && !r.executable) continue;
if (req.filterWritable && !r.writable) continue;
totalBytes += r.size;
uint64_t rStart = r.base, rEnd = r.base + r.size;
if (hasRange) {
if (rEnd <= req.startAddress || rStart >= req.endAddress) continue;
rStart = qMax(rStart, req.startAddress);
rEnd = qMin(rEnd, req.endAddress);
}
totalBytes += rEnd - rStart;
}
qDebug() << "[scan] total scannable:" << (totalBytes / 1024) << "KB across filtered regions";
@@ -450,21 +521,35 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
if (req.filterExecutable && !region.executable) continue;
if (req.filterWritable && !region.writable) continue;
if ((uint64_t)patternLen > region.size) {
scannedBytes += region.size;
// Clip region to requested address range
uint64_t regStart = region.base;
uint64_t regEnd = region.base + region.size;
if (hasRange) {
if (regEnd <= req.startAddress || regStart >= req.endAddress) {
// Entirely outside range — skip
continue;
}
regStart = qMax(regStart, req.startAddress);
regEnd = qMin(regEnd, req.endAddress);
}
uint64_t regSize = regEnd - regStart;
if ((uint64_t)patternLen > regSize) {
scannedBytes += regSize;
continue;
}
const int overlap = patternLen - 1;
QByteArray chunk(qMin((uint64_t)kChunk, region.size), Qt::Uninitialized);
QByteArray chunk(qMin((uint64_t)kChunk, regSize), Qt::Uninitialized);
uint64_t regOffset = regStart - region.base; // offset within provider region
for (uint64_t off = 0; off < region.size; ) {
for (uint64_t off = 0; off < regSize; ) {
if (m_abort.load()) break;
uint64_t remaining = region.size - off;
uint64_t remaining = regSize - off;
int readLen = (int)qMin((uint64_t)chunk.size(), remaining);
if (!prov->read(region.base + off, chunk.data(), readLen)) {
if (!prov->read(regStart + off, chunk.data(), readLen)) {
// Skip unreadable chunk
off += readLen;
scannedBytes += readLen;
@@ -474,24 +559,38 @@ QVector<ScanResult> ScanEngine::runScan(std::shared_ptr<Provider> prov,
int scanEnd = readLen - patternLen;
const char* data = chunk.constData();
for (int i = 0; i <= scanEnd; i += alignment) {
bool match = true;
for (int j = 0; j < patternLen; j++) {
if ((data[i + j] & msk[j]) != (pat[j] & msk[j])) {
match = false;
break;
}
}
if (match) {
if (isUnknown) {
// Unknown value: capture every aligned address
for (int i = 0; i <= scanEnd; i += alignment) {
ScanResult r;
r.address = region.base + off + (uint64_t)i;
r.regionModule = region.moduleName;
r.scanValue = QByteArray(data + i, qMin(16, readLen - i));
r.address = regStart + off + (uint64_t)i;
r.scanValue = QByteArray(data + i, valSize);
results.append(r);
if (results.size() >= req.maxResults)
goto done;
}
} else {
// Exact pattern match
for (int i = 0; i <= scanEnd; i += alignment) {
bool match = true;
for (int j = 0; j < patternLen; j++) {
if ((data[i + j] & msk[j]) != (pat[j] & msk[j])) {
match = false;
break;
}
}
if (match) {
ScanResult r;
r.address = regStart + off + (uint64_t)i;
r.regionModule = region.moduleName;
r.scanValue = QByteArray(data + i, qMin(16, readLen - i));
results.append(r);
if (results.size() >= req.maxResults)
goto done;
}
}
}
// Advance with overlap to catch patterns that straddle chunks
@@ -522,6 +621,7 @@ done:
void ScanEngine::startRescan(std::shared_ptr<Provider> provider,
QVector<ScanResult> results, int readSize,
ScanCondition condition, ValueType valueType,
const QByteArray& filterPattern,
const QByteArray& filterMask) {
if (isRunning()) return;
@@ -541,14 +641,15 @@ void ScanEngine::startRescan(std::shared_ptr<Provider> provider,
watcher->setFuture(QtConcurrent::run(
[this, provider, results = std::move(results), readSize,
filterPattern, filterMask]() mutable {
condition, valueType, filterPattern, filterMask]() mutable {
return runRescan(provider, std::move(results), readSize,
filterPattern, filterMask);
condition, valueType, filterPattern, filterMask);
}));
}
QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
QVector<ScanResult> results, int readSize,
ScanCondition condition, ValueType valueType,
const QByteArray& filterPattern,
const QByteArray& filterMask) {
QElapsedTimer timer;
@@ -557,9 +658,17 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
int total = results.size();
if (total == 0 || !prov) return results;
bool hasFilter = !filterPattern.isEmpty();
bool hasExactFilter = !filterPattern.isEmpty() && condition == ScanCondition::ExactValue;
bool hasComparison = (condition == ScanCondition::Changed ||
condition == ScanCondition::Unchanged ||
condition == ScanCondition::Increased ||
condition == ScanCondition::Decreased);
bool needsFilter = hasExactFilter || hasComparison;
qDebug() << "[rescan] start:" << total << "results, readSize:" << readSize
<< "filter:" << (hasFilter ? "yes" : "no");
<< "condition:" << (int)condition
<< "exactFilter:" << (hasExactFilter ? "yes" : "no")
<< "comparison:" << (hasComparison ? "yes" : "no");
// Save previous values
for (auto& r : results)
@@ -579,8 +688,8 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
uint64_t totalBytesRead = 0;
int i = 0;
// Track which results matched the filter (by original index)
QVector<bool> matched(total, !hasFilter); // if no filter, all match
// Track which results matched (by original index)
QVector<bool> matched(total, !needsFilter); // if no filter, all match
while (i < total && !m_abort.load()) {
uint64_t spanBase = results[order[i]].address;
@@ -604,8 +713,8 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
int off = (int)(r.address - spanBase);
r.scanValue = chunk.mid(off, readSize);
// Apply filter: compare re-read bytes against the new pattern
if (hasFilter) {
// Apply exact-value filter
if (hasExactFilter) {
int patLen = filterPattern.size();
if (r.scanValue.size() >= patLen) {
bool ok = true;
@@ -621,6 +730,18 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
matched[idx] = ok;
}
}
// Apply comparison-based filter
if (hasComparison && !r.previousValue.isEmpty()) {
int cmp = compareTyped(r.scanValue, r.previousValue, valueType);
switch (condition) {
case ScanCondition::Changed: matched[idx] = (cmp != 0); break;
case ScanCondition::Unchanged: matched[idx] = (cmp == 0); break;
case ScanCondition::Increased: matched[idx] = (cmp > 0); break;
case ScanCondition::Decreased: matched[idx] = (cmp < 0); break;
default: break;
}
}
}
chunks++;
@@ -637,7 +758,7 @@ QVector<ScanResult> ScanEngine::runRescan(std::shared_ptr<Provider> prov,
}
// Filter out non-matching results
if (hasFilter) {
if (needsFilter) {
QVector<ScanResult> filtered;
filtered.reserve(total);
for (int k = 0; k < total; k++) {

View File

@@ -10,26 +10,6 @@
namespace rcx {
// ── Scan request / result ──
struct ScanRequest {
QByteArray pattern; // literal bytes to match
QByteArray mask; // 0xFF = must match, 0x00 = wildcard
bool filterExecutable = false; // only scan +x regions
bool filterWritable = false; // only scan +w regions
int alignment = 1; // 1 = every byte, 4 = dword, 8 = qword
int maxResults = 50000;
};
struct ScanResult {
uint64_t address;
QString regionModule;
QByteArray scanValue; // cached bytes at scan/update time
QByteArray previousValue; // value before last update
};
// ── Value scan types ──
enum class ValueType {
@@ -41,6 +21,43 @@ enum class ValueType {
HexBytes
};
// ── Scan condition (Cheat Engine-style) ──
enum class ScanCondition {
ExactValue, // first scan + rescan: match specific bytes
UnknownValue, // first scan only: capture all aligned addresses
Changed, // rescan: current != previous
Unchanged, // rescan: current == previous
Increased, // rescan: current > previous (numeric)
Decreased // rescan: current < previous (numeric)
};
// ── Scan request / result ──
struct ScanRequest {
QByteArray pattern; // literal bytes to match (empty for UnknownValue)
QByteArray mask; // 0xFF = must match, 0x00 = wildcard
bool filterExecutable = false; // only scan +x regions
bool filterWritable = false; // only scan +w regions
int alignment = 1; // 1 = every byte, 4 = dword, 8 = qword
int maxResults = 50000;
ScanCondition condition = ScanCondition::ExactValue;
int valueSize = 4; // bytes per value (for unknown scans)
uint64_t startAddress = 0; // 0 = no limit (scan all regions)
uint64_t endAddress = 0; // 0 = no limit (scan all regions)
};
struct ScanResult {
uint64_t address;
QString regionModule;
QByteArray scanValue; // cached bytes at scan/update time
QByteArray previousValue; // value before last update
};
// ── Pattern parsing ──
// Parse IDA-style signature string ("48 8B ?? 05") into pattern + mask.
@@ -57,6 +74,9 @@ bool serializeValue(ValueType type, const QString& input,
// Natural alignment for a value type (used as default alignment for value scans).
int naturalAlignment(ValueType type);
// Byte-size for a value type (used for unknown scans and rescan read size).
int valueSizeForType(ValueType type);
// ── Scan engine ──
class ScanEngine : public QObject {
@@ -67,6 +87,8 @@ public:
void start(std::shared_ptr<Provider> provider, const ScanRequest& req);
void startRescan(std::shared_ptr<Provider> provider,
QVector<ScanResult> results, int readSize,
ScanCondition condition = ScanCondition::ExactValue,
ValueType valueType = ValueType::Int32,
const QByteArray& filterPattern = {},
const QByteArray& filterMask = {});
void abort();
@@ -82,6 +104,7 @@ private:
QVector<ScanResult> runScan(std::shared_ptr<Provider> prov, const ScanRequest& req);
QVector<ScanResult> runRescan(std::shared_ptr<Provider> prov,
QVector<ScanResult> results, int readSize,
ScanCondition condition, ValueType valueType,
const QByteArray& filterPattern,
const QByteArray& filterMask);

View File

@@ -93,6 +93,18 @@ ScannerPanel::ScannerPanel(QWidget* parent)
m_typeCombo->setCurrentIndex(2); // default: int32
inputRow->addWidget(m_typeCombo);
m_condLabel = new QLabel(QStringLiteral("Scan:"), this);
inputRow->addWidget(m_condLabel);
m_condCombo = new QComboBox(this);
m_condCombo->addItem(QStringLiteral("Exact Value"), (int)ScanCondition::ExactValue);
m_condCombo->addItem(QStringLiteral("Unknown Value"), (int)ScanCondition::UnknownValue);
m_condCombo->addItem(QStringLiteral("Changed"), (int)ScanCondition::Changed);
m_condCombo->addItem(QStringLiteral("Unchanged"), (int)ScanCondition::Unchanged);
m_condCombo->addItem(QStringLiteral("Increased"), (int)ScanCondition::Increased);
m_condCombo->addItem(QStringLiteral("Decreased"), (int)ScanCondition::Decreased);
inputRow->addWidget(m_condCombo);
m_valueLabel = new QLabel(QStringLiteral("Value:"), this);
inputRow->addWidget(m_valueLabel);
@@ -112,6 +124,9 @@ ScannerPanel::ScannerPanel(QWidget* parent)
m_writeCheck = new QCheckBox(QStringLiteral("Writable"), this);
filterRow->addWidget(m_writeCheck);
m_structOnlyCheck = new QCheckBox(QStringLiteral("Current Struct"), this);
filterRow->addWidget(m_structOnlyCheck);
filterRow->addStretch();
m_scanBtn = new QPushButton(QIcon(QStringLiteral(":/vsicons/search.svg")),
@@ -174,6 +189,8 @@ ScannerPanel::ScannerPanel(QWidget* parent)
// ── Initial state: signature mode ──
m_typeLabel->hide();
m_typeCombo->hide();
m_condLabel->hide();
m_condCombo->hide();
m_valueLabel->hide();
m_valueEdit->hide();
m_execCheck->setChecked(true);
@@ -181,6 +198,8 @@ ScannerPanel::ScannerPanel(QWidget* parent)
// ── Connections ──
connect(m_modeCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &ScannerPanel::onModeChanged);
connect(m_condCombo, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, &ScannerPanel::onConditionChanged);
connect(m_scanBtn, &QPushButton::clicked,
this, &ScannerPanel::onScanClicked);
connect(m_updateBtn, &QPushButton::clicked,
@@ -241,6 +260,10 @@ void ScannerPanel::setProviderGetter(ProviderGetter getter) {
m_providerGetter = std::move(getter);
}
void ScannerPanel::setBoundsGetter(BoundsGetter getter) {
m_boundsGetter = std::move(getter);
}
void ScannerPanel::setEditorFont(const QFont& font) {
m_resultTable->setFont(font);
QFontMetrics fm(font);
@@ -251,15 +274,18 @@ void ScannerPanel::setEditorFont(const QFont& font) {
m_valueEdit->setFont(font);
m_modeCombo->setFont(font);
m_typeCombo->setFont(font);
m_condCombo->setFont(font);
m_statusLabel->setFont(font);
m_scanBtn->setFont(font);
m_gotoBtn->setFont(font);
m_copyBtn->setFont(font);
m_patternLabel->setFont(font);
m_typeLabel->setFont(font);
m_condLabel->setFont(font);
m_valueLabel->setFont(font);
m_execCheck->setFont(font);
m_writeCheck->setFont(font);
m_structOnlyCheck->setFont(font);
m_updateBtn->setFont(font);
updateComboWidth();
}
@@ -280,14 +306,29 @@ void ScannerPanel::onModeChanged(int index) {
m_typeLabel->setVisible(!isSig);
m_typeCombo->setVisible(!isSig);
m_condLabel->setVisible(!isSig);
m_condCombo->setVisible(!isSig);
// Enable/disable value input based on condition
auto cond = (ScanCondition)m_condCombo->currentData().toInt();
bool needsValue = !isSig && (cond == ScanCondition::ExactValue);
m_valueLabel->setVisible(!isSig);
m_valueEdit->setVisible(!isSig);
m_valueEdit->setEnabled(needsValue);
m_valueLabel->setEnabled(needsValue);
// Auto-toggle filters: signatures → executable code, values → writable data
m_execCheck->setChecked(isSig);
m_writeCheck->setChecked(!isSig);
}
void ScannerPanel::onConditionChanged(int /*index*/) {
auto cond = (ScanCondition)m_condCombo->currentData().toInt();
bool needsValue = (cond == ScanCondition::ExactValue);
m_valueEdit->setEnabled(needsValue);
m_valueLabel->setEnabled(needsValue);
}
void ScannerPanel::onScanClicked() {
if (m_engine->isRunning()) {
m_engine->abort();
@@ -306,12 +347,14 @@ void ScannerPanel::onScanClicked() {
// Build request
ScanRequest req = buildRequest();
if (req.pattern.isEmpty())
if (req.condition != ScanCondition::UnknownValue && req.pattern.isEmpty())
return; // error already shown by buildRequest
m_lastScanMode = m_modeCombo->currentIndex();
if (m_lastScanMode == 1)
if (m_lastScanMode == 1) {
m_lastValueType = (ValueType)m_typeCombo->currentData().toInt();
m_lastCondition = req.condition;
}
m_lastPattern = req.pattern;
m_scanBtn->setText(QStringLiteral("Cancel"));
@@ -336,16 +379,41 @@ ScanRequest ScannerPanel::buildRequest() {
} else {
// Value mode
auto vt = (ValueType)m_typeCombo->currentData().toInt();
if (!serializeValue(vt, m_valueEdit->text(), req.pattern, req.mask, &err)) {
m_statusLabel->setText(QStringLiteral("Value error: %1").arg(err));
return {};
auto cond = (ScanCondition)m_condCombo->currentData().toInt();
// Comparison conditions on fresh scan → treat as unknown
if (cond == ScanCondition::Changed || cond == ScanCondition::Unchanged ||
cond == ScanCondition::Increased || cond == ScanCondition::Decreased) {
cond = ScanCondition::UnknownValue;
}
req.condition = cond;
req.alignment = naturalAlignment(vt);
req.valueSize = valueSizeForType(vt);
if (cond == ScanCondition::UnknownValue) {
// No pattern needed — capture all aligned addresses
req.maxResults = 10000000;
} else {
// Exact value mode
if (!serializeValue(vt, m_valueEdit->text(), req.pattern, req.mask, &err)) {
m_statusLabel->setText(QStringLiteral("Value error: %1").arg(err));
return {};
}
}
}
req.filterExecutable = m_execCheck->isChecked();
req.filterWritable = m_writeCheck->isChecked();
if (m_structOnlyCheck->isChecked() && m_boundsGetter) {
auto bounds = m_boundsGetter();
if (bounds.size > 0) {
req.startAddress = bounds.start;
req.endAddress = bounds.start + bounds.size;
}
}
return req;
}
@@ -355,10 +423,11 @@ void ScannerPanel::onScanFinished(QVector<ScanResult> results) {
m_results = std::move(results);
// Bytes are cached by the engine during scan.
// Value mode: override with exact search pattern (engine caches raw chunk bytes).
// Value mode (exact): override with exact search pattern (engine caches raw chunk bytes).
// Unknown mode: keep engine-captured bytes as-is (they're the baseline).
for (auto& r : m_results) {
r.previousValue.clear();
if (m_lastScanMode == 1)
if (m_lastScanMode == 1 && m_lastCondition == ScanCondition::ExactValue)
r.scanValue = m_lastPattern;
}
@@ -372,8 +441,10 @@ void ScannerPanel::onScanFinished(QVector<ScanResult> results) {
}
int n = m_results.size();
m_statusLabel->setText(QStringLiteral("%1 result%2")
.arg(n).arg(n == 1 ? "" : "s"));
if (m_lastCondition == ScanCondition::UnknownValue && n >= 10000000)
m_statusLabel->setText(QStringLiteral("%1 results (capped — narrow with Re-scan)").arg(n));
else
m_statusLabel->setText(QStringLiteral("%1 result%2").arg(n).arg(n == 1 ? "" : "s"));
}
void ScannerPanel::populateTable(bool showPrevious) {
@@ -425,29 +496,41 @@ void ScannerPanel::onUpdateClicked() {
int readSize = (m_lastScanMode == 1) ? valueSize() : 16;
// Build filter from current input field
// Determine rescan condition
ScanCondition cond = ScanCondition::ExactValue;
if (m_lastScanMode == 1)
cond = (ScanCondition)m_condCombo->currentData().toInt();
// For UnknownValue on rescan, just re-read all (update only, no filter)
if (cond == ScanCondition::UnknownValue)
cond = ScanCondition::ExactValue; // with empty filter = update only
// Build filter from current input field (only for ExactValue condition)
QByteArray filterPattern, filterMask;
if (m_lastScanMode == 0) {
// Signature mode
QString err;
if (!m_patternEdit->text().trimmed().isEmpty()) {
if (!parseSignature(m_patternEdit->text(), filterPattern, filterMask, &err)) {
m_statusLabel->setText(QStringLiteral("Pattern error: %1").arg(err));
return;
if (cond == ScanCondition::ExactValue) {
if (m_lastScanMode == 0) {
// Signature mode
QString err;
if (!m_patternEdit->text().trimmed().isEmpty()) {
if (!parseSignature(m_patternEdit->text(), filterPattern, filterMask, &err)) {
m_statusLabel->setText(QStringLiteral("Pattern error: %1").arg(err));
return;
}
}
}
} else {
// Value mode
QString err;
if (!m_valueEdit->text().trimmed().isEmpty()) {
auto vt = (ValueType)m_typeCombo->currentData().toInt();
if (!serializeValue(vt, m_valueEdit->text(), filterPattern, filterMask, &err)) {
m_statusLabel->setText(QStringLiteral("Value error: %1").arg(err));
return;
} else {
// Value mode — exact value filter
QString err;
if (!m_valueEdit->text().trimmed().isEmpty()) {
auto vt = (ValueType)m_typeCombo->currentData().toInt();
if (!serializeValue(vt, m_valueEdit->text(), filterPattern, filterMask, &err)) {
m_statusLabel->setText(QStringLiteral("Value error: %1").arg(err));
return;
}
m_lastValueType = vt;
}
m_lastValueType = vt;
}
}
// Comparison conditions (Changed/Unchanged/Increased/Decreased) don't need a filter pattern
// Update last pattern so display uses the new value
if (!filterPattern.isEmpty())
@@ -460,7 +543,8 @@ void ScannerPanel::onUpdateClicked() {
m_progressBar->setValue(0);
m_progressBar->show();
m_engine->startRescan(prov, m_results, readSize, filterPattern, filterMask);
m_engine->startRescan(prov, m_results, readSize, cond, m_lastValueType,
filterPattern, filterMask);
}
void ScannerPanel::onRescanFinished(QVector<ScanResult> results) {
@@ -666,12 +750,14 @@ void ScannerPanel::applyTheme(const Theme& theme) {
theme.border.name(), theme.hover.name());
m_modeCombo->setStyleSheet(comboStyle);
m_typeCombo->setStyleSheet(comboStyle);
m_condCombo->setStyleSheet(comboStyle);
// Labels
QPalette lp;
lp.setColor(QPalette::WindowText, theme.textDim);
m_patternLabel->setPalette(lp);
m_typeLabel->setPalette(lp);
m_condLabel->setPalette(lp);
m_valueLabel->setPalette(lp);
m_statusLabel->setPalette(lp);
@@ -680,6 +766,7 @@ void ScannerPanel::applyTheme(const Theme& theme) {
cp.setColor(QPalette::WindowText, theme.textDim);
m_execCheck->setPalette(cp);
m_writeCheck->setPalette(cp);
m_structOnlyCheck->setPalette(cp);
// Buttons
QString btnStyle = QStringLiteral(

View File

@@ -34,6 +34,10 @@ public:
using ProviderGetter = std::function<std::shared_ptr<Provider>()>;
void setProviderGetter(ProviderGetter getter);
struct StructBounds { uint64_t start = 0; uint64_t size = 0; };
using BoundsGetter = std::function<StructBounds()>;
void setBoundsGetter(BoundsGetter getter);
void setEditorFont(const QFont& font);
void applyTheme(const Theme& theme);
@@ -52,6 +56,9 @@ public:
QPushButton* gotoButton() const { return m_gotoBtn; }
QPushButton* copyButton() const { return m_copyBtn; }
ScanEngine* engine() const { return m_engine; }
QComboBox* condCombo() const { return m_condCombo; }
QLabel* condLabel() const { return m_condLabel; }
QCheckBox* structOnlyCheck() const { return m_structOnlyCheck; }
signals:
void goToAddress(uint64_t address);
@@ -72,18 +79,23 @@ private:
void populateTable(bool showPrevious);
void updateComboWidth();
void onConditionChanged(int index);
// Input widgets
QComboBox* m_modeCombo; // Signature / Value
QLineEdit* m_patternEdit; // Signature pattern input
QComboBox* m_typeCombo; // Value type dropdown
QComboBox* m_condCombo; // Scan condition (Exact/Unknown/Changed/...)
QLineEdit* m_valueEdit; // Value input
QLabel* m_patternLabel;
QLabel* m_typeLabel;
QLabel* m_condLabel;
QLabel* m_valueLabel;
// Filters
QCheckBox* m_execCheck;
QCheckBox* m_writeCheck;
QCheckBox* m_structOnlyCheck;
// Actions
QPushButton* m_scanBtn;
@@ -100,9 +112,11 @@ private:
// Engine
ScanEngine* m_engine;
ProviderGetter m_providerGetter;
BoundsGetter m_boundsGetter;
QVector<ScanResult> m_results;
int m_lastScanMode = 0; // 0=signature, 1=value
ValueType m_lastValueType = ValueType::Int32;
ScanCondition m_lastCondition = ScanCondition::ExactValue;
QByteArray m_lastPattern; // serialized search value
int m_preRescanCount = 0; // result count before last rescan

View File

@@ -10,8 +10,8 @@
"textDim": "#505C74",
"textMuted": "#384258",
"textFaint": "#2C3448",
"hover": "#121720",
"selected": "#121720",
"hover": "#181E2A",
"selected": "#1A2D4A",
"selection": "#1A2038",
"syntaxKeyword": "#5688C0",
"syntaxNumber": "#90B480",

View File

@@ -0,0 +1,32 @@
{
"name": "Light",
"background": "#e8e8ec",
"backgroundAlt": "#dcdce0",
"surface": "#d4d4d8",
"border": "#b8b8be",
"borderFocused": "#6870a0",
"button": "#ccccd0",
"text": "#1b1b22",
"textDim": "#5c5c68",
"textMuted": "#84848e",
"textFaint": "#a8a8b0",
"hover": "#d8d8de",
"selected": "#d0d0d8",
"selection": "#b4c8e8",
"syntaxKeyword": "#4455aa",
"syntaxNumber": "#2a7a4c",
"syntaxString": "#9a4040",
"syntaxComment": "#6a7a6a",
"syntaxPreproc": "#787880",
"syntaxType": "#2e7a8a",
"indHoverSpan": "#5a68a0",
"indCmdPill": "#dcdce0",
"indDataChanged": "#2a7a4c",
"indHeatCold": "#6a6a30",
"indHeatWarm": "#a06828",
"indHeatHot": "#b83030",
"indHintGreen": "#387a44",
"markerPtr": "#b83030",
"markerCycle": "#9a7010",
"markerError": "#e8c8c8"
}

View File

@@ -15,8 +15,8 @@
"selection": "#21213A",
"syntaxKeyword": "#AA9565",
"syntaxNumber": "#AAA98C",
"syntaxString": "#6B3B21",
"syntaxComment": "#464646",
"syntaxString": "#C0825A",
"syntaxComment": "#8A8878",
"syntaxPreproc": "#AA9565",
"syntaxType": "#6B959F",
"indHoverSpan": "#AA9565",
@@ -25,8 +25,8 @@
"indHeatCold": "#C4A44A",
"indHeatWarm": "#AA9565",
"indHeatHot": "#A05040",
"indHintGreen": "#464646",
"markerPtr": "#6B3B21",
"indHintGreen": "#688A58",
"markerPtr": "#B85A42",
"markerCycle": "#AA9565",
"markerError": "#3C2121"
}

View File

@@ -33,7 +33,12 @@ ThemeManager::ThemeManager() {
// ── Load built-in themes from JSON files next to the executable ──
QString ThemeManager::builtInDir() const {
#ifdef Q_OS_MACOS
// In a macOS .app bundle, resources live in Contents/Resources, not Contents/MacOS
return QCoreApplication::applicationDirPath() + "/../Resources/themes";
#else
return QCoreApplication::applicationDirPath() + "/themes";
#endif
}
void ThemeManager::loadBuiltInThemes() {

View File

@@ -74,7 +74,7 @@ void TitleBarWidget::applyTheme(const Theme& theme) {
// App label
m_appLabel->setStyleSheet(
QStringLiteral("QLabel { color: %1; font-size: 12px; font-weight: bold; }")
.arg(theme.textDim.name()));
.arg(theme.text.name()));
// Menu bar palette — hover/bg handled by MenuBarStyle QProxyStyle.
// Set Window + Button to background so Fusion never paints a foreign color.
@@ -82,7 +82,7 @@ void TitleBarWidget::applyTheme(const Theme& theme) {
QPalette mbPal = m_menuBar->palette();
mbPal.setColor(QPalette::Window, theme.background);
mbPal.setColor(QPalette::Button, theme.background);
mbPal.setColor(QPalette::ButtonText, theme.textDim);
mbPal.setColor(QPalette::ButtonText, theme.text);
m_menuBar->setPalette(mbPal);
m_menuBar->setAutoFillBackground(false);
}
@@ -112,7 +112,7 @@ void TitleBarWidget::setShowIcon(bool show) {
m_appLabel->setText(QStringLiteral("Reclass"));
m_appLabel->setStyleSheet(
QStringLiteral("QLabel { color: %1; font-size: 12px; font-weight: bold; }")
.arg(m_theme.textDim.name()));
.arg(m_theme.text.name()));
}
}

View File

@@ -46,10 +46,17 @@ inline void buildProjectExplorer(QStandardItemModel* model,
auto nameOf = [](const Node* n) {
return n->structTypeName.isEmpty() ? n->name : n->structTypeName;
};
// Sort structs by children count descending (most fields first)
auto cmpChildren = [&](const Entry& a, const Entry& b) {
int ca = a.tree->childrenOf(a.node->id).size();
int cb = b.tree->childrenOf(b.node->id).size();
if (ca != cb) return ca > cb;
return nameOf(a.node).compare(nameOf(b.node), Qt::CaseInsensitive) < 0;
};
std::sort(types.begin(), types.end(), cmpChildren);
auto cmpName = [&](const Entry& a, const Entry& b) {
return nameOf(a.node).compare(nameOf(b.node), Qt::CaseInsensitive) < 0;
};
std::sort(types.begin(), types.end(), cmpName);
std::sort(enums.begin(), enums.end(), cmpName);
// Helper: type display string for a member node

View File

@@ -2627,6 +2627,122 @@ private slots:
QVERIFY2(result.text.contains(QStringLiteral("\u2192")),
qPrintable("Expected arrow (\u2192) in text:\n" + result.text));
}
void testTreeLinesDepth2() {
// Diagnostic test: verify tree chars at depth 2+ with hex64 nodes
// (matches user's actual scenario — Hex64 inside pointer expansion)
NodeTree tree;
tree.baseAddress = 0;
// Root struct "Unnamed"
Node root;
root.kind = NodeKind::Struct;
root.name = "Unnamed";
root.parentId = 0;
root.offset = 0;
int ri = tree.addNode(root);
uint64_t rootId = tree.nodes[ri].id;
// First child: hex64 at depth 1
Node f1;
f1.kind = NodeKind::Hex64;
f1.name = "";
f1.parentId = rootId;
f1.offset = 0;
tree.addNode(f1);
// Ref struct "NewClass" (separate root-level definition)
Node inner;
inner.kind = NodeKind::Struct;
inner.name = "NewClass";
inner.parentId = 0;
inner.offset = 200;
int ii = tree.addNode(inner);
uint64_t innerId = tree.nodes[ii].id;
// hex64 children of NewClass
Node if1;
if1.kind = NodeKind::Hex64;
if1.name = "";
if1.parentId = innerId;
if1.offset = 0;
tree.addNode(if1);
Node if2;
if2.kind = NodeKind::Hex64;
if2.name = "";
if2.parentId = innerId;
if2.offset = 8;
tree.addNode(if2);
Node if3;
if3.kind = NodeKind::Hex64;
if3.name = "";
if3.parentId = innerId;
if3.offset = 16;
tree.addNode(if3);
// Pointer in root referencing NewClass
Node ptr;
ptr.kind = NodeKind::Pointer64;
ptr.name = "field_0008";
ptr.parentId = rootId;
ptr.offset = 8;
ptr.refId = innerId;
tree.addNode(ptr);
// Last child: hex64 at depth 1
Node f2;
f2.kind = NodeKind::Hex64;
f2.name = "";
f2.parentId = rootId;
f2.offset = 16;
tree.addNode(f2);
// Provider with pointer value
QByteArray data(256, '\0');
uint64_t ptrVal = 100;
memcpy(data.data() + 8, &ptrVal, 8);
BufferProvider prov(data);
// Compose WITH tree lines
ComposeResult result = compose(tree, prov, 0, false, true);
QStringList lines = result.text.split('\n');
// Print output with char codes for debugging
qDebug() << "=== Tree lines compose output (hex64 scenario) ===";
for (int i = 0; i < lines.size(); i++) {
// Also show hex of first 15 chars to see tree chars
QString hexChars;
for (int c = 0; c < qMin(15, lines[i].size()); c++)
hexChars += QString("U+%1 ").arg(static_cast<uint>(lines[i][c].unicode()), 4, 16, QChar('0'));
qDebug().noquote() << QString("[%1] d=%2 k=%3: %4")
.arg(i, 2).arg(result.meta[i].depth).arg((int)result.meta[i].lineKind).arg(lines[i]);
qDebug().noquote() << QString(" hex: %1").arg(hexChars);
}
qDebug() << "=== end ===";
// Verify depth-2 lines contain tree chars
QChar vertLine(0x2502); // │
QChar tee(0x251C); // ├
QChar corner(0x2514); // └
bool foundDepth2TreeChar = false;
for (int i = 0; i < result.meta.size(); i++) {
if (result.meta[i].depth == 2
&& result.meta[i].lineKind != LineKind::Footer) {
bool has = lines[i].contains(vertLine)
|| lines[i].contains(tee)
|| lines[i].contains(corner);
if (has) foundDepth2TreeChar = true;
QVERIFY2(has,
qPrintable(QString("Depth-2 line %1 missing tree chars: %2")
.arg(i).arg(lines[i])));
}
}
QVERIFY2(foundDepth2TreeChar,
qPrintable("No depth-2 lines with tree chars found:\n" + result.text));
}
};
QTEST_MAIN(TestCompose)

View File

@@ -815,6 +815,68 @@ private slots:
QCOMPARE(m_doc->tree.nodes[idx].isStatic, true);
}
// ── Test: clearing value history actually resets heat to 0 ──
void testClearValueHistoryResetsHeat() {
// Use a live provider so value tracking runs during refresh()
m_doc->provider = std::make_unique<BaseAwareProvider>(makeSmallBuffer(), 0);
m_ctrl->setTrackValues(true);
// Do initial refresh to populate m_lastResult.meta
m_ctrl->refresh();
QApplication::processEvents();
// Find field_u32 nodeId
uint64_t targetId = 0;
for (const auto& n : m_doc->tree.nodes) {
if (n.name == "field_u32") { targetId = n.id; break; }
}
QVERIFY(targetId != 0);
// Seed value history with multiple changes to get heat > 0
auto& history = const_cast<QHash<uint64_t, ValueHistory>&>(m_ctrl->valueHistory());
history[targetId].record("val_1");
history[targetId].record("val_2");
history[targetId].record("val_3");
QVERIFY2(history[targetId].heatLevel() >= 2,
"Pre-clear: should have heat >= 2 (warm)");
// Refresh so heatLevel propagates to LineMeta
m_ctrl->refresh();
QApplication::processEvents();
// Verify heat is visible in meta
bool foundHot = false;
for (const auto& lm : m_ctrl->lastResult().meta) {
if (lm.nodeId == targetId && lm.heatLevel > 0) {
foundHot = true;
break;
}
}
QVERIFY2(foundHot, "Pre-clear: LineMeta should show heat > 0");
// Now simulate what the "Clear Value History" context menu does:
// remove from history map + clear subtree + refresh
history.remove(targetId);
for (int ci : m_doc->tree.subtreeIndices(targetId))
history.remove(m_doc->tree.nodes[ci].id);
m_ctrl->refresh();
QApplication::processEvents();
// After clear + refresh, heatLevel must be 0 for this node
for (const auto& lm : m_ctrl->lastResult().meta) {
if (lm.nodeId == targetId) {
QCOMPARE(lm.heatLevel, 0);
}
}
// The history entry should exist again (re-recorded by refresh)
// but with only 1 unique value → heatLevel 0
QVERIFY(history.contains(targetId));
QCOMPARE(history[targetId].heatLevel(), 0);
QCOMPARE(history[targetId].uniqueCount(), 1);
}
void testStaticFieldTypeChangePreservesFlags() {
uint64_t rootId = m_doc->tree.nodes[0].id;

112
tests/test_dbgdump.cpp Normal file
View File

@@ -0,0 +1,112 @@
#include <cstdio>
#include <cstdint>
#include <windows.h>
#include <initguid.h>
#include <dbgeng.h>
int main(int argc, char* argv[])
{
const char* dumpPath = "F:\\MEMORY_EaService2024.DMP";
if (argc > 1) dumpPath = argv[1];
HRESULT hrCom = CoInitializeEx(NULL, COINIT_MULTITHREADED);
printf("CoInitializeEx: 0x%08lX\n", hrCom);
fflush(stdout);
IDebugClient* client = nullptr;
HRESULT hr = DebugCreate(IID_IDebugClient, (void**)&client);
printf("DebugCreate: 0x%08lX, client=%p\n", hr, (void*)client);
fflush(stdout);
if (FAILED(hr) || !client) {
printf("FAILED to create debug client\n");
if (SUCCEEDED(hrCom)) CoUninitialize();
return 1;
}
printf("Opening dump: %s\n", dumpPath);
fflush(stdout);
hr = client->OpenDumpFile(dumpPath);
printf("OpenDumpFile: 0x%08lX\n", hr);
fflush(stdout);
if (FAILED(hr)) {
printf("FAILED to open dump\n");
client->Release();
if (SUCCEEDED(hrCom)) CoUninitialize();
return 1;
}
IDebugControl* ctrl = nullptr;
client->QueryInterface(IID_IDebugControl, (void**)&ctrl);
if (ctrl) {
printf("WaitForEvent(10s)...\n");
fflush(stdout);
hr = ctrl->WaitForEvent(0, 10000);
printf("WaitForEvent: 0x%08lX\n", hr);
fflush(stdout);
ULONG debugClass = 0, debugQual = 0;
hr = ctrl->GetDebuggeeType(&debugClass, &debugQual);
printf("GetDebuggeeType: 0x%08lX, class=%lu, qualifier=%lu\n",
hr, debugClass, debugQual);
printf(" -> %s\n", debugQual >= 1024 ? "DUMP" : "LIVE");
fflush(stdout);
}
IDebugDataSpaces* ds = nullptr;
client->QueryInterface(IID_IDebugDataSpaces, (void**)&ds);
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, loaded=%lu, unloaded=%lu\n",
hr, numMods, numUnloaded);
fflush(stdout);
if (numMods > 0) {
ULONG64 base = 0;
hr = sym->GetModuleByIndex(0, &base);
printf("Module[0] base: 0x%llX (hr=0x%08lX)\n", base, hr);
fflush(stdout);
if (SUCCEEDED(hr) && base && ds) {
uint8_t buf[16] = {};
ULONG got = 0;
hr = ds->ReadVirtual(base, buf, 16, &got);
printf("ReadVirtual(0x%llX, 16): hr=0x%08lX, got=%lu\n", base, hr, got);
printf(" data: ");
for (int i = 0; i < 16; i++) printf("%02X ", buf[i]);
printf("\n");
fflush(stdout);
}
}
}
// Try reading kernel base directly
uint64_t ntBase = 0xfffff80123c00000ULL;
if (ds) {
uint8_t buf[16] = {};
ULONG got = 0;
hr = ds->ReadVirtual(ntBase, buf, 16, &got);
printf("ReadVirtual(nt base 0x%llX, 16): hr=0x%08lX, got=%lu\n", ntBase, hr, got);
printf(" data: ");
for (int i = 0; i < 16; i++) printf("%02X ", buf[i]);
printf("\n");
fflush(stdout);
}
if (sym) sym->Release();
if (ds) ds->Release();
if (ctrl) ctrl->Release();
client->DetachProcesses();
client->Release();
printf("Done.\n");
if (SUCCEEDED(hrCom)) CoUninitialize();
return 0;
}

View File

@@ -1072,6 +1072,120 @@ private slots:
auto results = finSpy.first().first().value<QVector<ScanResult>>();
QCOMPARE(results.size(), 7); // 8 - 2 + 1 = 7 positions
}
// ═══════════════════════════════════════════════════════════════════
// Address range filtering — "Current Struct" support
// ═══════════════════════════════════════════════════════════════════
void scan_addressRangeNoLimit() {
// startAddress=0, endAddress=0 → scan all (default behavior unchanged)
QByteArray data(32, '\x00');
data[8] = '\xAA'; data[16] = '\xAA'; data[24] = '\xAA';
auto prov = std::make_shared<BufferProvider>(data);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
ScanRequest req;
req.pattern = QByteArray("\xAA", 1);
req.mask = QByteArray("\xFF", 1);
engine.start(prov, req);
QVERIFY(finSpy.wait(5000));
auto results = finSpy.first().first().value<QVector<ScanResult>>();
QCOMPARE(results.size(), 3); // all 3 found
}
void scan_addressRangeClipsResults() {
// Only scan addresses [8, 20) — should find match at offset 8 and 16 but not 24
QByteArray data(32, '\x00');
data[8] = '\xAA'; data[16] = '\xAA'; data[24] = '\xAA';
auto prov = std::make_shared<BufferProvider>(data);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
ScanRequest req;
req.pattern = QByteArray("\xAA", 1);
req.mask = QByteArray("\xFF", 1);
req.startAddress = 8;
req.endAddress = 20;
engine.start(prov, req);
QVERIFY(finSpy.wait(5000));
auto results = finSpy.first().first().value<QVector<ScanResult>>();
QCOMPARE(results.size(), 2);
QCOMPARE(results[0].address, (uint64_t)8);
QCOMPARE(results[1].address, (uint64_t)16);
}
void scan_addressRangeOutsideData() {
// Range entirely outside data → no results
QByteArray data(16, '\xAA');
auto prov = std::make_shared<BufferProvider>(data);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
ScanRequest req;
req.pattern = QByteArray("\xAA", 1);
req.mask = QByteArray("\xFF", 1);
req.startAddress = 100;
req.endAddress = 200;
engine.start(prov, req);
QVERIFY(finSpy.wait(5000));
auto results = finSpy.first().first().value<QVector<ScanResult>>();
QCOMPARE(results.size(), 0);
}
void scan_addressRangeWithRegions() {
// Two regions: [1000, 1016) and [2000, 2016). Range [1000, 1020) clips to first region only.
QByteArray data(4096, '\x00');
// Place \xBB at offset 1000 and 2000
data[1000] = '\xBB';
data[2000] = '\xBB';
QVector<MemoryRegion> regions;
{ MemoryRegion r; r.base = 1000; r.size = 16; r.readable = true; r.writable = true; regions.append(r); }
{ MemoryRegion r; r.base = 2000; r.size = 16; r.readable = true; r.writable = true; regions.append(r); }
auto prov = std::make_shared<RegionProvider>(data, regions);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
ScanRequest req;
req.pattern = QByteArray("\xBB", 1);
req.mask = QByteArray("\xFF", 1);
req.startAddress = 1000;
req.endAddress = 1020;
engine.start(prov, req);
QVERIFY(finSpy.wait(5000));
auto results = finSpy.first().first().value<QVector<ScanResult>>();
QCOMPARE(results.size(), 1);
QCOMPARE(results[0].address, (uint64_t)1000);
}
void scan_unknownWithAddressRange() {
// Unknown scan with address range should only capture within range
QByteArray data(32, '\x42');
auto prov = std::make_shared<BufferProvider>(data);
ScanEngine engine;
QSignalSpy finSpy(&engine, &ScanEngine::finished);
ScanRequest req;
req.condition = ScanCondition::UnknownValue;
req.valueSize = 4;
req.alignment = 4;
req.startAddress = 8;
req.endAddress = 24;
engine.start(prov, req);
QVERIFY(finSpy.wait(5000));
auto results = finSpy.first().first().value<QVector<ScanResult>>();
// Range [8, 24) = 16 bytes, alignment 4, valueSize 4 → offsets 8, 12, 16, 20 = 4 results
QCOMPARE(results.size(), 4);
QCOMPARE(results[0].address, (uint64_t)8);
QCOMPARE(results[3].address, (uint64_t)20);
}
};
QTEST_MAIN(TestScanner)

View File

@@ -1103,6 +1103,89 @@ private slots:
// Provider getter is lazy (captures at scan time)
// ═══════════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════════
// "Current Struct" checkbox
// ═══════════════════════════════════════════════════════════════════
void structOnly_checkboxExists() {
QVERIFY(m_panel->structOnlyCheck() != nullptr);
QCOMPARE(m_panel->structOnlyCheck()->isChecked(), false);
QCOMPARE(m_panel->structOnlyCheck()->text(), QStringLiteral("Current Struct"));
}
void structOnly_setsAddressRange() {
// Set up a bounds getter that returns a known range
m_panel->setBoundsGetter([]() -> ScannerPanel::StructBounds {
return { 0x1000, 0x200 };
});
// Set up a simple buffer provider
QByteArray data(0x2000, '\x00');
data[0x1000] = '\xCC';
data[0x1100] = '\xCC';
data[0x1500] = '\xCC'; // outside bounds (0x1000 + 0x200 = 0x1200)
auto prov = std::make_shared<BufferProvider>(data);
m_panel->setProviderGetter([prov]() { return prov; });
// Enable struct-only mode
m_panel->structOnlyCheck()->setChecked(true);
// Scan for \xCC
m_panel->patternEdit()->setText("CC");
QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished);
QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton);
QVERIFY(finSpy.wait(5000));
QApplication::processEvents();
// Should only find results within [0x1000, 0x1200)
auto results = finSpy.first().first().value<QVector<ScanResult>>();
QCOMPARE(results.size(), 2);
}
void structOnly_uncheckedScansAll() {
// Same setup but with checkbox unchecked — should find all 3
m_panel->setBoundsGetter([]() -> ScannerPanel::StructBounds {
return { 0x1000, 0x200 };
});
QByteArray data(0x2000, '\x00');
data[0x1000] = '\xCC';
data[0x1100] = '\xCC';
data[0x1500] = '\xCC';
auto prov = std::make_shared<BufferProvider>(data);
m_panel->setProviderGetter([prov]() { return prov; });
m_panel->structOnlyCheck()->setChecked(false); // unchecked
m_panel->patternEdit()->setText("CC");
QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished);
QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton);
QVERIFY(finSpy.wait(5000));
QApplication::processEvents();
auto results = finSpy.first().first().value<QVector<ScanResult>>();
QCOMPARE(results.size(), 3);
}
void structOnly_noBoundsGetterIgnored() {
// No bounds getter set — checkbox checked but no effect
QByteArray data(16, '\xDD');
auto prov = std::make_shared<BufferProvider>(data);
m_panel->setProviderGetter([prov]() { return prov; });
m_panel->structOnlyCheck()->setChecked(true);
// Don't set bounds getter
m_panel->patternEdit()->setText("DD");
QSignalSpy finSpy(m_panel->engine(), &ScanEngine::finished);
QTest::mouseClick(m_panel->scanButton(), Qt::LeftButton);
QVERIFY(finSpy.wait(5000));
QApplication::processEvents();
auto results = finSpy.first().first().value<QVector<ScanResult>>();
QCOMPARE(results.size(), 16); // all 16 bytes match
}
void providerGetter_lazy() {
auto prov1 = std::make_shared<BufferProvider>(QByteArray(16, '\xAA'));
auto prov2 = std::make_shared<BufferProvider>(QByteArray(16, '\xBB'));

432
tests/test_tooltip.cpp Normal file
View File

@@ -0,0 +1,432 @@
#include <QtTest>
#include <QApplication>
#include <QPushButton>
#include <QScreen>
#include <QImage>
#include "rcxtooltip.h"
#include "themes/thememanager.h"
using namespace rcx;
// ─────────────────────────────────────────────────────────────────
// Test suite for the RcxTooltip callout widget
//
// These tests verify both geometry math AND real-world behavior:
// - Actual pixel rendering (catches WA_TranslucentBackground failures)
// - Leave-event resilience (catches spurious dismiss on tooltip popup)
// - Dismiss correctness (cursor truly leaves trigger zone)
// ─────────────────────────────────────────────────────────────────
class TestTooltip : public QObject {
Q_OBJECT
private:
QWidget* m_window = nullptr;
QPushButton* m_btnTop = nullptr;
QPushButton* m_btnMid = nullptr;
QPushButton* m_btnLeft = nullptr;
QPushButton* m_btnRight= nullptr;
void showAndProcess(QWidget* trigger, const QString& text) {
RcxTooltip::instance()->showFor(trigger, text);
// Process events + allow paint to complete
QCoreApplication::processEvents();
QTest::qWait(20);
QCoreApplication::processEvents();
}
// Count non-transparent pixels in a QImage region
int countOpaquePixels(const QImage& img, const QRect& region) {
int count = 0;
QRect r = region.intersected(img.rect());
for (int y = r.top(); y <= r.bottom(); ++y)
for (int x = r.left(); x <= r.right(); ++x)
if (qAlpha(img.pixel(x, y)) > 0)
++count;
return count;
}
private slots:
void initTestCase() {
m_window = new QWidget;
m_window->setFixedSize(800, 600);
QScreen* scr = QApplication::primaryScreen();
QRect avail = scr->availableGeometry();
m_window->move(avail.center() - QPoint(400, 300));
m_btnMid = new QPushButton("Middle", m_window);
m_btnMid->setFixedSize(80, 24);
m_btnMid->move(360, 288);
m_btnTop = new QPushButton("Top", m_window);
m_btnTop->setFixedSize(80, 24);
m_btnTop->move(360, 0);
m_btnLeft = new QPushButton("Left", m_window);
m_btnLeft->setFixedSize(80, 24);
m_btnLeft->move(0, 288);
m_btnRight = new QPushButton("Right", m_window);
m_btnRight->setFixedSize(80, 24);
m_btnRight->move(720, 288);
m_window->show();
QVERIFY(QTest::qWaitForWindowExposed(m_window));
}
void cleanupTestCase() {
RcxTooltip::instance()->dismiss();
delete m_window;
m_window = nullptr;
}
void cleanup() {
RcxTooltip::instance()->dismiss();
QCoreApplication::processEvents();
}
// ── Singleton ──
void testSingleton() {
QCOMPARE(RcxTooltip::instance(), RcxTooltip::instance());
}
// ── Basic show/dismiss ──
void testShowAndDismiss() {
auto* tip = RcxTooltip::instance();
QVERIFY(!tip->isVisible());
showAndProcess(m_btnMid, "Hello");
QVERIFY(tip->isVisible());
QCOMPARE(tip->currentText(), QString("Hello"));
QCOMPARE(tip->currentTrigger(), m_btnMid);
tip->dismiss();
QVERIFY(!tip->isVisible());
QVERIFY(tip->currentTrigger() == nullptr);
}
// ── Empty text / null trigger = dismiss ──
void testEmptyTextDismisses() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Test");
QVERIFY(tip->isVisible());
showAndProcess(m_btnMid, "");
QVERIFY(!tip->isVisible());
}
void testNullTriggerDismisses() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Test");
QVERIFY(tip->isVisible());
showAndProcess(nullptr, "Test");
QVERIFY(!tip->isVisible());
}
// ── Arrow direction ──
void testArrowDownByDefault() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Default placement");
QVERIFY(tip->isVisible());
QVERIFY(tip->arrowPointsDown());
QRect trigGlobal(m_btnMid->mapToGlobal(QPoint(0,0)), m_btnMid->size());
int tipBottom = tip->y() + tip->height();
QVERIFY2(tipBottom <= trigGlobal.top() + RcxTooltip::kGap + 2,
qPrintable(QStringLiteral("tipBottom=%1 trigTop=%2")
.arg(tipBottom).arg(trigGlobal.top())));
}
void testArrowFlipsAtScreenTop() {
QScreen* scr = QApplication::primaryScreen();
QRect avail = scr->availableGeometry();
QPoint oldPos = m_window->pos();
m_window->move(avail.center().x() - 400, avail.top());
QCoreApplication::processEvents();
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnTop, "Flipped");
QVERIFY(tip->isVisible());
QVERIFY2(!tip->arrowPointsDown(),
"Expected arrow to flip upward when trigger is near screen top");
QRect trigGlobal(m_btnTop->mapToGlobal(QPoint(0,0)), m_btnTop->size());
QVERIFY2(tip->y() >= trigGlobal.bottom(),
qPrintable(QStringLiteral("tipY=%1 trigBottom=%2")
.arg(tip->y()).arg(trigGlobal.bottom())));
m_window->move(oldPos);
QCoreApplication::processEvents();
}
// ── Arrow centering ──
void testArrowCenteredOnTrigger() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Center");
QVERIFY(tip->isVisible());
QRect trigGlobal(m_btnMid->mapToGlobal(QPoint(0,0)), m_btnMid->size());
int trigCenterX = trigGlobal.center().x();
int arrowGlobalX = tip->x() + tip->arrowLocalX();
int delta = qAbs(arrowGlobalX - trigCenterX);
QVERIFY2(delta <= 2,
qPrintable(QStringLiteral("arrowGlobalX=%1 trigCenterX=%2 delta=%3")
.arg(arrowGlobalX).arg(trigCenterX).arg(delta)));
}
// ── Anti-teleport ──
void testNoTeleportSameWidget() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Stable");
QPoint pos1 = tip->pos();
showAndProcess(m_btnMid, "Stable");
QCOMPARE(tip->pos(), pos1);
}
// ── Repositions for different widget ──
void testRepositionsForDifferentWidget() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnLeft, "Left");
QPoint pos1 = tip->pos();
showAndProcess(m_btnRight, "Right");
QVERIFY2(tip->pos() != pos1, "Tooltip should move when trigger widget changes");
}
// ── Horizontal clamping ──
void testHorizontalClampLeft() {
QScreen* scr = QApplication::primaryScreen();
QRect avail = scr->availableGeometry();
QPoint oldPos = m_window->pos();
m_window->move(avail.left(), avail.center().y() - 300);
QCoreApplication::processEvents();
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnLeft, "Clamped left");
QVERIFY(tip->isVisible());
QVERIFY2(tip->x() >= avail.left(),
qPrintable(QStringLiteral("tipX=%1 screenLeft=%2")
.arg(tip->x()).arg(avail.left())));
m_window->move(oldPos);
QCoreApplication::processEvents();
}
void testHorizontalClampRight() {
QScreen* scr = QApplication::primaryScreen();
QRect avail = scr->availableGeometry();
QPoint oldPos = m_window->pos();
m_window->move(avail.right() - m_window->width(), avail.center().y() - 300);
QCoreApplication::processEvents();
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnRight, "Clamped right");
QVERIFY(tip->isVisible());
QVERIFY2(tip->x() + tip->width() <= avail.right() + 2,
qPrintable(QStringLiteral("tipRight=%1 screenRight=%2")
.arg(tip->x() + tip->width()).arg(avail.right())));
m_window->move(oldPos);
QCoreApplication::processEvents();
}
// ── Body rect dimensions ──
void testBodyRectSanity() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Body");
QVERIFY(tip->isVisible());
QRect body = tip->bodyRect();
QVERIFY(body.width() > 0);
QVERIFY(body.height() > 0);
QCOMPARE(tip->height(), body.height() + RcxTooltip::kArrowH);
}
// ── Constants ──
void testConstants() {
QCOMPARE(RcxTooltip::kArrowH, 6);
QCOMPARE(RcxTooltip::kArrowHalfW, 6);
QCOMPARE(RcxTooltip::kGap, 2);
}
// ──────────────────────────────────────────────────────────────
// RENDERING VERIFICATION — catches invisible tooltip bugs
// ──────────────────────────────────────────────────────────────
void testShowForRendersBodyPixels() {
// Show tooltip and grab its rendered pixels.
// Verify that the body area has non-transparent content.
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Render test");
QVERIFY(tip->isVisible());
// Force full opacity so grab gets real pixels
tip->setWindowOpacity(1.0);
QCoreApplication::processEvents();
QImage img = tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
QVERIFY2(!img.isNull(), "grab() returned null image");
QVERIFY2(img.width() > 0 && img.height() > 0, "grab() returned empty image");
// Check body rect area for opaque pixels
QRect body = tip->bodyRect();
// Inset by 2px to avoid anti-aliased border edges
QRect checkRect = body.adjusted(2, 2, -2, -2);
int opaquePixels = countOpaquePixels(img, checkRect);
int totalPixels = checkRect.width() * checkRect.height();
QVERIFY2(opaquePixels > totalPixels / 2,
qPrintable(QStringLiteral(
"Body area has too few opaque pixels: %1 / %2 (< 50%%). "
"The tooltip is not rendering its background.")
.arg(opaquePixels).arg(totalPixels)));
}
void testArrowRendersPixels() {
// Verify the triangle arrow region has some opaque pixels.
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Arrow test");
QVERIFY(tip->isVisible());
QVERIFY(tip->arrowPointsDown());
tip->setWindowOpacity(1.0);
QCoreApplication::processEvents();
QImage img = tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
// Arrow region: below the body rect, centered on arrowLocalX
QRect body = tip->bodyRect();
int arrowTop = body.bottom();
int arrowLeft = tip->arrowLocalX() - RcxTooltip::kArrowHalfW;
int arrowRight = tip->arrowLocalX() + RcxTooltip::kArrowHalfW;
QRect arrowRect(arrowLeft, arrowTop, arrowRight - arrowLeft, RcxTooltip::kArrowH);
int opaquePixels = countOpaquePixels(img, arrowRect);
QVERIFY2(opaquePixels > 0,
qPrintable(QStringLiteral(
"Arrow region has 0 opaque pixels — triangle not painted. "
"arrowRect=(%1,%2 %3x%4) imgSize=(%5x%6)")
.arg(arrowRect.x()).arg(arrowRect.y())
.arg(arrowRect.width()).arg(arrowRect.height())
.arg(img.width()).arg(img.height())));
}
// ──────────────────────────────────────────────────────────────
// LEAVE EVENT RESILIENCE — catches spurious dismiss bugs
// ──────────────────────────────────────────────────────────────
void testSurvivesLeaveEvent() {
// The tooltip should NOT be dismissed when a Leave event fires
// on the trigger widget while the cursor is still in the
// trigger+tooltip zone (simulates the synthetic Leave that Qt
// sends when a tooltip window pops up above the trigger).
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Survive Leave");
QVERIFY(tip->isVisible());
tip->setWindowOpacity(1.0);
// Move real cursor to center of trigger (so geometry check passes)
QPoint trigCenter = m_btnMid->mapToGlobal(
QPoint(m_btnMid->width() / 2, m_btnMid->height() / 2));
QCursor::setPos(trigCenter);
QCoreApplication::processEvents();
// Send a Leave event to the trigger (like DarkApp::notify would)
QEvent leaveEvent(QEvent::Leave);
QApplication::sendEvent(m_btnMid, &leaveEvent);
// Now call scheduleDismiss as DarkApp would
tip->scheduleDismiss();
QCoreApplication::processEvents();
// Tooltip should STILL be visible — cursor is inside trigger zone
QVERIFY2(tip->isVisible(),
"Tooltip was dismissed by spurious Leave event while cursor "
"was still over the trigger widget");
// Wait beyond the dismiss timer to be sure
QTest::qWait(200);
QCoreApplication::processEvents();
QVERIFY2(tip->isVisible(),
"Tooltip was dismissed after 200ms despite cursor being over trigger");
}
void testDismissesOnRealLeave() {
// When the cursor truly leaves the trigger+tooltip zone,
// scheduleDismiss() should queue dismissal and it should fire.
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Real leave");
QVERIFY(tip->isVisible());
tip->setWindowOpacity(1.0);
// Move cursor far away from both trigger and tooltip
QScreen* scr = QApplication::primaryScreen();
QRect avail = scr->availableGeometry();
QCursor::setPos(avail.bottomRight() - QPoint(10, 10));
QCoreApplication::processEvents();
// scheduleDismiss should detect cursor is outside zone
tip->scheduleDismiss();
QCoreApplication::processEvents();
// Wait for the 100ms dismiss timer
QTest::qWait(200);
QCoreApplication::processEvents();
QVERIFY2(!tip->isVisible(),
"Tooltip should have been dismissed when cursor left the zone");
}
void testLeaveAndReshow() {
// Dismiss via real leave, then re-show on a different widget.
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "First");
QVERIFY(tip->isVisible());
// Force dismiss
tip->dismiss();
QCoreApplication::processEvents();
QVERIFY(!tip->isVisible());
// Re-show on different widget
showAndProcess(m_btnLeft, "Second");
QVERIFY2(tip->isVisible(), "Tooltip failed to re-appear after dismiss");
QCOMPARE(tip->currentText(), QString("Second"));
QCOMPARE(tip->currentTrigger(), m_btnLeft);
}
// ── Scheduled dismiss cancelled by new showFor ──
void testScheduledDismissCancelledByShow() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "First");
// Move cursor far away and schedule dismiss
QScreen* scr = QApplication::primaryScreen();
QCursor::setPos(scr->availableGeometry().bottomRight() - QPoint(10, 10));
QCoreApplication::processEvents();
tip->scheduleDismiss();
// Before timer fires, show on a different widget
showAndProcess(m_btnLeft, "Second");
QTest::qWait(200);
QCoreApplication::processEvents();
// Should still be visible — new showFor cancelled the timer
QVERIFY(tip->isVisible());
QCOMPARE(tip->currentText(), QString("Second"));
}
// ── Text change on same widget ──
void testTextChangeOnSameWidget() {
auto* tip = RcxTooltip::instance();
showAndProcess(m_btnMid, "Text A");
QCOMPARE(tip->currentText(), QString("Text A"));
tip->dismiss();
showAndProcess(m_btnMid, "Text B");
QCOMPARE(tip->currentText(), QString("Text B"));
}
};
QTEST_MAIN(TestTooltip)
#include "test_tooltip.moc"

View File

@@ -0,0 +1,292 @@
// Tests the full tooltip flow including DarkApp-style ToolTip interception.
// Verifies that QEvent::ToolTip fires and our custom tooltip appears.
#include <QtTest>
#include <QApplication>
#include <QPushButton>
#include <QScreen>
#include <QHelpEvent>
#include <QImage>
#include "rcxtooltip.h"
#include "themes/thememanager.h"
#include <cstdio>
using namespace rcx;
static void LOG(const char* fmt, ...) {
va_list ap;
va_start(ap, fmt);
vfprintf(stdout, fmt, ap);
va_end(ap);
fflush(stdout);
}
// Simulates DarkApp::notify behavior — installed as a global event filter
class DarkAppSimulator : public QObject {
public:
int tooltipEventCount = 0;
int leaveEventCount = 0;
int showForCallCount = 0;
bool eventFilter(QObject* obj, QEvent* ev) override {
if (ev->type() == QEvent::ToolTip) {
tooltipEventCount++;
if (obj->isWidgetType()) {
auto* w = static_cast<QWidget*>(obj);
QString tip = w->toolTip();
LOG(" [darkapp-sim] ToolTip #%d on '%s' tip='%s'\n",
tooltipEventCount, qPrintable(w->objectName()),
qPrintable(tip.left(60)));
if (!tip.isEmpty()) {
showForCallCount++;
LOG(" [darkapp-sim] calling showFor #%d\n", showForCallCount);
RcxTooltip::instance()->showFor(w, tip);
LOG(" [darkapp-sim] after showFor: visible=%d pos=(%d,%d) size=%dx%d\n",
RcxTooltip::instance()->isVisible(),
RcxTooltip::instance()->x(), RcxTooltip::instance()->y(),
RcxTooltip::instance()->width(), RcxTooltip::instance()->height());
return true; // consume — same as DarkApp
}
}
return true; // suppress default QToolTip
}
if (ev->type() == QEvent::Leave && obj->isWidgetType()) {
auto* tip = RcxTooltip::instance();
if (tip->isVisible() && tip->currentTrigger() == obj) {
leaveEventCount++;
LOG(" [darkapp-sim] Leave #%d on trigger\n", leaveEventCount);
tip->scheduleDismiss();
}
}
return false;
}
};
class TestTooltipEvent : public QObject {
Q_OBJECT
private:
QWidget* m_window = nullptr;
QPushButton* m_btn = nullptr;
QPushButton* m_btn2 = nullptr;
DarkAppSimulator* m_sim = nullptr;
private slots:
void initTestCase() {
LOG("=== TestTooltipEvent starting ===\n");
m_window = new QWidget;
m_window->setFixedSize(400, 300);
QScreen* scr = QApplication::primaryScreen();
QRect avail = scr->availableGeometry();
m_window->move(avail.center() - QPoint(200, 150));
m_btn = new QPushButton("Scan", m_window);
m_btn->setToolTip("Start scanning memory");
m_btn->setFixedSize(120, 40);
m_btn->move(30, 130);
m_btn->setObjectName("btnScan");
m_btn2 = new QPushButton("Copy", m_window);
m_btn2->setToolTip("Copy to clipboard");
m_btn2->setFixedSize(120, 40);
m_btn2->move(250, 130);
m_btn2->setObjectName("btnCopy");
// Install DarkApp simulator as global event filter
m_sim = new DarkAppSimulator;
qApp->installEventFilter(m_sim);
m_window->show();
m_window->activateWindow();
m_window->raise();
QVERIFY(QTest::qWaitForWindowExposed(m_window));
// Let window become active
QTest::qWait(200);
QCoreApplication::processEvents();
LOG(" window at (%d,%d)\n", m_window->x(), m_window->y());
LOG(" btn global: (%d,%d)\n",
m_btn->mapToGlobal(QPoint(60, 20)).x(),
m_btn->mapToGlobal(QPoint(60, 20)).y());
}
void cleanupTestCase() {
qApp->removeEventFilter(m_sim);
RcxTooltip::instance()->dismiss();
delete m_sim;
delete m_window;
LOG("=== TestTooltipEvent finished ===\n");
}
void cleanup() {
RcxTooltip::instance()->dismiss();
QCoreApplication::processEvents();
m_sim->tooltipEventCount = 0;
m_sim->leaveEventCount = 0;
m_sim->showForCallCount = 0;
}
// Test 1: Post QHelpEvent → DarkApp simulator intercepts → RcxTooltip shows
void testManualEventShowsTooltip() {
LOG("\n--- testManualEventShowsTooltip ---\n");
auto* tip = RcxTooltip::instance();
QPoint btnGlobal = m_btn->mapToGlobal(QPoint(60, 20));
QCursor::setPos(btnGlobal);
QCoreApplication::processEvents();
LOG(" posting QHelpEvent\n");
QHelpEvent helpEvent(QEvent::ToolTip, QPoint(60, 20), btnGlobal);
QApplication::sendEvent(m_btn, &helpEvent);
QCoreApplication::processEvents();
QTest::qWait(100);
QCoreApplication::processEvents();
LOG(" sim: tooltipEvents=%d showForCalls=%d\n",
m_sim->tooltipEventCount, m_sim->showForCallCount);
LOG(" tip: visible=%d text='%s'\n",
tip->isVisible(), qPrintable(tip->currentText()));
QVERIFY2(m_sim->tooltipEventCount > 0, "Event filter didn't see ToolTip event");
QVERIFY2(m_sim->showForCallCount > 0, "showFor was never called");
QVERIFY2(tip->isVisible(), "RcxTooltip not visible after manual event");
QCOMPARE(tip->currentText(), QString("Start scanning memory"));
// Verify pixels
tip->setWindowOpacity(1.0);
QCoreApplication::processEvents();
QImage img = tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
QRect body = tip->bodyRect().adjusted(2, 2, -2, -2);
int opaque = 0;
for (int y = body.top(); y <= body.bottom(); ++y)
for (int x = body.left(); x <= body.right(); ++x)
if (qAlpha(img.pixel(x, y)) > 0) opaque++;
LOG(" pixels: %d/%d opaque\n", opaque, body.width() * body.height());
QVERIFY2(opaque > body.width() * body.height() / 2, "Body not rendered");
LOG("--- testManualEventShowsTooltip PASSED ---\n");
}
// Test 2: Qt's native tooltip timer fires → our filter intercepts → tooltip shows
void testNativeTimerShowsTooltip() {
LOG("\n--- testNativeTimerShowsTooltip ---\n");
auto* tip = RcxTooltip::instance();
// Move cursor away first
QPoint away = m_window->mapToGlobal(QPoint(380, 10));
QCursor::setPos(away);
QTest::qWait(200);
QCoreApplication::processEvents();
// Move to button
QPoint btnCenter = m_btn->mapToGlobal(QPoint(60, 20));
LOG(" moving cursor to (%d,%d)\n", btnCenter.x(), btnCenter.y());
QCursor::setPos(btnCenter);
// Send Enter + MouseMove to kick the tooltip timer
QEvent enterEv(QEvent::Enter);
QApplication::sendEvent(m_btn, &enterEv);
QMouseEvent moveEv(QEvent::MouseMove, QPointF(60, 20),
m_btn->mapToGlobal(QPointF(60, 20)),
Qt::NoButton, Qt::NoButton, Qt::NoModifier);
QApplication::sendEvent(m_btn, &moveEv);
// Wait up to 2000ms for tooltip to appear
LOG(" waiting for Qt tooltip timer...\n");
bool appeared = false;
for (int i = 0; i < 20; i++) {
QTest::qWait(100);
QCoreApplication::processEvents();
if (m_sim->tooltipEventCount > 0) {
LOG(" tooltip event at ~%dms! events=%d showFor=%d\n",
(i+1)*100, m_sim->tooltipEventCount, m_sim->showForCallCount);
appeared = true;
break;
}
}
// Process remaining events
QTest::qWait(100);
QCoreApplication::processEvents();
LOG(" final: events=%d showFor=%d visible=%d text='%s'\n",
m_sim->tooltipEventCount, m_sim->showForCallCount,
tip->isVisible(), qPrintable(tip->currentText()));
QVERIFY2(appeared, "Qt tooltip timer never fired (no ToolTip event in 2 seconds)");
QVERIFY2(tip->isVisible(), "Tooltip not visible after native timer fired");
LOG("--- testNativeTimerShowsTooltip PASSED ---\n");
}
// Test 3: Leave after tooltip shown → tooltip survives (cursor still in zone)
void testLeaveSurvival() {
LOG("\n--- testLeaveSurvival ---\n");
auto* tip = RcxTooltip::instance();
QPoint btnCenter = m_btn->mapToGlobal(QPoint(60, 20));
QCursor::setPos(btnCenter);
QCoreApplication::processEvents();
// Show via manual event
QHelpEvent helpEvent(QEvent::ToolTip, QPoint(60, 20), btnCenter);
QApplication::sendEvent(m_btn, &helpEvent);
QCoreApplication::processEvents();
QTest::qWait(100);
QCoreApplication::processEvents();
QVERIFY(tip->isVisible());
// Send Leave (cursor still on button)
LOG(" sending Leave while cursor on button\n");
QEvent leaveEv(QEvent::Leave);
QApplication::sendEvent(m_btn, &leaveEv);
QTest::qWait(200);
QCoreApplication::processEvents();
LOG(" after Leave+200ms: visible=%d leaves=%d\n",
tip->isVisible(), m_sim->leaveEventCount);
QVERIFY2(tip->isVisible(), "Tooltip dismissed by spurious Leave");
LOG("--- testLeaveSurvival PASSED ---\n");
}
// Test 4: Switch between widgets
void testWidgetSwitch() {
LOG("\n--- testWidgetSwitch ---\n");
auto* tip = RcxTooltip::instance();
// Show on btn1
QPoint btn1Center = m_btn->mapToGlobal(QPoint(60, 20));
QCursor::setPos(btn1Center);
QCoreApplication::processEvents();
QHelpEvent ev1(QEvent::ToolTip, QPoint(60, 20), btn1Center);
QApplication::sendEvent(m_btn, &ev1);
QCoreApplication::processEvents();
QTest::qWait(100);
QVERIFY(tip->isVisible());
QCOMPARE(tip->currentText(), QString("Start scanning memory"));
QPoint pos1 = tip->pos();
// Switch to btn2
QPoint btn2Center = m_btn2->mapToGlobal(QPoint(60, 20));
QCursor::setPos(btn2Center);
QCoreApplication::processEvents();
QHelpEvent ev2(QEvent::ToolTip, QPoint(60, 20), btn2Center);
QApplication::sendEvent(m_btn2, &ev2);
QCoreApplication::processEvents();
QTest::qWait(100);
LOG(" after switch: visible=%d text='%s' pos=(%d,%d)\n",
tip->isVisible(), qPrintable(tip->currentText()),
tip->x(), tip->y());
QVERIFY(tip->isVisible());
QCOMPARE(tip->currentText(), QString("Copy to clipboard"));
QVERIFY(tip->pos() != pos1);
LOG("--- testWidgetSwitch PASSED ---\n");
}
};
QTEST_MAIN(TestTooltipEvent)
#include "test_tooltip_event.moc"

253
tests/test_tooltip_ui.cpp Normal file
View File

@@ -0,0 +1,253 @@
// Integration test: simulates the full tooltip flow as DarkApp would see it.
// Posts QHelpEvent (ToolTip), sends Leave events, verifies RcxTooltip behavior
// with fprintf at every stage so we can see exactly what happens.
#include <QtTest>
#include <QApplication>
#include <QPushButton>
#include <QHelpEvent>
#include <QScreen>
#include <QImage>
#include "rcxtooltip.h"
#include "themes/thememanager.h"
#include <cstdio>
using namespace rcx;
static void LOG(const char* fmt, ...) {
va_list ap;
va_start(ap, fmt);
vfprintf(stdout, fmt, ap);
va_end(ap);
fflush(stdout);
}
// Simulates what DarkApp::notify does when a ToolTip event arrives
static bool simulateDarkAppToolTip(QWidget* w) {
QString tip = w->toolTip();
LOG(" [darkapp] widget='%s' class=%s tip='%s'\n",
qPrintable(w->objectName()), w->metaObject()->className(),
qPrintable(tip));
if (!tip.isEmpty()) {
LOG(" [darkapp] calling RcxTooltip::showFor\n");
RcxTooltip::instance()->showFor(w, tip);
LOG(" [darkapp] showFor returned, visible=%d opacity=%.2f pos=(%d,%d) size=%dx%d\n",
RcxTooltip::instance()->isVisible(),
RcxTooltip::instance()->windowOpacity(),
RcxTooltip::instance()->x(), RcxTooltip::instance()->y(),
RcxTooltip::instance()->width(), RcxTooltip::instance()->height());
return true;
}
return false;
}
// Simulates what DarkApp::notify does when a Leave event arrives
static void simulateDarkAppLeave(QWidget* w) {
auto* tip = RcxTooltip::instance();
if (tip->isVisible() && tip->currentTrigger() == w) {
LOG(" [darkapp] Leave on trigger — calling scheduleDismiss\n");
tip->scheduleDismiss();
LOG(" [darkapp] after scheduleDismiss: visible=%d\n", tip->isVisible());
} else {
LOG(" [darkapp] Leave ignored (visible=%d trigger_match=%d)\n",
tip->isVisible(), tip->currentTrigger() == w);
}
}
class TestTooltipUI : public QObject {
Q_OBJECT
private:
QWidget* m_window = nullptr;
QPushButton* m_btn = nullptr;
QPushButton* m_btn2 = nullptr;
private slots:
void initTestCase() {
LOG("=== TestTooltipUI starting ===\n");
m_window = new QWidget;
m_window->setFixedSize(400, 300);
QScreen* scr = QApplication::primaryScreen();
QRect avail = scr->availableGeometry();
m_window->move(avail.center() - QPoint(200, 150));
m_btn = new QPushButton("Scan", m_window);
m_btn->setToolTip("Start scanning memory");
m_btn->setFixedSize(80, 28);
m_btn->move(160, 140);
m_btn->setObjectName("btnScan");
m_btn2 = new QPushButton("Copy", m_window);
m_btn2->setToolTip("Copy address to clipboard");
m_btn2->setFixedSize(80, 28);
m_btn2->move(260, 140);
m_btn2->setObjectName("btnCopy");
m_window->show();
QVERIFY(QTest::qWaitForWindowExposed(m_window));
LOG(" window shown at (%d,%d)\n", m_window->x(), m_window->y());
}
void cleanupTestCase() {
RcxTooltip::instance()->dismiss();
delete m_window;
LOG("=== TestTooltipUI finished ===\n");
}
void cleanup() {
RcxTooltip::instance()->dismiss();
QCoreApplication::processEvents();
}
// ─── Test 1: Full tooltip lifecycle with event simulation ───
void testFullLifecycle() {
LOG("\n--- testFullLifecycle ---\n");
auto* tip = RcxTooltip::instance();
// Step 1: Post a ToolTip event (what Qt does after hover delay)
LOG("Step 1: Posting ToolTip event to btn\n");
QPoint btnCenter = m_btn->mapToGlobal(QPoint(40, 14));
LOG(" btn global center: (%d,%d)\n", btnCenter.x(), btnCenter.y());
// Move real cursor to button center
QCursor::setPos(btnCenter);
QCoreApplication::processEvents();
LOG(" cursor moved to button\n");
// Simulate what DarkApp does on ToolTip event
bool handled = simulateDarkAppToolTip(m_btn);
QVERIFY2(handled, "DarkApp should have handled the tooltip");
// Process events (paint, animation start)
QCoreApplication::processEvents();
QTest::qWait(100); // let fade-in animation run
QCoreApplication::processEvents();
LOG("Step 2: Check tooltip state after 100ms\n");
LOG(" visible=%d opacity=%.2f text='%s'\n",
tip->isVisible(), tip->windowOpacity(),
qPrintable(tip->currentText()));
LOG(" pos=(%d,%d) size=%dx%d\n",
tip->x(), tip->y(), tip->width(), tip->height());
LOG(" arrowDown=%d arrowX=%d bodyRect=(%d,%d %dx%d)\n",
tip->arrowPointsDown(), tip->arrowLocalX(),
tip->bodyRect().x(), tip->bodyRect().y(),
tip->bodyRect().width(), tip->bodyRect().height());
QVERIFY2(tip->isVisible(), "Tooltip should be visible after showFor + 100ms");
QCOMPARE(tip->currentText(), QString("Start scanning memory"));
// Step 3: Grab pixels and verify rendering
LOG("Step 3: Verify rendering\n");
tip->setWindowOpacity(1.0);
QCoreApplication::processEvents();
QImage img = tip->grab().toImage().convertToFormat(QImage::Format_ARGB32);
LOG(" grabbed image: %dx%d format=%d\n", img.width(), img.height(), img.format());
int opaquePixels = 0;
QRect body = tip->bodyRect().adjusted(2, 2, -2, -2);
for (int y = body.top(); y <= body.bottom(); ++y)
for (int x = body.left(); x <= body.right(); ++x)
if (qAlpha(img.pixel(x, y)) > 0)
++opaquePixels;
int totalPixels = body.width() * body.height();
LOG(" body opaque pixels: %d / %d (%.1f%%)\n",
opaquePixels, totalPixels,
totalPixels > 0 ? 100.0 * opaquePixels / totalPixels : 0.0);
QVERIFY2(opaquePixels > totalPixels / 2,
qPrintable(QStringLiteral("Only %1/%2 opaque pixels in body — tooltip not rendering")
.arg(opaquePixels).arg(totalPixels)));
// Step 4: Simulate Leave event (spurious — cursor still on button)
LOG("Step 4: Simulate spurious Leave (cursor still on button)\n");
simulateDarkAppLeave(m_btn);
QTest::qWait(200);
QCoreApplication::processEvents();
LOG(" after 200ms: visible=%d\n", tip->isVisible());
QVERIFY2(tip->isVisible(),
"Tooltip dismissed by spurious Leave — geometry check failed");
// Step 5: Move cursor away and simulate real Leave
LOG("Step 5: Move cursor away, simulate real Leave\n");
QScreen* scr = QApplication::primaryScreen();
QPoint farAway = scr->availableGeometry().bottomRight() - QPoint(50, 50);
QCursor::setPos(farAway);
QCoreApplication::processEvents();
LOG(" cursor at (%d,%d)\n", farAway.x(), farAway.y());
simulateDarkAppLeave(m_btn);
QTest::qWait(200);
QCoreApplication::processEvents();
LOG(" after 200ms: visible=%d\n", tip->isVisible());
QVERIFY2(!tip->isVisible(),
"Tooltip should be dismissed when cursor truly left the zone");
// Step 6: Re-show on different widget
LOG("Step 6: Re-show on different widget\n");
QPoint btn2Center = m_btn2->mapToGlobal(QPoint(40, 14));
QCursor::setPos(btn2Center);
QCoreApplication::processEvents();
handled = simulateDarkAppToolTip(m_btn2);
QVERIFY(handled);
QCoreApplication::processEvents();
QTest::qWait(100);
QCoreApplication::processEvents();
LOG(" visible=%d text='%s'\n", tip->isVisible(), qPrintable(tip->currentText()));
QVERIFY(tip->isVisible());
QCOMPARE(tip->currentText(), QString("Copy address to clipboard"));
LOG("--- testFullLifecycle PASSED ---\n");
}
// ─── Test 2: Rapid widget switching (no dismiss between) ───
void testRapidSwitch() {
LOG("\n--- testRapidSwitch ---\n");
auto* tip = RcxTooltip::instance();
QCursor::setPos(m_btn->mapToGlobal(QPoint(40, 14)));
QCoreApplication::processEvents();
simulateDarkAppToolTip(m_btn);
QCoreApplication::processEvents();
QTest::qWait(50);
LOG(" switch to btn2 immediately\n");
QCursor::setPos(m_btn2->mapToGlobal(QPoint(40, 14)));
QCoreApplication::processEvents();
simulateDarkAppToolTip(m_btn2);
QCoreApplication::processEvents();
QTest::qWait(100);
QCoreApplication::processEvents();
LOG(" visible=%d text='%s'\n", tip->isVisible(), qPrintable(tip->currentText()));
QVERIFY(tip->isVisible());
QCOMPARE(tip->currentText(), QString("Copy address to clipboard"));
LOG("--- testRapidSwitch PASSED ---\n");
}
// ─── Test 3: Widget with no tooltip ───
void testNoTooltipWidget() {
LOG("\n--- testNoTooltipWidget ---\n");
QPushButton noTip("NoTip", m_window);
noTip.setFixedSize(80, 28);
noTip.move(50, 50);
noTip.show();
// No setToolTip called
auto* tip = RcxTooltip::instance();
bool handled = simulateDarkAppToolTip(&noTip);
LOG(" handled=%d visible=%d\n", handled, tip->isVisible());
QVERIFY(!handled);
QVERIFY(!tip->isVisible());
LOG("--- testNoTooltipWidget PASSED ---\n");
}
};
QTEST_MAIN(TestTooltipUI)
#include "test_tooltip_ui.moc"