feat: value history heatmap, write-fail guard, crash handler hardening

- Value history ring buffer (10 slots) tracks per-node change frequency
- Three-level heatmap: cold (blue), warm (amber), hot (red) via theme
- Heat persists indefinitely (no fade) — shows analysis history
- Calltip on hover shows previous values list
- Old themes auto-derive heat colors from existing palette
- Write failures no longer apply optimistic visual updates
- Crash handler: re-entrancy guard, context dump before risky APIs
This commit is contained in:
IChooseYou
2026-02-16 16:44:46 -07:00
parent e064646c02
commit 5ae9ca0979
12 changed files with 363 additions and 41 deletions

View File

@@ -633,6 +633,52 @@ void RcxController::refresh() {
} }
} }
// Update value history and compute heat levels
// Use the snapshot provider if active; skip entirely if no valid provider
{
const Provider* prov = nullptr;
if (m_snapshotProv)
prov = m_snapshotProv.get();
else if (m_doc->provider && m_doc->provider->isValid())
prov = m_doc->provider.get();
if (prov) {
for (auto& lm : m_lastResult.meta) {
if (lm.nodeIdx < 0 || lm.nodeIdx >= m_doc->tree.nodes.size()) continue;
if (isSyntheticLine(lm) || lm.isContinuation) continue;
if (lm.lineKind != LineKind::Field) continue;
const Node& node = m_doc->tree.nodes[lm.nodeIdx];
// Skip containers — they don't have scalar values
if (node.kind == NodeKind::Struct || node.kind == NodeKind::Array) continue;
// Skip hex preview nodes — they show raw bytes, not a single value
if (isHexPreview(node.kind)) continue;
int64_t nodeOff = m_doc->tree.computeOffset(lm.nodeIdx);
uint64_t addr = m_doc->tree.baseAddress + static_cast<uint64_t>(nodeOff);
int sz = node.byteSize();
if (sz <= 0 || !prov->isReadable(addr, sz)) continue;
QString val = fmt::readValue(node, *prov, addr, lm.subLine);
if (!val.isEmpty()) {
m_valueHistory[lm.nodeId].record(val);
lm.heatLevel = m_valueHistory[lm.nodeId].heatLevel();
}
}
}
// Apply persisted heat levels even when provider is unavailable
if (!prov) {
for (auto& lm : m_lastResult.meta) {
if (lm.nodeId != 0) {
auto it = m_valueHistory.find(lm.nodeId);
if (it != m_valueHistory.end())
lm.heatLevel = it->heatLevel();
}
}
}
}
// Prune stale selections (nodes removed by undo/redo/delete) // Prune stale selections (nodes removed by undo/redo/delete)
QSet<uint64_t> valid; QSet<uint64_t> valid;
for (uint64_t id : m_selIds) { for (uint64_t id : m_selIds) {
@@ -656,6 +702,7 @@ void RcxController::refresh() {
for (auto* editor : m_editors) { for (auto* editor : m_editors) {
editor->setCustomTypeNames(customTypes); editor->setCustomTypeNames(customTypes);
editor->setValueHistoryRef(&m_valueHistory);
ViewState vs = editor->saveViewState(); ViewState vs = editor->saveViewState();
editor->applyDocument(m_lastResult); editor->applyDocument(m_lastResult);
editor->restoreViewState(vs); editor->restoreViewState(vs);
@@ -914,11 +961,13 @@ void RcxController::applyCommand(const Command& command, bool isUndo) {
int ai = tree.indexOfId(adj.nodeId); int ai = tree.indexOfId(adj.nodeId);
if (ai >= 0) tree.nodes[ai].offset = adj.newOffset; if (ai >= 0) tree.nodes[ai].offset = adj.newOffset;
} }
// Remove nodes // Remove nodes and their value history
QVector<int> indices = tree.subtreeIndices(c.nodeId); QVector<int> indices = tree.subtreeIndices(c.nodeId);
std::sort(indices.begin(), indices.end(), std::greater<int>()); std::sort(indices.begin(), indices.end(), std::greater<int>());
for (int idx : indices) for (int idx : indices) {
m_valueHistory.remove(tree.nodes[idx].id);
tree.nodes.remove(idx); tree.nodes.remove(idx);
}
tree.invalidateIdCache(); tree.invalidateIdCache();
} }
} else if constexpr (std::is_same_v<T, cmd::ChangeBase>) { } else if constexpr (std::is_same_v<T, cmd::ChangeBase>) {
@@ -932,11 +981,14 @@ void RcxController::applyCommand(const Command& command, bool isUndo) {
resetSnapshot(); resetSnapshot();
} else if constexpr (std::is_same_v<T, cmd::WriteBytes>) { } else if constexpr (std::is_same_v<T, cmd::WriteBytes>) {
const QByteArray& bytes = isUndo ? c.oldBytes : c.newBytes; const QByteArray& bytes = isUndo ? c.oldBytes : c.newBytes;
if (!m_doc->provider->writeBytes(c.addr, bytes)) // Write through snapshot (patches pages only on success) or provider directly.
// If write fails, the snapshot is NOT patched, so the next compose shows the
// real unchanged value — no optimistic visual leak.
bool ok = m_snapshotProv
? m_snapshotProv->write(c.addr, bytes.constData(), bytes.size())
: m_doc->provider->writeBytes(c.addr, bytes);
if (!ok)
qWarning() << "WriteBytes failed at address" << QString::number(c.addr, 16); qWarning() << "WriteBytes failed at address" << QString::number(c.addr, 16);
// Patch snapshot so compose sees the new value immediately
if (m_snapshotProv)
m_snapshotProv->patchPages(c.addr, bytes.constData(), bytes.size());
} else if constexpr (std::is_same_v<T, cmd::ChangeArrayMeta>) { } else if constexpr (std::is_same_v<T, cmd::ChangeArrayMeta>) {
int idx = tree.indexOfId(c.nodeId); int idx = tree.indexOfId(c.nodeId);
if (idx >= 0) { if (idx >= 0) {
@@ -1019,8 +1071,21 @@ void RcxController::setNodeValue(int nodeIdx, int subLine, const QString& text,
// Validate write range before pushing command // Validate write range before pushing command
if (!m_doc->provider->isReadable(addr, writeSize)) return; if (!m_doc->provider->isReadable(addr, writeSize)) return;
// Read old bytes before writing (for undo)
QByteArray oldBytes = m_doc->provider->readBytes(addr, writeSize); QByteArray oldBytes = m_doc->provider->readBytes(addr, writeSize);
// Test the write first — don't push a command that will silently fail.
// This prevents optimistic visual updates for read-only providers.
bool writeOk = m_snapshotProv
? m_snapshotProv->write(addr, newBytes.constData(), newBytes.size())
: m_doc->provider->writeBytes(addr, newBytes);
if (!writeOk) {
qWarning() << "Write failed at address" << QString::number(addr, 16);
refresh(); // refresh to show the real unchanged value
return;
}
// Write succeeded — push undo command (redo will write again, which is harmless)
m_doc->undoStack.push(new RcxCommand(this, m_doc->undoStack.push(new RcxCommand(this,
cmd::WriteBytes{addr, oldBytes, newBytes})); cmd::WriteBytes{addr, oldBytes, newBytes}));
} }
@@ -2194,6 +2259,7 @@ void RcxController::resetSnapshot() {
m_snapshotProv.reset(); m_snapshotProv.reset();
m_prevPages.clear(); m_prevPages.clear();
m_changedOffsets.clear(); m_changedOffsets.clear();
m_valueHistory.clear();
} }
void RcxController::handleMarginClick(RcxEditor* editor, int margin, void RcxController::handleMarginClick(RcxEditor* editor, int margin,

View File

@@ -148,6 +148,7 @@ private:
std::unique_ptr<SnapshotProvider> m_snapshotProv; std::unique_ptr<SnapshotProvider> m_snapshotProv;
PageMap m_prevPages; PageMap m_prevPages;
QSet<int64_t> m_changedOffsets; QSet<int64_t> m_changedOffsets;
QHash<uint64_t, ValueHistory> m_valueHistory;
uint64_t m_refreshGen = 0; uint64_t m_refreshGen = 0;
uint64_t m_readGen = 0; uint64_t m_readGen = 0;
bool m_readInFlight = false; bool m_readInFlight = false;

View File

@@ -8,6 +8,7 @@
#include <QHash> #include <QHash>
#include <QSet> #include <QSet>
#include <cstdint> #include <cstdint>
#include <array>
#include <memory> #include <memory>
#include <variant> #include <variant>
@@ -405,6 +406,49 @@ struct NodeTree {
}; };
// ── Value History (ring buffer for heatmap) ──
struct ValueHistory {
static constexpr int kCapacity = 10;
std::array<QString, kCapacity> values;
int count = 0; // total unique values recorded
int head = 0; // next write position in ring
void record(const QString& v) {
if (count > 0) {
int last = (head + kCapacity - 1) % kCapacity;
if (values[last] == v) return; // no change
}
values[head] = v;
head = (head + 1) % kCapacity;
if (count < INT_MAX) count++;
}
int uniqueCount() const { return qMin(count, kCapacity); }
// 0=static, 1=cold(2 unique), 2=warm(3-4), 3=hot(5+)
int heatLevel() const {
if (count <= 1) return 0;
if (count == 2) return 1;
if (count <= 4) return 2;
return 3;
}
QString last() const {
if (count == 0) return {};
return values[(head + kCapacity - 1) % kCapacity];
}
// Iterate from oldest to newest (up to uniqueCount entries)
template<typename Fn>
void forEach(Fn&& fn) const {
int n = uniqueCount();
int start = (head + kCapacity - n) % kCapacity;
for (int i = 0; i < n; i++)
fn(values[(start + i) % kCapacity]);
}
};
// ── LineMeta ── // ── LineMeta ──
enum class LineKind : uint8_t { enum class LineKind : uint8_t {
@@ -439,6 +483,7 @@ struct LineMeta {
uint64_t offsetAddr = 0; // Raw absolute address (for margin toggle) uint64_t offsetAddr = 0; // Raw absolute address (for margin toggle)
uint32_t markerMask = 0; uint32_t markerMask = 0;
bool dataChanged = false; // true if any byte in this node changed since last refresh bool dataChanged = false; // true if any byte in this node changed since last refresh
int heatLevel = 0; // 0=static, 1=cold, 2=warm, 3=hot (from ValueHistory)
QVector<int> changedByteIndices; // Hex preview: which byte indices (0-based) changed on this line QVector<int> changedByteIndices; // Hex preview: which byte indices (0-based) changed on this line
int lineByteCount = 0; // Hex preview: actual data byte count on this line int lineByteCount = 0; // Hex preview: actual data byte count on this line
int effectiveTypeW = 14; // Per-line type column width used for rendering int effectiveTypeW = 14; // Per-line type column width used for rendering

View File

@@ -24,10 +24,12 @@ static constexpr int IND_HEX_DIM = 9;
static constexpr int IND_BASE_ADDR = 10; // Default text color override for command row address static constexpr int IND_BASE_ADDR = 10; // Default text color override for command row address
static constexpr int IND_HOVER_SPAN = 11; // Blue text on hover (link-like) static constexpr int IND_HOVER_SPAN = 11; // Blue text on hover (link-like)
static constexpr int IND_CMD_PILL = 12; // Rounded chip behind command row spans static constexpr int IND_CMD_PILL = 12; // Rounded chip behind command row spans
static constexpr int IND_DATA_CHANGED = 13; // Amber text for changed data values static constexpr int IND_HEAT_COLD = 13; // Heatmap level 1 (changed once)
static constexpr int IND_CLASS_NAME = 14; // Teal text for root class name static constexpr int IND_CLASS_NAME = 14; // Teal text for root class name
static constexpr int IND_HINT_GREEN = 15; // Green text for hint/comment text static constexpr int IND_HINT_GREEN = 15; // Green text for hint/comment text
static constexpr int IND_LOCAL_OFF = 16; // Dim text for inline local offset in relative mode static constexpr int IND_LOCAL_OFF = 16; // Dim text for inline local offset in relative mode
static constexpr int IND_HEAT_WARM = 17; // Heatmap level 2 (moderate changes)
static constexpr int IND_HEAT_HOT = 18; // Heatmap level 3 (frequent changes)
static QString g_fontName = "JetBrains Mono"; static QString g_fontName = "JetBrains Mono";
@@ -161,9 +163,13 @@ void RcxEditor::setupScintilla() {
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETUNDER, m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETUNDER,
IND_CMD_PILL, (long)1); IND_CMD_PILL, (long)1);
// Data-changed indicator // Heatmap indicators (cold / warm / hot)
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE, m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_DATA_CHANGED, 17 /*INDIC_TEXTFORE*/); IND_HEAT_COLD, 17 /*INDIC_TEXTFORE*/);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_HEAT_WARM, 17 /*INDIC_TEXTFORE*/);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
IND_HEAT_HOT, 17 /*INDIC_TEXTFORE*/);
// Root class name — type color // Root class name — type color
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE, m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETSTYLE,
@@ -300,8 +306,13 @@ void RcxEditor::applyTheme(const Theme& theme) {
IND_HOVER_SPAN, theme.indHoverSpan); IND_HOVER_SPAN, theme.indHoverSpan);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE, m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_CMD_PILL, theme.indCmdPill); IND_CMD_PILL, theme.indCmdPill);
// Heatmap colors
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE, m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_DATA_CHANGED, theme.indDataChanged); IND_HEAT_COLD, theme.indHeatCold);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_HEAT_WARM, theme.indHeatWarm);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_HEAT_HOT, theme.indHeatHot);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE, m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
IND_CLASS_NAME, theme.syntaxType); IND_CLASS_NAME, theme.syntaxType);
m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE, m_sci->SendScintilla(QsciScintillaBase::SCI_INDICSETFORE,
@@ -401,7 +412,7 @@ void RcxEditor::applyDocument(const ComposeResult& result) {
applyMarkers(result.meta); applyMarkers(result.meta);
applyFoldLevels(result.meta); applyFoldLevels(result.meta);
applyHexDimming(result.meta); applyHexDimming(result.meta);
applyDataChangedHighlight(result.meta); applyHeatmapHighlight(result.meta);
applyCommandRowPills(); applyCommandRowPills();
// Reset hint line - applySelectionOverlay will repaint indicators // Reset hint line - applySelectionOverlay will repaint indicators
@@ -765,35 +776,48 @@ static QString getLineText(QsciScintilla* sci, int line) {
return text; return text;
} }
void RcxEditor::applyDataChangedHighlight(const QVector<LineMeta>& meta) { void RcxEditor::applyHeatmapHighlight(const QVector<LineMeta>& meta) {
for (int i = 0; i < meta.size(); i++) { static constexpr int heatIndicators[] = { IND_HEAT_COLD, IND_HEAT_WARM, IND_HEAT_HOT };
if (!meta[i].dataChanged) continue;
if (isSyntheticLine(meta[i])) continue;
for (int i = 0; i < meta.size(); i++) {
const LineMeta& lm = meta[i]; const LineMeta& lm = meta[i];
if (isSyntheticLine(lm)) continue;
int heat = lm.heatLevel;
int typeW = lm.effectiveTypeW; int typeW = lm.effectiveTypeW;
int nameW = lm.effectiveNameW; int nameW = lm.effectiveNameW;
if (isHexPreview(lm.nodeKind) && !lm.changedByteIndices.isEmpty()) { // For hex preview nodes: use dataChanged + changedByteIndices (per-byte heat)
// Per-byte highlighting in ASCII + hex areas if (isHexPreview(lm.nodeKind) && lm.dataChanged && !lm.changedByteIndices.isEmpty()) {
// Hex nodes don't track heatLevel (they're skipped in controller).
// Use IND_HEAT_COLD for any changed byte (simple visual feedback).
int ind = kFoldCol + lm.depth * 3; int ind = kFoldCol + lm.depth * 3;
int asciiStart = ind + typeW + kSepWidth; int asciiStart = ind + typeW + kSepWidth;
// ASCII column is padded to nameW (aligned with value column)
int hexStart = asciiStart + nameW + kSepWidth; int hexStart = asciiStart + nameW + kSepWidth;
for (int byteIdx : lm.changedByteIndices) { for (int byteIdx : lm.changedByteIndices) {
// Highlight in ASCII area (1 char per byte) fillIndicatorCols(IND_HEAT_COLD, i, asciiStart + byteIdx, asciiStart + byteIdx + 1);
fillIndicatorCols(IND_DATA_CHANGED, i, asciiStart + byteIdx, asciiStart + byteIdx + 1);
// Highlight in hex area (2 hex chars per byte at position byteIdx*3)
int hexCol = hexStart + byteIdx * 3; int hexCol = hexStart + byteIdx * 3;
fillIndicatorCols(IND_DATA_CHANGED, i, hexCol, hexCol + 2); fillIndicatorCols(IND_HEAT_COLD, i, hexCol, hexCol + 2);
} }
} else { continue;
// Non-hex nodes: highlight entire value span }
QString lineText = getLineText(m_sci, i);
ColumnSpan vs = valueSpan(lm, lineText.size(), typeW, nameW); // Non-hex nodes: apply heat-level indicator to value span
if (vs.valid) if (heat <= 0) continue;
fillIndicatorCols(IND_DATA_CHANGED, i, vs.start, vs.end);
QString lineText = getLineText(m_sci, i);
ColumnSpan vs = valueSpan(lm, lineText.size(), typeW, nameW);
if (!vs.valid) continue;
// Pick the right indicator for this heat level (1→cold, 2→warm, 3→hot)
int activeInd = heatIndicators[qBound(0, heat - 1, 2)];
fillIndicatorCols(activeInd, i, vs.start, vs.end);
// Clear the other two heat indicators on this span to avoid overlap
for (int hi : heatIndicators) {
if (hi != activeInd)
clearIndicatorLine(hi, i);
} }
} }
} }
@@ -2204,8 +2228,13 @@ void RcxEditor::applyHoverCursor() {
return; return;
} }
// Mouse left viewport - set Arrow // Mouse left viewport - set Arrow, cancel calltip
if (!m_hoverInside) { if (!m_hoverInside) {
if (m_calltipVisible) {
m_sci->SendScintilla(QsciScintillaBase::SCI_CALLTIPCANCEL);
m_calltipVisible = false;
m_calltipLine = -1;
}
m_sci->viewport()->setCursor(Qt::ArrowCursor); m_sci->viewport()->setCursor(Qt::ArrowCursor);
return; return;
} }
@@ -2294,6 +2323,43 @@ void RcxEditor::applyHoverCursor() {
m_hoverSpanLines.append(h.line); m_hoverSpanLines.append(h.line);
} }
// Value history calltip on hover
{
bool showCalltip = false;
if (m_valueHistory && h.line >= 0 && h.line < m_meta.size() && !m_editState.active) {
const LineMeta& lm = m_meta[h.line];
if (lm.heatLevel > 0 && lm.nodeId != 0) {
auto it = m_valueHistory->find(lm.nodeId);
if (it != m_valueHistory->end() && it->uniqueCount() > 1) {
// Check cursor is over the value span
QString lineText = getLineText(m_sci, h.line);
ColumnSpan vs = valueSpan(lm, lineText.size(), lm.effectiveTypeW, lm.effectiveNameW);
if (vs.valid && h.col >= vs.start && h.col < vs.end) {
QString tip = QStringLiteral("Previous Values:");
it->forEach([&](const QString& v) {
tip += QStringLiteral("\n ") + v;
});
if (m_calltipLine != h.line) {
long pos = m_sci->SendScintilla(QsciScintillaBase::SCI_POSITIONFROMLINE,
(unsigned long)h.line);
QByteArray tipUtf8 = tip.toUtf8();
m_sci->SendScintilla(QsciScintillaBase::SCI_CALLTIPSHOW,
pos, tipUtf8.constData());
m_calltipLine = h.line;
m_calltipVisible = true;
}
showCalltip = true;
}
}
}
}
if (!showCalltip && m_calltipVisible) {
m_sci->SendScintilla(QsciScintillaBase::SCI_CALLTIPCANCEL);
m_calltipVisible = false;
m_calltipLine = -1;
}
}
// Determine cursor shape based on interaction type // Determine cursor shape based on interaction type
Qt::CursorShape desired = Qt::ArrowCursor; Qt::CursorShape desired = Qt::ArrowCursor;

View File

@@ -54,6 +54,7 @@ public:
// Custom type names (struct types from the tree) shown in type picker + lexer GlobalClass coloring // Custom type names (struct types from the tree) shown in type picker + lexer GlobalClass coloring
QString textWithMargins() const; QString textWithMargins() const;
void setCustomTypeNames(const QStringList& names); void setCustomTypeNames(const QStringList& names);
void setValueHistoryRef(const QHash<uint64_t, ValueHistory>* ref) { m_valueHistory = ref; }
// Saved sources for quick-switch in source picker // Saved sources for quick-switch in source picker
void setSavedSources(const QVector<SavedSourceDisplay>& sources) { m_savedSourceDisplay = sources; } void setSavedSources(const QVector<SavedSourceDisplay>& sources) { m_savedSourceDisplay = sources; }
@@ -129,6 +130,11 @@ private:
// ── Saved sources for quick-switch ── // ── Saved sources for quick-switch ──
QVector<SavedSourceDisplay> m_savedSourceDisplay; QVector<SavedSourceDisplay> m_savedSourceDisplay;
// ── Value history ref (owned by controller) ──
const QHash<uint64_t, ValueHistory>* m_valueHistory = nullptr;
bool m_calltipVisible = false;
int m_calltipLine = -1;
// ── Reentrancy guards ── // ── Reentrancy guards ──
bool m_clampingSelection = false; bool m_clampingSelection = false;
bool m_updatingComment = false; bool m_updatingComment = false;
@@ -145,7 +151,7 @@ private:
void applyMarkers(const QVector<LineMeta>& meta); void applyMarkers(const QVector<LineMeta>& meta);
void applyFoldLevels(const QVector<LineMeta>& meta); void applyFoldLevels(const QVector<LineMeta>& meta);
void applyHexDimming(const QVector<LineMeta>& meta); void applyHexDimming(const QVector<LineMeta>& meta);
void applyDataChangedHighlight(const QVector<LineMeta>& meta); void applyHeatmapHighlight(const QVector<LineMeta>& meta);
void applyBaseAddressColoring(const QVector<LineMeta>& meta); void applyBaseAddressColoring(const QVector<LineMeta>& meta);
void applyCommandRowPills(); void applyCommandRowPills();

View File

@@ -67,29 +67,56 @@ static void setDarkTitleBar(QWidget* widget) {
} }
} }
// Guard flag to prevent re-entrant crash inside the handler
static volatile LONG s_inCrashHandler = 0;
static LONG WINAPI crashHandler(EXCEPTION_POINTERS* ep) { static LONG WINAPI crashHandler(EXCEPTION_POINTERS* ep) {
// Prevent re-entrant crash: if we fault inside the handler, skip the
// risky dbghelp work and just terminate with what we already printed.
if (InterlockedCompareExchange(&s_inCrashHandler, 1, 0) != 0) {
fprintf(stderr, "\n(re-entrant fault inside crash handler — aborting)\n");
fflush(stderr);
return EXCEPTION_EXECUTE_HANDLER;
}
// Phase 1: always-safe output (no allocations, no complex APIs)
fprintf(stderr, "\n=== UNHANDLED EXCEPTION ===\n"); fprintf(stderr, "\n=== UNHANDLED EXCEPTION ===\n");
fprintf(stderr, "Code : 0x%08lX\n", ep->ExceptionRecord->ExceptionCode); fprintf(stderr, "Code : 0x%08lX\n", ep->ExceptionRecord->ExceptionCode);
fprintf(stderr, "Addr : %p\n", ep->ExceptionRecord->ExceptionAddress); fprintf(stderr, "Addr : %p\n", ep->ExceptionRecord->ExceptionAddress);
#ifdef _M_X64
fprintf(stderr, "RIP : 0x%016llx\n", (unsigned long long)ep->ContextRecord->Rip);
fprintf(stderr, "RSP : 0x%016llx\n", (unsigned long long)ep->ContextRecord->Rsp);
#else
fprintf(stderr, "EIP : 0x%08lx\n", (unsigned long)ep->ContextRecord->Eip);
#endif
fflush(stderr);
// Phase 2: attempt symbol resolution + stack walk
// Copy context so StackWalk64 can mutate it safely
CONTEXT ctxCopy = *ep->ContextRecord;
HANDLE process = GetCurrentProcess(); HANDLE process = GetCurrentProcess();
HANDLE thread = GetCurrentThread(); HANDLE thread = GetCurrentThread();
SymSetOptions(SYMOPT_LOAD_LINES | SYMOPT_UNDNAME); SymSetOptions(SYMOPT_LOAD_LINES | SYMOPT_UNDNAME | SYMOPT_FAIL_CRITICAL_ERRORS);
SymInitialize(process, NULL, TRUE); if (!SymInitialize(process, NULL, TRUE)) {
fprintf(stderr, "\n(SymInitialize failed — no stack trace available)\n");
fprintf(stderr, "=== END CRASH ===\n");
fflush(stderr);
return EXCEPTION_EXECUTE_HANDLER;
}
CONTEXT* ctx = ep->ContextRecord;
STACKFRAME64 frame = {}; STACKFRAME64 frame = {};
DWORD machineType; DWORD machineType;
#ifdef _M_X64 #ifdef _M_X64
machineType = IMAGE_FILE_MACHINE_AMD64; machineType = IMAGE_FILE_MACHINE_AMD64;
frame.AddrPC.Offset = ctx->Rip; frame.AddrPC.Offset = ctxCopy.Rip;
frame.AddrFrame.Offset = ctx->Rbp; frame.AddrFrame.Offset = ctxCopy.Rbp;
frame.AddrStack.Offset = ctx->Rsp; frame.AddrStack.Offset = ctxCopy.Rsp;
#else #else
machineType = IMAGE_FILE_MACHINE_I386; machineType = IMAGE_FILE_MACHINE_I386;
frame.AddrPC.Offset = ctx->Eip; frame.AddrPC.Offset = ctxCopy.Eip;
frame.AddrFrame.Offset = ctx->Ebp; frame.AddrFrame.Offset = ctxCopy.Ebp;
frame.AddrStack.Offset = ctx->Esp; frame.AddrStack.Offset = ctxCopy.Esp;
#endif #endif
frame.AddrPC.Mode = AddrModeFlat; frame.AddrPC.Mode = AddrModeFlat;
frame.AddrFrame.Mode = AddrModeFlat; frame.AddrFrame.Mode = AddrModeFlat;
@@ -97,7 +124,7 @@ static LONG WINAPI crashHandler(EXCEPTION_POINTERS* ep) {
fprintf(stderr, "\nStack trace:\n"); fprintf(stderr, "\nStack trace:\n");
for (int i = 0; i < 64; i++) { for (int i = 0; i < 64; i++) {
if (!StackWalk64(machineType, process, thread, &frame, ctx, if (!StackWalk64(machineType, process, thread, &frame, &ctxCopy,
NULL, SymFunctionTableAccess64, NULL, SymFunctionTableAccess64,
SymGetModuleBase64, NULL)) SymGetModuleBase64, NULL))
break; break;

View File

@@ -22,6 +22,9 @@
"indHoverSpan": "#E6B450", "indHoverSpan": "#E6B450",
"indCmdPill": "#2a2a2a", "indCmdPill": "#2a2a2a",
"indDataChanged": "#8fbc7a", "indDataChanged": "#8fbc7a",
"indHeatCold": "#569cd6",
"indHeatWarm": "#E6B450",
"indHeatHot": "#f44747",
"indHintGreen": "#5a8248", "indHintGreen": "#5a8248",
"markerPtr": "#f44747", "markerPtr": "#f44747",
"markerCycle": "#e5a00d", "markerCycle": "#e5a00d",

View File

@@ -22,6 +22,9 @@
"indHoverSpan": "#b180d7", "indHoverSpan": "#b180d7",
"indCmdPill": "#2d2d30", "indCmdPill": "#2d2d30",
"indDataChanged": "#8fbc7a", "indDataChanged": "#8fbc7a",
"indHeatCold": "#569cd6",
"indHeatWarm": "#d69d85",
"indHeatHot": "#f44747",
"indHintGreen": "#5a8248", "indHintGreen": "#5a8248",
"markerPtr": "#f44747", "markerPtr": "#f44747",
"markerCycle": "#e5a00d", "markerCycle": "#e5a00d",

View File

@@ -22,6 +22,9 @@
"indHoverSpan": "#AA9565", "indHoverSpan": "#AA9565",
"indCmdPill": "#2a2a2a", "indCmdPill": "#2a2a2a",
"indDataChanged": "#6B959F", "indDataChanged": "#6B959F",
"indHeatCold": "#6B959F",
"indHeatWarm": "#AA9565",
"indHeatHot": "#A05040",
"indHintGreen": "#464646", "indHintGreen": "#464646",
"markerPtr": "#6B3B21", "markerPtr": "#6B3B21",
"markerCycle": "#AA9565", "markerCycle": "#AA9565",

View File

@@ -28,6 +28,9 @@ const ThemeFieldMeta kThemeFields[] = {
{"indHoverSpan", "Hover Span", "Indicators", &Theme::indHoverSpan}, {"indHoverSpan", "Hover Span", "Indicators", &Theme::indHoverSpan},
{"indCmdPill", "Cmd Pill", "Indicators", &Theme::indCmdPill}, {"indCmdPill", "Cmd Pill", "Indicators", &Theme::indCmdPill},
{"indDataChanged","Data Changed", "Indicators", &Theme::indDataChanged}, {"indDataChanged","Data Changed", "Indicators", &Theme::indDataChanged},
{"indHeatCold", "Heat Cold", "Indicators", &Theme::indHeatCold},
{"indHeatWarm", "Heat Warm", "Indicators", &Theme::indHeatWarm},
{"indHeatHot", "Heat Hot", "Indicators", &Theme::indHeatHot},
{"indHintGreen", "Hint Green", "Indicators", &Theme::indHintGreen}, {"indHintGreen", "Hint Green", "Indicators", &Theme::indHintGreen},
{"markerPtr", "Pointer", "Markers", &Theme::markerPtr}, {"markerPtr", "Pointer", "Markers", &Theme::markerPtr},
{"markerCycle", "Cycle", "Markers", &Theme::markerCycle}, {"markerCycle", "Cycle", "Markers", &Theme::markerCycle},
@@ -50,6 +53,14 @@ Theme Theme::fromJson(const QJsonObject& o) {
if (o.contains(kThemeFields[i].key)) if (o.contains(kThemeFields[i].key))
t.*kThemeFields[i].ptr = QColor(o[kThemeFields[i].key].toString()); t.*kThemeFields[i].ptr = QColor(o[kThemeFields[i].key].toString());
} }
// Derive heat colors from the theme's own palette when keys are absent
// cold = keyword blue, warm = hover/string amber, hot = marker red
if (!t.indHeatCold.isValid())
t.indHeatCold = t.syntaxKeyword;
if (!t.indHeatWarm.isValid())
t.indHeatWarm = t.indHoverSpan.isValid() ? t.indHoverSpan : t.syntaxString;
if (!t.indHeatHot.isValid())
t.indHeatHot = t.markerPtr;
return t; return t;
} }

View File

@@ -38,7 +38,10 @@ struct Theme {
// ── Indicators ── // ── Indicators ──
QColor indHoverSpan; // hover link text QColor indHoverSpan; // hover link text
QColor indCmdPill; // command row pill bg QColor indCmdPill; // command row pill bg
QColor indDataChanged; // changed data values QColor indDataChanged; // changed data values (legacy, fallback for old themes)
QColor indHeatCold; // heatmap level 1 (changed once)
QColor indHeatWarm; // heatmap level 2 (moderate changes)
QColor indHeatHot; // heatmap level 3 (frequent changes)
QColor indHintGreen; // comment/hint text QColor indHintGreen; // comment/hint text
// ── Markers ── // ── Markers ──

View File

@@ -583,6 +583,94 @@ private slots:
QCOMPARE(norm.size(), 1); QCOMPARE(norm.size(), 1);
QVERIFY(norm.contains(rootId)); QVERIFY(norm.contains(rootId));
} }
// ── ValueHistory tests ──
void testValueHistory_empty() {
rcx::ValueHistory h;
QCOMPARE(h.heatLevel(), 0);
QCOMPARE(h.uniqueCount(), 0);
QCOMPARE(h.last(), QString());
}
void testValueHistory_singleValue() {
rcx::ValueHistory h;
h.record("42");
QCOMPARE(h.heatLevel(), 0); // only 1 unique → static
QCOMPARE(h.uniqueCount(), 1);
QCOMPARE(h.last(), QString("42"));
}
void testValueHistory_duplicateIgnored() {
rcx::ValueHistory h;
h.record("42");
h.record("42");
h.record("42");
QCOMPARE(h.count, 1);
QCOMPARE(h.heatLevel(), 0);
}
void testValueHistory_heatLevels() {
rcx::ValueHistory h;
h.record("a");
QCOMPARE(h.heatLevel(), 0); // 1 unique
h.record("b");
QCOMPARE(h.heatLevel(), 1); // 2 unique → cold
h.record("c");
QCOMPARE(h.heatLevel(), 2); // 3 unique → warm
h.record("d");
QCOMPARE(h.heatLevel(), 2); // 4 unique → warm
h.record("e");
QCOMPARE(h.heatLevel(), 3); // 5 unique → hot
}
void testValueHistory_ringWrap() {
rcx::ValueHistory h;
// Fill beyond capacity
for (int i = 0; i < 15; i++)
h.record(QString::number(i));
QCOMPARE(h.count, 15);
QCOMPARE(h.uniqueCount(), 10); // capped at kCapacity
QCOMPARE(h.heatLevel(), 3); // hot
QCOMPARE(h.last(), QString("14"));
// Verify oldest values were pushed out, newest 10 remain
QStringList collected;
h.forEach([&](const QString& v) { collected.append(v); });
QCOMPARE(collected.size(), 10);
QCOMPARE(collected.first(), QString("5")); // oldest surviving
QCOMPARE(collected.last(), QString("14")); // newest
}
void testValueHistory_forEach() {
rcx::ValueHistory h;
h.record("x");
h.record("y");
h.record("z");
QStringList items;
h.forEach([&](const QString& v) { items.append(v); });
QCOMPARE(items.size(), 3);
QCOMPARE(items[0], QString("x"));
QCOMPARE(items[1], QString("y"));
QCOMPARE(items[2], QString("z"));
}
void testValueHistory_oscillation() {
// Values that oscillate (A → B → A → B) should still count each unique transition
rcx::ValueHistory h;
h.record("A");
h.record("B");
h.record("A");
h.record("B");
QCOMPARE(h.count, 4); // 4 transitions
QCOMPARE(h.heatLevel(), 2); // warm (count=4 → 3-4 range)
}
}; };
QTEST_MAIN(TestCore) QTEST_MAIN(TestCore)