diff --git a/src/xenia/kernel/util/frame_state_probe.h b/src/xenia/kernel/util/frame_state_probe.h
new file mode 100644
index 000000000..ff165a3cf
--- /dev/null
+++ b/src/xenia/kernel/util/frame_state_probe.h
@@ -0,0 +1,197 @@
+/**
+ ******************************************************************************
+ * Xenia : Xbox 360 Emulator Research Project *
+ ******************************************************************************
+ * Copyright 2026 Ben Vanik. All rights reserved. *
+ * Released under the BSD license - see LICENSE in the root for more details. *
+ ******************************************************************************
+ */
+
+#ifndef XENIA_KERNEL_UTIL_FRAME_STATE_PROBE_H_
+#define XENIA_KERNEL_UTIL_FRAME_STATE_PROBE_H_
+
+// A per-guest-frame sampler of guest memory, for reverse engineering.
+//
+// Reading guest RAM from outside the emulator (Canary backs it with a
+// /dev/shm file, so a host process can pread it) is easy but UNSYNCHRONISED:
+// the reader has no idea where the guest is in its update, so successive reads
+// are separated by an unknown, jittering number of guest updates. Measuring a
+// rate that way aliases badly -- a project measuring the craft's angular
+// velocity got 3x swings between adjacent 0.25 s windows purely from sampling.
+//
+// This samples from INSIDE, once per VdSwap, i.e. exactly once per guest frame
+// at a fixed point in it. Consecutive lines are then one frame apart by
+// construction, and the frame counter is exact.
+//
+// Which bytes to sample is not known at launch (object addresses are found by
+// scanning at runtime), so the regions are read from a small control file that
+// is re-read whenever its mtime changes -- the same trick the file input pad
+// uses. Format, one region per line, '#' comments ignored:
+//
+// 0x40D10590 128 a guest VA and a byte count
+//
+// Output, one line per frame, appended to --frame_probe_log:
+//
+// F H G R0 R1 ...
+//
+// Disabled unless --frame_probe_log is set, and then costs one stat() plus the
+// listed reads per frame.
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include "xenia/base/cvar.h"
+#include "xenia/base/clock.h"
+#include "xenia/base/logging.h"
+#include "xenia/memory.h"
+
+DEFINE_string(frame_probe_log, "",
+ "RE: append one line of guest state per frame to this file. "
+ "Empty disables the probe entirely.",
+ "RE");
+DEFINE_string(frame_probe, "/tmp/xenia_frame_probe.txt",
+ "RE: control file listing the guest regions --frame_probe_log "
+ "samples, one ' ' per line. Re-read when it changes.",
+ "RE");
+
+namespace xe {
+namespace kernel {
+namespace util {
+
+class FrameStateProbe {
+ public:
+ static FrameStateProbe& instance() {
+ static FrameStateProbe probe;
+ return probe;
+ }
+
+ // Called once per guest frame, from VdSwap.
+ void Sample(Memory* memory) {
+ if (cvars::frame_probe_log.empty() || !memory) {
+ return;
+ }
+ ++frame_;
+ ReloadIfChanged();
+ if (regions_.empty()) {
+ return;
+ }
+ if (!out_) {
+ out_ = std::fopen(cvars::frame_probe_log.c_str(), "a");
+ if (!out_) {
+ XELOGE("[frame-probe] cannot open {}", cvars::frame_probe_log);
+ // Do not retry every frame on a bad path.
+ cvars::frame_probe_log.clear();
+ return;
+ }
+ }
+ std::fprintf(out_, "F %" PRIu64 " H %" PRIu64 " G %" PRIu64, frame_,
+ Clock::QueryHostSystemTime(), Clock::QueryGuestTickCount());
+ for (size_t i = 0; i < regions_.size(); ++i) {
+ const auto& r = regions_[i];
+ std::fprintf(out_, " R%zu ", i);
+ if (!ReadRegion(memory, r.address, r.length)) {
+ std::fprintf(out_, "-");
+ continue;
+ }
+ for (uint32_t b = 0; b < r.length; ++b) {
+ std::fprintf(out_, "%02x", scratch_[b]);
+ }
+ }
+ std::fputc('\n', out_);
+ // Flushed per frame on purpose: the host analysis reads this file while the
+ // game is still flying, and a run can end in a crash or a pkill -9.
+ std::fflush(out_);
+ }
+
+ private:
+ static constexpr uint32_t kMaxRegionBytes = 1024;
+ static constexpr size_t kMaxRegions = 8;
+
+ struct Region {
+ uint32_t address;
+ uint32_t length;
+ };
+
+ // Copies out first so the line cannot be torn across a guest write mid-print.
+ bool ReadRegion(Memory* memory, uint32_t address, uint32_t length) {
+ auto heap = memory->LookupHeap(address);
+ if (!heap) {
+ return false;
+ }
+ uint32_t protect = 0;
+ if (!heap->QueryProtect(address, &protect) || !protect) {
+ return false;
+ }
+ if (!heap->QueryProtect(address + length - 1, &protect) || !protect) {
+ return false;
+ }
+ std::memcpy(scratch_, memory->TranslateVirtual(address), length);
+ return true;
+ }
+
+ void ReloadIfChanged() {
+ struct stat st;
+ if (::stat(cvars::frame_probe.c_str(), &st) != 0) {
+ if (!regions_.empty()) {
+ regions_.clear();
+ XELOGI("[frame-probe] control file gone -- sampling stopped");
+ }
+ return;
+ }
+ const int64_t stamp = static_cast(st.st_mtim.tv_sec) * 1000000000 +
+ st.st_mtim.tv_nsec;
+ if (stamp == stamp_ && st.st_size == size_) {
+ return;
+ }
+ stamp_ = stamp;
+ size_ = st.st_size;
+ Parse();
+ }
+
+ void Parse() {
+ regions_.clear();
+ FILE* f = std::fopen(cvars::frame_probe.c_str(), "r");
+ if (!f) {
+ return;
+ }
+ char line[256];
+ while (std::fgets(line, sizeof(line), f)) {
+ char* p = line;
+ while (*p == ' ' || *p == '\t') ++p;
+ if (*p == '#' || *p == '\n' || *p == '\0') {
+ continue;
+ }
+ uint32_t addr = 0, len = 0;
+ if (std::sscanf(p, "%" SCNx32 " %" SCNu32, &addr, &len) != 2) {
+ continue;
+ }
+ if (!len || len > kMaxRegionBytes || regions_.size() >= kMaxRegions) {
+ continue;
+ }
+ regions_.push_back({addr, len});
+ }
+ std::fclose(f);
+ for (size_t i = 0; i < regions_.size(); ++i) {
+ XELOGI("[frame-probe] R{} = {:08X} +{}", i, regions_[i].address,
+ regions_[i].length);
+ }
+ }
+
+ std::vector regions_;
+ uint8_t scratch_[kMaxRegionBytes] = {};
+ FILE* out_ = nullptr;
+ uint64_t frame_ = 0;
+ int64_t stamp_ = -1;
+ off_t size_ = -1;
+};
+
+} // namespace util
+} // namespace kernel
+} // namespace xe
+
+#endif // XENIA_KERNEL_UTIL_FRAME_STATE_PROBE_H_
diff --git a/src/xenia/kernel/xboxkrnl/xboxkrnl_video.cc b/src/xenia/kernel/xboxkrnl/xboxkrnl_video.cc
index 12835232b..267e153e5 100644
--- a/src/xenia/kernel/xboxkrnl/xboxkrnl_video.cc
+++ b/src/xenia/kernel/xboxkrnl/xboxkrnl_video.cc
@@ -13,6 +13,7 @@
#include "xenia/emulator.h"
#include "xenia/gpu/graphics_system.h"
#include "xenia/kernel/kernel_state.h"
+#include "xenia/kernel/util/frame_state_probe.h"
#include "xenia/kernel/util/shim_utils.h"
#include "xenia/kernel/xboxkrnl/xboxkrnl_private.h"
#include "xenia/kernel/xboxkrnl/xboxkrnl_rtl.h"
@@ -472,6 +473,11 @@ void VdSwap_entry(
lpdword_t frontbuffer_ptr, // ptr to frontbuffer address
lpdword_t texture_format_ptr, lpdword_t color_space_ptr, lpdword_t width,
lpdword_t height) {
+ // RE probe: one guest frame has just finished, and we are on the guest thread
+ // that finished it -- the only place a sample of guest state is guaranteed to
+ // be exactly one frame after the previous one. No-op unless --frame_probe_log.
+ util::FrameStateProbe::instance().Sample(kernel_memory());
+
// All of these parameters are REQUIRED.
assert(buffer_ptr);
assert(fetch_ptr);