[Kernel] frame probe: sample guest memory once per guest frame, for RE
Some checks failed
Orchestrator / Commit Message Validation (push) Has been skipped
Orchestrator / Lint (push) Failing after 1m52s
Orchestrator / Windows (x86-64) (push) Has been skipped
Orchestrator / Linux (x86-64) (push) Has been skipped
Orchestrator / Create Release (push) Has been skipped
Some checks failed
Orchestrator / Commit Message Validation (push) Has been skipped
Orchestrator / Lint (push) Failing after 1m52s
Orchestrator / Windows (x86-64) (push) Has been skipped
Orchestrator / Linux (x86-64) (push) Has been skipped
Orchestrator / Create Release (push) Has been skipped
Reading guest RAM from outside the emulator is easy -- Canary backs it with a
/dev/shm file, so a host process can just pread it -- but it is 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.
That is not a theoretical concern. Measuring the player craft's angular velocity
this way produced 3x swings between adjacent 0.25 s windows, which made it
impossible to tell "the rate ramps up after the stick goes over" from "the
sampler is aliasing" -- the one question standing between a confirmed law shape
and its absolute scale. Widening the window does not rescue it either: a hold
long enough to average bleeds the speed, and speed is the variable under test.
So sample from inside, in VdSwap: the guest's own thread, once per guest frame,
at a fixed point in it. Consecutive lines are then exactly 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 come from a small control file that is
re-read whenever its mtime changes, the same trick the file input pad uses:
0x40D10590 128 a guest VA and a byte count, one region per line
and each frame appends
F <frame> H <host_ns> G <guest_ticks> R0 <hex> R1 <hex> ...
Reads are bounds-checked through LookupHeap/QueryProtect and copied out before
printing, so an unmapped or stale address logs "-" instead of taking the
emulator down. Header-only and off unless --frame_probe_log is set, so it adds
no build target and no cost to anyone not using it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
197
src/xenia/kernel/util/frame_state_probe.h
Normal file
197
src/xenia/kernel/util/frame_state_probe.h
Normal file
@@ -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 <frame> H <host_ns> G <guest_ticks> R0 <hex> R1 <hex> ...
|
||||||
|
//
|
||||||
|
// Disabled unless --frame_probe_log is set, and then costs one stat() plus the
|
||||||
|
// listed reads per frame.
|
||||||
|
|
||||||
|
#include <cinttypes>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#include <sys/stat.h>
|
||||||
|
|
||||||
|
#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 '<hex_va> <len>' 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<int64_t>(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<Region> 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_
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
#include "xenia/emulator.h"
|
#include "xenia/emulator.h"
|
||||||
#include "xenia/gpu/graphics_system.h"
|
#include "xenia/gpu/graphics_system.h"
|
||||||
#include "xenia/kernel/kernel_state.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/util/shim_utils.h"
|
||||||
#include "xenia/kernel/xboxkrnl/xboxkrnl_private.h"
|
#include "xenia/kernel/xboxkrnl/xboxkrnl_private.h"
|
||||||
#include "xenia/kernel/xboxkrnl/xboxkrnl_rtl.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 frontbuffer_ptr, // ptr to frontbuffer address
|
||||||
lpdword_t texture_format_ptr, lpdword_t color_space_ptr, lpdword_t width,
|
lpdword_t texture_format_ptr, lpdword_t color_space_ptr, lpdword_t width,
|
||||||
lpdword_t height) {
|
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.
|
// All of these parameters are REQUIRED.
|
||||||
assert(buffer_ptr);
|
assert(buffer_ptr);
|
||||||
assert(fetch_ptr);
|
assert(fetch_ptr);
|
||||||
|
|||||||
Reference in New Issue
Block a user