From f81e90f03a366fd3ca5517e0067c9035ca454290 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 14 Jun 2026 12:21:02 +0200 Subject: [PATCH] audit: add poll-based arbitrary guest-VA memory-watch cvars Adds audit_mem_watch_addr (comma-separated hex guest VAs) and audit_mem_watch_size (1/2/4/8 bytes) to watch arbitrary guest memory locations at runtime. Once per vblank, GraphicsSystem::MarkVblank() reads each VA big-endian via Memory::TranslateVirtual and emits an XELOGKERNEL "AUDIT-MEM-WATCH" line on every value change vs the prior frame (same greppable log stream as AUDIT-HLC / AUDIT-MEM-READ). Mechanism is poll / value-change: it captures WHEN a value changes (vblank index) but NOT the writer guest-PC; pair with audit_jit_prolog_pc on a suspected writer to recover the PC. Heap VAs vary per run/engine, so they are supplied at runtime via cvar/CLI. Default empty => disabled => zero overhead and no emulation-behavior change. Validated against the controller vsync "clock A" field (+0x58): the watch fires once per change, advancing monotonically in lockstep with vblanks. Game still boots and runs muted with the cvar unset. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/xenia/gpu/graphics_system.cc | 133 +++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) diff --git a/src/xenia/gpu/graphics_system.cc b/src/xenia/gpu/graphics_system.cc index 50956d463..d6d4ff6b8 100644 --- a/src/xenia/gpu/graphics_system.cc +++ b/src/xenia/gpu/graphics_system.cc @@ -13,6 +13,8 @@ #include "xenia/base/clock.h" #include "xenia/base/logging.h" #include "xenia/base/math.h" +#include "xenia/base/memory.h" +#include "xenia/base/string_util.h" #include "xenia/base/profiling.h" #include "xenia/base/threading.h" #include "xenia/config.h" @@ -36,6 +38,32 @@ DEFINE_bool( "runtime spikes and freezes when playing the game not for the first time.", "GPU"); +// Audit memory-watch: poll-based value-change logger for arbitrary guest VAs. +// Reads the configured guest virtual address(es) once per vblank (per frame) +// from inside GraphicsSystem::MarkVblank() and emits one XELOGKERNEL +// "AUDIT-MEM-WATCH" line whenever the read value changes vs the previous +// frame. Greppable on the same log stream as AUDIT-HLC / AUDIT-MEM-READ. +// Default empty => disabled => zero overhead. Mechanism is poll/value-change +// (NOT a write-trap), so it captures WHEN a value changes (vblank index + +// emulator instruction count) but NOT the writer guest-PC. To find the writer +// PC, pair this with audit_jit_prolog_pc on the suspected writer function. +DEFINE_string( + audit_mem_watch_addr, "", + "Audit memory-watch — comma-separated list of guest virtual addresses " + "(hex, e.g. \"0x40d09a40\" or \"0x40d09a40,0x40929c00\") to poll once per " + "vblank. On every value change vs the previous frame, emit one " + "XELOGKERNEL AUDIT-MEM-WATCH line (watched VA, old value, new value, " + "vblank index, instruction count). Empty (default) disables the watch. " + "Poll-based value-change log: tells you WHEN a value changes, not the " + "writer PC.", + "Auditing"); +DEFINE_uint32( + audit_mem_watch_size, 4, + "Audit memory-watch — number of bytes to read at each watched VA (1, 2, " + "4, or 8). Read big-endian (guest byte order) and compared as a 64-bit " + "value. Default 4.", + "Auditing"); + namespace xe { namespace gpu { @@ -333,12 +361,117 @@ void GraphicsSystem::DispatchInterruptCallback(uint32_t source, uint32_t cpu) { interrupt_callback_data_, source, cpu); } +// Audit memory-watch poll. Called once per vblank from MarkVblank(). Parses +// the comma-separated VA list from cvars::audit_mem_watch_addr exactly once +// (cached), then reads each VA (big-endian, audit_mem_watch_size bytes) and +// logs an AUDIT-MEM-WATCH line on every value change vs the previous frame. +// Poll/value-change mechanism: captures WHEN, not the writer PC. Zero work +// when the cvar is empty. +static void AuditMemWatchPoll(Memory* memory, uint64_t vblank_index) { + struct WatchEntry { + uint32_t va; + uint64_t last_value; + bool have_last; + }; + static std::vector entries; + static std::string parsed_spec; + static bool parse_failed = false; + + const std::string& spec = cvars::audit_mem_watch_addr; + if (spec.empty()) { + return; + } + // (Re)parse only when the cvar string changes (set once at startup). + if (spec != parsed_spec) { + parsed_spec = spec; + entries.clear(); + parse_failed = false; + size_t start = 0; + while (start <= spec.size()) { + size_t comma = spec.find(',', start); + std::string tok = spec.substr( + start, comma == std::string::npos ? std::string::npos : comma - start); + // Trim whitespace. + while (!tok.empty() && (tok.front() == ' ' || tok.front() == '\t')) + tok.erase(tok.begin()); + while (!tok.empty() && (tok.back() == ' ' || tok.back() == '\t')) + tok.pop_back(); + if (!tok.empty()) { + uint32_t va = 0; + try { + va = static_cast(std::stoul(tok, nullptr, 16)); + } catch (...) { + XELOGKERNEL("AUDIT-MEM-WATCH bad VA token '{}' in '{}'", tok, spec); + parse_failed = true; + va = 0; + } + if (va != 0) { + entries.push_back({va, 0, false}); + } + } + if (comma == std::string::npos) break; + start = comma + 1; + } + XELOGKERNEL("AUDIT-MEM-WATCH armed: {} address(es), size={} bytes", + entries.size(), + static_cast(cvars::audit_mem_watch_size)); + } + if (entries.empty()) { + return; + } + (void)parse_failed; + + uint32_t size = static_cast(cvars::audit_mem_watch_size); + for (auto& e : entries) { + if (e.va < 0x10000 || e.va >= 0xE0000000) { + continue; + } + uint8_t* host = memory->TranslateVirtual(e.va); + if (!host) { + continue; + } + uint64_t value = 0; + switch (size) { + case 1: + value = *host; + break; + case 2: + value = xe::load_and_swap(host); + break; + case 8: + value = xe::load_and_swap(host); + break; + case 4: + default: + value = xe::load_and_swap(host); + break; + } + if (!e.have_last) { + e.have_last = true; + e.last_value = value; + XELOGKERNEL( + "AUDIT-MEM-WATCH va={:08X} init={:016X} vblank={} (first read)", + e.va, value, vblank_index); + continue; + } + if (value != e.last_value) { + XELOGKERNEL( + "AUDIT-MEM-WATCH va={:08X} old={:016X} new={:016X} vblank={}", + e.va, e.last_value, value, vblank_index); + e.last_value = value; + } + } +} + void GraphicsSystem::MarkVblank() { SCOPE_profile_cpu_f("gpu"); // Increment vblank counter (so the game sees us making progress). command_processor_->increment_counter(); + // Audit memory-watch (poll-based; no-op unless audit_mem_watch_addr set). + AuditMemWatchPoll(memory_, command_processor_->counter()); + // TODO(benvanik): we shouldn't need to do the dispatch here, but there's // something wrong and the CP will block waiting for code that // needs to be run in the interrupt.