Compare commits

..

6 Commits

Author SHA1 Message Date
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
16 changed files with 11803 additions and 153 deletions

View File

@@ -2,7 +2,8 @@ name: Build
on:
push:
branches: [main]
branches:
- '**'
pull_request:
branches: [main]

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

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

View File

@@ -1581,6 +1581,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 +1631,18 @@ 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[id].clear();
for (auto& lm : m_lastResult.meta)
if (lm.nodeId == id) lm.heatLevel = 0;
}
refresh();
});
}
menu.addSeparator();
// Check if all selected nodes share the same parent (required for grouping)
@@ -1723,6 +1737,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 +1830,17 @@ 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"));
act->setEnabled(m_valueHistory.contains(nodeId) && m_valueHistory[nodeId].uniqueCount() > 0);
connect(act, &QAction::triggered, this, [this, nodeId]() {
m_valueHistory[nodeId].clear();
for (auto& lm : m_lastResult.meta)
if (lm.nodeId == nodeId) lm.heatLevel = 0;
refresh();
});
}
menu.addSeparator();
// Convert to Hex nodes (decompose non-hex types into Hex64/32/16/8)
@@ -2934,7 +2963,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();

View File

@@ -11,6 +11,7 @@
#include <array>
#include <memory>
#include <variant>
#include <QDateTime>
#include "providers/provider.h"
#include "providers/buffer_provider.h"
@@ -500,6 +501,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 +511,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 +544,17 @@ struct ValueHistory {
for (int i = 0; i < n; i++)
fn(values[(start + i) % kCapacity]);
}
// Iterate with timestamps from oldest to newest
template<typename Fn>
void forEachWithTime(Fn&& fn) const {
int n = uniqueCount();
int start = (head + kCapacity - n) % kCapacity;
for (int i = 0; i < n; i++) {
int idx = (start + i) % kCapacity;
fn(values[idx], timestamps[idx]);
}
}
};
// ── LineMeta ──

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,7 +151,7 @@ public:
row->addWidget(setBtn);
}
vbox->addLayout(row);
}
});
adjustSize();
}
@@ -380,6 +401,12 @@ 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_findBar = new QLineEdit(this);
m_findBar->setPlaceholderText(QStringLiteral("Find..."));
m_findBar->setVisible(false);
layout->addWidget(m_findBar);
setupScintilla();
setupLexer();
setupMargins();
@@ -395,6 +422,27 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
m_sci->viewport()->installEventFilter(this);
m_sci->viewport()->setMouseTracking(true);
// Find bar: live search on text change
connect(m_findBar, &QLineEdit::textChanged, this, [this](const QString& text) {
if (text.isEmpty()) return;
m_sci->findFirst(text, false, false, false, true, true, 0, 0);
});
// Find bar: Enter jumps to next match (wraps at end)
connect(m_findBar, &QLineEdit::returnPressed, this, [this]() {
QString text = m_findBar->text();
if (text.isEmpty()) return;
if (!m_sci->findNext())
m_sci->findFirst(text, false, false, false, true, true, 0, 0);
});
// 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,
@@ -782,6 +830,14 @@ void RcxEditor::applyTheme(const Theme& theme) {
abs, theme.background);
}
}
// Find bar
if (m_findBar) {
m_findBar->setStyleSheet(
QStringLiteral("QLineEdit { background: %1; color: %2; border: 1px solid %3;"
" padding: 4px 8px; font-size: 13px; }")
.arg(theme.backgroundAlt.name(), theme.text.name(), theme.border.name()));
}
}
void RcxEditor::applyDocument(const ComposeResult& result) {
@@ -1243,6 +1299,17 @@ int RcxEditor::currentNodeIndex() const {
return lm ? lm->nodeIdx : -1;
}
void RcxEditor::showFindBar() {
m_findBar->setVisible(true);
m_findBar->setFocus();
m_findBar->selectAll();
}
void RcxEditor::hideFindBar() {
m_findBar->setVisible(false);
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) {
@@ -1810,6 +1877,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)

View File

@@ -6,6 +6,7 @@
#include <QPoint>
#include <QHash>
class QLineEdit;
class QsciScintilla;
class QsciLexerCPP;
@@ -154,6 +155,11 @@ private:
const Provider* m_disasmRealProv = nullptr; // real process provider — for reading code at arbitrary addresses
const NodeTree* m_disasmTree = nullptr;
// ── Find bar ──
QLineEdit* m_findBar = nullptr;
void showFindBar();
void hideFindBar();
// ── Reentrancy guards ──
bool m_applyingDocument = false;
bool m_clampingSelection = false;

11329
src/examples/t6zm.rcx Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -514,6 +514,8 @@ void MainWindow::createMenus() {
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);
@@ -550,8 +552,13 @@ void MainWindow::createMenus() {
// View
auto* view = m_titleBar->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();
@@ -1342,52 +1349,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 +1477,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 +1498,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
}
@@ -2566,6 +2487,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 +2504,7 @@ QMdiSubWindow* MainWindow::project_open(const QString& path) {
auto* sub = createTab(doc);
rebuildWorkspaceModel();
m_workspaceDock->show();
addRecentFile(filePath);
return sub;
}
@@ -2595,8 +2518,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 +2737,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();
@@ -2967,6 +2904,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();

View File

@@ -92,7 +92,9 @@ private:
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;
@@ -119,6 +121,8 @@ private:
void createStatusBar();
void showPluginsDialog();
void populateSourceMenu();
void addRecentFile(const QString& path);
void updateRecentFilesMenu();
QIcon makeIcon(const QString& svgPath);
RcxController* activeController() const;

View File

@@ -256,7 +256,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'}. "
@@ -396,6 +396,24 @@ 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. 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 +438,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 +770,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") {
@@ -1226,6 +1247,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>

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