8 Commits

Author SHA1 Message Date
4040c701c5 [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
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>
2026-08-14 06:03:09 +00:00
15fe11d5d9 [HID] file-pad: implement GetKeystroke -- menus do not read GetState
Some checks failed
Orchestrator / Commit Message Validation (push) Has been skipped
Orchestrator / Lint (push) Failing after 2m3s
Orchestrator / Windows (x86-64) (push) Has been skipped
Orchestrator / Linux (x86-64) (push) Has been skipped
Orchestrator / Create Release (push) Has been skipped
The pad looked correct and did nothing. Its own log showed A arriving, the
emulator sat on "PRESS (A) BUTTON", and the title never advanced.

Cause: 360 front-ends poll XamInputGetKeystrokeEx, not XamInputGetState. This
title imports both, and its menus use the keystroke path; the driver returned
X_ERROR_EMPTY there, so every scripted press went into the void while GetState
faithfully reported a button nobody asked about.

Implement it edge-triggered, one event per call: KEYUPs for everything released
first, then KEYDOWNs, matching the SDL driver's ordering (so a thumb transition
clears before it sets). Deliberately NO auto-repeat -- scripted input wants
exactly one event per press, and repeat is precisely what makes menu steps
overshoot. Bits without a virtual key (guide, unused) are swallowed rather than
re-offered forever.

Verified on the real game: title -> main menu -> EXTRAS driven entirely from the
pad file, with `[file-pad] keystroke vk=5800 down/up` in the log for each press.
2026-08-13 20:27:38 +00:00
e3e17e4951 [HID] file-pad: nanosecond change detection, and log every state change
Some checks failed
Orchestrator / Commit Message Validation (push) Has been skipped
Orchestrator / Lint (push) Failing after 2m24s
Orchestrator / Windows (x86-64) (push) Has been skipped
Orchestrator / Linux (x86-64) (push) Has been skipped
Orchestrator / Create Release (push) Has been skipped
Two things that only show up once you actually script this pad.

st_mtime is whole seconds. Combined with size it looked like enough and is not:
a script stepping a menu writes several same-length states per second
(`press=A` then `press=B`, both 8 bytes), and every one after the first was
silently dropped -- the emulator simply did not react, with nothing in any log
to say why. Compare st_mtim.tv_nsec as well, and track whether the file existed
at all so a delete is registered once rather than every frame.

Also log one line per state change (not per frame, so it stays quiet). Driving
the emulator headless means there is nothing to watch; this line is the only
proof that a scripted press was picked up, which turns "did my input land?" from
a guess into a grep.
2026-08-13 20:14:29 +00:00
d15c8cfab6 [HID] file-pad: a controller driven by a text file, for scripted RE
Some checks failed
Orchestrator / Commit Message Validation (push) Has been skipped
Orchestrator / Lint (push) Failing after 2m24s
Orchestrator / Windows (x86-64) (push) Has been skipped
Orchestrator / Linux (x86-64) (push) Has been skipped
Orchestrator / Create Release (push) Has been skipped
The scripted-input tool this project uses for reverse engineering created its
pad through /dev/uinput. Input devices are NOT namespaced by the kernel, so a
uinput device created inside a container registers with the HOST's input stack:
every trigger hold and button press is delivered to whatever on the host reads
gamepads, not just to the emulator. That was noticed the hard way, and it made
every runtime experiment -- booting, menu navigation, unit harvesting, flight
measurement -- unusable from inside the box.

This driver takes the kernel out of the loop. Pad state lives in an ordinary
text file only the container can see; GetState re-reads it when it changes.
Nothing is registered with the host and no X server is involved. A bonus for RE:
analogue values are exact rather than whatever a virtual stick quantises to.

    press=A,START     buttons by name, comma separated
    buttons=0x1010    or the raw XINPUT mask
    lt=0 rt=255       triggers, 0..255
    lx=0 ly=0         thumbs, -32768..32767

Absent keys are neutral, so `press=A` alone is a valid file, and a missing or
empty file means no input -- the safe default if it is deleted mid-run.

Selected with --hid=file, path from --pad_file (default /tmp/xenia_pad.txt).
Deliberately NOT part of "any": this pad has to be asked for. Header-only, so it
adds no build target and no cost to anyone not using it.
2026-08-13 20:12:02 +00:00
31366e5cac [GPU] ship-capture: log each draw's index buffer, and key the de-dup on it
The offline XBG7 decoder can only assume where a block's index data lives; the
capture now states it. Each captured draw gains an `ib base=… count=… fmt=…
endian=… len=… delta_vb=… min=… max=… idx: …` line — the guest index base, the
draw's index count, and the min/max index value read out of guest memory, which
together say both where the index buffer sits relative to the vertex buffer and
how much of the vertex pool the draw covers.

Also mix the index range into the capture's de-dup key. The engine issues several
draws over one vertex buffer, each indexing a sub-range (a 119-vertex hull LOD
draws 21 indices first, then 225); keying on (vbase, transform) alone kept only
the first batch, which reads like a mysteriously short draw and cost an earlier
session a spurious "the capture disagrees with the descriptor" mystery.

Read-only diagnostics behind the existing F10 hotkey; no emulation behaviour
changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NptfmpjdpNCKEez6d2xvA9
2026-08-13 05:58:02 +00:00
MechaCat02
a08526dff0 [GPU] ship-capture: numbered per-press snapshots + capture every instance
Some checks failed
Orchestrator / Commit Message Validation (push) Has been skipped
Orchestrator / Lint (push) Failing after 1m45s
Orchestrator / Windows (x86-64) (push) Has been skipped
Orchestrator / Linux (x86-64) (push) Has been skipped
Orchestrator / Create Release (push) Has been skipped
F10 now writes xenia_ship_capture_NN.log (NN = press number) so several angles
can be captured in one run without overwriting, and de-dups by (vertex-buffer
address, c0..c2 WVP hash) instead of address alone -- the same part buffer
drawn at different transforms (two engine nacelles, each ship of a fleet) now
yields one record per distinct placement. Seen-set cap raised to 8192.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 20:35:20 +02:00
MechaCat02
93fe5d69df [GPU] ship-capture: also dump VS float constants (the placement matrix)
The captured vertex BUFFER holds LOCAL positions (byte-identical to the .xpr) —
capital-ship parts are placed entirely in the vertex shader, not in the buffer.
So the per-part world/WVP matrix lives in the VS float constants. Extend the F10
capture to dump the first 48 vec4 from the SQ_VS_CONST base per draw; diffing two
parts isolates the world matrix (the camera VP block is shared across draws).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 20:35:20 +02:00
MechaCat02
067a373734 [GPU] F10 ship-placement capture: dump world-space draw vertices for RE
One-shot snapshot for reversing capital-ship part placement. F10 arms
RequestShipCaptureFrame(); the next batch of draws is dumped to
xenia_ship_capture.log with each draw's guest vertex-buffer address, vertex/
index counts, and up to 64 WORLD-space vertex positions. Capital-ship parts are
transformed into world space before the draw (unlike stage geometry, which
matches the .xpr byte-for-byte), so these are ground truth for the assembled
placement; a reborn-side correlator affine-fits each decoded .xpr part to a
captured draw to recover its exact transform. De-duped by buffer address, gated
independently of the log_draws cvar, budget-capped at 8000 draws / 4096 buffers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-27 20:35:20 +02:00
7 changed files with 779 additions and 1 deletions

View File

@@ -1067,6 +1067,11 @@ void EmulatorWindow::OnKeyDown(ui::KeyEvent& e) {
case ui::VirtualKey::kF12: {
TakeScreenshot();
} break;
case ui::VirtualKey::kF10: {
// RE: snapshot the next frame's draws (world-space vertex positions) to
// xenia_ship_capture.log for capital-ship placement correlation.
xe::gpu::RequestShipCaptureFrame();
} break;
case ui::VirtualKey::kEscape: {
// Allow users to escape fullscreen (but not enter it).

View File

@@ -64,6 +64,12 @@
#include "xenia/hid/winkey/winkey_hid.h"
#include "xenia/hid/xinput/xinput_hid.h"
#endif // XE_PLATFORM_WIN32
// RE aid: a controller driven by a text file instead of a kernel input device.
// A uinput pad created inside a container registers with the HOST's input stack
// (input devices are not namespaced), so scripted input leaks to the desktop.
// This one is visible only to whoever can read the file. Header-only on purpose:
// it adds no build target.
#include "xenia/hid/file/file_input_driver.h"
#if XE_PLATFORM_WIN32
#define APU_OPTIONS "[any, nop, sdl, xaudio2]"
@@ -72,7 +78,7 @@
#elif XE_PLATFORM_LINUX
#define APU_OPTIONS "[any, alsa, nop, sdl]"
#define GPU_OPTIONS "[any, vulkan, null]"
#define HID_OPTIONS "[any, nop, sdl]"
#define HID_OPTIONS "[any, file, nop, sdl]"
#else
#define APU_OPTIONS "[any, nop, sdl]"
#define GPU_OPTIONS "[any, vulkan, null]"
@@ -82,6 +88,11 @@
DEFINE_string(apu, "any", "Audio system. Use: " APU_OPTIONS, "APU");
DEFINE_string(gpu, "any", "Graphics system. Use: " GPU_OPTIONS, "GPU");
DEFINE_string(hid, "any", "Input system. Use: " HID_OPTIONS, "HID");
DEFINE_string(pad_file, "/tmp/xenia_pad.txt",
"Controller state file read by `--hid=file`: key=value pairs such "
"as `press=A,START lt=0 rt=255 lx=0 ly=0`. Absent keys are "
"neutral, a missing file means no input.",
"HID");
DEFINE_path(
storage_root, "",
@@ -446,6 +457,10 @@ std::vector<std::unique_ptr<hid::InputDriver>> EmulatorApp::CreateInputDrivers(
if (cvars::hid.compare("nop") == 0) {
drivers.emplace_back(
xe::hid::nop::Create(window, EmulatorWindow::kZOrderHidInput));
} else if (cvars::hid.compare("file") == 0) {
// Explicit, never part of "any": this pad must be asked for.
drivers.emplace_back(
xe::hid::filepad::Create(window, EmulatorWindow::kZOrderHidInput));
} else {
Factory<hid::InputDriver, ui::Window*, size_t> factory;
#if XE_PLATFORM_WIN32

View File

@@ -125,8 +125,210 @@ const char* ReVertexFormatName(xenos::VertexFormat f) {
}
} // namespace
// ── Ship-placement capture (RE) ─────────────────────────────────────────────
// A hotkey (F10 in the emulator window) requests a one-shot snapshot: the next
// batch of draws is dumped to xenia_ship_capture_NN.log (NN = press number —
// several angles can be captured in one run without overwriting) with each
// draw's guest vertex-buffer address, vertex/index counts, up to 64 LOCAL
// vertex positions, and the first 48 VS float constants (c0..c2 = the WVP rows
// that place the part). De-duped by (buffer address, c0..c2 transform) — NOT by
// address alone — so the SAME part buffer drawn several times (two engine
// nacelles, every ship of a fleet) yields one record per distinct placement.
namespace {
constexpr int kShipCaptureBudget = 8000; // draws to scan per request
std::atomic<uint32_t> g_ship_capture_gen{0};
std::atomic<int> g_ship_capture_remaining{0};
} // namespace
void RequestShipCaptureFrame() {
uint32_t gen = g_ship_capture_gen.fetch_add(1, std::memory_order_relaxed) + 1;
g_ship_capture_remaining.store(kShipCaptureBudget, std::memory_order_relaxed);
XELOGI("[SHIP-CAP] capture armed → xenia_ship_capture_{:02d}.log", gen);
}
void CommandProcessor::CaptureShipDrawForRE(
uint32_t vgt_draw_initiator_value,
const IndexBufferInfo* index_buffer_info) {
if (g_ship_capture_remaining.load(std::memory_order_relaxed) <= 0) {
return;
}
static std::mutex cap_mutex;
static std::ofstream cap_out;
static std::unordered_set<uint64_t> cap_seen; // by (vbase, WVP transform)
static uint32_t cap_gen = 0;
std::lock_guard<std::mutex> lock(cap_mutex);
if (g_ship_capture_remaining.load(std::memory_order_relaxed) <= 0) {
return;
}
// A new request (fresh generation) opens its own numbered snapshot file and
// clears the de-dup set, so each F10 press yields an independent capture.
uint32_t gen = g_ship_capture_gen.load(std::memory_order_relaxed);
if (gen != cap_gen || !cap_out.is_open()) {
if (cap_out.is_open()) {
cap_out.close();
}
auto name = fmt::format("xenia_ship_capture_{:02d}.log", gen);
cap_out.open(name, std::ios::out | std::ios::trunc);
cap_seen.clear();
cap_gen = gen;
XELOGI("[SHIP-CAP] writing {}", name);
}
g_ship_capture_remaining.fetch_sub(1, std::memory_order_relaxed);
if (!cap_out.is_open()) {
return;
}
reg::VGT_DRAW_INITIATOR init;
init.value = vgt_draw_initiator_value;
Shader* vs = active_vertex_shader_;
if (!vs || !vs->is_ucode_analyzed() || vs->vertex_bindings().empty()) {
return;
}
const auto& binding = vs->vertex_bindings()[0];
xenos::xe_gpu_vertex_fetch_t fetch =
register_file_->GetVertexFetch(binding.fetch_constant);
int32_t pos_off_bytes = -1;
for (const auto& attr : binding.attributes) {
if (attr.fetch_instr.attributes.data_format ==
xenos::VertexFormat::k_32_32_32_FLOAT) {
pos_off_bytes = attr.fetch_instr.attributes.offset * 4;
break;
}
}
uint32_t stride = binding.stride_words * 4;
uint32_t vbase = uint32_t(fetch.address) << 2;
uint32_t buf_bytes = uint32_t(fetch.size) * 4;
if (pos_off_bytes < 0 || stride == 0 || vbase == 0) {
return; // no float-position stream (UI/effects) — skip
}
// One record per distinct (buffer, placement): hash the c0..c2 WVP rows so
// repeated draws of the SAME buffer at DIFFERENT transforms (multi-instance
// parts, fleet ships) each get their own record.
auto vsc0 = register_file_->Get<reg::SQ_VS_CONST>();
uint64_t th = 1469598103934665603ull; // FNV-1a over the 12 c0..c2 floats
for (uint32_t i = 0; i < 12; ++i) {
uint32_t idx = vsc0.base + (i / 4);
if (idx >= 256) {
break;
}
uint32_t r = XE_GPU_REG_SHADER_CONSTANT_000_X + 4 * idx + (i % 4);
uint32_t bits = register_file_->values[r];
th = (th ^ bits) * 1099511628211ull;
}
// Mix the index range into the key as well (added 2026-08-13). The engine
// issues SEVERAL draws over one vertex buffer, each with its own index
// sub-range (the first Stage_02 capture showed a 119-vertex hull LOD drawn
// with 21 indices) — keying on (vbase, transform) alone kept only the first
// batch, which reads like a mysteriously short draw. With the range in the key
// every batch is recorded, so the block's full index extent is observable.
uint64_t ib_key = 0;
if (index_buffer_info) {
ib_key = (uint64_t(index_buffer_info->guest_base) << 20) ^
uint64_t(index_buffer_info->count);
}
uint64_t key = (uint64_t(vbase) << 32) ^ (th & 0xFFFFFFFFull) ^ (th >> 32) ^
(ib_key * 1099511628211ull);
if (!cap_seen.insert(key).second) {
return;
}
if (cap_seen.size() > 8192) {
return;
}
uint32_t vcount = buf_bytes / stride;
cap_out << fmt::format(
"DRAW vbase=0x{:08X} stride={} vcount={} indices={} prim={} vs=0x{:016X}\n",
vbase, stride, vcount, uint32_t(init.num_indices),
uint32_t(init.prim_type), vs->ucode_data_hash());
// The guest's INDEX buffer for this draw. Our offline mesh decoder only
// *assumes* the index buffer sits immediately before the vertex buffer; this
// line is the ground truth for that assumption (ib base vs vbase), and the
// decoded min/max index says how much of the vertex pool the draw really
// covers — which is what the `indices=` field alone cannot answer.
if (index_buffer_info) {
const auto& ib = *index_buffer_info;
bool i32 = ib.format == xenos::IndexFormat::kInt32;
uint32_t icount = ib.count;
cap_out << fmt::format(
" ib base=0x{:08X} count={} fmt={} endian={} len={} delta_vb={}",
ib.guest_base, icount, i32 ? "u32" : "u16", uint32_t(ib.endianness),
ib.length, int64_t(vbase) - int64_t(ib.guest_base));
const uint8_t* ip = memory_->TranslatePhysical<const uint8_t*>(ib.guest_base);
if (ip && icount) {
uint32_t scan = icount < 65536 ? icount : 65536;
uint32_t imin = 0xFFFFFFFFu, imax = 0;
auto rd = [&](uint32_t k) -> uint32_t {
// Guest index data is big-endian in memory (the endianness field says
// how the GPU swaps it); read it that way and record the field so the
// offline side can compensate if a draw ever differs.
const uint8_t* q = ip + (i32 ? k * 4 : k * 2);
return i32 ? (uint32_t(q[0]) << 24) | (uint32_t(q[1]) << 16) |
(uint32_t(q[2]) << 8) | uint32_t(q[3])
: (uint32_t(q[0]) << 8) | uint32_t(q[1]);
};
for (uint32_t k = 0; k < scan; ++k) {
uint32_t v = rd(k);
if (v < imin) imin = v;
if (v > imax) imax = v;
}
cap_out << fmt::format(" min={} max={} idx:", imin, imax);
uint32_t nd = icount < 24 ? icount : 24;
for (uint32_t k = 0; k < nd; ++k) {
cap_out << fmt::format(" {}", rd(k));
}
}
cap_out << "\n";
} else {
cap_out << " ib auto\n";
}
uint32_t n = vcount < 64 ? vcount : 64;
cap_out << " pos:";
auto be_f32 = [](const uint8_t* q) {
uint32_t w = (uint32_t(q[0]) << 24) | (uint32_t(q[1]) << 16) |
(uint32_t(q[2]) << 8) | uint32_t(q[3]);
float f;
std::memcpy(&f, &w, 4);
return f;
};
for (uint32_t v = 0; v < n; ++v) {
uint32_t a = vbase + v * stride + uint32_t(pos_off_bytes);
const uint8_t* p = memory_->TranslatePhysical<const uint8_t*>(a);
if (!p) {
break;
}
cap_out << fmt::format(" ({:.4f},{:.4f},{:.4f})", be_f32(p), be_f32(p + 4),
be_f32(p + 8));
}
cap_out << "\n";
// Vertex-shader float constants: the buffer holds LOCAL positions, so the
// per-part world (or world-view-projection) matrix that places the part lives
// here as a run of float4 constants. Diffing two parts' constants isolates the
// matrix (the camera VP block is shared). Dump the first 48 vec4 from the VS
// constant base as host-float (the register file stores them host-endian).
auto vsc = register_file_->Get<reg::SQ_VS_CONST>();
uint32_t cbase = vsc.base; // starting float4 index
cap_out << fmt::format(" vsconst base={}:", cbase);
for (uint32_t i = 0; i < 48; ++i) {
uint32_t idx = cbase + i;
if (idx >= 256) {
break;
}
uint32_t r = XE_GPU_REG_SHADER_CONSTANT_000_X + 4 * idx;
float cx = register_file_->Get<float>(r);
float cy = register_file_->Get<float>(r + 1);
float cz = register_file_->Get<float>(r + 2);
float cw = register_file_->Get<float>(r + 3);
cap_out << fmt::format(" c{}=({:.4f},{:.4f},{:.4f},{:.4f})", i, cx, cy, cz,
cw);
}
cap_out << "\n";
cap_out.flush();
}
void CommandProcessor::LogDrawForRE(uint32_t vgt_draw_initiator_value,
const IndexBufferInfo* index_buffer_info) {
// One-shot ship-placement capture runs independently of the log_draws cvar.
CaptureShipDrawForRE(vgt_draw_initiator_value, index_buffer_info);
if (!cvars::log_draws) {
return;
}

View File

@@ -35,6 +35,10 @@ class ByteStream;
namespace gpu {
// Arm a one-shot ship-placement capture (see CommandProcessor::CaptureShipDrawForRE).
// Called from the UI thread (F10 hotkey); thread-safe. Defined in command_processor.cc.
void RequestShipCaptureFrame();
enum class GPUSetting { ClearMemoryPageState, ReadbackMemexport };
enum class ReadbackResolveMode {
@@ -453,6 +457,14 @@ class CommandProcessor {
void LogDrawForRE(uint32_t vgt_draw_initiator_value,
const IndexBufferInfo* index_buffer_info);
// Ship-placement capture (RE): a one-shot snapshot armed by RequestShipCaptureFrame()
// (F10 hotkey). Dumps each draw's guest vertex-buffer address, vertex/index
// counts, and up to 64 WORLD-space positions to xenia_ship_capture.log, so a
// correlator can recover each capital-ship part's exact placement. Defined in
// command_processor.cc.
void CaptureShipDrawForRE(uint32_t vgt_draw_initiator_value,
const IndexBufferInfo* index_buffer_info);
// "Actual" is for the command processor thread, to be read by the
// implementations.
SwapPostEffect GetActualSwapPostEffect() const {

View File

@@ -0,0 +1,341 @@
/**
******************************************************************************
* Xenia : Xbox 360 Emulator Research Project *
******************************************************************************
* RE aid: a controller whose state comes from a FILE, not from a device.
******************************************************************************
*/
#ifndef XENIA_HID_FILE_FILE_INPUT_DRIVER_H_
#define XENIA_HID_FILE_FILE_INPUT_DRIVER_H_
// Why this exists
// ---------------
// The scripted-input tool used for reverse engineering created its pad through
// `/dev/uinput`. Input devices are NOT namespaced by the kernel, so a uinput
// device created inside a container is registered with the HOST's input stack:
// every trigger hold and button press is delivered to whatever on the host reads
// gamepads, not only to the emulator. That is a real leak, and it was noticed the
// hard way.
//
// This driver takes the kernel out of the loop entirely. The pad state lives in
// an ordinary text file that only this container can see, and `GetState` reads it
// (re-parsing only when the file changes). Nothing is registered with the host,
// no X server is involved either, and — a bonus for RE — the analogue values are
// exact rather than whatever a virtual stick quantises to.
//
// File format: one or more whitespace/newline separated `key=value` pairs.
//
// press=A,START buttons by name (see kButtonNames), comma separated
// buttons=0x1010 or the raw XINPUT mask, if you prefer
// lt=0 rt=255 triggers, 0..255
// lx=0 ly=0 left thumb, -32768..32767
// rx=0 ry=0 right thumb, -32768..32767
//
// Anything absent is neutral, so `press=A` alone is a valid file. An empty or
// missing file means "no input" — which is also the safe default if the file is
// deleted mid-run.
//
// Path: `--pad_file=<path>`, default `/tmp/xenia_pad.txt`. Only user 0 is
// connected; other slots report no device, as a single-pad console would.
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <memory>
#include <string>
#include <sys/stat.h>
#include "xenia/base/cvar.h"
#include "xenia/base/logging.h"
#include "xenia/hid/input_driver.h"
#include "xenia/ui/virtual_key.h"
DECLARE_string(pad_file);
namespace xe {
namespace hid {
namespace filepad {
struct NamedButton {
const char* name;
uint16_t mask;
};
static constexpr NamedButton kButtonNames[] = {
{"UP", X_INPUT_GAMEPAD_DPAD_UP},
{"DOWN", X_INPUT_GAMEPAD_DPAD_DOWN},
{"LEFT", X_INPUT_GAMEPAD_DPAD_LEFT},
{"RIGHT", X_INPUT_GAMEPAD_DPAD_RIGHT},
{"START", X_INPUT_GAMEPAD_START},
{"BACK", X_INPUT_GAMEPAD_BACK},
{"LS", X_INPUT_GAMEPAD_LEFT_THUMB},
{"RS", X_INPUT_GAMEPAD_RIGHT_THUMB},
{"LB", X_INPUT_GAMEPAD_LEFT_SHOULDER},
{"RB", X_INPUT_GAMEPAD_RIGHT_SHOULDER},
{"A", X_INPUT_GAMEPAD_A},
{"B", X_INPUT_GAMEPAD_B},
{"X", X_INPUT_GAMEPAD_X},
{"Y", X_INPUT_GAMEPAD_Y},
};
class FileInputDriver final : public InputDriver {
public:
FileInputDriver(xe::ui::Window* window, size_t window_z_order)
: InputDriver(window, window_z_order) {}
~FileInputDriver() override = default;
X_STATUS Setup() override {
XELOGI("[file-pad] reading controller state from {}", cvars::pad_file);
return X_STATUS_SUCCESS;
}
X_RESULT GetCapabilities(uint32_t user_index, uint32_t flags,
X_INPUT_CAPABILITIES* out_caps) override {
if (user_index != 0) {
return X_ERROR_DEVICE_NOT_CONNECTED;
}
std::memset(out_caps, 0, sizeof(*out_caps));
out_caps->type = 0x01; // XINPUT_DEVTYPE_GAMEPAD
out_caps->sub_type = 0x01; // XINPUT_DEVSUBTYPE_GAMEPAD
out_caps->flags = 0;
out_caps->gamepad.buttons = 0xFFFF;
out_caps->gamepad.left_trigger = 0xFF;
out_caps->gamepad.right_trigger = 0xFF;
out_caps->gamepad.thumb_lx = static_cast<int16_t>(0xFFFFu);
out_caps->gamepad.thumb_ly = static_cast<int16_t>(0xFFFFu);
out_caps->gamepad.thumb_rx = static_cast<int16_t>(0xFFFFu);
out_caps->gamepad.thumb_ry = static_cast<int16_t>(0xFFFFu);
return X_ERROR_SUCCESS;
}
X_RESULT GetState(uint32_t user_index, X_INPUT_STATE* out_state) override {
if (user_index != 0) {
return X_ERROR_DEVICE_NOT_CONNECTED;
}
Refresh();
std::memset(out_state, 0, sizeof(*out_state));
out_state->packet_number = packet_;
out_state->gamepad.buttons = buttons_;
out_state->gamepad.left_trigger = lt_;
out_state->gamepad.right_trigger = rt_;
out_state->gamepad.thumb_lx = lx_;
out_state->gamepad.thumb_ly = ly_;
out_state->gamepad.thumb_rx = rx_;
out_state->gamepad.thumb_ry = ry_;
return X_ERROR_SUCCESS;
}
X_RESULT SetState(uint32_t user_index, X_INPUT_VIBRATION* vibration) override {
return user_index == 0 ? X_ERROR_SUCCESS : X_ERROR_DEVICE_NOT_CONNECTED;
}
// Menus do NOT read the pad through GetState. "PRESS (A) BUTTON" and most
// 360 front-ends poll XamInputGetKeystrokeEx, so a driver that only answers
// GetState looks completely dead on a title screen while its own log happily
// shows the button arriving. Returning X_ERROR_EMPTY here is what made the
// first scripted run press A into the void.
//
// One event per call, edge triggered: KEYUPs for everything released, then
// KEYDOWNs for everything pressed, exactly as the SDL driver orders them.
// Deliberately NO auto-repeat — scripted input wants precisely one event per
// press, and repeat is what makes menu steps overshoot.
X_RESULT GetKeystroke(uint32_t user_index, uint32_t flags,
X_INPUT_KEYSTROKE* out_keystroke) override {
const bool user_any = user_index == 0xFF || user_index == 0xFFFFFFFFu;
if (!user_any && user_index != 0) {
return X_ERROR_DEVICE_NOT_CONNECTED;
}
if (!out_keystroke) {
return X_ERROR_BAD_ARGUMENTS;
}
Refresh();
// Bit index in X_INPUT_GAMEPAD::buttons -> virtual key. Order matters: it is
// the order multiple simultaneous changes are reported in.
static constexpr uint16_t kVk[16] = {
uint16_t(ui::VirtualKey::kXInputPadDpadUp),
uint16_t(ui::VirtualKey::kXInputPadDpadDown),
uint16_t(ui::VirtualKey::kXInputPadDpadLeft),
uint16_t(ui::VirtualKey::kXInputPadDpadRight),
uint16_t(ui::VirtualKey::kXInputPadStart),
uint16_t(ui::VirtualKey::kXInputPadBack),
uint16_t(ui::VirtualKey::kXInputPadLThumbPress),
uint16_t(ui::VirtualKey::kXInputPadRThumbPress),
uint16_t(ui::VirtualKey::kXInputPadLShoulder),
uint16_t(ui::VirtualKey::kXInputPadRShoulder),
0, /* guide */
0, /* unused */
uint16_t(ui::VirtualKey::kXInputPadA),
uint16_t(ui::VirtualKey::kXInputPadB),
uint16_t(ui::VirtualKey::kXInputPadX),
uint16_t(ui::VirtualKey::kXInputPadY),
};
const uint16_t changed = static_cast<uint16_t>(buttons_ ^ reported_);
if (!changed) {
return X_ERROR_EMPTY;
}
for (int pass = 0; pass < 2; ++pass) {
const bool clear_pass = pass == 0;
for (uint8_t i = 0; i < 16; ++i) {
const uint16_t bit = static_cast<uint16_t>(1u << i);
if (!(changed & bit) || kVk[i] == 0) {
continue;
}
const bool pressed = (buttons_ & bit) != 0;
if (clear_pass == pressed) {
continue;
}
reported_ = static_cast<uint16_t>(pressed ? (reported_ | bit)
: (reported_ & ~bit));
out_keystroke->virtual_key = kVk[i];
out_keystroke->unicode = 0;
out_keystroke->flags =
pressed ? X_INPUT_KEYSTROKE_KEYDOWN : X_INPUT_KEYSTROKE_KEYUP;
out_keystroke->user_index = 0;
out_keystroke->hid_code = 0;
XELOGI("[file-pad] keystroke vk={:04X} {}", kVk[i],
pressed ? "down" : "up");
return X_ERROR_SUCCESS;
}
}
// Only bits without a virtual key changed (guide/unused): swallow them so
// the caller is not asked again forever.
reported_ = buttons_;
return X_ERROR_EMPTY;
}
InputType GetInputType() const override { return InputType::Controller; }
private:
// Re-parse only when the file actually changed: `GetState` is polled every
// frame and a stat is far cheaper than a read+parse.
//
// The change test uses **nanosecond** mtime, not `st_mtime`. Whole-second
// granularity plus size looked sufficient and is not: a script that steps a
// menu writes several same-length states per second (`press=A` then `press=B`,
// both 8 bytes), and every one of those after the first would be silently
// dropped. That failure is invisible — the emulator just does not react — so
// it is worth the extra field.
void Refresh() {
struct stat st;
if (::stat(cvars::pad_file.c_str(), &st) != 0) {
if (present_) {
present_ = false;
Neutral();
++packet_;
XELOGI("[file-pad] {} gone -> neutral", cvars::pad_file);
}
return;
}
if (present_ && st.st_mtim.tv_sec == mtime_sec_ &&
st.st_mtim.tv_nsec == mtime_nsec_ && st.st_size == size_) {
return;
}
present_ = true;
mtime_sec_ = st.st_mtim.tv_sec;
mtime_nsec_ = st.st_mtim.tv_nsec;
size_ = st.st_size;
std::FILE* f = std::fopen(cvars::pad_file.c_str(), "rb");
if (!f) {
Neutral();
return;
}
char buf[512] = {0};
size_t n = std::fread(buf, 1, sizeof(buf) - 1, f);
std::fclose(f);
buf[n] = '\0';
Parse(buf);
++packet_;
// One line per change (not per frame): with no display to watch, this log is
// the only proof that a scripted press was actually picked up.
XELOGI("[file-pad] #{} buttons={:04X} lt={} rt={} lx={} ly={} rx={} ry={}",
packet_, buttons_, lt_, rt_, lx_, ly_, rx_, ry_);
}
void Neutral() {
buttons_ = 0;
lt_ = rt_ = 0;
lx_ = ly_ = rx_ = ry_ = 0;
}
void Parse(const char* text) {
Neutral();
std::string s(text);
size_t pos = 0;
while (pos < s.size()) {
size_t end = s.find_first_of(" \t\r\n", pos);
if (end == std::string::npos) {
end = s.size();
}
std::string tok = s.substr(pos, end - pos);
pos = end + 1;
size_t eq = tok.find('=');
if (eq == std::string::npos) {
continue;
}
std::string key = tok.substr(0, eq), val = tok.substr(eq + 1);
if (key == "press") {
size_t p = 0;
while (p < val.size()) {
size_t c = val.find(',', p);
if (c == std::string::npos) {
c = val.size();
}
std::string name = val.substr(p, c - p);
p = c + 1;
for (const auto& b : kButtonNames) {
if (name == b.name) {
buttons_ |= b.mask;
break;
}
}
}
} else if (key == "buttons") {
buttons_ |= static_cast<uint16_t>(std::strtoul(val.c_str(), nullptr, 0));
} else if (key == "lt") {
lt_ = Clamp8(std::strtol(val.c_str(), nullptr, 0));
} else if (key == "rt") {
rt_ = Clamp8(std::strtol(val.c_str(), nullptr, 0));
} else if (key == "lx") {
lx_ = Clamp16(std::strtol(val.c_str(), nullptr, 0));
} else if (key == "ly") {
ly_ = Clamp16(std::strtol(val.c_str(), nullptr, 0));
} else if (key == "rx") {
rx_ = Clamp16(std::strtol(val.c_str(), nullptr, 0));
} else if (key == "ry") {
ry_ = Clamp16(std::strtol(val.c_str(), nullptr, 0));
}
}
}
static uint8_t Clamp8(long v) {
return static_cast<uint8_t>(v < 0 ? 0 : (v > 255 ? 255 : v));
}
static int16_t Clamp16(long v) {
return static_cast<int16_t>(v < -32768 ? -32768 : (v > 32767 ? 32767 : v));
}
uint16_t buttons_ = 0;
uint8_t lt_ = 0, rt_ = 0;
int16_t lx_ = 0, ly_ = 0, rx_ = 0, ry_ = 0;
uint32_t packet_ = 1;
// Buttons already reported through GetKeystroke; the edge detector's memory.
uint16_t reported_ = 0;
bool present_ = false;
time_t mtime_sec_ = 0;
long mtime_nsec_ = -1;
off_t size_ = -1;
};
inline std::unique_ptr<InputDriver> Create(xe::ui::Window* window,
size_t window_z_order) {
return std::make_unique<FileInputDriver>(window, window_z_order);
}
} // namespace filepad
} // namespace hid
} // namespace xe
#endif // XENIA_HID_FILE_FILE_INPUT_DRIVER_H_

View 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_

View File

@@ -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);