[RE] log_ui_draws: a per-draw, submission-order capture for 2D screens

Neither existing RE hook can say in what order the game paints a UI screen:
`log_draws` de-dups by vertex-declaration fingerprint, so a screen's sprites —
which share a declaration — collapse into one record; and the F10 ship capture
returns early on any draw without an f32x3 position stream, which is every UI
quad.

So: `--log_ui_draws` arms (on F10, alongside the ship capture) a snapshot of the
next `--ui_draw_capture_frames` submitted frames, undeduplicated, writing every
draw with its primitive type, index count, VS/PS hashes, each bound texture's
base and dimensions, and the quad's vertex positions. The positions are the part
that matters: a screen's sprites all share one shader and sample big texture
pages, so the geometry is what names an element.

Frames are counted here rather than from `counter_`. `counter_` looks like a
frame number — the VdSwap packet increments it — but MarkVblank() increments it
too, from the vblank thread, so it advances between two draws of the SAME frame.
Bounded by it, the first capture ended after one draw having "covered 6 frames".

Measured on the title screen: 11 draws a frame, every frame.
This commit is contained in:
Sylpheed RE agent
2026-08-18 18:57:18 +00:00
parent a05350ced3
commit 2263470723
4 changed files with 230 additions and 1 deletions

View File

@@ -1071,6 +1071,11 @@ void EmulatorWindow::OnKeyDown(ui::KeyEvent& e) {
// RE: snapshot the next frame's draws (world-space vertex positions) to
// xenia_ship_capture.log for capital-ship placement correlation.
xe::gpu::RequestShipCaptureFrame();
// …and, when `log_ui_draws` is set, the next few frames' draws in
// SUBMISSION ORDER for 2D screen paint-order correlation. It shares this
// hotkey because every F-key in this switch is already bound; the cvar
// keeps it off the normal ship-capture path.
xe::gpu::RequestUiDrawCapture();
} break;
case ui::VirtualKey::kEscape: {

View File

@@ -90,6 +90,30 @@ DEFINE_bool(
"game mesh formats against GPU ground truth.",
"GPU");
DEFINE_bool(
log_ui_draws, false,
"Reverse-engineering aid: arm a UI DRAW-ORDER capture with F10. Writes "
"every draw of the next few swapped frames to xenia_re_ui_draws_NN.log in "
"SUBMISSION ORDER and undeduplicated, with each draw's bound texture base "
"and dimensions. For recovering the paint order of a 2D screen, which "
"log_draws cannot give (it de-dups by vertex declaration) and the F10 ship "
"capture cannot either (it skips draws with no f32x3 position stream).",
"GPU");
DEFINE_int32(
ui_draw_capture_frames, 3,
"Reverse-engineering aid: how many submitted frames one `log_ui_draws` "
"capture covers. 3 is enough for a screen that redraws its elements every "
"frame; a screen that composites once and then re-blits needs a window long "
"enough to contain the rebuild.",
"GPU");
DEFINE_int32(
ui_draw_capture_max, 20000,
"Reverse-engineering aid: hard stop on the number of draws one "
"`log_ui_draws` capture records, whatever the frame window says.",
"GPU");
DEFINE_bool(
readback_memexport, false,
"Read data written by memory export in shaders on the CPU. "
@@ -146,6 +170,174 @@ void RequestShipCaptureFrame() {
XELOGI("[SHIP-CAP] capture armed → xenia_ship_capture_{:02d}.log", gen);
}
// ── UI draw-order capture (RE) ──────────────────────────────────────────────
// F9 arms a snapshot of the next few swapped frames. Unlike the two hooks
// above this one does NOT de-duplicate: the whole question it answers is what
// order the guest submits a 2D screen's sprites in, and a de-duplicating log
// cannot answer that. Bounded by a frame count rather than a draw count so the
// records line up with what is on screen.
namespace {
std::atomic<uint32_t> g_ui_capture_gen{0};
std::atomic<bool> g_ui_capture_armed{false};
// Frames are counted HERE rather than from `counter_`, which looks like a frame
// number and is not one: MarkVblank() also increments it, from the vblank
// thread, so it advances between two draws of the SAME frame. A capture bounded
// by it ended after a single draw. This one is bumped only by the VdSwap packet,
// on the command-processor thread, so it is exactly "frames submitted".
std::atomic<uint32_t> g_ui_swap_count{0};
} // namespace
void NoteUiDrawCaptureSwap() {
if (g_ui_capture_armed.load(std::memory_order_relaxed)) {
g_ui_swap_count.fetch_add(1, std::memory_order_relaxed);
}
}
void RequestUiDrawCapture() {
if (!cvars::log_ui_draws) {
return;
}
uint32_t gen = g_ui_capture_gen.fetch_add(1, std::memory_order_relaxed) + 1;
g_ui_swap_count.store(0, std::memory_order_relaxed);
g_ui_capture_armed.store(true, std::memory_order_relaxed);
XELOGI("[UI-CAP] capture armed -> xenia_re_ui_draws_{:02d}.log", gen);
}
void CommandProcessor::CaptureUiDrawForRE(
uint32_t vgt_draw_initiator_value,
const IndexBufferInfo* index_buffer_info) {
if (!g_ui_capture_armed.load(std::memory_order_relaxed)) {
return;
}
static std::mutex ui_mutex;
static std::ofstream ui_out;
static uint32_t ui_gen = 0;
static uint32_t ui_first_frame = 0;
static int ui_draws = 0;
static uint32_t ui_last_frame = 0;
std::lock_guard<std::mutex> lock(ui_mutex);
if (!g_ui_capture_armed.load(std::memory_order_relaxed)) {
return;
}
uint32_t gen = g_ui_capture_gen.load(std::memory_order_relaxed);
if (gen != ui_gen || !ui_out.is_open()) {
if (ui_out.is_open()) {
ui_out.close();
}
auto name = fmt::format("xenia_re_ui_draws_{:02d}.log", gen);
ui_out.open(name, std::ios::out | std::ios::trunc);
ui_gen = gen;
ui_first_frame = g_ui_swap_count.load(std::memory_order_relaxed);
ui_last_frame = ui_first_frame;
ui_draws = 0;
XELOGI("[UI-CAP] writing {} (from frame {})", name, ui_first_frame);
ui_out << fmt::format(
"# every draw in SUBMISSION ORDER, undeduplicated, frames {}..{}\n"
"# tex dimensions identify the sprite; base is the guest address\n",
ui_first_frame,
ui_first_frame + uint32_t(std::max(1, cvars::ui_draw_capture_frames)) -
1);
}
if (!ui_out.is_open()) {
g_ui_capture_armed.store(false, std::memory_order_relaxed);
return;
}
uint32_t frame = g_ui_swap_count.load(std::memory_order_relaxed);
const uint32_t capture_frames =
uint32_t(std::max(1, cvars::ui_draw_capture_frames));
if (frame - ui_first_frame >= capture_frames ||
ui_draws >= std::max(1, cvars::ui_draw_capture_max)) {
ui_out.flush();
g_ui_capture_armed.store(false, std::memory_order_relaxed);
XELOGI("[UI-CAP] done: {} draws over {} frames", ui_draws,
frame - ui_first_frame);
return;
}
if (frame != ui_last_frame) {
ui_out << fmt::format("--- frame {} ---\n", frame);
ui_last_frame = frame;
}
reg::VGT_DRAW_INITIATOR init;
init.value = vgt_draw_initiator_value;
ui_out << fmt::format("{:4} prim={} indices={}", ui_draws++,
uint32_t(init.prim_type),
uint32_t(init.num_indices));
if (index_buffer_info) {
ui_out << fmt::format(" ib=0x{:08X}", index_buffer_info->guest_base);
}
if (active_vertex_shader_) {
ui_out << fmt::format(" vs=0x{:016X}",
active_vertex_shader_->ucode_data_hash());
}
Shader* ps = active_pixel_shader_;
if (ps) {
ui_out << fmt::format(" ps=0x{:016X}", ps->ucode_data_hash());
}
if (ps && ps->is_ucode_analyzed()) {
for (const auto& tb : ps->texture_bindings()) {
xenos::xe_gpu_texture_fetch_t tf =
register_file_->GetTextureFetch(tb.fetch_constant);
// Dimensions are stored as (actual - 1).
ui_out << fmt::format(" tex[base=0x{:08X} {}x{} fmt={}]",
uint32_t(tf.base_address) << 12,
uint32_t(tf.size_2d.width) + 1,
uint32_t(tf.size_2d.height) + 1,
uint32_t(tf.format));
}
}
// The quad's geometry is the only thing that says WHICH element a draw is:
// these sprites all share one shader and sample big texture pages, so the
// vertex data is the identity. Dump attribute 0 of binding 0 for the first
// few vertices, raw and as big-endian floats — the format is not assumed.
Shader* vs = active_vertex_shader_;
if (vs && vs->is_ucode_analyzed() && !vs->vertex_bindings().empty()) {
const auto& binding = vs->vertex_bindings()[0];
xenos::xe_gpu_vertex_fetch_t fetch =
register_file_->GetVertexFetch(binding.fetch_constant);
uint32_t stride = binding.stride_words * 4;
uint32_t vbase = uint32_t(fetch.address) << 2;
ui_out << fmt::format("\n vb=0x{:08X} stride={} attrs=[", vbase, stride);
int32_t pos_off = -1;
uint32_t pos_fmt = 0;
for (const auto& attr : binding.attributes) {
uint32_t f = uint32_t(attr.fetch_instr.attributes.data_format);
uint32_t off = attr.fetch_instr.attributes.offset * 4;
ui_out << fmt::format("{}@{} ", f, off);
if (pos_off < 0) {
pos_off = int32_t(off);
pos_fmt = f;
}
}
ui_out << "]";
if (stride && vbase && pos_off >= 0) {
auto be_u32 = [](const uint8_t* q) {
return (uint32_t(q[0]) << 24) | (uint32_t(q[1]) << 16) |
(uint32_t(q[2]) << 8) | uint32_t(q[3]);
};
uint32_t nv = uint32_t(init.num_indices);
if (nv > 8) {
nv = 8;
}
ui_out << fmt::format(" fmt0={} v:", pos_fmt);
for (uint32_t v = 0; v < nv; ++v) {
const uint8_t* q = memory_->TranslatePhysical<const uint8_t*>(
vbase + v * stride + uint32_t(pos_off));
if (!q) {
break;
}
uint32_t w0 = be_u32(q), w1 = be_u32(q + 4);
float f0, f1;
std::memcpy(&f0, &w0, 4);
std::memcpy(&f1, &w1, 4);
ui_out << fmt::format(" [{:08X},{:08X}={:.2f},{:.2f}]", w0, w1, f0, f1);
}
}
}
ui_out << "\n";
}
void CommandProcessor::CaptureShipDrawForRE(
uint32_t vgt_draw_initiator_value,
const IndexBufferInfo* index_buffer_info) {
@@ -327,8 +519,9 @@ void CommandProcessor::CaptureShipDrawForRE(
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.
// One-shot captures run independently of the log_draws cvar.
CaptureShipDrawForRE(vgt_draw_initiator_value, index_buffer_info);
CaptureUiDrawForRE(vgt_draw_initiator_value, index_buffer_info);
if (!cvars::log_draws) {
return;
}

View File

@@ -39,6 +39,15 @@ namespace gpu {
// Called from the UI thread (F10 hotkey); thread-safe. Defined in command_processor.cc.
void RequestShipCaptureFrame();
// Arm a one-shot UI DRAW-ORDER capture (see CommandProcessor::CaptureUiDrawForRE).
// Shares the F10 hotkey with the ship capture; the `log_ui_draws` cvar is what
// separates them.
void RequestUiDrawCapture();
// Count one submitted frame for that capture. Called from the VdSwap packet
// handler, on the command-processor thread.
void NoteUiDrawCaptureSwap();
enum class GPUSetting { ClearMemoryPageState, ReadbackMemexport };
enum class ReadbackResolveMode {
@@ -465,6 +474,24 @@ class CommandProcessor {
void CaptureShipDrawForRE(uint32_t vgt_draw_initiator_value,
const IndexBufferInfo* index_buffer_info);
// UI draw-order capture (RE): a one-shot snapshot armed by
// RequestUiDrawCapture() (F9 hotkey). Writes EVERY draw of the next few
// swapped frames to xenia_re_ui_draws_NN.log **in submission order and
// undeduplicated**, with each draw's bound texture base and dimensions.
//
// This exists because neither of the other two hooks can answer "in what
// order does the game paint a 2D screen":
// * `log_draws` de-dups by vertex-declaration fingerprint, so a screen's
// sprites — which share a declaration — collapse to one record, and what
// survives is first-seen order, not per-frame submission order;
// * CaptureShipDrawForRE returns early on any draw with no f32x3 position
// stream, which is every UI quad.
// Texture dimensions are the identity here: a screen's sprites decode to
// near-unique sizes, so `640x360` names the title screen's background as
// surely as a resource name would. Defined in command_processor.cc.
void CaptureUiDrawForRE(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

@@ -663,6 +663,10 @@ bool COMMAND_PROCESSOR::ExecutePacketType3_XE_SWAP(uint32_t packet,
COMMAND_PROCESSOR::IssueSwap(frontbuffer_ptr, frontbuffer_width,
frontbuffer_height);
// RE: bracket the UI draw-order capture on real submitted frames (`counter_`
// below is also bumped by MarkVblank, from another thread, so it cannot).
xe::gpu::NoteUiDrawCaptureSwap();
++counter_;
return true;
}