//! 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);
    }
}
