mirror of
https://github.com/NohamR/Reclass.git
synced 2026-05-10 19:59:21 +00:00
Compare commits
6 Commits
snapshot-0
...
snapshot-0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
efae193520 | ||
|
|
ba1c2f8e5a | ||
|
|
5a0a4d1802 | ||
|
|
030eb34510 | ||
|
|
2939b25895 | ||
|
|
d38cb02fa2 |
3
.github/workflows/build.yml
vendored
3
.github/workflows/build.yml
vendored
@@ -2,7 +2,8 @@ name: Build
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches:
|
||||||
|
- '**'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
|
||||||
|
|||||||
@@ -36,10 +36,35 @@ file(GLOB RAW_PDB_SRCS third_party/raw_pdb/src/*.cpp)
|
|||||||
add_library(raw_pdb STATIC ${RAW_PDB_SRCS})
|
add_library(raw_pdb STATIC ${RAW_PDB_SRCS})
|
||||||
target_include_directories(raw_pdb PUBLIC third_party/raw_pdb/src)
|
target_include_directories(raw_pdb PUBLIC third_party/raw_pdb/src)
|
||||||
target_compile_features(raw_pdb PRIVATE cxx_std_11)
|
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)
|
if(WIN32)
|
||||||
target_link_libraries(raw_pdb PRIVATE rpcrt4)
|
target_link_libraries(raw_pdb PRIVATE rpcrt4)
|
||||||
endif()
|
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
|
add_executable(Reclass
|
||||||
src/main.cpp
|
src/main.cpp
|
||||||
src/editor.h
|
src/editor.h
|
||||||
|
|||||||
29
cmake/raw_pdb_prefix.h
Normal file
29
cmake/raw_pdb_prefix.h
Normal 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
|
||||||
@@ -1581,6 +1581,8 @@ void RcxController::showContextMenu(RcxEditor* editor, int line, int nodeIdx,
|
|||||||
} else if (commonKind == NodeKind::Hex32) {
|
} else if (commonKind == NodeKind::Hex32) {
|
||||||
menu.addAction("Change to uint32_t", [this, collectIndices]() {
|
menu.addAction("Change to uint32_t", [this, collectIndices]() {
|
||||||
batchChangeKind(collectIndices(), NodeKind::UInt32); });
|
batchChangeKind(collectIndices(), NodeKind::UInt32); });
|
||||||
|
menu.addAction("Change to float", [this, collectIndices]() {
|
||||||
|
batchChangeKind(collectIndices(), NodeKind::Float); });
|
||||||
addedQuickConvert = true;
|
addedQuickConvert = true;
|
||||||
} else if (commonKind == NodeKind::Hex16) {
|
} else if (commonKind == NodeKind::Hex16) {
|
||||||
menu.addAction("Change to int16_t", [this, collectIndices]() {
|
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);
|
act->setChecked(m_trackValues);
|
||||||
connect(act, &QAction::toggled, this, &RcxController::setTrackValues);
|
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();
|
menu.addSeparator();
|
||||||
|
|
||||||
// Check if all selected nodes share the same parent (required for grouping)
|
// 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);
|
int ni = m_doc->tree.indexOfId(nodeId);
|
||||||
if (ni >= 0) changeNodeKind(ni, NodeKind::UInt32);
|
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;
|
addedQuickConvert = true;
|
||||||
} else if (node.kind == NodeKind::Hex16) {
|
} else if (node.kind == NodeKind::Hex16) {
|
||||||
menu.addAction("Change to int16_t", [this, nodeId]() {
|
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);
|
act->setChecked(m_trackValues);
|
||||||
connect(act, &QAction::toggled, this, &RcxController::setTrackValues);
|
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();
|
menu.addSeparator();
|
||||||
|
|
||||||
// Convert to Hex nodes (decompose non-hex types into Hex64/32/16/8)
|
// 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->undoStack.clear();
|
||||||
m_doc->provider = std::move(provider);
|
m_doc->provider = std::move(provider);
|
||||||
m_doc->dataPath.clear();
|
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();
|
resetSnapshot();
|
||||||
emit m_doc->documentChanged();
|
emit m_doc->documentChanged();
|
||||||
|
|
||||||
|
|||||||
19
src/core.h
19
src/core.h
@@ -11,6 +11,7 @@
|
|||||||
#include <array>
|
#include <array>
|
||||||
#include <memory>
|
#include <memory>
|
||||||
#include <variant>
|
#include <variant>
|
||||||
|
#include <QDateTime>
|
||||||
|
|
||||||
#include "providers/provider.h"
|
#include "providers/provider.h"
|
||||||
#include "providers/buffer_provider.h"
|
#include "providers/buffer_provider.h"
|
||||||
@@ -500,6 +501,7 @@ struct NodeTree {
|
|||||||
struct ValueHistory {
|
struct ValueHistory {
|
||||||
static constexpr int kCapacity = 10;
|
static constexpr int kCapacity = 10;
|
||||||
std::array<QString, kCapacity> values;
|
std::array<QString, kCapacity> values;
|
||||||
|
std::array<qint64, kCapacity> timestamps{}; // msec since epoch
|
||||||
int count = 0; // total unique values recorded
|
int count = 0; // total unique values recorded
|
||||||
int head = 0; // next write position in ring
|
int head = 0; // next write position in ring
|
||||||
|
|
||||||
@@ -509,10 +511,16 @@ struct ValueHistory {
|
|||||||
if (values[last] == v) return; // no change
|
if (values[last] == v) return; // no change
|
||||||
}
|
}
|
||||||
values[head] = v;
|
values[head] = v;
|
||||||
|
timestamps[head] = QDateTime::currentMSecsSinceEpoch();
|
||||||
head = (head + 1) % kCapacity;
|
head = (head + 1) % kCapacity;
|
||||||
if (count < INT_MAX) count++;
|
if (count < INT_MAX) count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
count = 0;
|
||||||
|
head = 0;
|
||||||
|
}
|
||||||
|
|
||||||
int uniqueCount() const { return qMin(count, kCapacity); }
|
int uniqueCount() const { return qMin(count, kCapacity); }
|
||||||
|
|
||||||
// 0=static, 1=cold(2 unique), 2=warm(3-4), 3=hot(5+)
|
// 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++)
|
for (int i = 0; i < n; i++)
|
||||||
fn(values[(start + i) % kCapacity]);
|
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 ──
|
// ── LineMeta ──
|
||||||
|
|||||||
@@ -19,8 +19,10 @@
|
|||||||
#include <QClipboard>
|
#include <QClipboard>
|
||||||
#include <QLabel>
|
#include <QLabel>
|
||||||
#include <QToolButton>
|
#include <QToolButton>
|
||||||
|
#include <QLineEdit>
|
||||||
#include <QScreen>
|
#include <QScreen>
|
||||||
#include <QScrollBar>
|
#include <QScrollBar>
|
||||||
|
#include <QDateTime>
|
||||||
#include <functional>
|
#include <functional>
|
||||||
#include "themes/thememanager.h"
|
#include "themes/thememanager.h"
|
||||||
|
|
||||||
@@ -102,7 +104,8 @@ public:
|
|||||||
sep->setPalette(sp);
|
sep->setPalette(sp);
|
||||||
vbox->addWidget(sep);
|
vbox->addWidget(sep);
|
||||||
|
|
||||||
for (const QString& v : vals) {
|
qint64 now = QDateTime::currentMSecsSinceEpoch();
|
||||||
|
hist.forEachWithTime([&](const QString& v, qint64 msec) {
|
||||||
auto* row = new QHBoxLayout;
|
auto* row = new QHBoxLayout;
|
||||||
row->setContentsMargins(0, 1, 0, 1);
|
row->setContentsMargins(0, 1, 0, 1);
|
||||||
row->setSpacing(8);
|
row->setSpacing(8);
|
||||||
@@ -113,6 +116,24 @@ public:
|
|||||||
row->addWidget(label, 1);
|
row->addWidget(label, 1);
|
||||||
m_labels.append(label);
|
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) {
|
if (showButtons) {
|
||||||
auto* setBtn = new QToolButton;
|
auto* setBtn = new QToolButton;
|
||||||
setBtn->setText(QStringLiteral("Set"));
|
setBtn->setText(QStringLiteral("Set"));
|
||||||
@@ -130,7 +151,7 @@ public:
|
|||||||
row->addWidget(setBtn);
|
row->addWidget(setBtn);
|
||||||
}
|
}
|
||||||
vbox->addLayout(row);
|
vbox->addLayout(row);
|
||||||
}
|
});
|
||||||
|
|
||||||
adjustSize();
|
adjustSize();
|
||||||
}
|
}
|
||||||
@@ -380,6 +401,12 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
|
|||||||
m_sci = new QsciScintilla(this);
|
m_sci = new QsciScintilla(this);
|
||||||
layout->addWidget(m_sci);
|
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();
|
setupScintilla();
|
||||||
setupLexer();
|
setupLexer();
|
||||||
setupMargins();
|
setupMargins();
|
||||||
@@ -395,6 +422,27 @@ RcxEditor::RcxEditor(QWidget* parent) : QWidget(parent) {
|
|||||||
m_sci->viewport()->installEventFilter(this);
|
m_sci->viewport()->installEventFilter(this);
|
||||||
m_sci->viewport()->setMouseTracking(true);
|
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
|
// Recalculate hover when the viewport scrolls (scrollbar drag, wheel
|
||||||
// deceleration, etc.) so the highlight tracks whatever is under the cursor.
|
// deceleration, etc.) so the highlight tracks whatever is under the cursor.
|
||||||
connect(m_sci->verticalScrollBar(), &QScrollBar::valueChanged,
|
connect(m_sci->verticalScrollBar(), &QScrollBar::valueChanged,
|
||||||
@@ -782,6 +830,14 @@ void RcxEditor::applyTheme(const Theme& theme) {
|
|||||||
abs, theme.background);
|
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) {
|
void RcxEditor::applyDocument(const ComposeResult& result) {
|
||||||
@@ -1243,6 +1299,17 @@ int RcxEditor::currentNodeIndex() const {
|
|||||||
return lm ? lm->nodeIdx : -1;
|
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) {
|
void RcxEditor::scrollToNodeId(uint64_t nodeId) {
|
||||||
for (int i = 0; i < m_meta.size(); i++) {
|
for (int i = 0; i < m_meta.size(); i++) {
|
||||||
if (m_meta[i].nodeId == nodeId && m_meta[i].lineKind != LineKind::Footer) {
|
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) {
|
bool RcxEditor::eventFilter(QObject* obj, QEvent* event) {
|
||||||
if (obj == m_sci && event->type() == QEvent::KeyPress) {
|
if (obj == m_sci && event->type() == QEvent::KeyPress) {
|
||||||
auto* ke = static_cast<QKeyEvent*>(event);
|
auto* ke = static_cast<QKeyEvent*>(event);
|
||||||
|
if (ke->matches(QKeySequence::Find)) {
|
||||||
|
showFindBar();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
bool handled = m_editState.active ? handleEditKey(ke) : handleNormalKey(ke);
|
bool handled = m_editState.active ? handleEditKey(ke) : handleNormalKey(ke);
|
||||||
if (!handled && !m_editState.active) {
|
if (!handled && !m_editState.active) {
|
||||||
// Clear hover on keyboard navigation (stale after scroll)
|
// Clear hover on keyboard navigation (stale after scroll)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
#include <QPoint>
|
#include <QPoint>
|
||||||
#include <QHash>
|
#include <QHash>
|
||||||
|
|
||||||
|
class QLineEdit;
|
||||||
class QsciScintilla;
|
class QsciScintilla;
|
||||||
class QsciLexerCPP;
|
class QsciLexerCPP;
|
||||||
|
|
||||||
@@ -154,6 +155,11 @@ private:
|
|||||||
const Provider* m_disasmRealProv = nullptr; // real process provider — for reading code at arbitrary addresses
|
const Provider* m_disasmRealProv = nullptr; // real process provider — for reading code at arbitrary addresses
|
||||||
const NodeTree* m_disasmTree = nullptr;
|
const NodeTree* m_disasmTree = nullptr;
|
||||||
|
|
||||||
|
// ── Find bar ──
|
||||||
|
QLineEdit* m_findBar = nullptr;
|
||||||
|
void showFindBar();
|
||||||
|
void hideFindBar();
|
||||||
|
|
||||||
// ── Reentrancy guards ──
|
// ── Reentrancy guards ──
|
||||||
bool m_applyingDocument = false;
|
bool m_applyingDocument = false;
|
||||||
bool m_clampingSelection = 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
162
src/main.cpp
162
src/main.cpp
@@ -514,6 +514,8 @@ void MainWindow::createMenus() {
|
|||||||
Qt5Qt6AddAction(file, "New &Struct", QKeySequence(Qt::CTRL | Qt::Key_T), QIcon(), this, &MainWindow::newStruct);
|
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, "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);
|
Qt5Qt6AddAction(file, "&Open...", QKeySequence::Open, makeIcon(":/vsicons/folder-opened.svg"), this, &MainWindow::openFile);
|
||||||
|
m_recentFilesMenu = file->addMenu("Recent &Files");
|
||||||
|
updateRecentFilesMenu();
|
||||||
file->addSeparator();
|
file->addSeparator();
|
||||||
Qt5Qt6AddAction(file, "&Save", QKeySequence::Save, makeIcon(":/vsicons/save.svg"), this, &MainWindow::saveFile);
|
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);
|
Qt5Qt6AddAction(file, "Save &As...", QKeySequence::SaveAs, makeIcon(":/vsicons/save-as.svg"), this, &MainWindow::saveFileAs);
|
||||||
@@ -550,8 +552,13 @@ void MainWindow::createMenus() {
|
|||||||
// View
|
// View
|
||||||
auto* view = m_titleBar->menuBar()->addMenu("&View");
|
auto* view = m_titleBar->menuBar()->addMenu("&View");
|
||||||
Qt5Qt6AddAction(view, "Split &Horizontal", QKeySequence::UnknownKey, makeIcon(":/vsicons/split-horizontal.svg"), this, &MainWindow::splitView);
|
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();
|
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");
|
m_sourceMenu = view->addMenu("&Data Source");
|
||||||
connect(m_sourceMenu, &QMenu::aboutToShow, this, &MainWindow::populateSourceMenu);
|
connect(m_sourceMenu, &QMenu::aboutToShow, this, &MainWindow::populateSourceMenu);
|
||||||
view->addSeparator();
|
view->addSeparator();
|
||||||
@@ -1342,52 +1349,6 @@ static void buildEmptyStruct(NodeTree& tree, const QString& classKeyword = QStri
|
|||||||
tree.addNode(e);
|
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);
|
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() {
|
void MainWindow::selfTest() {
|
||||||
#ifdef Q_OS_WIN
|
#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();
|
project_new();
|
||||||
|
|
||||||
auto* ctrl = activeController();
|
auto* ctrl = activeController();
|
||||||
@@ -1583,8 +1498,14 @@ void MainWindow::selfTest() {
|
|||||||
QString target = QString("%1:Reclass.exe").arg(pid);
|
QString target = QString("%1:Reclass.exe").arg(pid);
|
||||||
|
|
||||||
ctrl->attachViaPlugin(QStringLiteral("processmemory"), target);
|
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
|
#else
|
||||||
project_new();
|
project_new();
|
||||||
|
auto* userTab = project_new(QStringLiteral("class"));
|
||||||
|
m_mdiArea->setActiveSubWindow(userTab);
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2566,6 +2487,7 @@ QMdiSubWindow* MainWindow::project_open(const QString& path) {
|
|||||||
if (n.parentId == 0 && n.kind == NodeKind::Struct) classCount++;
|
if (n.parentId == 0 && n.kind == NodeKind::Struct) classCount++;
|
||||||
setAppStatus(QStringLiteral("Imported %1 classes from %2")
|
setAppStatus(QStringLiteral("Imported %1 classes from %2")
|
||||||
.arg(classCount).arg(QFileInfo(filePath).fileName()));
|
.arg(classCount).arg(QFileInfo(filePath).fileName()));
|
||||||
|
addRecentFile(filePath);
|
||||||
return sub;
|
return sub;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2582,6 +2504,7 @@ QMdiSubWindow* MainWindow::project_open(const QString& path) {
|
|||||||
auto* sub = createTab(doc);
|
auto* sub = createTab(doc);
|
||||||
rebuildWorkspaceModel();
|
rebuildWorkspaceModel();
|
||||||
m_workspaceDock->show();
|
m_workspaceDock->show();
|
||||||
|
addRecentFile(filePath);
|
||||||
return sub;
|
return sub;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2595,8 +2518,10 @@ bool MainWindow::project_save(QMdiSubWindow* sub, bool saveAs) {
|
|||||||
"Save Definition", {}, "Reclass (*.rcx);;JSON (*.json)");
|
"Save Definition", {}, "Reclass (*.rcx);;JSON (*.json)");
|
||||||
if (path.isEmpty()) return false;
|
if (path.isEmpty()) return false;
|
||||||
tab.doc->save(path);
|
tab.doc->save(path);
|
||||||
|
addRecentFile(path);
|
||||||
} else {
|
} else {
|
||||||
tab.doc->save(tab.doc->filePath);
|
tab.doc->save(tab.doc->filePath);
|
||||||
|
addRecentFile(tab.doc->filePath);
|
||||||
}
|
}
|
||||||
updateWindowTitle();
|
updateWindowTitle();
|
||||||
return true;
|
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);
|
m_workspaceDock->setWidget(dockContainer);
|
||||||
addDockWidget(Qt::LeftDockWidgetArea, m_workspaceDock);
|
addDockWidget(Qt::LeftDockWidgetArea, m_workspaceDock);
|
||||||
m_workspaceDock->hide();
|
m_workspaceDock->hide();
|
||||||
@@ -2967,6 +2904,43 @@ void MainWindow::rebuildWorkspaceModel() {
|
|||||||
m_workspaceTree->expandToDepth(0);
|
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() {
|
void MainWindow::populateSourceMenu() {
|
||||||
m_sourceMenu->clear();
|
m_sourceMenu->clear();
|
||||||
auto* ctrl = activeController();
|
auto* ctrl = activeController();
|
||||||
|
|||||||
@@ -92,7 +92,9 @@ private:
|
|||||||
PluginManager m_pluginManager;
|
PluginManager m_pluginManager;
|
||||||
McpBridge* m_mcp = nullptr;
|
McpBridge* m_mcp = nullptr;
|
||||||
QAction* m_mcpAction = nullptr;
|
QAction* m_mcpAction = nullptr;
|
||||||
|
QAction* m_removeSplitAction = nullptr;
|
||||||
QMenu* m_sourceMenu = nullptr;
|
QMenu* m_sourceMenu = nullptr;
|
||||||
|
QMenu* m_recentFilesMenu = nullptr;
|
||||||
|
|
||||||
struct SplitPane {
|
struct SplitPane {
|
||||||
QTabWidget* tabWidget = nullptr;
|
QTabWidget* tabWidget = nullptr;
|
||||||
@@ -119,6 +121,8 @@ private:
|
|||||||
void createStatusBar();
|
void createStatusBar();
|
||||||
void showPluginsDialog();
|
void showPluginsDialog();
|
||||||
void populateSourceMenu();
|
void populateSourceMenu();
|
||||||
|
void addRecentFile(const QString& path);
|
||||||
|
void updateRecentFilesMenu();
|
||||||
QIcon makeIcon(const QString& svgPath);
|
QIcon makeIcon(const QString& svgPath);
|
||||||
|
|
||||||
RcxController* activeController() const;
|
RcxController* activeController() const;
|
||||||
|
|||||||
@@ -256,7 +256,7 @@ QJsonObject McpBridge::handleToolsList(const QJsonValue& id) {
|
|||||||
"insert: {op:'insert', kind:'Hex64', name:'field', parentId:'ID', offset:0}. "
|
"insert: {op:'insert', kind:'Hex64', name:'field', parentId:'ID', offset:0}. "
|
||||||
"change_kind: {op:'change_kind', nodeId:'ID', kind:'UInt32'}. "
|
"change_kind: {op:'change_kind', nodeId:'ID', kind:'UInt32'}. "
|
||||||
"change_offset: {op:'change_offset', nodeId:'ID', offset:16}. "
|
"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_struct_type: {op:'change_struct_type', nodeId:'ID', structTypeName:'Name'}. "
|
||||||
"change_class_keyword: {op:'change_class_keyword', nodeId:'ID', classKeyword:'class'}. "
|
"change_class_keyword: {op:'change_class_keyword', nodeId:'ID', classKeyword:'class'}. "
|
||||||
"change_pointer_ref: {op:'change_pointer_ref', nodeId:'ID', refId:'targetID'}. "
|
"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}});
|
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 == "status.set") result = toolStatusSet(args);
|
||||||
else if (toolName == "ui.action") result = toolUiAction(args);
|
else if (toolName == "ui.action") result = toolUiAction(args);
|
||||||
else if (toolName == "tree.search") result = toolTreeSearch(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);
|
else return errReply(id, -32601, "Unknown tool: " + toolName);
|
||||||
|
|
||||||
m_mainWindow->clearMcpStatus();
|
m_mainWindow->clearMcpStatus();
|
||||||
@@ -751,8 +770,10 @@ QJsonObject McpBridge::toolTreeApply(const QJsonObject& args) {
|
|||||||
}
|
}
|
||||||
else if (opType == "change_base") {
|
else if (opType == "change_base") {
|
||||||
uint64_t newBase = op.value("baseAddress").toString().toULongLong(nullptr, 16);
|
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,
|
doc->undoStack.push(new RcxCommand(ctrl,
|
||||||
cmd::ChangeBase{tree.baseAddress, newBase}));
|
cmd::ChangeBase{tree.baseAddress, newBase, oldFormula, newFormula}));
|
||||||
applied++;
|
applied++;
|
||||||
}
|
}
|
||||||
else if (opType == "change_struct_type") {
|
else if (opType == "change_struct_type") {
|
||||||
@@ -1226,6 +1247,43 @@ QJsonObject McpBridge::toolTreeSearch(const QJsonObject& args) {
|
|||||||
QJsonDocument(out).toJson(QJsonDocument::Indented)));
|
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)
|
// Notifications (call from MainWindow/Controller hooks)
|
||||||
// ════════════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ private:
|
|||||||
QJsonObject toolStatusSet(const QJsonObject& args);
|
QJsonObject toolStatusSet(const QJsonObject& args);
|
||||||
QJsonObject toolUiAction(const QJsonObject& args);
|
QJsonObject toolUiAction(const QJsonObject& args);
|
||||||
QJsonObject toolTreeSearch(const QJsonObject& args);
|
QJsonObject toolTreeSearch(const QJsonObject& args);
|
||||||
|
QJsonObject toolNodeHistory(const QJsonObject& args);
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
QJsonObject makeTextResult(const QString& text, bool isError = false);
|
QJsonObject makeTextResult(const QString& text, bool isError = false);
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QFileInfo>
|
#include <QFileInfo>
|
||||||
#include <QPixmap>
|
#include <QPixmap>
|
||||||
|
#include <QSettings>
|
||||||
|
#include <QApplication>
|
||||||
|
#include <QClipboard>
|
||||||
|
#include <QMenu>
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
#include <windows.h>
|
#include <windows.h>
|
||||||
@@ -27,22 +31,9 @@ ProcessPicker::ProcessPicker(QWidget *parent)
|
|||||||
, m_useCustomList(false)
|
, m_useCustomList(false)
|
||||||
{
|
{
|
||||||
ui->setupUi(this);
|
ui->setupUi(this);
|
||||||
|
initUi();
|
||||||
// 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
|
|
||||||
refreshProcessList();
|
refreshProcessList();
|
||||||
|
selectPreferredProcess();
|
||||||
}
|
}
|
||||||
|
|
||||||
ProcessPicker::ProcessPicker(const QList<ProcessInfo>& customProcesses, QWidget *parent)
|
ProcessPicker::ProcessPicker(const QList<ProcessInfo>& customProcesses, QWidget *parent)
|
||||||
@@ -51,23 +42,102 @@ ProcessPicker::ProcessPicker(const QList<ProcessInfo>& customProcesses, QWidget
|
|||||||
, m_useCustomList(true)
|
, m_useCustomList(true)
|
||||||
{
|
{
|
||||||
ui->setupUi(this);
|
ui->setupUi(this);
|
||||||
|
initUi();
|
||||||
// Configure table
|
ui->refreshButton->setVisible(false);
|
||||||
ui->processTable->setColumnWidth(0, 80);
|
m_allProcesses = customProcesses;
|
||||||
ui->processTable->setColumnWidth(1, 200);
|
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->horizontalHeader()->setStretchLastSection(true);
|
||||||
ui->processTable->setWordWrap(false);
|
ui->processTable->setWordWrap(false);
|
||||||
ui->processTable->setTextElideMode(Qt::ElideLeft);
|
ui->processTable->setTextElideMode(Qt::ElideLeft);
|
||||||
|
ui->processTable->setShowGrid(false);
|
||||||
// Connect signals (no refresh button for custom lists)
|
ui->processTable->verticalHeader()->setDefaultSectionSize(fontMetrics().height() + 6);
|
||||||
ui->refreshButton->setVisible(false);
|
|
||||||
|
// Signal connections
|
||||||
|
connect(ui->refreshButton, &QPushButton::clicked, this, &ProcessPicker::refreshProcessList);
|
||||||
connect(ui->processTable, &QTableWidget::itemDoubleClicked, this, &ProcessPicker::onProcessSelected);
|
connect(ui->processTable, &QTableWidget::itemDoubleClicked, this, &ProcessPicker::onProcessSelected);
|
||||||
connect(ui->filterEdit, &QLineEdit::textChanged, this, &ProcessPicker::filterProcesses);
|
connect(ui->filterEdit, &QLineEdit::textChanged, this, &ProcessPicker::filterProcesses);
|
||||||
connect(ui->attachButton, &QPushButton::clicked, this, &ProcessPicker::onProcessSelected);
|
connect(ui->attachButton, &QPushButton::clicked, this, &ProcessPicker::onProcessSelected);
|
||||||
|
|
||||||
// Use custom process list
|
// Derive theme colors from the global palette (set by applyGlobalTheme)
|
||||||
m_allProcesses = customProcesses;
|
QPalette pal = qApp->palette();
|
||||||
applyFilter();
|
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()
|
ProcessPicker::~ProcessPicker()
|
||||||
@@ -97,31 +167,31 @@ void ProcessPicker::onProcessSelected()
|
|||||||
{
|
{
|
||||||
auto* item = ui->processTable->currentItem();
|
auto* item = ui->processTable->currentItem();
|
||||||
if (!item) return;
|
if (!item) return;
|
||||||
|
|
||||||
int row = item->row();
|
int row = item->row();
|
||||||
m_selectedPid = ui->processTable->item(row, 0)->data(Qt::EditRole).toUInt();
|
m_selectedPid = ui->processTable->item(row, 0)->data(Qt::EditRole).toUInt();
|
||||||
// Use original name stored in UserRole (without architecture suffix)
|
// Use original name stored in UserRole (without architecture suffix)
|
||||||
QVariant origName = ui->processTable->item(row, 1)->data(Qt::UserRole);
|
QVariant origName = ui->processTable->item(row, 1)->data(Qt::UserRole);
|
||||||
m_selectedName = origName.isValid() ? origName.toString()
|
m_selectedName = origName.isValid() ? origName.toString()
|
||||||
: ui->processTable->item(row, 1)->text();
|
: ui->processTable->item(row, 1)->text();
|
||||||
|
|
||||||
accept();
|
accept();
|
||||||
}
|
}
|
||||||
|
|
||||||
void ProcessPicker::enumerateProcesses()
|
void ProcessPicker::enumerateProcesses()
|
||||||
{
|
{
|
||||||
QList<ProcessInfo> processes;
|
QList<ProcessInfo> processes;
|
||||||
|
|
||||||
#ifdef _WIN32
|
#ifdef _WIN32
|
||||||
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
|
||||||
if (snapshot == INVALID_HANDLE_VALUE) {
|
if (snapshot == INVALID_HANDLE_VALUE) {
|
||||||
QMessageBox::warning(this, "Error", "Failed to enumerate processes.");
|
QMessageBox::warning(this, "Error", "Failed to enumerate processes.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
PROCESSENTRY32W pe32;
|
PROCESSENTRY32W pe32;
|
||||||
pe32.dwSize = sizeof(PROCESSENTRY32W);
|
pe32.dwSize = sizeof(PROCESSENTRY32W);
|
||||||
|
|
||||||
if (Process32FirstW(snapshot, &pe32))
|
if (Process32FirstW(snapshot, &pe32))
|
||||||
{
|
{
|
||||||
do
|
do
|
||||||
@@ -129,10 +199,7 @@ void ProcessPicker::enumerateProcesses()
|
|||||||
ProcessInfo info;
|
ProcessInfo info;
|
||||||
info.pid = pe32.th32ProcessID;
|
info.pid = pe32.th32ProcessID;
|
||||||
info.name = QString::fromWCharArray(pe32.szExeFile);
|
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);
|
HANDLE hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pe32.th32ProcessID);
|
||||||
if (hProcess)
|
if (hProcess)
|
||||||
{
|
{
|
||||||
@@ -143,7 +210,7 @@ void ProcessPicker::enumerateProcesses()
|
|||||||
GetModuleFileNameExW(hProcess, nullptr, path, pathLen))
|
GetModuleFileNameExW(hProcess, nullptr, path, pathLen))
|
||||||
{
|
{
|
||||||
info.path = QString::fromWCharArray(path);
|
info.path = QString::fromWCharArray(path);
|
||||||
|
|
||||||
// Extract icon from executable
|
// Extract icon from executable
|
||||||
SHFILEINFOW sfi = {};
|
SHFILEINFOW sfi = {};
|
||||||
if (SHGetFileInfoW(path, 0, &sfi, sizeof(sfi), SHGFI_ICON | SHGFI_SMALLICON)) {
|
if (SHGetFileInfoW(path, 0, &sfi, sizeof(sfi), SHGFI_ICON | SHGFI_SMALLICON)) {
|
||||||
@@ -292,3 +359,22 @@ void ProcessPicker::applyFilter()
|
|||||||
|
|
||||||
populateTable(filtered);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,9 +35,11 @@ private slots:
|
|||||||
void filterProcesses(const QString& text);
|
void filterProcesses(const QString& text);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
|
void initUi();
|
||||||
void enumerateProcesses();
|
void enumerateProcesses();
|
||||||
void populateTable(const QList<ProcessInfo>& processes);
|
void populateTable(const QList<ProcessInfo>& processes);
|
||||||
void applyFilter();
|
void applyFilter();
|
||||||
|
void selectPreferredProcess();
|
||||||
|
|
||||||
Ui::ProcessPicker *ui;
|
Ui::ProcessPicker *ui;
|
||||||
uint32_t m_selectedPid = 0;
|
uint32_t m_selectedPid = 0;
|
||||||
|
|||||||
@@ -127,22 +127,6 @@
|
|||||||
</widget>
|
</widget>
|
||||||
<resources/>
|
<resources/>
|
||||||
<connections>
|
<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>
|
<connection>
|
||||||
<sender>cancelButton</sender>
|
<sender>cancelButton</sender>
|
||||||
<signal>clicked()</signal>
|
<signal>clicked()</signal>
|
||||||
|
|||||||
@@ -46,10 +46,17 @@ inline void buildProjectExplorer(QStandardItemModel* model,
|
|||||||
auto nameOf = [](const Node* n) {
|
auto nameOf = [](const Node* n) {
|
||||||
return n->structTypeName.isEmpty() ? n->name : n->structTypeName;
|
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) {
|
auto cmpName = [&](const Entry& a, const Entry& b) {
|
||||||
return nameOf(a.node).compare(nameOf(b.node), Qt::CaseInsensitive) < 0;
|
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);
|
std::sort(enums.begin(), enums.end(), cmpName);
|
||||||
|
|
||||||
// Helper: type display string for a member node
|
// Helper: type display string for a member node
|
||||||
|
|||||||
Reference in New Issue
Block a user