From a7532b4d80225e0d8054dfdfd34dac4a70b65e43 Mon Sep 17 00:00:00 2001 From: pancake Date: Tue, 29 Sep 2026 21:30:37 +0200 Subject: [PATCH] Introduce the advanced DataAnalysis widget --- .gitignore | 3 + doc/data-analysis.md | 50 ++ src/Iaito.pro | 11 +- src/common/DataAnalysis.cpp | 122 ++++ src/common/DataAnalysis.h | 64 ++ src/core/MainWindow.cpp | 34 +- src/core/MainWindow.h | 3 + src/core/MainWindow.ui | 6 + src/widgets/AddressableItemList.h | 2 +- src/widgets/DataAnalysisView.cpp | 632 +++++++++++++++++++ src/widgets/DataAnalysisView.h | 86 +++ src/widgets/DataAnalysisWidget.cpp | 966 +++++++++++++++++++++++++++++ src/widgets/DataAnalysisWidget.h | 81 +++ src/widgets/DataAnalysisWidget.ui | 28 + src/widgets/HexWidget.cpp | 5 + src/widgets/HexWidget.h | 1 + src/widgets/HexdumpWidget.cpp | 20 + src/widgets/SectionsWidget.cpp | 14 + src/widgets/SegmentsWidget.cpp | 15 + 19 files changed, 2139 insertions(+), 4 deletions(-) create mode 100644 doc/data-analysis.md create mode 100644 src/common/DataAnalysis.cpp create mode 100644 src/common/DataAnalysis.h create mode 100644 src/widgets/DataAnalysisView.cpp create mode 100644 src/widgets/DataAnalysisView.h create mode 100644 src/widgets/DataAnalysisWidget.cpp create mode 100644 src/widgets/DataAnalysisWidget.h create mode 100644 src/widgets/DataAnalysisWidget.ui diff --git a/.gitignore b/.gitignore index 9830de26..05390db7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,9 @@ AStyle* ./config.mk config.mk +# Generated configuration header (qmake / Meson) +IaitoConfig.h + # OSX files *.DS_Store .AppleDouble diff --git a/doc/data-analysis.md b/doc/data-analysis.md new file mode 100644 index 00000000..2b7c8f9e --- /dev/null +++ b/doc/data-analysis.md @@ -0,0 +1,50 @@ +# Data Analysis + +Open **View → Data → Analysis**. The dock is hidden in the default layout and when +first added to an older saved layout. Hidden docks do not sample data. **Zoom** is +also available under **View → Data**. + +- **Algorithms** enables multiple byte metrics. Hover over a named legend entry + for its definition; click it to edit its color or visibility. Clicking the legend + does not remove the series. +- **Options → Resolution (buckets)** selects 32, 64, 128, 256, 512, 1024, 2048 or + 4096 buckets. Lower values give coarser graphs and less work. The default is 1024. +- **Options** also controls lines, filled lines or vertical bars, separate tracks, + greyscale/rainbow/theme colors, custom colors, and structural markers. +- Click the graph to seek. Drag to select bytes. Wheel or `+`/`-` zooms; + Shift+wheel or the arrow keys pans. `Home` fits all data, and `Esc` clears selection. +- The bottom entropy overview shows the complete address span. Drag its window + to pan, drag its edges to resize the range, or double-click to fit all data. +- **Range** fits the current section, segment, map, block or selection. + **Edit range** accepts radare2 expressions and inclusive endpoints. +- **Follow** keeps the current seek address visible. Offset synchronization and + the global address-selection synchronization setting control integration with + disassembly and hexdump widgets. +- **Find marker** searches sections, segments, maps, flags (including search hits) + and comments. The graph context menu provides comments, flags and “Show in”. + Hover details appear after the pointer rests for 650 ms. +- Hexdump selections, sections and segments have direct analysis actions. + Other addressable widgets can use **Show in → Data Analysis**. + +The graph uses the current radare2 address space: virtual addresses with `io.va` +enabled, otherwise physical offsets in the active descriptor. Gaps and failed reads +do not contribute invented bytes. Each bucket reads at most 4096 bytes from centered +windows in readable spans. Tooltips, the status line and exported CSV report how +many bytes were actually read. Zoom in for complete coverage of a region. Entropy +is measured per bucket, so very small buckets have a lower attainable entropy. + +Multiple overlaid series use a normalized percentage scale. Entropy's native scale +is 0–8 bits/byte, unique bytes 0–256, and mean byte 0–255. A single series or separate +tracks uses its native scale. CSV exports normalized values plus exact bucket bounds +and coverage. Display settings are saved with the layout. + +Other widgets can call `MainWindow::showDataAnalysis(start, end)` or +`DataAnalysisWidget::showRange(start, end)` with **inclusive** endpoints. Internally, +`DataAnalysis::Range` and graph signals use half-open ranges. + +Run the focused tests with: + +```sh +make -C tests/dataanalysis -j8 > /dev/null +QT_QPA_PLATFORM=offscreen tests/dataanalysis/build/test_dataanalysis +``` diff --git a/src/Iaito.pro b/src/Iaito.pro index ccc26637..bc568a8f 100644 --- a/src/Iaito.pro +++ b/src/Iaito.pro @@ -359,7 +359,10 @@ SOURCES += \ dialogs/AssemblerDialog.cpp \ dialogs/ScriptManagerDialog.cpp \ dialogs/ScriptManagerWidget.cpp \ - widgets/ZoomWidget.cpp + widgets/ZoomWidget.cpp \ + common/DataAnalysis.cpp \ + widgets/DataAnalysisView.cpp \ + widgets/DataAnalysisWidget.cpp GRAPHVIZ_SOURCES = \ widgets/GraphvizLayout.cpp @@ -576,11 +579,15 @@ HEADERS += \ dialogs/AssemblerDialog.h \ dialogs/ScriptManagerDialog.h \ dialogs/ScriptManagerWidget.h \ - widgets/ZoomWidget.h + widgets/ZoomWidget.h \ + common/DataAnalysis.h \ + widgets/DataAnalysisView.h \ + widgets/DataAnalysisWidget.h GRAPHVIZ_HEADERS = widgets/GraphvizLayout.h FORMS += \ + widgets/DataAnalysisWidget.ui \ dialogs/AboutDialog.ui \ dialogs/EditStringDialog.ui \ dialogs/Base64EnDecodedWriteDialog.ui \ diff --git a/src/common/DataAnalysis.cpp b/src/common/DataAnalysis.cpp new file mode 100644 index 00000000..e92f7701 --- /dev/null +++ b/src/common/DataAnalysis.cpp @@ -0,0 +1,122 @@ +#include "DataAnalysis.h" + +#include +#include + +namespace DataAnalysis { + +Range sizedRange(RVA start, RVA size) +{ + return {start, start + qMin(size, RVA_MAX - start)}; +} + +Range intersect(Range a, Range b) +{ + const RVA start = qMax(a.start, b.start); + return {start, qMax(start, qMin(a.end, b.end))}; +} + +Range bucket(Range range, int index, int count) +{ + if (!range.valid() || count <= 0 || index < 0 || index >= count) { + return {}; + } + // Avoid both floating point address rounding and multiplication overflow. + auto boundary = [range, count](RVA i) { + return range.start + (range.size() / count) * i + (range.size() % count) * i / count; + }; + return {boundary(index), boundary(index + 1)}; +} + +Range centered(Range domain, RVA address, RVA size) +{ + if (!domain.valid()) { + return {}; + } + size = qBound(1, size, domain.size()); + address = qBound(domain.start, address, domain.end - 1); + const RVA start = address - qMin(address - domain.start, size / 2); + const RVA clamped = qMin(start, domain.end - size); + return {clamped, clamped + size}; +} + +QVector mergeRanges(QVector ranges) +{ + std::sort(ranges.begin(), ranges.end(), [](Range a, Range b) { return a.start < b.start; }); + QVector result; + for (auto range : ranges) { + if (!range.valid()) { + continue; + } + if (result.isEmpty() || result.last().end < range.start) { + result.append(range); + } else { + result.last().end = qMax(result.last().end, range.end); + } + } + return result; +} + +Sample sample( + Range range, + const QVector &readable, + const std::function &read, + int budget) +{ + Sample result; + result.range = range; + if (!range.valid() || budget <= 0) { + return result; + } + QVector spans; + for (auto candidate : readable) { + const Range span = intersect(range, candidate); + if (span.valid()) { + spans.append(span); + result.covered += span.size(); + } + } + std::array histogram{}; + RVA remaining = result.covered; + for (auto span : spans) { + if (budget <= 0) { + break; + } + // Allocate the budget proportionally among disjoint readable spans. + const int share = qMax(1, int((long double) span.size() / remaining * budget)); + const int length = int(qMin(span.size(), qMin(budget, share))); + const RVA address = span.start + (span.size() - length) / 2; + const QByteArray data = read(address, length).left(length); + for (unsigned char byte : data) { + ++histogram[byte]; + } + result.bytes += data.size(); + budget -= length; + remaining -= span.size(); + } + if (!result.bytes) { + return result; + } + auto &v = result.values; + for (int byte = 0; byte < 256; ++byte) { + const int n = histogram[byte]; + if (!n) { + continue; + } + const double p = double(n) / result.bytes; + v[Entropy] -= p * std::log2(p) / 8.0; + v[Printable] += (byte >= 32 && byte <= 126) ? p : 0; + v[Zero] += byte == 0 ? p : 0; + v[FF] += byte == 255 ? p : 0; + v[Unique] += 1.0 / 256; + v[Mean] += p * byte / 255; + v[Alpha] += ((byte >= 'a' && byte <= 'z') || (byte >= 'A' && byte <= 'Z')) ? p : 0; + v[Digit] += (byte >= '0' && byte <= '9') ? p : 0; + v[Space] += (byte == ' ' || (byte >= 9 && byte <= 13)) ? p : 0; + v[Control] += (byte < 32 || byte == 127) ? p : 0; + v[High] += byte >= 128 ? p : 0; + } + return result; +} + +} // namespace DataAnalysis diff --git a/src/common/DataAnalysis.h b/src/common/DataAnalysis.h new file mode 100644 index 00000000..9f77a3ea --- /dev/null +++ b/src/common/DataAnalysis.h @@ -0,0 +1,64 @@ +#ifndef DATAANALYSIS_H +#define DATAANALYSIS_H + +#include "core/IaitoCommon.h" + +#include +#include +#include +#include + +namespace DataAnalysis { + +// Half-open ranges, including when the exclusive end is RVA_MAX. +struct Range +{ + RVA start = 0; + RVA end = 0; + bool valid() const { return start < end; } + RVA size() const { return end - start; } + bool contains(RVA address) const { return start <= address && address < end; } + bool operator==(const Range &other) const { return start == other.start && end == other.end; } + bool operator!=(const Range &other) const { return !(*this == other); } +}; + +enum Metric { + Entropy, + Printable, + Zero, + FF, + Unique, + Mean, + Alpha, + Digit, + Space, + Control, + High, + Count +}; + +struct Sample +{ + Range range; + RVA covered = 0; + int bytes = 0; + std::array values{}; // Normalized to [0, 1]; entropy / 8. +}; + +Range sizedRange(RVA start, RVA size); +Range intersect(Range a, Range b); +Range bucket(Range range, int index, int count); +Range centered(Range domain, RVA address, RVA size); +QVector mergeRanges(QVector ranges); + +// Bounded, deterministic sampling. Reads only known readable spans; missing bytes +// never contribute fabricated 0xff data. Large buckets use centered windows. +Sample sample( + Range range, + const QVector &readable, + const std::function &read, + int budget = 4096); + +} // namespace DataAnalysis + +#endif diff --git a/src/core/MainWindow.cpp b/src/core/MainWindow.cpp index c949128e..d6260ef9 100644 --- a/src/core/MainWindow.cpp +++ b/src/core/MainWindow.cpp @@ -48,6 +48,7 @@ #include "widgets/ConsoleWidget.h" #include "widgets/CustomCommandWidget.h" #include "widgets/Dashboard.h" +#include "widgets/DataAnalysisWidget.h" #include "widgets/DebugActions.h" #include "widgets/DecompilerWidget.h" #include "widgets/DisassemblerGraphView.h" @@ -1208,6 +1209,7 @@ void MainWindow::initUI() connectMenuStatusTips(ui->menuEdit); connectMenuStatusTips(ui->menuCode); connectMenuStatusTips(ui->menuAnalysis); + connectMenuStatusTips(ui->menuData); connectMenuStatusTips(ui->menuTools); connectMenuStatusTips(ui->menuPlugins); @@ -1578,8 +1580,15 @@ void MainWindow::initDocks() r2GraphDock = new R2GraphWidget(this), callGraphDock = new CallGraphWidget(this, false), globalCallGraphDock = new CallGraphWidget(this, true), + }; + QList dataDocks = { + dataAnalysisDock = new DataAnalysisWidget(this), zoomDock = new ZoomWidget(this), }; + dataAnalysisDock->toggleViewAction()->setText(tr("Analysis")); + connect(dataAnalysisDock, &QWidget::windowTitleChanged, this, [this]() { + dataAnalysisDock->toggleViewAction()->setText(tr("Analysis")); + }); auto makeActionList = [this](QList docks) { QList result; @@ -1599,6 +1608,7 @@ void MainWindow::initDocks() ui->menuCode->addSeparator(); ui->menuCode->addActions(makeActionList(codeDocks)); ui->menuAnalysis->addActions(makeActionList(analysisDocks)); + ui->menuData->addActions(makeActionList(dataDocks)); QList windowDocks2 = { consoleDock, }; @@ -1624,7 +1634,7 @@ void MainWindow::initDocks() ui->menuAddDebugWidgets->addActions(makeActionList(debugDocks)); auto uniqueDocks = mainViewDocks + windowDocks2 + infoDocks + analysisDocks; - uniqueDocks += codeDocks + ioDocks + debugDocks; + uniqueDocks += codeDocks + ioDocks + debugDocks + dataDocks; uniqueDocks.append(overviewDock); for (auto dock : uniqueDocks) { if (dock) { // ignore nullptr used as separators @@ -1678,6 +1688,7 @@ void MainWindow::applyTopLevelMenuIcons() setAppMenuIcon(this, ui->menuAddInfoWidgets, AppMenuIcon::Info, viewColor); setAppMenuIcon(this, ui->menuAddIoWidgets, AppMenuIcon::Storage, viewColor); setAppMenuIcon(this, ui->menuAnalysis, AppMenuIcon::Analysis, analysisColor); + setAppMenuIcon(this, ui->menuData, AppMenuIcon::Storage, viewColor); setAppMenuIcon(this, ui->menuAddAnother, AppMenuIcon::Extra, viewColor); setAppMenuIcon(this, ui->menuZoom, AppMenuIcon::ZoomIn, viewColor); setAppMenuIcon(this, ui->actionZoomIn, AppMenuIcon::ZoomIn, viewColor); @@ -2460,6 +2471,8 @@ void MainWindow::restoreDocks() filesDock->hide(); binariesDock->hide(); tabifyDockWidget(dashboardDock, zoomDock); + tabifyDockWidget(dashboardDock, dataAnalysisDock); + dataAnalysisDock->hide(); for (const auto &it : m_dockManager->docks()) { // Check whether or not current widgets is graph, hexdump or disasm if (isExtraMemoryWidget(it)) { @@ -2547,6 +2560,11 @@ void MainWindow::showMemoryWidget(MemoryWidgetType type) memoryDockWidget->raiseMemoryWidget(); } +void MainWindow::showDataAnalysis(RVA start, RVA end) +{ + dataAnalysisDock->showRange(start, end); +} + void MainWindow::gotoOffset(const QString &offset) { ut64 address; @@ -2590,6 +2608,16 @@ QMenu *MainWindow::createShowInMenu(QWidget *parent, RVA address, AddressTypeHin } QAction *action = new QAction(memoryWidget->windowTitle(), menu); connect(action, &QAction::triggered, this, [memoryWidget, address]() { + if (auto *analysis = qobject_cast(memoryWidget)) { + if (Core()->hasAddressRangeSelection() + && Core()->getAddressRangeSelectionStart() <= address + && address <= Core()->getAddressRangeSelectionEnd()) { + analysis->showRange( + Core()->getAddressRangeSelectionStart(), + Core()->getAddressRangeSelectionEnd()); + return; + } + } memoryWidget->getSeekable()->seek(address); memoryWidget->raiseMemoryWidget(); }); @@ -3201,6 +3229,10 @@ void MainWindow::setViewLayout(const IaitoLayout &layout) // other } for (auto dock : newDocks) { + // Data analysis scans are opt-in, including when upgrading a saved layout. + if (dock == dataAnalysisDock) { + continue; + } dockOnMainArea(dock); // Show any new docks by default. // Showing new builtin docks helps discovering features added in diff --git a/src/core/MainWindow.h b/src/core/MainWindow.h index 5ac84486..9b17e0ca 100644 --- a/src/core/MainWindow.h +++ b/src/core/MainWindow.h @@ -66,6 +66,7 @@ class FilesWidget; class FilesystemWidget; class BinariesWidget; class ZoomWidget; +class DataAnalysisWidget; class XrefsWidget; class RefsWidget; class CodeInfoWidget; @@ -151,6 +152,7 @@ public: QString getUniqueObjectName(const QString &widgetType) const; void showMemoryWidget(); void showMemoryWidget(MemoryWidgetType type); + void showDataAnalysis(RVA start, RVA end); void gotoOffset(const QString &offset); enum class AddressTypeHint { Function, Data, Unknown }; QMenu *createShowInMenu( @@ -366,6 +368,7 @@ private: CallGraphWidget *callGraphDock = nullptr; CallGraphWidget *globalCallGraphDock = nullptr; ZoomWidget *zoomDock = nullptr; + DataAnalysisWidget *dataAnalysisDock = nullptr; XrefsWidget *xrefsDock = nullptr; RefsWidget *refsDock = nullptr; CodeInfoWidget *codeInfoDock = nullptr; diff --git a/src/core/MainWindow.ui b/src/core/MainWindow.ui index 4d5a5193..e29c24b8 100644 --- a/src/core/MainWindow.ui +++ b/src/core/MainWindow.ui @@ -130,9 +130,15 @@ Analysis + + + Data + + + diff --git a/src/widgets/AddressableItemList.h b/src/widgets/AddressableItemList.h index f0bdcdad..f6680467 100644 --- a/src/widgets/AddressableItemList.h +++ b/src/widgets/AddressableItemList.h @@ -5,8 +5,8 @@ #include #include #include -#include #include +#include #include "IaitoDockWidget.h" #include "IaitoTreeView.h" diff --git a/src/widgets/DataAnalysisView.cpp b/src/widgets/DataAnalysisView.cpp new file mode 100644 index 00000000..5ed28ef6 --- /dev/null +++ b/src/widgets/DataAnalysisView.cpp @@ -0,0 +1,632 @@ +#include "DataAnalysisView.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +using namespace DataAnalysis; + +DataAnalysisView::DataAnalysisView(bool overview, QWidget *parent) + : QWidget(parent) + , overview(overview) +{ + setMouseTracking(true); + setFocusPolicy(Qt::StrongFocus); + setMinimumSize(220, overview ? 70 : 180); + setAccessibleName(overview ? tr("Data overview") : tr("Data analysis graph")); + setAccessibleDescription( + overview ? tr("Drag the window to pan; drag its edges to resize. Double-click to fit.") + : tr("Click to seek; drag to select bytes; wheel to zoom; Shift+wheel to pan." + " Right-click for comments, flags and range actions.")); + tooltipTimer.setSingleShot(true); + tooltipTimer.setInterval(650); + connect(&tooltipTimer, &QTimer::timeout, this, &DataAnalysisView::showTooltip); +} + +QString DataAnalysisView::metricName(int metric) +{ + static const char *names[] + = {QT_TR_NOOP("Entropy"), + QT_TR_NOOP("Printable ASCII"), + QT_TR_NOOP("0x00 bytes"), + QT_TR_NOOP("0xFF bytes"), + QT_TR_NOOP("Unique bytes"), + QT_TR_NOOP("Mean byte"), + QT_TR_NOOP("ASCII letters"), + QT_TR_NOOP("ASCII digits"), + QT_TR_NOOP("ASCII whitespace"), + QT_TR_NOOP("ASCII control"), + QT_TR_NOOP("High bytes")}; + return metric >= 0 && metric < Count ? tr(names[metric]) : QString(); +} + +QString DataAnalysisView::metricValue(int metric, double value) +{ + if (metric == Entropy) { + return tr("%1 bits/byte").arg(value * 8, 0, 'f', 3); + } + if (metric == Unique) { + return tr("%1 / 256").arg(qRound(value * 256)); + } + if (metric == Mean) { + return QString::number(value * 255, 'f', 2); + } + return tr("%1%").arg(value * 100, 0, 'f', 2); +} + +QString DataAnalysisView::metricDescription(int metric) +{ + static const char *descriptions[] + = {QT_TR_NOOP( + "Shannon entropy: 0–8 bits per byte. Higher values indicate a more varied byte " + "distribution. Overlaid values are divided by 8."), + QT_TR_NOOP("Percentage of printable ASCII bytes (0x20–0x7e)."), + QT_TR_NOOP("Percentage of zero bytes (0x00), often padding or uninitialized data."), + QT_TR_NOOP("Percentage of 0xff bytes, often padding or erased storage."), + QT_TR_NOOP("Number of distinct byte values observed, out of 256 possible values."), + QT_TR_NOOP("Average byte value (0–255). Overlaid values are divided by 255."), + QT_TR_NOOP("Percentage of ASCII letters (A–Z and a–z)."), + QT_TR_NOOP("Percentage of ASCII decimal digits (0–9)."), + QT_TR_NOOP( + "Percentage of ASCII whitespace (space, tab, newline, vertical tab, form feed and " + "carriage return)."), + QT_TR_NOOP("Percentage of ASCII control bytes (0x00–0x1f and 0x7f)."), + QT_TR_NOOP("Percentage of bytes with the high bit set (0x80–0xff).")}; + return metric >= 0 && metric < Count ? tr(descriptions[metric]) : QString(); +} + +QString DataAnalysisView::markerName(MarkerType type) +{ + static const char *names[] + = {QT_TR_NOOP("Section"), + QT_TR_NOOP("Segment"), + QT_TR_NOOP("Map"), + QT_TR_NOOP("Flag"), + QT_TR_NOOP("Comment")}; + return tr(names[type]); +} + +Range DataAnalysisView::axisRange() const +{ + return overview ? domain : visibleRange; +} + +QRectF DataAnalysisView::plotRect() const +{ + return QRectF( + 46, overview ? 5 : 30, qMax(1, width() - 60), qMax(1, height() - (overview ? 26 : 59))); +} + +qreal DataAnalysisView::xForAddress(RVA address) const +{ + const Range range = axisRange(); + if (!range.valid()) { + return plotRect().left(); + } + const long double distance = address >= range.start ? (long double) (address - range.start) + : -(long double) (range.start - address); + return plotRect().left() + distance / range.size() * plotRect().width(); +} + +RVA DataAnalysisView::addressAt(qreal x) const +{ + const Range range = axisRange(); + if (!range.valid()) { + return RVA_INVALID; + } + const long double fraction = (x - plotRect().left()) / plotRect().width(); + if (fraction <= 0) { + return range.start; + } + if (fraction >= 1) { + return range.end - 1; + } + return range.start + qMin(RVA(fraction * range.size()), range.size() - 1); +} + +void DataAnalysisView::paintEvent(QPaintEvent *) +{ + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing); + p.fillRect(rect(), palette().color(QPalette::Base)); + const QRectF plot = plotRect(); + const Range range = axisRange(); + const QColor text = palette().color(QPalette::Text); + QColor grid = text; + grid.setAlpha(35); + p.setPen(grid); + p.drawRect(plot); + if (!range.valid()) { + p.setPen(text); + p.drawText(plot, Qt::AlignCenter, tr("Open a file to analyze its data")); + return; + } + + const bool split = !overview && separateTracks; + const int tracks = split ? qMax(1, int(metrics.size())) : 1; + for (int track = 0; track < tracks; ++track) { + const qreal top = plot.top() + track * plot.height() / tracks; + const qreal bottom = plot.top() + (track + 1) * plot.height() / tracks; + const qreal graphTop = top + (split ? 19 : 0); + const qreal graphBottom = bottom - (split ? 5 : 0); + const qreal graphHeight = qMax(1.0, graphBottom - graphTop); + if (!overview) { + for (int tick = 0; tick <= 4; ++tick) { + const qreal y = graphBottom - graphHeight * tick / 4; + p.setPen(grid); + p.drawLine(QPointF(plot.left(), y), QPointF(plot.right(), y)); + if (tracks == 1 || tick == 0 || tick == 4) { + int maximum = 100; + if ((separateTracks || metrics.size() == 1) && !metrics.isEmpty()) { + const int metric = metrics[track]; + maximum = metric == Entropy ? 8 + : metric == Mean ? 255 + : metric == Unique ? 256 + : 100; + } + p.setPen(text); + QString label = QString::number(maximum * tick / 4.0, 'g', 3); + if (maximum == 100 && tick == 4) { + label += '%'; + } + p.drawText( + QRectF(0, y - 8, plot.left() - 7, 16), + Qt::AlignRight | Qt::AlignVCenter, + label); + } + } + if (split && !metrics.isEmpty()) { + p.setPen(colors[metrics[track]]); + p.drawText( + QRectF(plot.left() + 5, top, plot.width() - 10, 18), + Qt::AlignLeft | Qt::AlignVCenter, + metricName(metrics[track])); + } + } + p.save(); + p.setClipRect(QRectF(plot.left(), graphTop, plot.width(), graphHeight)); + const QVector drawn = overview ? QVector{Entropy} + : split && !metrics.isEmpty() ? QVector{metrics[track]} + : metrics; + for (int series = 0; series < drawn.size(); ++series) { + const int metric = drawn[series]; + const QColor color = colors[metric]; + QPainterPath path; + qreal firstX = 0, lastX = 0; + auto flush = [&]() { + if (path.isEmpty()) { + return; + } + if (overview || style == Areas) { + QPainterPath area = path; + area.lineTo(lastX, graphBottom); + area.lineTo(firstX, graphBottom); + area.closeSubpath(); + QLinearGradient gradient(0, graphTop, 0, graphBottom); + QColor fill = color; + fill.setAlpha(overview ? 110 : 85); + gradient.setColorAt(0, fill); + fill.setAlpha(12); + gradient.setColorAt(1, fill); + p.fillPath(area, gradient); + } + p.setPen(QPen(color, overview ? 1 : 1.5)); + p.drawPath(path); + path = QPainterPath(); + }; + for (const auto &sample : samples) { + if (!sample.bytes) { + flush(); + continue; + } + const qreal left = xForAddress(sample.range.start); + const qreal right = xForAddress(sample.range.end); + const qreal y = graphBottom - qBound(0.0, sample.values[metric], 1.0) * graphHeight; + if (!overview && style == Bars) { + const qreal barWidth = (right - left) / qMax(1, int(drawn.size())); + QColor fill = color; + fill.setAlpha(190); + p.fillRect( + QRectF(left + series * barWidth, y, qMax(0.6, barWidth), graphBottom - y), + fill); + } else { + if (path.isEmpty()) { + firstX = left; + path.moveTo(left, y); + } + path.lineTo((left + right) / 2, y); + lastX = right; + // Retain single-bucket signals and reach both ends of the range. + path.lineTo(right, y); + } + } + flush(); + } + p.restore(); + } + + // Markers are coalesced by pixel and category to keep dense flag sets cheap to paint. + std::array lastPixel; + lastPixel.fill(-1000); + qreal labelEnd = plot.left(); + for (const auto &marker : markers) { + if (!markerEnabled[marker.type]) { + continue; + } + const QColor color = colors[(marker.type + 4) % Count]; + for (RVA address : {marker.range.start, marker.range.end}) { + if (address == marker.range.end && marker.type >= Flag) { + continue; + } + if (address < range.start || address > range.end) { + continue; + } + const qreal x = xForAddress(address); + const int pixel = qRound(x); + if (std::abs(pixel - lastPixel[marker.type]) < 2) { + continue; + } + lastPixel[marker.type] = pixel; + QColor line = color; + line.setAlpha(95); + p.setPen(QPen(line, 1, marker.type >= Flag ? Qt::DotLine : Qt::DashLine)); + p.drawLine(QPointF(x, plot.top()), QPointF(x, plot.bottom())); + p.setPen(color); + p.drawLine(QPointF(x, plot.top()), QPointF(x, plot.top() + 5)); + } + if (!overview && marker.type <= Map && intersect(marker.range, range).valid()) { + const qreal left = qMax(plot.left(), xForAddress(marker.range.start)); + const qreal right = qMin(plot.right(), xForAddress(marker.range.end)); + if (left >= labelEnd && right - left > 38) { + p.setPen(color); + const QString label + = fontMetrics().elidedText(marker.name, Qt::ElideRight, int(right - left - 6)); + p.drawText(QRectF(left + 3, 4, right - left - 6, 20), Qt::AlignLeft, label); + labelEnd = left + fontMetrics().horizontalAdvance(label) + 10; + } + } + } + + auto highlight = [&](Range selected, bool window) { + selected = intersect(selected, range); + if (!selected.valid()) { + return; + } + const qreal left = xForAddress(selected.start), right = xForAddress(selected.end); + QColor fill = palette().color(QPalette::Highlight); + fill.setAlpha(window ? 35 : 55); + p.fillRect(QRectF(left, plot.top(), qMax(1.0, right - left), plot.height()), fill); + p.setPen(QPen(palette().color(QPalette::Highlight), window ? 2 : 1)); + p.drawRect(QRectF(left, plot.top(), qMax(1.0, right - left), plot.height())); + if (window) { + p.fillRect(QRectF(left - 2, plot.center().y() - 7, 4, 14), text); + p.fillRect(QRectF(right - 2, plot.center().y() - 7, 4, 14), text); + } + }; + if (overview) { + const QColor shade(0, 0, 0, 65); + const qreal left = xForAddress(visibleRange.start), right = xForAddress(visibleRange.end); + p.fillRect( + QRectF(plot.left(), plot.top(), qMax(0.0, left - plot.left()), plot.height()), shade); + p.fillRect(QRectF(right, plot.top(), qMax(0.0, plot.right() - right), plot.height()), shade); + highlight(visibleRange, true); + } + highlight(drag == Select && dragSelection.valid() ? dragSelection : selection, false); + if (range.contains(seekAddress)) { + const qreal x = xForAddress(seekAddress); + p.setPen(QPen(palette().color(QPalette::Highlight), 2)); + p.drawLine(QPointF(x, plot.top()), QPointF(x, plot.bottom())); + p.setBrush(palette().color(QPalette::Highlight)); + p.drawPolygon(QPolygonF( + QVector{ + QPointF(x - 4, plot.top()), QPointF(x + 4, plot.top()), QPointF(x, plot.top() + 6)})); + } + if (!overview && hoverX >= plot.left() && hoverX <= plot.right()) { + p.setPen(QPen(text, 1, Qt::DotLine)); + p.drawLine(QPointF(hoverX, plot.top()), QPointF(hoverX, plot.bottom())); + } + p.setPen(text); + const int addressWidth = fontMetrics().horizontalAdvance(RAddressString(range.end - 1)); + const int labels = qBound(1, int(plot.width() / (addressWidth + 24)), 4); + for (int i = 0; i <= labels; ++i) { + const qreal x = plot.left() + plot.width() * i / labels; + const QString label = fontMetrics().elidedText( + RAddressString(addressAt(x)), Qt::ElideMiddle, int(plot.width() / (labels + 1))); + const qreal width = fontMetrics().horizontalAdvance(label) + 2; + const qreal left + = qBound(plot.left(), x - width / 2, qMax(plot.left(), plot.right() - width)); + p.drawText(QRectF(left, plot.bottom() + 4, width, 20), Qt::AlignLeft, label); + } + const bool hasData = std::any_of(samples.cbegin(), samples.cend(), [](const Sample &sample) { + return sample.bytes > 0; + }); + if (!overview && !hasData) { + p.drawText(plot, Qt::AlignCenter, loading ? tr("Analyzing…") : tr("No readable data")); + } +} + +void DataAnalysisView::zoom(double factor, RVA anchor) +{ + if (!domain.valid() || !visibleRange.valid()) { + return; + } + const long double scaled = (long double) visibleRange.size() * factor; + const RVA size = scaled >= domain.size() ? domain.size() : qMax(1, RVA(scaled)); + anchor = qBound(visibleRange.start, anchor, visibleRange.end - 1); + const long double ratio = (long double) (anchor - visibleRange.start) / visibleRange.size(); + const RVA before = qMin(size - 1, RVA(ratio * size)); + const RVA start = anchor - qMin(anchor - domain.start, before); + const RVA clamped = qMin(start, domain.end - size); + emit rangeRequested(clamped, clamped + size); +} + +void DataAnalysisView::pan(int direction) +{ + if (!visibleRange.valid() || !domain.valid()) { + return; + } + const RVA step = qMax(1, visibleRange.size() / 4); + const RVA start = direction < 0 + ? visibleRange.start - qMin(step, visibleRange.start - domain.start) + : visibleRange.start + qMin(step, domain.end - visibleRange.end); + emit rangeRequested(start, start + visibleRange.size()); +} + +void DataAnalysisView::mousePressEvent(QMouseEvent *event) +{ + cancelTooltip(); + if (event->button() != Qt::LeftButton || !axisRange().valid() + || !plotRect().contains(event->pos())) { + return; + } + setFocus(); + pressPosition = event->pos(); + pressAddress = addressAt(event->pos().x()); + pressRange = visibleRange; + dragSelection = {}; + if (overview) { + if (std::abs(event->pos().x() - xForAddress(visibleRange.start)) < 7) { + drag = LeftEdge; + } else if (std::abs(event->pos().x() - xForAddress(visibleRange.end)) < 7) { + drag = RightEdge; + } else { + drag = Move; + if (!visibleRange.contains(pressAddress)) { + pressRange = centered(domain, pressAddress, visibleRange.size()); + emit rangeRequested(pressRange.start, pressRange.end); + } + } + } else { + drag = Select; + } + event->accept(); +} + +void DataAnalysisView::updateDrag(QPoint position) +{ + const RVA address = addressAt(position.x()); + if (drag == Select) { + dragSelection = {qMin(address, pressAddress), qMax(address, pressAddress) + 1}; + update(); + } else if (drag == LeftEdge) { + emit rangeRequested(qMin(address, pressRange.end - 1), pressRange.end); + } else if (drag == RightEdge) { + emit rangeRequested(pressRange.start, qMax(address + 1, pressRange.start + 1)); + } else if (drag == Move) { + const RVA start = address >= pressAddress + ? pressRange.start + + qMin(address - pressAddress, domain.end - pressRange.end) + : pressRange.start + - qMin(pressAddress - address, pressRange.start - domain.start); + emit rangeRequested(start, start + pressRange.size()); + } +} + +void DataAnalysisView::mouseMoveEvent(QMouseEvent *event) +{ + const bool moved = hoverPosition != event->pos(); + hoverPosition = event->pos(); + hoverX = event->pos().x(); + if (drag != None) { + updateDrag(event->pos()); + } else if (plotRect().contains(event->pos())) { + if (moved) { + cancelTooltip(); + tooltipTimer.start(); + } + const bool edge = overview + && (std::abs(hoverX - xForAddress(visibleRange.start)) < 7 + || std::abs(hoverX - xForAddress(visibleRange.end)) < 7); + setCursor(edge ? Qt::SizeHorCursor : overview ? Qt::OpenHandCursor : Qt::CrossCursor); + } else { + cancelTooltip(); + } + update(); +} + +void DataAnalysisView::mouseReleaseEvent(QMouseEvent *event) +{ + if (event->button() != Qt::LeftButton || drag == None) { + return; + } + updateDrag(event->pos()); + if (drag == Select) { + if ((event->pos() - pressPosition).manhattanLength() < QApplication::startDragDistance()) { + emit selectionRequested(0, 0); + emit seekRequested(nearestMarker(event->pos().x())); + } else { + emit selectionRequested(dragSelection.start, dragSelection.end); + } + } + drag = None; + dragSelection = {}; + update(); +} + +void DataAnalysisView::mouseDoubleClickEvent(QMouseEvent *event) +{ + cancelTooltip(); + if (event->button() == Qt::LeftButton && axisRange().valid()) { + drag = None; + if (overview) { + emit rangeRequested(domain.start, domain.end); + } else { + zoom(0.5, addressAt(event->pos().x())); + } + } +} + +void DataAnalysisView::wheelEvent(QWheelEvent *event) +{ + cancelTooltip(); + if (!event->angleDelta().y()) { + event->ignore(); + return; + } + if (event->modifiers() & Qt::ShiftModifier) { + pan(event->angleDelta().y() > 0 ? -1 : 1); + } else { +#if QT_VERSION >= QT_VERSION_CHECK(5, 14, 0) + const qreal x = event->position().x(); +#else + const qreal x = event->posF().x(); +#endif + zoom(event->angleDelta().y() > 0 ? 0.5 : 2.0, addressAt(x)); + } + event->accept(); +} + +void DataAnalysisView::keyPressEvent(QKeyEvent *event) +{ + cancelTooltip(); + const RVA center = visibleRange.start + visibleRange.size() / 2; + switch (event->key()) { + case Qt::Key_Plus: + case Qt::Key_Equal: + zoom(0.5, center); + break; + case Qt::Key_Minus: + zoom(2, center); + break; + case Qt::Key_Left: + pan(-1); + break; + case Qt::Key_Right: + pan(1); + break; + case Qt::Key_Home: + emit rangeRequested(domain.start, domain.end); + break; + case Qt::Key_Escape: + drag = None; + emit selectionRequested(0, 0); + update(); + break; + default: + QWidget::keyPressEvent(event); + return; + } + event->accept(); +} + +void DataAnalysisView::leaveEvent(QEvent *) +{ + cancelTooltip(); + hoverX = -1; + unsetCursor(); + QToolTip::hideText(); + update(); +} + +void DataAnalysisView::cancelTooltip() +{ + tooltipTimer.stop(); + QToolTip::hideText(); +} + +void DataAnalysisView::hideEvent(QHideEvent *event) +{ + cancelTooltip(); + QWidget::hideEvent(event); +} + +RVA DataAnalysisView::nearestMarker(qreal x) const +{ + qreal distance = 5; + RVA result = addressAt(x); + for (const auto &marker : markers) { + if (markerEnabled[marker.type] && axisRange().contains(marker.range.start)) { + const qreal candidate = std::abs(x - xForAddress(marker.range.start)); + if (candidate < distance) { + distance = candidate; + result = marker.range.start; + } + } + } + return result; +} + +void DataAnalysisView::showTooltip() +{ + if (!isVisible() || drag != None || !plotRect().contains(hoverPosition)) { + return; + } + const RVA address = addressAt(hoverPosition.x()); + if (address == RVA_INVALID) { + return; + } + QString tip = RAddressString(address); + for (const auto &sample : samples) { + if (!sample.range.contains(address)) { + continue; + } + tip += tr("\nBucket: %1 – %2 (%3 bytes)\nRead %4 of %5 mapped bytes") + .arg(RAddressString(sample.range.start), RAddressString(sample.range.end - 1)) + .arg(sample.range.size()) + .arg(sample.bytes) + .arg(sample.covered); + if (sample.bytes) { + for (int metric : metrics) { + tip += QString("\n%1: %2") + .arg(metricName(metric), metricValue(metric, sample.values[metric])); + } + } else { + tip += tr("\nNo readable data"); + } + break; + } + int found = 0; + for (const auto &marker : markers) { + if (markerEnabled[marker.type] + && (marker.range.contains(address) + || std::abs(xForAddress(marker.range.start) - hoverPosition.x()) < 5)) { + tip += QString("\n%1: %2 @ %3") + .arg( + markerName(marker.type), + marker.name.left(160), + RAddressString(marker.range.start)); + if (++found == 12) { + break; + } + } + } + QToolTip::showText(mapToGlobal(hoverPosition), tip.toHtmlEscaped().replace('\n', "
"), this); +} + +void DataAnalysisView::contextMenuEvent(QContextMenuEvent *event) +{ + cancelTooltip(); + if (axisRange().valid()) { + emit contextRequested(nearestMarker(event->pos().x()), event->globalPos()); + event->accept(); + } +} diff --git a/src/widgets/DataAnalysisView.h b/src/widgets/DataAnalysisView.h new file mode 100644 index 00000000..6d4636dd --- /dev/null +++ b/src/widgets/DataAnalysisView.h @@ -0,0 +1,86 @@ +#ifndef DATAANALYSISVIEW_H +#define DATAANALYSISVIEW_H + +#include "common/DataAnalysis.h" + +#include +#include +#include + +class DataAnalysisView : public QWidget +{ + Q_OBJECT + +public: + enum MarkerType { Section, Segment, Map, Flag, Comment, MarkerCount }; + enum Style { Lines, Areas, Bars }; + struct Marker + { + DataAnalysis::Range range; + QString name; + MarkerType type; + }; + + explicit DataAnalysisView(bool overview, QWidget *parent = nullptr); + static QString metricName(int metric); + static QString metricDescription(int metric); + static QString metricValue(int metric, double value); + static QString markerName(MarkerType type); + + DataAnalysis::Range domain; + DataAnalysis::Range visibleRange; + DataAnalysis::Range selection; + QVector samples; + QVector markers; + QVector metrics = {DataAnalysis::Entropy, DataAnalysis::Printable, DataAnalysis::Zero}; + std::array colors; + std::array markerEnabled = {true, false, false, true, true}; + RVA seekAddress = RVA_INVALID; + Style style = Areas; + bool separateTracks = false; + bool loading = false; + + QRectF plotRect() const; + RVA addressAt(qreal x) const; + qreal xForAddress(RVA address) const; + void zoom(double factor, RVA anchor); + void pan(int direction); + +signals: + void seekRequested(RVA address); + void selectionRequested(RVA start, RVA end); // Half-open. + void rangeRequested(RVA start, RVA end); // Half-open. + void contextRequested(RVA address, QPoint globalPosition); + +protected: + void paintEvent(QPaintEvent *) override; + void mousePressEvent(QMouseEvent *) override; + void mouseMoveEvent(QMouseEvent *) override; + void mouseReleaseEvent(QMouseEvent *) override; + void mouseDoubleClickEvent(QMouseEvent *) override; + void wheelEvent(QWheelEvent *) override; + void keyPressEvent(QKeyEvent *) override; + void leaveEvent(QEvent *) override; + void hideEvent(QHideEvent *) override; + void contextMenuEvent(QContextMenuEvent *) override; + +private: + bool overview; + enum Drag { None, Select, Move, LeftEdge, RightEdge }; + Drag drag = None; + QPoint pressPosition; + RVA pressAddress = 0; + DataAnalysis::Range pressRange; + DataAnalysis::Range dragSelection; + int hoverX = -1; + QTimer tooltipTimer; + QPoint hoverPosition; + + DataAnalysis::Range axisRange() const; + RVA nearestMarker(qreal x) const; + void updateDrag(QPoint position); + void showTooltip(); + void cancelTooltip(); +}; + +#endif diff --git a/src/widgets/DataAnalysisWidget.cpp b/src/widgets/DataAnalysisWidget.cpp new file mode 100644 index 00000000..5a37afc4 --- /dev/null +++ b/src/widgets/DataAnalysisWidget.cpp @@ -0,0 +1,966 @@ +#include "DataAnalysisWidget.h" +#include "common/IaitoSeekable.h" +#include "core/MainWindow.h" +#include "dialogs/CommentsDialog.h" +#include "dialogs/FlagDialog.h" +#include "ui_DataAnalysisWidget.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +using namespace DataAnalysis; + +namespace { +RVA number(const QJsonObject &object, const char *key) +{ + return object[QLatin1String(key)].toVariant().toULongLong(); +} + +QByteArray readData(RVA address, int length) +{ + auto core = Core()->core(); + QByteArray bytes(length, '\0'); + int count = 0; + while (count < length) { + // nread distinguishes short/failed reads from actual 0xff bytes. A read + // can end at a submap boundary even when the following map is readable. + const int n = r_io_nread_at( + core->io, + address + count, + reinterpret_cast(bytes.data()) + count, + length - count); + if (n <= 0) { + break; + } + count += n; + } + bytes.resize(count); + return bytes; +} +} // namespace + +DataAnalysisWidget::DataAnalysisWidget(MainWindow *main) + : AddressableDockWidget(main) + , ui(new Ui::DataAnalysisWidget) +{ + setObjectName("DataAnalysisWidget"); + updateWindowTitle(); + auto *contents = new QWidget(this); + ui->setupUi(contents); + ui->legend->setToolButtonStyle(Qt::ToolButtonTextBesideIcon); + setWidget(contents); + plot = new DataAnalysisView(false, contents); + plot->setObjectName("analysisPlot"); + overview = new DataAnalysisView(true, contents); + overview->setObjectName("analysisOverview"); + overview->setFixedHeight(80); + auto *scroll = new QScrollArea(contents); + scroll->setWidgetResizable(true); + scroll->setFrameShape(QFrame::NoFrame); + scroll->setWidget(plot); + ui->plotLayout->addWidget(scroll, 1); + ui->plotLayout->addWidget(overview); + ui->verticalLayout->setStretch(3, 1); + setupActions(); + updateColors(); + + refreshDeferrer = createRefreshDeferrer([this]() { reloadTimer.start(); }); + reloadTimer.setSingleShot(true); + reloadTimer.setInterval(100); + rangeTimer.setSingleShot(true); + rangeTimer.setInterval(80); + sampleTimer.setInterval(0); + connect(&reloadTimer, &QTimer::timeout, this, &DataAnalysisWidget::reload); + connect(&rangeTimer, &QTimer::timeout, this, [this]() { + if (!samplingOverview) { + beginSampling(false); + } + }); + connect(&sampleTimer, &QTimer::timeout, this, &DataAnalysisWidget::sampleBatch); + connect(plot, &DataAnalysisView::seekRequested, seekable, &IaitoSeekable::seek); + connect(plot, &DataAnalysisView::selectionRequested, this, [this](RVA start, RVA end) { + selectRange({start, end}, true); + }); + for (auto *view : {plot, overview}) { + connect(view, &DataAnalysisView::rangeRequested, this, [this](RVA start, RVA end) { + setRange({start, end}); + }); + connect(view, &DataAnalysisView::contextRequested, this, &DataAnalysisWidget::showContextMenu); + } + connect(seekable, &IaitoSeekable::seekableSeekChanged, this, &DataAnalysisWidget::onSeekChanged); + connect(Core(), &IaitoCore::addressRangeSelectionChanged, this, [this](RVA start, RVA end) { + if (!publishingSelection && seekable->isSynchronized() + && Config()->getAddressRangeSelectionSyncEnabled()) { + selectRange( + start != RVA_INVALID && end != RVA_INVALID && end >= start ? Range{start, end + 1} + : Range{}, + false); + } + }); + connect(Core(), &IaitoCore::refreshAll, this, &DataAnalysisWidget::scheduleReload); + connect(Core(), &IaitoCore::codeRebased, this, &DataAnalysisWidget::scheduleReload); + connect(Core(), &IaitoCore::ioModeChanged, this, &DataAnalysisWidget::scheduleReload); + connect(Core(), &IaitoCore::ioCacheChanged, this, &DataAnalysisWidget::scheduleReload); + connect(Core(), &IaitoCore::flagsChanged, this, &DataAnalysisWidget::scheduleReload); + connect(Core(), &IaitoCore::commentsChanged, this, &DataAnalysisWidget::scheduleReload); + connect(Core(), &IaitoCore::refreshCodeViews, this, &DataAnalysisWidget::scheduleReload); + connect(Core(), &IaitoCore::instructionChanged, this, &DataAnalysisWidget::scheduleReload); + connect(Core(), &IaitoCore::debugTaskStateChanged, this, &DataAnalysisWidget::scheduleReload); + connect(Config(), &Configuration::colorsUpdated, this, &DataAnalysisWidget::updateColors); + connect(this, &QDockWidget::visibilityChanged, this, [this](bool visible) { + if (!visible) { + metadataDirty |= sampleTimer.isActive() || rangeTimer.isActive(); + sampleTimer.stop(); + rangeTimer.stop(); + reloadTimer.stop(); + } else if (metadataDirty) { + scheduleReload(); + } + }); + scheduleReload(); +} + +DataAnalysisWidget::~DataAnalysisWidget() = default; + +QWidget *DataAnalysisWidget::widgetToFocusOnRaise() +{ + return plot; +} + +void DataAnalysisWidget::setupActions() +{ + auto menuButton = [this](const QString &label, QMenu *menu) { + auto *button = new QToolButton(ui->toolbar); + button->setText(label); + button->setPopupMode(QToolButton::InstantPopup); + button->setMenu(menu); + ui->toolbar->addWidget(button); + }; + auto *ranges = new QMenu(this); + ranges->addAction(tr("All data"), this, [this]() { setRange(plot->domain); }); + ranges->addAction(tr("Current section"), this, [this]() { + fitMarker(DataAnalysisView::Section); + }); + ranges->addAction(tr("Current segment"), this, [this]() { + fitMarker(DataAnalysisView::Segment); + }); + ranges->addAction(tr("Current map"), this, [this]() { fitMarker(DataAnalysisView::Map); }); + ranges->addAction(tr("Current block"), this, [this]() { + const int size = Core()->core()->blocksize; + setRange(sizedRange(seekable->getOffset(), qMax(1, size))); + }); + selectionAction + = ranges->addAction(tr("Fit selection"), this, &DataAnalysisWidget::fitSelection); + selectionAction->setEnabled(false); + ranges->addSeparator(); + ranges->addAction(tr("Edit range…"), this, &DataAnalysisWidget::editRange); + menuButton(tr("Range"), ranges); + auto *algorithms = new QMenu(this); + for (int i = 0; i < Count; ++i) { + auto *action = algorithms->addAction(DataAnalysisView::metricName(i)); + action->setCheckable(true); + action->setChecked(plot->metrics.contains(i)); + metricActions[i] = action; + action->setToolTip(DataAnalysisView::metricDescription(i)); + auto *entry = ui->legend->addAction(DataAnalysisView::metricName(i)); + entry->setToolTip( + DataAnalysisView::metricDescription(i) + tr("\nClick to configure this algorithm.")); + legendActions[i] = entry; + connect(entry, &QAction::triggered, this, [this, i]() { configureMetric(i); }); + connect(action, &QAction::toggled, this, &DataAnalysisWidget::updateMetrics); + } + menuButton(tr("Algorithms"), algorithms); + ui->toolbar + ->addAction( + tr("+"), + this, + [this]() { plot->zoom(0.5, plot->visibleRange.start + plot->visibleRange.size() / 2); }) + ->setToolTip(tr("Zoom in (+)")); + ui->toolbar + ->addAction( + tr("−"), + this, + [this]() { plot->zoom(2, plot->visibleRange.start + plot->visibleRange.size() / 2); }) + ->setToolTip(tr("Zoom out (-)")); + ui->toolbar->addAction(tr("Fit"), this, [this]() { setRange(plot->domain); }) + ->setToolTip(tr("Fit all data (Home)")); + followAction = ui->toolbar->addAction(tr("Follow")); + followAction->setCheckable(true); + followAction->setChecked(true); + followAction->setToolTip(tr("Pan to keep the current disassembly or hexdump address visible")); + connect(followAction, &QAction::toggled, this, [this](bool checked) { + if (checked) { + onSeekChanged(seekable->getOffset()); + } + }); + + optionsMenu = new QMenu(this); + tracksAction = optionsMenu->addAction(tr("Separate algorithm tracks")); + tracksAction->setCheckable(true); + connect(tracksAction, &QAction::toggled, this, [this](bool checked) { + plot->separateTracks = checked; + plot->setMinimumHeight(checked ? qMax(180, int(plot->metrics.size()) * 90) : 180); + plot->update(); + }); + auto *styles = optionsMenu->addMenu(tr("Drawing style")); + auto *styleGroup = new QActionGroup(styles); + const QStringList styleNames = {tr("Lines"), tr("Filled lines"), tr("Vertical bars")}; + for (int i = 0; i < styleNames.size(); ++i) { + auto *action = styles->addAction(styleNames[i]); + styleGroup->addAction(action); + action->setCheckable(true); + action->setData(i); + action->setChecked(i == plot->style); + connect(action, &QAction::triggered, this, [this, i]() { + plot->style = static_cast(i); + plot->update(); + }); + } + styles->setObjectName("analysisStyles"); + auto *colors = optionsMenu->addMenu(tr("Colors")); + auto *colorGroup = new QActionGroup(colors); + const QStringList modes + = {tr("Greyscale"), tr("Rainbow"), tr("Theme single color"), tr("Theme palette")}; + for (int i = 0; i < modes.size(); ++i) { + auto *action = colors->addAction(modes[i]); + colorGroup->addAction(action); + action->setCheckable(true); + action->setData(i); + action->setChecked(i == colorMode); + connect(action, &QAction::triggered, this, [this, i]() { + colorMode = i; + updateColors(); + }); + } + colors->setObjectName("analysisColors"); + colors->addSeparator(); + auto *custom = colors->addMenu(tr("Customize algorithm colors")); + for (int i = 0; i < Count; ++i) { + custom->addAction(DataAnalysisView::metricName(i), this, [this, i]() { + const QColor color + = QColorDialog::getColor(plot->colors[i], this, DataAnalysisView::metricName(i)); + if (color.isValid()) { + customColors[i] = color; + updateColors(); + } + }); + } + custom->addSeparator(); + custom->addAction(tr("Reset custom colors"), this, [this]() { + customColors.fill(QColor()); + updateColors(); + }); + auto *markers = optionsMenu->addMenu(tr("Markers")); + markers->setObjectName("analysisMarkers"); + for (int i = 0; i < DataAnalysisView::MarkerCount; ++i) { + auto *action = markers->addAction( + DataAnalysisView::markerName(static_cast(i))); + action->setCheckable(true); + action->setData(i); + action->setChecked(plot->markerEnabled[i]); + connect(action, &QAction::toggled, this, [this, i](bool checked) { + plot->markerEnabled[i] = overview->markerEnabled[i] = checked; + plot->update(); + overview->update(); + }); + } + auto *samples = optionsMenu->addMenu(tr("Resolution (buckets)")); + samples->setObjectName("analysisResolution"); + auto *sampleGroup = new QActionGroup(samples); + for (int n : {32, 64, 128, 256, 512, 1024, 2048, 4096}) { + auto *action = samples->addAction(QString::number(n)); + sampleGroup->addAction(action); + action->setCheckable(true); + action->setData(n); + action->setChecked(n == resolution); + connect(action, &QAction::triggered, this, [this, n]() { + resolution = n; + beginSampling(samplingOverview); + }); + } + optionsMenu->addSeparator(); + optionsMenu->addAction(&syncAction); + optionsMenu->addAction(tr("Find marker…"), this, &DataAnalysisWidget::findMarker); + optionsMenu->addAction(tr("Refresh"), this, &DataAnalysisWidget::scheduleReload); + exportAction = optionsMenu->addAction( + tr("Export visible samples as CSV…"), this, &DataAnalysisWidget::exportCsv); + exportAction->setEnabled(false); + menuButton(tr("Options"), optionsMenu); + ui->legend->setToolTip( + tr("Click a legend entry to configure its color and visibility." + " Overlaid values use a 0–100% scale; entropy is divided by 8." + " Separate tracks use native units.")); + updateMetrics(); +} + +void DataAnalysisWidget::updateMetrics() +{ + plot->metrics.clear(); + for (int i = 0; i < Count; ++i) { + legendActions[i]->setVisible(metricActions[i]->isChecked()); + if (metricActions[i]->isChecked()) { + plot->metrics.append(i); + } + } + overview->metrics = plot->metrics; + plot->setMinimumHeight(plot->separateTracks ? qMax(180, int(plot->metrics.size()) * 90) : 180); + plot->update(); +} + +void DataAnalysisWidget::updateColors() +{ + const char *keys[] + = {"gui.navbar.code", + "gui.navbar.str", + "gui.navbar.data", + "num", + "flag", + "comment", + "flow", + "func_var", + "reg", + "invalid", + "other"}; + QColor base = Config()->getColor("gui.navbar.code"); + if (!base.isValid()) { + base = QColor(120, 150, 230); + } + QSet used; + for (int i = 0; i < Count; ++i) { + QColor color; + switch (colorMode) { + case 0: + color = QColor::fromHsv(0, 0, 85 + i * 16); + break; + case 1: + color = QColor::fromHsv((270 + i * 137) % 360, 165, 220); + break; + case 2: + color = QColor::fromHsv(qMax(0, base.hue()), 70 + i * 16, 235 - i * 9); + break; + default: + color = Config()->getColor(keys[i]); + if (!color.isValid() || used.contains(color.rgb()) + || color == palette().color(QPalette::Base)) { + color = QColor::fromHsv((270 + i * 137) % 360, 155, 210); + } + break; + } + if (customColors[i].isValid()) { + color = customColors[i]; + } + used.insert(color.rgb()); + plot->colors[i] = overview->colors[i] = color; + QPixmap swatch(12, 12); + swatch.fill(color); + metricActions[i]->setIcon(QIcon(swatch)); + legendActions[i]->setIcon(QIcon(swatch)); + } + plot->update(); + overview->update(); +} + +void DataAnalysisWidget::configureMetric(int metric) +{ + QDialog dialog(this); + dialog.setObjectName("analysisMetricSettings"); + dialog.setWindowTitle(tr("%1 settings").arg(DataAnalysisView::metricName(metric))); + QFormLayout layout(&dialog); + QLabel description(DataAnalysisView::metricDescription(metric)); + description.setWordWrap(true); + description.setMaximumWidth(420); + QCheckBox visible(tr("Show this algorithm")); + visible.setObjectName("metricVisible"); + visible.setChecked(metricActions[metric]->isChecked()); + QColor chosen = customColors[metric]; + QPushButton colorButton(tr("Change color…")); + colorButton.setIcon(legendActions[metric]->icon()); + QPushButton resetButton(tr("Use palette color")); + QDialogButtonBox buttons(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + layout.addRow(&description); + layout.addRow(tr("Color:"), &colorButton); + layout.addRow(&resetButton); + layout.addRow(&visible); + layout.addRow(&buttons); + connect(&colorButton, &QPushButton::clicked, &dialog, [&]() { + const QColor color = QColorDialog::getColor( + chosen.isValid() ? chosen : plot->colors[metric], + &dialog, + DataAnalysisView::metricName(metric)); + if (color.isValid()) { + chosen = color; + QPixmap swatch(12, 12); + swatch.fill(color); + colorButton.setIcon(QIcon(swatch)); + colorButton.setText(color.name()); + } + }); + connect(&resetButton, &QPushButton::clicked, &dialog, [&]() { + chosen = QColor(); + colorButton.setIcon(QIcon()); + colorButton.setText(tr("Palette default")); + }); + connect(&buttons, &QDialogButtonBox::accepted, &dialog, &QDialog::accept); + connect(&buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + if (dialog.exec() == QDialog::Accepted) { + customColors[metric] = chosen; + metricActions[metric]->setChecked(visible.isChecked()); + updateColors(); + } +} + +void DataAnalysisWidget::scheduleReload() +{ + metadataDirty = true; + if (refreshDeferrer->attemptRefresh(nullptr)) { + sampleTimer.stop(); + reloadTimer.start(); + } +} + +void DataAnalysisWidget::loadMetadata() +{ + using View = DataAnalysisView; + virtualAddresses = Core()->getConfigb("io.va"); + readable.clear(); + plot->markers.clear(); + auto addMarker = [this](Range range, const QString &name, View::MarkerType type) { + if (range.valid()) { + plot->markers.append({range, name, type}); + } + }; + for (const auto &value : Core()->cmdj("omj").array()) { + const QJsonObject map = value.toObject(); + const RVA start = number(map, "from"), last = number(map, "to"); + const Range range = last >= start ? Range{start, last == RVA_MAX ? RVA_MAX : last + 1} + : Range{}; + if (virtualAddresses) { + addMarker(range, map["name"].toString(), View::Map); + if (range.valid() && map["perm"].toString().contains('r')) { + readable.append(range); + } + } + } + if (!virtualAddresses) { + auto core = Core()->core(); + const RVA size = r_io_size(core->io); + if (size != RVA_MAX && size) { + readable.append({0, size}); + } + } + readable = mergeRanges(readable); + const Range domain = readable.isEmpty() ? Range{} + : Range{readable.first().start, readable.last().end}; + const bool changed = domain != plot->domain; + plot->domain = overview->domain = domain; + if (changed || !plot->visibleRange.valid()) { + plot->visibleRange = overview->visibleRange = domain; + } + for (const auto &entry : + {qMakePair(QString("iSj"), View::Section), qMakePair(QString("iSSj"), View::Segment)}) { + const QJsonDocument document = Core()->cmdj(entry.first); + const QJsonArray objects + = document.isArray() + ? document.array() + : document.object()[entry.second == View::Section ? "sections" : "segments"] + .toArray(); + for (const auto &value : objects) { + const QJsonObject object = value.toObject(); + const RVA start = number(object, virtualAddresses ? "vaddr" : "paddr"); + const RVA size = number(object, virtualAddresses ? "vsize" : "size"); + addMarker(sizedRange(start, size), object["name"].toString(), entry.second); + } + } + // @F temporarily selects all flag spaces without changing the user's space. + for (const auto &value : Core()->cmdj("fj @F:*").array()) { + const QJsonObject flag = value.toObject(); + const RVA address = number(flag, flag.contains("offset") ? "offset" : "addr"); + addMarker( + sizedRange(address, qMax(1, number(flag, "size"))), + flag["name"].toString(), + View::Flag); + } + for (const auto &comment : Core()->getAllComments("CCu")) { + addMarker(sizedRange(comment.offset, 1), comment.name, View::Comment); + } + std::stable_sort( + plot->markers.begin(), + plot->markers.end(), + [](const View::Marker &a, const View::Marker &b) { return a.range.start < b.range.start; }); + overview->markers = plot->markers; + metadataDirty = false; +} + +void DataAnalysisWidget::reload() +{ + if (!refreshDeferrer->attemptRefresh(nullptr) || Core()->isDebugTaskInProgress()) { + return; + } + sampleTimer.stop(); + rangeTimer.stop(); + loadMetadata(); + plot->seekAddress = overview->seekAddress = seekable->getOffset(); + if (requestedRange.valid()) { + const Range requested = intersect(requestedRange, plot->domain); + if (requested.valid()) { + plot->visibleRange = overview->visibleRange = requested; + } + requestedRange = {}; + } + if (seekable->isSynchronized() && Config()->getAddressRangeSelectionSyncEnabled() + && Core()->hasAddressRangeSelection()) { + selectRange( + {Core()->getAddressRangeSelectionStart(), Core()->getAddressRangeSelectionEnd() + 1}, + false); + } + plot->samples.clear(); + overview->samples.clear(); + beginSampling(true); +} + +void DataAnalysisWidget::beginSampling(bool mini) +{ + rangeTimer.stop(); + sampleTimer.stop(); + if (!isVisibleToUser()) { + metadataDirty = true; + refreshDeferrer->attemptRefresh(nullptr); + return; + } + samplingOverview = mini; + pendingRange = mini ? plot->domain : plot->visibleRange; + pending.clear(); + nextBucket = 0; + plot->loading = pendingRange.valid(); + exportAction->setEnabled(false); + if (pendingRange.valid()) { + const int count = int(qMin(mini ? 256 : resolution, pendingRange.size())); + pending.resize(count); + sampleTimer.start(); + } + updateLabels(); + plot->update(); + overview->update(); +} + +void DataAnalysisWidget::sampleBatch() +{ + if (!isVisibleToUser() || Core()->isDebugTaskInProgress()) { + sampleTimer.stop(); + metadataDirty = true; + refreshDeferrer->attemptRefresh(nullptr); + return; + } + QElapsedTimer timer; + timer.start(); + // Yield to navigation and painting every few milliseconds. A range change + // cancels the old job; no stale worker result can overwrite the new range. + do { + pending[nextBucket] + = sample(bucket(pendingRange, nextBucket, pending.size()), readable, readData); + ++nextBucket; + } while (nextBucket < pending.size() && timer.elapsed() < 6); + if (nextBucket < pending.size()) { + ui->statusLabel->setText(tr("Analyzing %1… %2%") + .arg(samplingOverview ? tr("overview") : tr("visible range")) + .arg(nextBucket * 100 / pending.size())); + return; + } + sampleTimer.stop(); + if (samplingOverview) { + overview->samples = pending; + overview->update(); + beginSampling(false); + } else { + plot->samples = pending; + plot->loading = false; + exportAction->setEnabled(!plot->samples.isEmpty()); + updateLabels(); + plot->update(); + } +} + +void DataAnalysisWidget::setRange(Range range) +{ + range = intersect(range, plot->domain); + if (!range.valid() || range == plot->visibleRange) { + return; + } + plot->visibleRange = overview->visibleRange = range; + plot->samples.clear(); + plot->loading = true; + exportAction->setEnabled(false); + if (!samplingOverview) { + sampleTimer.stop(); + } + rangeTimer.start(); + updateLabels(); + plot->update(); + overview->update(); +} + +void DataAnalysisWidget::showRange(RVA start, RVA end) +{ + if (start == RVA_INVALID || end == RVA_INVALID || end < start) { + return; + } + requestedRange = {start, end + 1}; + raiseMemoryWidget(); + seekable->seek(start); + if (metadataDirty) { + scheduleReload(); + } else { + setRange(requestedRange); + requestedRange = {}; + } +} + +void DataAnalysisWidget::onSeekChanged(RVA address) +{ + plot->seekAddress = overview->seekAddress = address; + if (followAction->isChecked() && plot->domain.contains(address) + && !plot->visibleRange.contains(address)) { + setRange(centered(plot->domain, address, plot->visibleRange.size())); + } + updateLabels(); + plot->update(); + overview->update(); +} + +void DataAnalysisWidget::selectRange(Range range, bool publish) +{ + plot->selection = overview->selection = range; + selectionAction->setEnabled(range.valid()); + if (publish && seekable->isSynchronized() && Config()->getAddressRangeSelectionSyncEnabled()) { + publishingSelection = true; + if (range.valid()) { + Core()->setAddressRangeSelection(range.start, range.end - 1); + } else { + Core()->clearAddressRangeSelection(); + } + publishingSelection = false; + } + updateLabels(); + plot->update(); + overview->update(); +} + +void DataAnalysisWidget::updateLabels() +{ + const Range range = plot->visibleRange; + if (!range.valid()) { + ui->rangeLabel->setText(tr("No readable data in the current address space")); + ui->statusLabel->setText(tr("Open a file or refresh after mapping data.")); + return; + } + QString text = tr("%1: %2 – %3 · %4 bytes · Cursor %5") + .arg( + virtualAddresses ? tr("Addresses") : tr("File offsets"), + RAddressString(range.start), + RAddressString(range.end - 1)) + .arg(range.size()) + .arg(RAddressString(seekable->getOffset())); + if (plot->selection.valid()) { + text += tr("\nSelection: %1 – %2 · %3 bytes") + .arg( + RAddressString(plot->selection.start), + RAddressString(plot->selection.end - 1)) + .arg(plot->selection.size()); + } + ui->rangeLabel->setText(text); + if (plot->loading) { + ui->statusLabel->setText(tr("Analyzing…")); + } else { + RVA bytes = 0, covered = 0; + for (const auto &sample : plot->samples) { + bytes += sample.bytes; + covered += sample.covered; + } + if (!bytes) { + ui->statusLabel->setText(tr("No readable bytes in the visible range.")); + return; + } + ui->statusLabel->setText( + tr("%1 buckets · %2 / %3 mapped bytes read (%4) · Overview: entropy") + .arg(plot->samples.size()) + .arg(bytes) + .arg(covered) + .arg(bytes == covered ? tr("complete") : tr("sampled or unreadable"))); + ui->statusLabel->setToolTip( + tr("Each bucket reads up to 4096 bytes from centered windows in readable spans." + " Zoom in for complete coverage. Unmapped bytes are excluded." + " Shannon entropy: 0–8 bits/byte. Overlays use normalized percentages.")); + } +} + +void DataAnalysisWidget::fitSelection() +{ + if (plot->selection.valid()) { + setRange(plot->selection); + } +} + +void DataAnalysisWidget::fitMarker(DataAnalysisView::MarkerType type) +{ + Range best; + for (const auto &marker : plot->markers) { + if (marker.type == type && marker.range.contains(seekable->getOffset()) + && (!best.valid() || marker.range.size() < best.size())) { + best = marker.range; + } + } + if (best.valid()) { + setRange(best); + } else { + ui->statusLabel->setText( + tr("No %1 contains the current address.").arg(DataAnalysisView::markerName(type))); + } +} + +void DataAnalysisWidget::editRange() +{ + QDialog dialog(this); + dialog.setWindowTitle(tr("Data analysis range")); + QFormLayout layout(&dialog); + QLineEdit start(RAddressString(plot->visibleRange.start)); + QLineEdit end(RAddressString(plot->visibleRange.valid() ? plot->visibleRange.end - 1 : 0)); + QLabel hint(tr("Enter addresses or radare2 expressions. Both endpoints are inclusive.")); + hint.setWordWrap(true); + QLabel error; + error.setWordWrap(true); + QDialogButtonBox buttons(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + layout.addRow(&hint); + layout.addRow(tr("Start:"), &start); + layout.addRow(tr("End (inclusive):"), &end); + layout.addRow(&error); + layout.addRow(&buttons); + connect(&buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + connect(&buttons, &QDialogButtonBox::accepted, &dialog, [&]() { + RVA first, last; + if (!Core()->tryMath(start.text(), first) || !Core()->tryMath(end.text(), last) + || first == RVA_INVALID || last == RVA_INVALID || first > last) { + error.setText(tr("Enter a valid start and an end greater than or equal to it.")); + return; + } + const Range requested{first, last + 1}; + if (intersect(requested, plot->domain) != requested) { + error.setText( + tr("The range must be within %1 – %2.") + .arg(RAddressString(plot->domain.start), RAddressString(plot->domain.end - 1))); + return; + } + setRange(requested); + dialog.accept(); + }); + dialog.exec(); +} + +void DataAnalysisWidget::findMarker() +{ + QDialog dialog(this); + dialog.setWindowTitle(tr("Find section, map, flag or comment")); + dialog.resize(620, 400); + QVBoxLayout layout(&dialog); + QLineEdit filter; + filter.setPlaceholderText(tr("Filter by name, comment, type or hexadecimal address…")); + QListWidget list; + QLabel hint( + tr("Activate a result to seek. Section, segment and map results also fit the range." + " Showing up to 500 matches.")); + hint.setWordWrap(true); + QDialogButtonBox buttons(QDialogButtonBox::Close); + layout.addWidget(&filter); + layout.addWidget(&list); + layout.addWidget(&hint); + layout.addWidget(&buttons); + // Keep result indices stable while the modal loop receives metadata updates. + const auto markers = plot->markers; + auto fill = [&]() { + list.clear(); + for (int i = 0; i < markers.size(); ++i) { + const auto &marker = markers[i]; + const QString label = QString("%1 · %2 · %3") + .arg( + RAddressString(marker.range.start), + DataAnalysisView::markerName(marker.type), + marker.name); + if (label.contains(filter.text(), Qt::CaseInsensitive)) { + auto *item = new QListWidgetItem(label, &list); + item->setData(Qt::UserRole, i); + if (list.count() >= 500) { + break; + } + } + } + }; + connect(&filter, &QLineEdit::textChanged, &dialog, fill); + connect(&list, &QListWidget::itemActivated, &dialog, [&](QListWidgetItem *item) { + const int index = item->data(Qt::UserRole).toInt(); + if (index < 0 || index >= markers.size()) { + return; + } + const auto marker = markers[index]; + seekable->seek(marker.range.start); + if (marker.type <= DataAnalysisView::Map) { + setRange(marker.range); + } else if (!plot->visibleRange.contains(marker.range.start)) { + setRange(centered(plot->domain, marker.range.start, plot->visibleRange.size())); + } + dialog.accept(); + }); + connect(&buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); + fill(); + dialog.exec(); +} + +void DataAnalysisWidget::showContextMenu(RVA address, QPoint position) +{ + QMenu menu(this); + menu.addAction(RAddressString(address))->setEnabled(false); + menu.addAction(tr("Seek here"), this, [this, address]() { seekable->seek(address); }); + if (mainWindow) { + auto *showIn = mainWindow->createShowInMenu(&menu, address); + showIn->setTitle(tr("Show in")); + menu.addMenu(showIn); + } + menu.addAction(tr("Copy address"), this, [address]() { + QApplication::clipboard()->setText(RAddressString(address)); + }); + menu.addAction(tr("Add or edit comment…"), this, [this, address]() { + CommentsDialog::addOrEditComment(address, this); + }); + const Range selection = plot->selection; + const RVA flagSize = selection.contains(address) ? selection.end - address : 1; + menu.addAction(tr("Add or edit flag…"), this, [this, address, flagSize]() { + FlagDialog dialog(address, flagSize, this); + dialog.exec(); + }); + menu.addSeparator(); + menu.addAction(selectionAction); + menu.addAction(tr("Clear selection"), this, [this]() { selectRange({}, true); }); + menu.addAction(tr("Edit range…"), this, &DataAnalysisWidget::editRange); + menu.addAction(tr("Find marker…"), this, &DataAnalysisWidget::findMarker); + menu.addAction(tr("Fit all data"), this, [this]() { setRange(plot->domain); }); + menu.addSeparator(); + menu.addAction(&syncAction); + menu.exec(position); +} + +void DataAnalysisWidget::exportCsv() +{ + const auto samples = plot->samples; + const auto metrics = plot->metrics; + const QString path = QFileDialog::getSaveFileName( + this, tr("Export data analysis"), QString(), tr("CSV files (*.csv)")); + if (path.isEmpty()) { + return; + } + QSaveFile file(path); + if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) { + QMessageBox::warning(this, tr("Export failed"), file.errorString()); + return; + } + QTextStream out(&file); + out << "start,end_exclusive,bytes_read,mapped_bytes"; + for (int metric : metrics) { + QString name = DataAnalysisView::metricName(metric); + name.replace('"', "\"\""); + out << ",\"" << name << " (normalized 0-1)\""; + } + out << '\n'; + for (const auto &sample : samples) { + out << RAddressString(sample.range.start) << ',' << RAddressString(sample.range.end) << ',' + << sample.bytes << ',' << sample.covered; + for (int metric : metrics) { + out << ','; + if (sample.bytes) { + out << QString::number(sample.values[metric], 'g', 12); + } + } + out << '\n'; + } + out.flush(); + if (!file.commit()) { + QMessageBox::warning(this, tr("Export failed"), file.errorString()); + } +} + +QVariantMap DataAnalysisWidget::serializeViewProprties() +{ + auto properties = AddressableDockWidget::serializeViewProprties(); + QVariantList metrics, colors, markers; + for (int i = 0; i < Count; ++i) { + if (metricActions[i]->isChecked()) { + metrics.append(i); + } + colors.append(customColors[i].isValid() ? customColors[i].name() : QString()); + } + for (bool enabled : plot->markerEnabled) { + markers.append(enabled); + } + properties["metrics"] = metrics; + properties["colors"] = colors; + properties["markers"] = markers; + properties["style"] = int(plot->style); + properties["colorMode"] = colorMode; + properties["resolution"] = resolution; + properties["tracks"] = tracksAction->isChecked(); + properties["follow"] = followAction->isChecked(); + return properties; +} + +void DataAnalysisWidget::deserializeViewProperties(const QVariantMap &properties) +{ + AddressableDockWidget::deserializeViewProperties(properties); + const QVariantList metrics + = properties.value("metrics", QVariantList{Entropy, Printable, Zero}).toList(); + const QVariantList colors = properties.value("colors").toList(); + for (int i = 0; i < Count; ++i) { + metricActions[i]->setChecked(metrics.contains(i)); + customColors[i] = i < colors.size() ? QColor(colors[i].toString()) : QColor(); + } + auto restoreMenu = [this](const char *name, int selected) { + for (auto *action : optionsMenu->findChild(name)->actions()) { + if (action->isCheckable() && action->data().toInt() == selected) { + action->trigger(); + } + } + }; + restoreMenu("analysisStyles", qBound(0, properties.value("style", 1).toInt(), 2)); + restoreMenu("analysisColors", qBound(0, properties.value("colorMode", 3).toInt(), 3)); + int count = properties.value("resolution", 1024).toInt(); + if (!QList{32, 64, 128, 256, 512, 1024, 2048, 4096}.contains(count)) { + count = 1024; + } + restoreMenu("analysisResolution", count); + const QVariantList markers + = properties.value("markers", QVariantList{true, false, false, true, true}).toList(); + for (auto *action : optionsMenu->findChild("analysisMarkers")->actions()) { + const int index = action->data().toInt(); + action->setChecked(index < markers.size() && markers[index].toBool()); + } + tracksAction->setChecked(properties.value("tracks", false).toBool()); + followAction->setChecked(properties.value("follow", true).toBool()); + updateColors(); +} diff --git a/src/widgets/DataAnalysisWidget.h b/src/widgets/DataAnalysisWidget.h new file mode 100644 index 00000000..000965f7 --- /dev/null +++ b/src/widgets/DataAnalysisWidget.h @@ -0,0 +1,81 @@ +#ifndef DATAANALYSISWIDGET_H +#define DATAANALYSISWIDGET_H + +#include "AddressableDockWidget.h" +#include "DataAnalysisView.h" + +#include +#include + +namespace Ui { +class DataAnalysisWidget; +} +class QMenu; + +class DataAnalysisWidget : public AddressableDockWidget +{ + Q_OBJECT + +public: + explicit DataAnalysisWidget(MainWindow *main); + ~DataAnalysisWidget() override; + QVariantMap serializeViewProprties() override; + void deserializeViewProperties(const QVariantMap &properties) override; + + // Reusable entry point for other widgets. Both endpoints are inclusive. + void showRange(RVA start, RVA end); + +protected: + QString getWindowTitle() const override { return tr("Data Analysis"); } + QWidget *widgetToFocusOnRaise() override; + +private: + std::unique_ptr ui; + DataAnalysisView *plot; + DataAnalysisView *overview; + RefreshDeferrer *refreshDeferrer; + QTimer reloadTimer; + QTimer sampleTimer; + QTimer rangeTimer; + QVector readable; + QVector pending; + DataAnalysis::Range pendingRange; + DataAnalysis::Range requestedRange; + int nextBucket = 0; + int resolution = 1024; + int colorMode = 3; + bool samplingOverview = false; + bool virtualAddresses = true; + bool metadataDirty = true; + bool publishingSelection = false; + QAction *followAction; + QAction *tracksAction; + QAction *selectionAction; + QAction *exportAction; + QMenu *optionsMenu; + std::array metricActions; + std::array legendActions; + std::array customColors; + + void setupActions(); + void scheduleReload(); + void reload(); + void loadMetadata(); + void beginSampling(bool mini); + void sampleBatch(); + void setRange(DataAnalysis::Range range); + void updateLabels(); + void updateColors(); + void updateMetrics(); + void configureMetric(int metric); + void onSeekChanged(RVA address); + void selectRange(DataAnalysis::Range range, bool publish); + void fitSelection(); + void editRange(); + void fitMarker(DataAnalysisView::MarkerType type); + void findMarker(); + void showContextMenu(RVA address, QPoint position); + void exportCsv(); +}; + +#endif diff --git a/src/widgets/DataAnalysisWidget.ui b/src/widgets/DataAnalysisWidget.ui new file mode 100644 index 00000000..364b612f --- /dev/null +++ b/src/widgets/DataAnalysisWidget.ui @@ -0,0 +1,28 @@ + + + DataAnalysisWidget + + + 4 + 4 + 4 + 4 + 4 + false + + Open a file to analyze its data + true + Qt::TextSelectableByMouse + + false + + + true + Click to seek · Drag to select · Wheel to zoom · Shift+wheel to pan + Qt::TextSelectableByMouse + + + + + + diff --git a/src/widgets/HexWidget.cpp b/src/widgets/HexWidget.cpp index 757a22b3..78fd21e8 100644 --- a/src/widgets/HexWidget.cpp +++ b/src/widgets/HexWidget.cpp @@ -1655,6 +1655,11 @@ void HexWidget::contextMenuEvent(QContextMenuEvent *event) buildInsertMenu(menu); addSyncOffsetActions(menu); + if (ctx.effectiveAddress != RVA_INVALID && ctx.effectiveSize) { + const RVA size = qMin(ctx.effectiveSize, RVA_MAX - ctx.effectiveAddress); + emit contextMenuAboutToShow(menu, ctx.effectiveAddress, ctx.effectiveAddress + size - 1); + } + menu->exec(event->globalPos()); actionCopy->setEnabled(!selection.isEmpty()); actionCopyAddress->setEnabled(true); diff --git a/src/widgets/HexWidget.h b/src/widgets/HexWidget.h index 83bb03ee..25c42612 100644 --- a/src/widgets/HexWidget.h +++ b/src/widgets/HexWidget.h @@ -331,6 +331,7 @@ public slots: signals: void selectionChanged(Selection selection); void positionChanged(RVA start); + void contextMenuAboutToShow(QMenu *menu, RVA start, RVA end); protected: void paintEvent(QPaintEvent *event) override; diff --git a/src/widgets/HexdumpWidget.cpp b/src/widgets/HexdumpWidget.cpp index 6fb3f87e..9ff60463 100644 --- a/src/widgets/HexdumpWidget.cpp +++ b/src/widgets/HexdumpWidget.cpp @@ -166,6 +166,26 @@ HexdumpWidget::HexdumpWidget(MainWindow *main) this->ui->hexTextView->addAction(&syncAction); + connect( + ui->hexTextView, + &HexWidget::contextMenuAboutToShow, + this, + [this](QMenu *menu, RVA start, RVA end) { + menu->addSeparator(); + menu->addAction( + start == end ? tr("Analyze data here") : tr("Analyze selected data"), + this, + [this, start, end]() { + const RVA last = start == end ? start + + qMin( + qMax(1, Core()->core()->blocksize), + RVA_MAX - start) + - 1 + : end; + mainWindow->showDataAnalysis(start, last); + }); + }); + connect(Config(), &Configuration::fontsUpdated, this, &HexdumpWidget::fontsUpdated); connect(Core(), &IaitoCore::refreshAll, this, [this]() { refresh(); }); connect(Core(), &IaitoCore::refreshCodeViews, this, [this]() { refresh(); }); diff --git a/src/widgets/SectionsWidget.cpp b/src/widgets/SectionsWidget.cpp index 8b17c4c4..5b860b42 100644 --- a/src/widgets/SectionsWidget.cpp +++ b/src/widgets/SectionsWidget.cpp @@ -184,6 +184,20 @@ void SectionsWidget::initSectionsTable() setModels(proxyModel); ui->treeView->sortByColumn(SectionsModel::AddressColumn, Qt::AscendingOrder); + ui->treeView->getItemContextMenu()->addAction(tr("Analyze section data"), this, [this]() { + const QModelIndex index = ui->treeView->currentIndex(); + if (!index.isValid()) { + return; + } + const auto section + = index.data(SectionsModel::SectionDescriptionRole).value(); + const bool va = Core()->getConfigb("io.va"); + const RVA start = va ? section.vaddr : section.paddr; + const RVA size = qMin(va ? section.vsize : section.size, RVA_MAX - start); + if (size) { + mainWindow->showDataAnalysis(start, start + size - 1); + } + }); } void SectionsWidget::initQuickFilter() diff --git a/src/widgets/SegmentsWidget.cpp b/src/widgets/SegmentsWidget.cpp index 6b43ce0a..280f5fd6 100644 --- a/src/widgets/SegmentsWidget.cpp +++ b/src/widgets/SegmentsWidget.cpp @@ -140,6 +140,21 @@ SegmentsWidget::SegmentsWidget(MainWindow *main) ui->quickFilterView->closeFilter(); showCount(false); + ui->treeView->getItemContextMenu()->addAction(tr("Analyze segment data"), this, [this]() { + const QModelIndex index = ui->treeView->currentIndex(); + if (!index.isValid()) { + return; + } + const auto segment + = index.data(SegmentsModel::SegmentDescriptionRole).value(); + const bool va = Core()->getConfigb("io.va"); + const RVA start = va ? segment.vaddr : segment.paddr; + const RVA size = qMin(va ? segment.vsize : segment.size, RVA_MAX - start); + if (size) { + mainWindow->showDataAnalysis(start, start + size - 1); + } + }); + connect(Core(), &IaitoCore::refreshAll, this, &SegmentsWidget::refreshSegments); connect(Core(), &IaitoCore::codeRebased, this, &SegmentsWidget::refreshSegments); connect(Core(), &IaitoCore::commentsChanged, this, [this]() {