mirror of
https://github.com/NohamR/Reclass.git
synced 2026-05-10 19:59:21 +00:00
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>
This commit is contained in:
@@ -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();
|
||||
|
||||
|
||||
19
src/core.h
19
src/core.h
@@ -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 ──
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
11329
src/examples/t6zm.rcx
Normal file
File diff suppressed because it is too large
Load Diff
12
src/main.cpp
12
src/main.cpp
@@ -2737,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();
|
||||
|
||||
@@ -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)
|
||||
// ════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user