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>
204 lines
7.8 KiB
Rust
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());
|
|
}
|
|
}
|