The publisher/dev splash logo's intro fade-IN was skipped: the logo popped in at full brightness instead of ramping dim->bright like the canary oracle. Root (measured, iterate-3AF/3AI): ours' guest vsync counter is fed by a fixed-instruction-quantum proxy (one vsync per 150k retired instructions). During the ~1.1s splash asset-load the title's frame pump runs ~10M instructions inside a single guest frame, so the proxy fired ~66 vsyncs in that one frame. The pump's per-frame delta (counter_now - counter_last) was therefore ~66 on the first tick, which the anim tick (sub_823CDBF8) divides into the fade counter [item+72] @ 0x40c0add0 -> the counter JUMPED 0->0x42(66) in one step, landing past the fade-in region. Canary's wall-clock 60Hz vblank advances ~1 per heavy load frame, so its counter ramps smoothly 0->66 and the fade-in renders. Fix: anchor the lockstep vsync ticker to the guest's real present rate (VdSwap count), mirroring real hardware where the title double-buffers at vblank, so one heavy guest frame advances the vsync counter by ~1 instead of ~66. - interrupts.rs: tick_vsync_instr now takes the live present count. Two regimes: (1) bootstrap, before the guest's first present, keeps the original fixed instruction quantum unchanged -- the iterate-2W present-loop bootstrap needs vsyncs delivered BEFORE it can present (measured: callback registered ~6M instr, first delivered vsync and first present coincide; pure present-driven vsync would deadlock). (2) present-anchored, after the first present: one vblank per present, plus a small DRY_FALLBACK_CAP=4 instruction-quantum fallback per dry window so a non-presenting frame still ticks a few vsyncs (a small ramp like canary's 0/5/10/2/1...) without re-spiking to 66. - handle.rs: cheap GpuBackend::swaps_seen() accessor. - main.rs: pass the live present count into the lockstep ticker. Not masking: the fade dt/counter is never clamped or synthesized; the guest naturally computes a smooth dt once vblank tracks presents. Verified: - V1: fade counter 0x40c0add0 now ramps 0,6,8,10,12,13,+1... (was a 0->0x42 jump; direct baseline-vs-fix mem-watch). - V2 (--ui readback via per-frame logo vertex-alpha): logo alpha ramps 102,136,204,221,238,254 (dim->bright fade-IN) vs baseline all 255 (pop-in). Real artwork (has_real_vertices) still renders; milestone-1 intact. - V3: 150M boot progression intact -- texture_decodes=2, RTs=2, tex_cache=1 unchanged; draws/swaps higher (tighter present loop), 1B sanity linear, no stall/collapse. - V4: 50M --gpu-inline --stable-digest byte-identical 2x; golden re-baselined intentionally (pacing-only delta: draws 718->1274, swaps 147->259; structural fields unchanged). 688 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
688 lines
30 KiB
Rust
688 lines
30 KiB
Rust
//! Graphics interrupt + synthetic v-sync bookkeeping (P6).
|
||
//!
|
||
//! The Xbox 360 graphics driver calls `VdSetGraphicsInterruptCallback` to
|
||
//! register a single per-process callback that the OS invokes on:
|
||
//!
|
||
//! 1. **V-sync** — at 60 Hz; source code 0 (`INTERRUPT_SOURCE_VSYNC`).
|
||
//! 2. **Command-processor interrupt** — when `PM4_INTERRUPT` fires from the
|
||
//! guest-issued command stream; source code 1 (`INTERRUPT_SOURCE_CP`).
|
||
//!
|
||
//! Canary's [xboxkrnl_video.cc:303-310](xenia-canary/src/xenia/kernel/xboxkrnl/xboxkrnl_video.cc#L303-L310)
|
||
//! dispatches the callback on HW thread 0. We follow the same convention
|
||
//! for picking a *context donor*, but as of iterate-2.BE the dispatch
|
||
//! itself is **synchronous and host-driven**: the main loop runs the ISR
|
||
//! inline on the borrowed guest context, mirroring canary's
|
||
//! `EmulateCPInterruptDPC → Processor::Execute` path
|
||
//! ([kernel_state.cc:1370](../../../../xenia-canary/src/xenia/kernel/kernel_state.cc#L1370),
|
||
//! [processor.cc:413](../../../../xenia-canary/src/xenia/cpu/processor.cc#L413)).
|
||
//! Independent of whether the donor guest thread was Ready or Blocked.
|
||
//!
|
||
//! The audio callback path (audit-048) still uses asynchronous LR-sentinel
|
||
//! injection on a dedicated per-client worker thread; the
|
||
//! [`SavedCallbackCtx`] machinery below remains in use there.
|
||
|
||
use std::collections::VecDeque;
|
||
use std::time::{Duration, Instant};
|
||
|
||
use xenia_cpu::context::{CrField, PpcContext};
|
||
use xenia_cpu::ThreadRef;
|
||
|
||
pub const INTERRUPT_SOURCE_VSYNC: u32 = 0;
|
||
pub const INTERRUPT_SOURCE_CP: u32 = 1;
|
||
|
||
/// The processor the graphics ISR impersonates for a v-sync interrupt.
|
||
/// Canary hard-codes this: `MarkVblank` → `DispatchInterruptCallback(0, 2)`
|
||
/// (graphics_system.cc:478). CP interrupts instead use the bit index of the
|
||
/// `PM4_INTERRUPT` `cpu_mask`.
|
||
pub const VSYNC_TARGET_CPU: u8 = 2;
|
||
|
||
/// Guest-registered V-sync / graphics-interrupt callback (from
|
||
/// `VdSetGraphicsInterruptCallback`).
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct GraphicsInterruptCallback {
|
||
pub callback_pc: u32,
|
||
pub user_data: u32,
|
||
}
|
||
|
||
/// Snapshot of the fields we mutate when diverting a HW thread into an
|
||
/// interrupt callback. Restored when the callback returns to
|
||
/// `LR_HALT_SENTINEL`.
|
||
///
|
||
/// We save **all PPC volatile registers** (r0, r2–r12) plus `r1` (SP),
|
||
/// `pc`, `lr`, `ctr`, and `cr`. Non-volatile regs (r13–r31) are preserved
|
||
/// by the callback's own `__savegprlr_N` prologue/epilogue per the PPC
|
||
/// ELF ABI, so they don't need stashing here.
|
||
///
|
||
/// **SP (`gpr[1]`) is included because the injector decrements it by
|
||
/// [`CALLBACK_STACK_PAD`] before the callback runs** — see that constant's
|
||
/// docs for why. Without this, the callback's `__savegprlr_N` prologue
|
||
/// overwrites the interrupted function's own stack-saved LR (which lives
|
||
/// at `[r1 - 8]`), and when the interrupted function later tries to
|
||
/// return, `bclr` jumps to `LR_HALT_SENTINEL` and the thread exits
|
||
/// prematurely.
|
||
#[derive(Debug, Clone, Copy)]
|
||
pub struct SavedCallbackCtx {
|
||
pub pc: u32,
|
||
pub lr: u64,
|
||
pub ctr: u64,
|
||
/// All PPC volatile GPRs (r0, r2–r12) plus r1 (SP) in index order.
|
||
/// Index 0 = r0, 1 = r1, 2 = r2, …, 12 = r12. Index 13..32 unused.
|
||
pub gprs: [u64; 13],
|
||
pub cr: [CrField; 8],
|
||
pub source: u32,
|
||
}
|
||
|
||
/// Bytes the injector reserves below the interrupted thread's SP before
|
||
/// running the ISR callback. Matches Canary's
|
||
/// [`Processor::Execute`](../../../../xenia-canary/src/xenia/cpu/processor.cc#L383)
|
||
/// which decrements `r[1]` by `64 + 112 = 176` before
|
||
/// `function->Call(...)` and restores afterwards. The pad must be larger
|
||
/// than any plausible sum of `__savegprlr_N`'s save-area (up to 64 B for
|
||
/// r25-r31 + 8 B for LR) plus the callback's own `stwu r1,-N(r1)` frame
|
||
/// (the Sylpheed vsync ISR uses 128 B).
|
||
///
|
||
/// Pre-fix: the ISR's `__savegprlr_25` stored the callback's saved LR
|
||
/// (= `LR_HALT_SENTINEL`, from injection) at `[r1 - 8]` — exactly where
|
||
/// the interrupted thread's current `bl`-saved LR lived. The
|
||
/// interrupted function's return site got stomped with `SENTINEL`, so
|
||
/// `__restgprlr_N -> bclr` jumped to the halt sentinel and the thread
|
||
/// exited through the wrong path. Manifested in Sylpheed as tid=5
|
||
/// (producer for the render queue) terminating at cycle 7.5M, starving
|
||
/// both `0x10fc` (main's completion wait) and the PKEVENT that tid=6
|
||
/// polls — no second `VdSwap`, no first pixel.
|
||
pub const CALLBACK_STACK_PAD: u32 = 64 + 112;
|
||
|
||
impl SavedCallbackCtx {
|
||
pub fn capture(ctx: &PpcContext, source: u32) -> Self {
|
||
let mut gprs = [0u64; 13];
|
||
for i in 0..13 {
|
||
gprs[i] = ctx.gpr[i];
|
||
}
|
||
Self {
|
||
pc: ctx.pc,
|
||
lr: ctx.lr,
|
||
ctr: ctx.ctr,
|
||
gprs,
|
||
cr: ctx.cr,
|
||
source,
|
||
}
|
||
}
|
||
|
||
pub fn restore(self, ctx: &mut PpcContext) {
|
||
ctx.pc = self.pc;
|
||
ctx.lr = self.lr;
|
||
ctx.ctr = self.ctr;
|
||
for i in 0..13 {
|
||
ctx.gpr[i] = self.gprs[i];
|
||
}
|
||
ctx.cr = self.cr;
|
||
}
|
||
}
|
||
|
||
/// Maximum pending sources held in the FIFO queue before new ones are
|
||
/// dropped. Four is enough to absorb a short burst (a few v-syncs arriving
|
||
/// while HW 0 is mid-callback from a prior one) without letting runaway
|
||
/// delivery swamp the guest.
|
||
pub const INTERRUPT_QUEUE_CAP: usize = 4;
|
||
|
||
/// All interrupt bookkeeping — single field on `KernelState`.
|
||
///
|
||
/// **First-Pixels M2 (2026-04-20)** — changed from a single-slot
|
||
/// `pending_source: Option<u32>` coalesce to a bounded FIFO so bursts
|
||
/// don't drop silently, and dropped `VSYNC_INSTR_PERIOD` from 500k to
|
||
/// 150k so cadence approximates 60 Hz at the current ~10 MIPS interpreter
|
||
/// throughput. Combined with the `HwState::ServicingIrq` variant added to
|
||
/// `xenia-cpu::scheduler`, interrupts can now be delivered even when HW 0
|
||
/// is `Blocked(WaitAny)` — the injector stashes the block into the new
|
||
/// variant and the restore path re-blocks when the callback returns,
|
||
/// unless a `wake()` during the callback resolved the wait.
|
||
/// M2.5 — per-slot pending-IRQ bitmask. Each `AtomicU8` holds one bit per
|
||
/// interrupt source (currently 2 sources: VSYNC=bit 0, CP=bit 1) destined
|
||
/// for that specific HW slot. Used by the M3 parallel path: T_main (or
|
||
/// the GPU thread) sets a bit Release on the target slot's atomic; the
|
||
/// target T_cpu_i checks the bit Acquire at its quantum boundary and
|
||
/// self-injects without taking another thread's slot lock.
|
||
///
|
||
/// The 6-element fixed-size array mirrors `xenia_cpu::scheduler::HW_THREAD_COUNT`.
|
||
pub type PendingLocalIrq = [std::sync::atomic::AtomicU8;
|
||
xenia_cpu::scheduler::HW_THREAD_COUNT];
|
||
|
||
#[derive(Debug, Default)]
|
||
pub struct InterruptState {
|
||
/// Registered callback (set by `VdSetGraphicsInterruptCallback`).
|
||
pub callback: Option<GraphicsInterruptCallback>,
|
||
/// Bounded FIFO of pending interrupts awaiting injection, as
|
||
/// `(source, target_cpu)`. Push-back on queue, pop-front on inject.
|
||
/// Over-cap pushes drop. `target_cpu` is the processor the graphics
|
||
/// ISR must impersonate (canary `XThread::SetActiveCpu` / the
|
||
/// `DispatchInterruptCallback(source, cpu)` argument): the bit index
|
||
/// of the CP `PM4_INTERRUPT` `cpu_mask` for source=1, and a fixed `2`
|
||
/// for vsync (canary `DispatchInterruptCallback(0, 2)`). The ISR reads
|
||
/// it from the PCR (`[r13+268]`) to clear the matching per-CPU bit of
|
||
/// the swap-acknowledge fence.
|
||
pub pending: VecDeque<(u32, u8)>,
|
||
/// When `Some`, some HW thread is currently running a callback; on
|
||
/// return-to-sentinel we restore this and clear the flag.
|
||
pub saved: Option<SavedCallbackCtx>,
|
||
/// Which guest thread the current callback was injected into.
|
||
/// Required because we no longer anchor delivery to HW 0 — any
|
||
/// non-Exited thread is a valid target. Meaningful only while
|
||
/// `saved.is_some()`. Stored as a `ThreadRef` so per-slot
|
||
/// runqueues don't get ambiguous addressing.
|
||
pub injected_ref: Option<ThreadRef>,
|
||
/// Monotonic count of delivered interrupts.
|
||
pub delivered: u64,
|
||
/// Dropped interrupts (callback unset, queue full, or thread
|
||
/// exited/idle at inject time).
|
||
pub dropped: u64,
|
||
/// Instruction-count accumulator for the synthetic v-sync ticker
|
||
/// (legacy path used by unit tests via `tick_vsync_instr`). Production
|
||
/// uses `tick_vsync_wallclock` instead — see [`KRNBUG-D08`].
|
||
pub vsync_accumulator: u64,
|
||
/// Last observed instruction count for the legacy instruction-count
|
||
/// ticker. `tick_vsync_instr` diffs against this to advance
|
||
/// `vsync_accumulator`.
|
||
pub last_instr_count: u64,
|
||
/// **iterate-3AJ — present-anchored vsync.** Set `true` once the guest
|
||
/// has presented at least one frame (a `VdSwap`). Before this, the
|
||
/// vsync ticker uses the legacy fixed instruction-quantum cadence so
|
||
/// the boot present-loop bootstrap (iterate-2W) still gets the vsyncs
|
||
/// it needs *before* the first present. After this, vsync is anchored
|
||
/// to the guest's real present rate (≈1 vblank per present, as on real
|
||
/// hardware where the title double-buffers at vblank), with only a
|
||
/// small capped instruction-quantum *fallback* for frames where the
|
||
/// guest genuinely stops presenting (heavy asset load). This stops the
|
||
/// proxy from firing ~66 vsyncs during one heavy load frame, which
|
||
/// collapsed the splash-logo intro fade-in (the guest's vsync counter
|
||
/// jumped 0→66 in one frame instead of ramping smoothly).
|
||
pub vsync_present_anchored: bool,
|
||
/// Last observed guest present (`VdSwap`) count. `tick_vsync_instr`
|
||
/// diffs the live count against this each call to emit one vblank per
|
||
/// new present once `vsync_present_anchored` is set.
|
||
pub last_present_count: u64,
|
||
/// How many *fallback* (non-present-driven) vsyncs have fired in the
|
||
/// current dry (no-present) window. Reset to 0 whenever a present
|
||
/// occurs. Capped at [`DRY_FALLBACK_CAP`] so one heavy non-presenting
|
||
/// frame cannot fire a long burst of vsyncs (the fade-in regression).
|
||
pub dry_fallback_fired: u32,
|
||
/// Wall-clock anchor for the production v-sync ticker. `None` until
|
||
/// the first `tick_vsync_wallclock` call (lazy init so unit tests
|
||
/// that never invoke that function don't construct an Instant).
|
||
/// Each call fires `(elapsed / VSYNC_PERIOD)` v-syncs and advances
|
||
/// the anchor by that many full periods.
|
||
pub last_vsync_instant: Option<Instant>,
|
||
/// M2.5 — per-slot pending-IRQ bits. Set by the producer (M3's
|
||
/// IRQ-routing logic on `T_main`) with `Release`; consumed by the
|
||
/// target T_cpu_i with `Acquire` at quantum boundary. Unused under
|
||
/// the lockstep path (M2's single-host-thread model still uses
|
||
/// `pending` + `try_inject_graphics_interrupt`); the field is wired
|
||
/// here so M3's per-HW-thread path is a flag flip, not a refactor.
|
||
pub pending_local_irq: PendingLocalIrq,
|
||
}
|
||
|
||
/// How many guest instructions correspond to one synthetic v-sync.
|
||
///
|
||
/// **Legacy** — drives `tick_vsync_instr` only. Production uses
|
||
/// `tick_vsync_wallclock` with [`VSYNC_PERIOD`]. Kept because audit M11
|
||
/// observed this proxy drifts from 629 v-syncs/100M lockstep down to ~2
|
||
/// under `--parallel`, where the dispatcher executes more PPC instructions
|
||
/// per tick call. Unit tests still drive the instruction-count ticker for
|
||
/// determinism.
|
||
pub const VSYNC_INSTR_PERIOD: u64 = 150_000;
|
||
|
||
/// **iterate-3AJ — present-anchored vsync fallback.**
|
||
///
|
||
/// Once the guest is in its present loop (`vsync_present_anchored`), each
|
||
/// guest present emits exactly one vblank — vsync *is* the present cadence,
|
||
/// as on real Xbox 360 hardware where the title double-buffers at vblank.
|
||
/// For a frame where the guest stops presenting (e.g. the ~1.1 s splash
|
||
/// asset-load), we still need *some* vsyncs to keep timers / the present
|
||
/// loop alive, but firing one per [`VSYNC_INSTR_PERIOD`] would reproduce the
|
||
/// ~66-vsync spike that collapsed the fade-in. So the fallback fires one
|
||
/// vblank per `VSYNC_INSTR_PERIOD` of *non-presenting* instructions, but at
|
||
/// most [`DRY_FALLBACK_CAP`] per dry window (the counter resets on each
|
||
/// present). A heavy load frame therefore advances the guest vsync counter
|
||
/// by ≤ `DRY_FALLBACK_CAP` (a small ramp like canary's 0/5/10/2/1…), not 66.
|
||
pub const DRY_FALLBACK_CAP: u32 = 4;
|
||
|
||
/// Wall-clock period for the **production** v-sync ticker. 16.667 ms
|
||
/// targets exactly 60 Hz. KRNBUG-D08 — converting from the
|
||
/// instruction-count proxy fixes the `--parallel` rate drop while
|
||
/// keeping lockstep cadence stable (instruction-count was *also* an
|
||
/// approximation; wall-clock is the canonical Xbox 360 v-sync source).
|
||
pub const VSYNC_PERIOD: Duration = Duration::from_nanos(16_666_667);
|
||
|
||
impl InterruptState {
|
||
/// Record a new callback registration.
|
||
pub fn set_callback(&mut self, callback_pc: u32, user_data: u32) {
|
||
self.callback = Some(GraphicsInterruptCallback {
|
||
callback_pc,
|
||
user_data,
|
||
});
|
||
}
|
||
|
||
/// Queue an interrupt for the next safe injection point. `cpu` is the
|
||
/// processor the ISR must impersonate (see `pending`).
|
||
pub fn queue_interrupt(&mut self, source: u32, cpu: u8) {
|
||
if self.callback.is_none() {
|
||
self.dropped += 1;
|
||
return;
|
||
}
|
||
if self.pending.len() >= INTERRUPT_QUEUE_CAP {
|
||
self.dropped += 1;
|
||
return;
|
||
}
|
||
self.pending.push_back((source, cpu));
|
||
}
|
||
|
||
/// Peek at the next pending source without removing it.
|
||
pub fn peek_next(&self) -> Option<u32> {
|
||
self.pending.front().map(|&(source, _)| source)
|
||
}
|
||
|
||
/// Peek at the target CPU of the next pending interrupt.
|
||
pub fn peek_next_cpu(&self) -> Option<u8> {
|
||
self.pending.front().map(|&(_, cpu)| cpu)
|
||
}
|
||
|
||
/// Pop the next pending source (called by the injector after it has
|
||
/// committed to dispatching it).
|
||
pub fn take_next(&mut self) -> Option<u32> {
|
||
self.pending.pop_front().map(|(source, _)| source)
|
||
}
|
||
|
||
/// **Present-anchored** instruction-paced v-sync ticker (the lockstep
|
||
/// production path; also used by unit tests for a deterministic clock).
|
||
///
|
||
/// `current_instr_count` is the running retired-instruction count.
|
||
/// `present_count` is the guest's running `VdSwap` count (monotonic).
|
||
///
|
||
/// Two regimes:
|
||
///
|
||
/// 1. **Bootstrap** (`!vsync_present_anchored`, i.e. before the guest's
|
||
/// first present): legacy fixed-quantum cadence — one vsync per
|
||
/// [`VSYNC_INSTR_PERIOD`] retired instructions. The boot present loop
|
||
/// (iterate-2W) needs vsyncs delivered *before* it can present, so
|
||
/// this regime is unchanged from the original ticker. The first
|
||
/// observed present flips `vsync_present_anchored`.
|
||
///
|
||
/// 2. **Present-anchored** (after the first present): one vblank per
|
||
/// guest present (vsync *is* the present cadence on real hardware),
|
||
/// plus a small capped instruction-quantum fallback ([`DRY_FALLBACK_CAP`]
|
||
/// per dry window) so a frame where the guest stops presenting (heavy
|
||
/// asset load) still ticks a *few* vsyncs — not ~66, which collapsed
|
||
/// the splash fade-in.
|
||
///
|
||
/// Returns `true` if at least one v-sync was queued.
|
||
pub fn tick_vsync_instr(&mut self, current_instr_count: u64, present_count: u64) -> bool {
|
||
let delta = current_instr_count.saturating_sub(self.last_instr_count);
|
||
self.last_instr_count = current_instr_count;
|
||
self.vsync_accumulator = self.vsync_accumulator.saturating_add(delta);
|
||
|
||
let new_presents = present_count.saturating_sub(self.last_present_count);
|
||
self.last_present_count = present_count;
|
||
if new_presents > 0 {
|
||
self.vsync_present_anchored = true;
|
||
}
|
||
|
||
// Regime 1 — bootstrap: legacy fixed instruction quantum. Preserves
|
||
// the iterate-2W present-loop bootstrap exactly (vsyncs must fire
|
||
// before the guest can present).
|
||
if !self.vsync_present_anchored {
|
||
if self.vsync_accumulator < VSYNC_INSTR_PERIOD {
|
||
return false;
|
||
}
|
||
let periods = self.vsync_accumulator / VSYNC_INSTR_PERIOD;
|
||
self.vsync_accumulator %= VSYNC_INSTR_PERIOD;
|
||
for _ in 0..periods {
|
||
self.queue_interrupt(INTERRUPT_SOURCE_VSYNC, VSYNC_TARGET_CPU);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
// Regime 2 — present-anchored.
|
||
let mut queued = false;
|
||
|
||
if new_presents > 0 {
|
||
// One vblank per guest present. `queue_interrupt` caps the FIFO,
|
||
// so a burst of presents in one round can't flood. A fresh
|
||
// present resets the dry-window state.
|
||
for _ in 0..new_presents {
|
||
self.queue_interrupt(INTERRUPT_SOURCE_VSYNC, VSYNC_TARGET_CPU);
|
||
}
|
||
self.vsync_accumulator = 0;
|
||
self.dry_fallback_fired = 0;
|
||
queued = true;
|
||
} else if self.vsync_accumulator >= VSYNC_INSTR_PERIOD
|
||
&& self.dry_fallback_fired < DRY_FALLBACK_CAP
|
||
{
|
||
// Dry frame (no present this tick): the guest stopped presenting
|
||
// (heavy load). Tick a *capped* number of fallback vsyncs so
|
||
// timers/the present loop stay alive without re-introducing the
|
||
// ~66-vsync spike. Consume one period per fired vsync so the
|
||
// accumulator paces the few fallbacks.
|
||
self.vsync_accumulator -= VSYNC_INSTR_PERIOD;
|
||
self.dry_fallback_fired += 1;
|
||
self.queue_interrupt(INTERRUPT_SOURCE_VSYNC, VSYNC_TARGET_CPU);
|
||
queued = true;
|
||
}
|
||
|
||
queued
|
||
}
|
||
|
||
/// **Production** — wall-clock v-sync ticker. Fires
|
||
/// `floor(elapsed / VSYNC_PERIOD)` v-syncs since the last call and
|
||
/// advances the anchor by that many full periods (so a long pause
|
||
/// doesn't lose all the v-syncs it spans, and a quick succession of
|
||
/// calls doesn't over-fire). KRNBUG-D08 — replaces the legacy
|
||
/// instruction-count proxy that drifted under `--parallel`.
|
||
/// Returns `true` if at least one v-sync was queued.
|
||
pub fn tick_vsync_wallclock(&mut self) -> bool {
|
||
let now = Instant::now();
|
||
let anchor = match self.last_vsync_instant {
|
||
Some(t) => t,
|
||
None => {
|
||
self.last_vsync_instant = Some(now);
|
||
return false;
|
||
}
|
||
};
|
||
let elapsed = now.saturating_duration_since(anchor);
|
||
let period_ns = VSYNC_PERIOD.as_nanos() as u64;
|
||
let elapsed_ns = elapsed.as_nanos() as u64;
|
||
let periods = elapsed_ns / period_ns;
|
||
if periods == 0 {
|
||
return false;
|
||
}
|
||
// Advance the anchor by the number of full periods consumed,
|
||
// not to `now`. That lets a long pause distribute its missed
|
||
// v-syncs evenly without lazy-batching the entire backlog into
|
||
// one tick (over-fire would interleave dozens of callback
|
||
// injections back-to-back). Cap at INTERRUPT_QUEUE_CAP so a
|
||
// clock that jumped forward (system suspend) doesn't try to
|
||
// queue more than the FIFO can hold.
|
||
let advance = Duration::from_nanos(periods * period_ns);
|
||
self.last_vsync_instant = Some(anchor + advance);
|
||
let to_queue = (periods as usize).min(INTERRUPT_QUEUE_CAP);
|
||
for _ in 0..to_queue {
|
||
self.queue_interrupt(INTERRUPT_SOURCE_VSYNC, VSYNC_TARGET_CPU);
|
||
}
|
||
true
|
||
}
|
||
|
||
/// Is HW thread 0 currently in a callback?
|
||
pub fn is_in_callback(&self) -> bool {
|
||
self.saved.is_some()
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn queue_interrupt_drops_without_callback() {
|
||
let mut s = InterruptState::default();
|
||
s.queue_interrupt(INTERRUPT_SOURCE_VSYNC, VSYNC_TARGET_CPU);
|
||
assert_eq!(s.dropped, 1);
|
||
assert!(s.pending.is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn queue_interrupt_fifo_preserves_order() {
|
||
let mut s = InterruptState::default();
|
||
s.set_callback(0x1000, 0xAB);
|
||
s.queue_interrupt(INTERRUPT_SOURCE_VSYNC, VSYNC_TARGET_CPU);
|
||
s.queue_interrupt(INTERRUPT_SOURCE_CP, 2);
|
||
s.queue_interrupt(INTERRUPT_SOURCE_VSYNC, VSYNC_TARGET_CPU);
|
||
assert_eq!(s.dropped, 0);
|
||
// FIFO: take_next hands them out in push order.
|
||
assert_eq!(s.take_next(), Some(INTERRUPT_SOURCE_VSYNC));
|
||
assert_eq!(s.take_next(), Some(INTERRUPT_SOURCE_CP));
|
||
assert_eq!(s.take_next(), Some(INTERRUPT_SOURCE_VSYNC));
|
||
assert_eq!(s.take_next(), None);
|
||
}
|
||
|
||
#[test]
|
||
fn queue_interrupt_caps_at_queue_size() {
|
||
let mut s = InterruptState::default();
|
||
s.set_callback(0x1000, 0xAB);
|
||
for _ in 0..INTERRUPT_QUEUE_CAP {
|
||
s.queue_interrupt(INTERRUPT_SOURCE_VSYNC, VSYNC_TARGET_CPU);
|
||
}
|
||
// Over-cap: drops rather than evicting the oldest.
|
||
s.queue_interrupt(INTERRUPT_SOURCE_VSYNC, VSYNC_TARGET_CPU);
|
||
s.queue_interrupt(INTERRUPT_SOURCE_VSYNC, VSYNC_TARGET_CPU);
|
||
assert_eq!(s.dropped, 2);
|
||
assert_eq!(s.pending.len(), INTERRUPT_QUEUE_CAP);
|
||
}
|
||
|
||
#[test]
|
||
fn tick_vsync_instr_fires_at_new_150k_threshold() {
|
||
let mut s = InterruptState::default();
|
||
s.set_callback(0x1000, 0xAB);
|
||
assert_eq!(VSYNC_INSTR_PERIOD, 150_000);
|
||
// present_count = 0 → bootstrap regime (legacy fixed quantum).
|
||
assert!(!s.tick_vsync_instr(VSYNC_INSTR_PERIOD - 1, 0));
|
||
assert!(s.pending.is_empty());
|
||
assert!(s.tick_vsync_instr(VSYNC_INSTR_PERIOD, 0));
|
||
assert_eq!(s.peek_next(), Some(INTERRUPT_SOURCE_VSYNC));
|
||
}
|
||
|
||
#[test]
|
||
fn tick_vsync_instr_drains_multiple_periods_in_one_call() {
|
||
// Long kernel export → big instr delta → multiple v-syncs must
|
||
// be delivered, not lost.
|
||
let mut s = InterruptState::default();
|
||
s.set_callback(0x1000, 0xAB);
|
||
// present_count = 0 → bootstrap regime drains all 3 periods at once.
|
||
assert!(s.tick_vsync_instr(VSYNC_INSTR_PERIOD * 3 + 10, 0));
|
||
assert_eq!(s.pending.len(), 3);
|
||
}
|
||
|
||
#[test]
|
||
fn tick_vsync_instr_present_anchors_after_first_present() {
|
||
// iterate-3AJ: once the guest presents, vsync tracks presents (one
|
||
// vblank per present), NOT the fixed instruction quantum.
|
||
let mut s = InterruptState::default();
|
||
s.set_callback(0x1000, 0xAB);
|
||
// Bootstrap: instruction quantum fires (present_count still 0).
|
||
assert!(s.tick_vsync_instr(VSYNC_INSTR_PERIOD, 0));
|
||
assert_eq!(s.pending.len(), 1);
|
||
let _ = s.take_next();
|
||
// First present flips to anchored: exactly one vblank for the present.
|
||
assert!(s.tick_vsync_instr(VSYNC_INSTR_PERIOD * 2, 1));
|
||
assert!(s.vsync_present_anchored);
|
||
assert_eq!(s.pending.len(), 1);
|
||
let _ = s.take_next();
|
||
}
|
||
|
||
#[test]
|
||
fn tick_vsync_instr_heavy_dry_frame_capped_not_spiking() {
|
||
// iterate-3AJ: the regression. A heavy non-presenting frame retires
|
||
// ~10M instructions; the OLD ticker fired ~66 vsyncs (10M/150k) in
|
||
// that single frame, jumping the guest vsync counter 0→66 and
|
||
// skipping the fade-in. The present-anchored ticker caps the dry
|
||
// window at DRY_FALLBACK_CAP.
|
||
let mut s = InterruptState::default();
|
||
s.set_callback(0x1000, 0xAB);
|
||
// Enter anchored mode via one present.
|
||
let mut instr: u64 = VSYNC_INSTR_PERIOD;
|
||
assert!(s.tick_vsync_instr(instr, 1));
|
||
while s.take_next().is_some() {}
|
||
// Simulate a 10M-instruction frame with NO new present, ticked in
|
||
// chunks (as coord_pre_round would). Count fallback vsyncs queued.
|
||
let mut fallback = 0usize;
|
||
for _ in 0..100 {
|
||
instr += 100_000; // 100 chunks × 100k = 10M instructions
|
||
if s.tick_vsync_instr(instr, 1) {
|
||
while s.take_next().is_some() {
|
||
fallback += 1;
|
||
}
|
||
}
|
||
}
|
||
assert_eq!(
|
||
fallback, DRY_FALLBACK_CAP as usize,
|
||
"a heavy dry frame must cap fallback vsyncs at DRY_FALLBACK_CAP, \
|
||
not fire ~66"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
fn tick_vsync_wallclock_first_call_sets_anchor() {
|
||
// First call seeds the anchor and never fires. KRNBUG-D08:
|
||
// initial wall-clock state has no prior reference, so we can't
|
||
// know the elapsed delta yet.
|
||
let mut s = InterruptState::default();
|
||
s.set_callback(0x1000, 0xAB);
|
||
assert!(!s.tick_vsync_wallclock());
|
||
assert!(s.pending.is_empty());
|
||
assert!(s.last_vsync_instant.is_some());
|
||
}
|
||
|
||
#[test]
|
||
fn tick_vsync_wallclock_fires_after_period() {
|
||
// Sleeps one full v-sync period (16.667 ms) and verifies a
|
||
// single v-sync is queued. Sleep is fine in --release tests
|
||
// (one-shot, ~17 ms cost).
|
||
let mut s = InterruptState::default();
|
||
s.set_callback(0x1000, 0xAB);
|
||
s.tick_vsync_wallclock(); // seed
|
||
std::thread::sleep(VSYNC_PERIOD + Duration::from_millis(2));
|
||
assert!(s.tick_vsync_wallclock());
|
||
assert_eq!(s.pending.len(), 1);
|
||
assert_eq!(s.peek_next(), Some(INTERRUPT_SOURCE_VSYNC));
|
||
}
|
||
|
||
#[test]
|
||
fn tick_vsync_wallclock_caps_burst_at_queue_cap() {
|
||
// A multi-period elapsed window queues at most
|
||
// INTERRUPT_QUEUE_CAP v-syncs (the FIFO can't hold more anyway).
|
||
// Sleep 6 periods (~100 ms), expect INTERRUPT_QUEUE_CAP queued.
|
||
let mut s = InterruptState::default();
|
||
s.set_callback(0x1000, 0xAB);
|
||
s.tick_vsync_wallclock(); // seed
|
||
std::thread::sleep(VSYNC_PERIOD * 6 + Duration::from_millis(2));
|
||
assert!(s.tick_vsync_wallclock());
|
||
assert_eq!(s.pending.len(), INTERRUPT_QUEUE_CAP);
|
||
}
|
||
|
||
/// Simulates what the main loop does: inject, execute guest code up
|
||
/// to the sentinel, restore. Uses a single-instruction `bclr` callback
|
||
/// — the interpreter sees `pc == callback_pc`, steps, and the blr
|
||
/// instruction writes `lr` into `pc`, which equals `LR_HALT_SENTINEL`
|
||
/// → main loop detects and triggers restore.
|
||
#[test]
|
||
fn inject_restore_roundtrip_smoke() {
|
||
let mut ctx = PpcContext::new();
|
||
ctx.pc = 0x1000_0000;
|
||
ctx.lr = 0xCAFE_BABE;
|
||
ctx.gpr[3] = 0x1234;
|
||
ctx.gpr[4] = 0x5678;
|
||
|
||
let mut s = InterruptState::default();
|
||
s.set_callback(0x2000_0000, 0xDEAD);
|
||
|
||
// Simulate main loop inject: save ctx fields, divert pc/lr/r3/r4.
|
||
let saved = SavedCallbackCtx::capture(&ctx, INTERRUPT_SOURCE_VSYNC);
|
||
s.saved = Some(saved);
|
||
ctx.pc = 0x2000_0000;
|
||
ctx.lr = xenia_cpu::context::LR_HALT_SENTINEL;
|
||
ctx.gpr[3] = INTERRUPT_SOURCE_VSYNC as u64;
|
||
ctx.gpr[4] = 0xDEAD;
|
||
assert!(s.is_in_callback());
|
||
|
||
// Guest callback "runs" to the sentinel — simulate by writing
|
||
// pc = lr (what `blr` would do).
|
||
ctx.pc = ctx.lr as u32;
|
||
|
||
// Main loop detects pc == LR_HALT_SENTINEL while in_callback:
|
||
let saved = s.saved.take().unwrap();
|
||
saved.restore(&mut ctx);
|
||
s.delivered += 1;
|
||
|
||
assert_eq!(ctx.pc, 0x1000_0000);
|
||
assert_eq!(ctx.lr, 0xCAFE_BABE);
|
||
assert_eq!(ctx.gpr[3], 0x1234);
|
||
assert_eq!(ctx.gpr[4], 0x5678);
|
||
assert!(!s.is_in_callback());
|
||
assert_eq!(s.delivered, 1);
|
||
}
|
||
|
||
#[test]
|
||
fn saved_ctx_roundtrip() {
|
||
let mut ctx = PpcContext::new();
|
||
ctx.pc = 0x11223344;
|
||
ctx.lr = 0xDEADBEEF;
|
||
ctx.gpr[3] = 0xAAAA;
|
||
ctx.gpr[4] = 0xBBBB;
|
||
let saved = SavedCallbackCtx::capture(&ctx, INTERRUPT_SOURCE_VSYNC);
|
||
ctx.pc = 0;
|
||
ctx.lr = 0;
|
||
ctx.gpr[3] = 0;
|
||
ctx.gpr[4] = 0;
|
||
saved.restore(&mut ctx);
|
||
assert_eq!(ctx.pc, 0x11223344);
|
||
assert_eq!(ctx.lr, 0xDEADBEEF);
|
||
assert_eq!(ctx.gpr[3], 0xAAAA);
|
||
assert_eq!(ctx.gpr[4], 0xBBBB);
|
||
}
|
||
|
||
/// Full volatile-GPR + SP roundtrip. Regression test for the
|
||
/// 2026-04-24 IRQ-injection fix: the ISR callback's prologue clobbers
|
||
/// `[r1 - 8]` on the interrupted thread's stack unless the injector
|
||
/// pre-decrements SP by [`CALLBACK_STACK_PAD`] and the saved ctx puts
|
||
/// SP (and the rest of the PPC volatile set) back on return.
|
||
#[test]
|
||
fn saved_ctx_covers_sp_and_all_volatile_gprs() {
|
||
let mut ctx = PpcContext::new();
|
||
ctx.pc = 0xAAAA_BBBB;
|
||
ctx.lr = 0x1111_2222;
|
||
ctx.ctr = 0x3333_4444;
|
||
for i in 0..13 {
|
||
ctx.gpr[i] = 0x1000 + i as u64;
|
||
}
|
||
// r13..r31 are non-volatile and should survive the callback's own
|
||
// save/restore — the saved ctx deliberately does NOT cover them.
|
||
for i in 13..32 {
|
||
ctx.gpr[i] = 0xDEAD_0000 + i as u64;
|
||
}
|
||
|
||
let saved = SavedCallbackCtx::capture(&ctx, INTERRUPT_SOURCE_VSYNC);
|
||
|
||
// Simulate injector: flip pc/lr/r1/r3/r4 (what the real injector
|
||
// actually does — see try_inject_graphics_interrupt in main.rs).
|
||
ctx.pc = 0xCAFE;
|
||
ctx.lr = xenia_cpu::context::LR_HALT_SENTINEL;
|
||
ctx.gpr[1] = ctx.gpr[1].wrapping_sub(CALLBACK_STACK_PAD as u64);
|
||
ctx.gpr[3] = INTERRUPT_SOURCE_VSYNC as u64;
|
||
ctx.gpr[4] = 0xBEEF;
|
||
// Simulate callback clobbering a few volatile regs that aren't
|
||
// part of the "obviously diverted" set.
|
||
ctx.gpr[0] = 0xFEED_FACE;
|
||
ctx.gpr[7] = 0x9999;
|
||
ctx.gpr[12] = 0xABCD;
|
||
|
||
saved.restore(&mut ctx);
|
||
|
||
// All volatile GPRs restored to pre-injection.
|
||
for i in 0..13 {
|
||
assert_eq!(
|
||
ctx.gpr[i],
|
||
0x1000 + i as u64,
|
||
"volatile r{} clobbered by callback was not restored",
|
||
i
|
||
);
|
||
}
|
||
// SP specifically back to the pre-pad value.
|
||
assert_eq!(ctx.gpr[1], 0x1001, "SP must be restored to pre-injection");
|
||
// Non-volatile regs were never captured; they stay as the callback
|
||
// left them (here, untouched because we didn't modify 13..32).
|
||
for i in 13..32 {
|
||
assert_eq!(ctx.gpr[i], 0xDEAD_0000 + i as u64);
|
||
}
|
||
assert_eq!(ctx.pc, 0xAAAA_BBBB);
|
||
assert_eq!(ctx.lr, 0x1111_2222);
|
||
assert_eq!(ctx.ctr, 0x3333_4444);
|
||
}
|
||
}
|