Files
xenia-rs/crates/xenia-gpu/src/ring_view.rs
MechaCat02 034ec8b47f [iterate-2O] GPU: drain indirect buffers correctly — Sylpheed renders splash (draws 0→78)
Ours' GPU never drained the D3D driver's system command buffer past the first
11-dword indirect buffer, so DRAW_INDX / reg-0x57C-arm packets never executed
and draws stayed 0 (the long-hunted render gate; see UPDATE-18). Runtime tracing
(temporary, removed) showed the guest submits 6 INDIRECT_BUFFER packets at boot
(CP_RB_WPTR 22→37) but ours executed exactly ONE IB and then spun 15.7M packets
inside it. Three coupled command-processor bugs, all corrected to match canary:

1. `sync_with_mmio` applied the primary CP_RB_WPTR to whichever ring was active,
   including an executing indirect buffer — `37 % 11 = 3` clobbered the IB's
   write pointer so its read pointer looped 0→2→5→0 forever and never popped
   back to the primary ring. CP_RB_WPTR governs ONLY the primary ring; while an
   IB executes, the primary is the bottom of the IB stack. Canary executes each
   IB through a separate `RingBuffer reader_` (command_processor.cc), so the
   primary write pointer is structurally inapplicable to an IB.

2. Indirect buffers were treated as circular rings: read wrapped at `size_dwords`
   (`11 % 11 = 0`) and never reached the fixed write pointer, so even without the
   clobber the IB could not terminate. An IB is a fixed *linear* sub-stream; add
   `RingBufferView.indirect` and drain `[0, ib_size)` monotonically, then pop.

3. `is_ready` only checked the active ring, so an IB that now correctly exhausts
   would never get `execute_one` called again to pop back to the primary ring
   (whose WPTR may have advanced). Check the whole IB stack.

Also: the ring was sized `1 << size_log2` bytes (1024 dwords) vs canary's
`1 << (size_log2 + 3)` (8192 dwords) — an 8× undersize that desynced WPTR-wrap
math from the guest. Fixed in `GpuSystem::initialize_ring_buffer` (and the
dead bookkeeping copy in `vd_initialize_ring_buffer`).

Cascade (deterministic; threaded-default backend, byte-identical across runs):
reg 0x57C now written, IB jumps 1→12, packets 15.7M→9,825, and the splash
renders — draws 0→78, shaders 0→3, render_targets 0→2, swaps 2→3 — stable at
50M / 200M / 1B. Boot then reaches a new downstream gate (draws plateau at 78,
interrupts keep climbing → engine alive, not deadlocked).

golden `sylpheed_n50m.json` re-baselined (draws 78). `cargo test --workspace`
green (674; +2 ring_view regression tests). vd_swap's synthetic-swap
short-circuit is now redundant but left untouched (cascade works without
changing it); cleaning it up is a separate follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:06:16 +02:00

204 lines
7.8 KiB
Rust

//! Primary ring buffer view.
//!
//! Games allocate a ring buffer in physical memory (via
//! `MmAllocatePhysicalMemoryEx` with WRITE_COMBINE), then hand the base
//! address + log2(size) to `VdInitializeRingBuffer`. They subsequently push
//! PM4 packets into it, advancing the write-pointer by writing to a GPU
//! register (`CP_RB_WPTR`) or via kernel-call shims.
//!
//! The GPU consumes packets from `read_offset_dwords` up to (but not past)
//! the write pointer. After consuming enough bytes it writes `read_offset`
//! into the guest-memory address registered by `VdEnableRingBufferRPtrWriteBack`
//! so the game can know how much of the ring has been consumed.
/// Tracks the primary ring buffer as set up by the guest.
#[derive(Debug, Clone, Copy, Default)]
pub struct RingBufferView {
/// Guest physical/virtual base address. `0` means uninitialized.
pub base: u32,
/// Size of the ring in dwords. `0` means uninitialized.
pub size_dwords: u32,
/// Dword offset the GPU has consumed up to (relative to `base`).
pub read_offset_dwords: u32,
/// Dword offset the guest has last written into (relative to `base`).
/// Updated either by an MMIO write to `CP_RB_WPTR` or by the kernel
/// (`VdSwap` is a hint — the game reserves a 64-dword slot in the ring
/// for it).
pub write_offset_dwords: u32,
/// Guest address where we mirror `read_offset_dwords` each time we make
/// progress. `0` if the game never called `VdEnableRingBufferRPtrWriteBack`.
pub rptr_writeback_addr: u32,
/// Write-back block granularity in dwords (from the `log2` arg to
/// `VdEnableRingBufferRPtrWriteBack`). We always write back eagerly, so
/// we don't actually use this for scheduling — kept for observability.
pub rptr_writeback_block_dwords: u32,
/// True for an indirect-buffer (`INDIRECT_BUFFER`) view. An IB is a fixed
/// *linear* sub-stream, not a circular ring: it is fully written when the
/// GPU jumps to it, so the read pointer advances monotonically from `0` to
/// `size_dwords` and then the buffer is exhausted (the caller ring is
/// popped). It must NOT wrap, and the primary `CP_RB_WPTR` must not be
/// applied to it. Mirrors canary `ExecuteIndirectBuffer`, which executes
/// the IB through a separate `RingBuffer reader_` and restores the primary
/// reader afterward (command_processor.cc). Circular (primary-ring)
/// semantics are used when this is `false`.
pub indirect: bool,
}
impl RingBufferView {
pub fn new() -> Self {
Self::default()
}
/// True if the guest has provided a base + size.
pub fn is_initialized(&self) -> bool {
self.base != 0 && self.size_dwords != 0
}
/// True if there is pending unread data to consume.
pub fn has_pending(&self) -> bool {
if !self.is_initialized() {
return false;
}
if self.indirect {
// Linear sub-stream: exhausted once the read pointer reaches the
// (fixed) write pointer. Never wraps.
self.read_offset_dwords < self.write_offset_dwords
} else {
self.read_offset_dwords != self.write_offset_dwords
}
}
/// Number of dwords we can consume without wrapping past the write ptr.
pub fn pending_dwords(&self) -> u32 {
if !self.is_initialized() {
return 0;
}
if self.indirect {
self.write_offset_dwords
.saturating_sub(self.read_offset_dwords)
} else if self.write_offset_dwords >= self.read_offset_dwords {
self.write_offset_dwords - self.read_offset_dwords
} else {
// write has wrapped — we can read up to the end of the ring.
self.size_dwords - self.read_offset_dwords
}
}
/// Advance the read pointer by `dwords`. Circular rings wrap at
/// `size_dwords`; an indirect buffer advances linearly (no wrap) so it
/// terminates exactly at its fixed write pointer.
pub fn advance_read(&mut self, dwords: u32) {
if self.size_dwords == 0 {
return;
}
if self.indirect {
self.read_offset_dwords = self.read_offset_dwords.saturating_add(dwords);
} else {
self.read_offset_dwords =
(self.read_offset_dwords + dwords) % self.size_dwords;
}
}
/// Guest address for the dword at relative offset `i` from the current
/// read pointer. `None` if uninitialized.
pub fn addr_at_offset(&self, offset_dwords: u32) -> Option<u32> {
if !self.is_initialized() {
return None;
}
let off = if self.indirect {
self.read_offset_dwords.saturating_add(offset_dwords)
} else {
(self.read_offset_dwords + offset_dwords) % self.size_dwords
};
Some(self.base.wrapping_add(off.wrapping_mul(4)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uninitialized_view_reports_empty() {
let v = RingBufferView::new();
assert!(!v.is_initialized());
assert!(!v.has_pending());
assert_eq!(v.pending_dwords(), 0);
}
#[test]
fn wrap_around_arithmetic() {
let mut v = RingBufferView::new();
v.base = 0x4000_0000;
v.size_dwords = 16;
v.read_offset_dwords = 14;
v.write_offset_dwords = 2; // wrapped
// We can only read to end-of-ring in one chunk.
assert_eq!(v.pending_dwords(), 2);
v.advance_read(2);
assert_eq!(v.read_offset_dwords, 0);
// Now unwrapped, 2 more to go.
assert_eq!(v.pending_dwords(), 2);
}
#[test]
fn addr_at_offset_wraps() {
let mut v = RingBufferView::new();
v.base = 0x4000_0000;
v.size_dwords = 4;
v.read_offset_dwords = 3;
assert_eq!(v.addr_at_offset(0), Some(0x4000_000C));
assert_eq!(v.addr_at_offset(1), Some(0x4000_0000));
assert_eq!(v.addr_at_offset(2), Some(0x4000_0004));
}
#[test]
fn indirect_buffer_drains_linearly_and_terminates() {
// An indirect buffer is a fixed linear sub-stream: read advances from
// 0 to `size_dwords` and then is exhausted — it must NOT wrap back to
// 0 (which previously caused an infinite re-read of a system command
// buffer; iterate-2O). write_offset == size, exactly as the
// INDIRECT_BUFFER handler sets it.
let mut ib = RingBufferView {
base: 0x4adf_5080,
size_dwords: 11,
read_offset_dwords: 0,
write_offset_dwords: 11,
rptr_writeback_addr: 0,
rptr_writeback_block_dwords: 0,
indirect: true,
};
assert!(ib.has_pending());
// Drain the exact packet layout observed for Sylpheed's init IB:
// 2 + 3 + 6 dwords = 11.
ib.advance_read(2);
assert!(ib.has_pending());
ib.advance_read(3);
assert!(ib.has_pending());
ib.advance_read(6); // reaches 11 == write
assert_eq!(ib.read_offset_dwords, 11);
assert!(
!ib.has_pending(),
"indirect buffer must terminate at write ptr, not wrap to 0"
);
// addr_at_offset must not modulo-wrap for an indirect buffer.
ib.read_offset_dwords = 9;
assert_eq!(ib.addr_at_offset(1), Some(0x4adf_5080 + 10 * 4));
}
#[test]
fn indirect_flag_does_not_affect_circular_ring() {
// Sanity: a circular (primary) ring still wraps as before.
let mut v = RingBufferView::new();
v.base = 0x4adc_c000;
v.size_dwords = 8192;
v.read_offset_dwords = 8190;
v.write_offset_dwords = 2;
assert!(v.has_pending());
v.advance_read(4); // (8190 + 4) % 8192 = 2
assert_eq!(v.read_offset_dwords, 2);
assert!(!v.has_pending());
}
}