//! NVENC hardware encoder (Windows, D3D11 input) — zero-copy capture→encode on the GPU.
//!
//! Drives the raw NVENC API via the `nvidia_video_codec_sdk` `sys` types and a **runtime-loaded**
//! entry table ([`EncodeApi`] — the crate's `ENCODE_API`/safe `Encoder` are deliberately unused:
//! the safe wrapper is CUDA-only, and its statically-declared entry points would put a load-time
//! `nvEncodeAPI64.dll` import on the all-vendor binary, killing it on every AMD/Intel-only box).
//! Opens an encode session bound to the **same** `ID3D11Device` as the DXGI
//! capturer (the device is carried on `FramePayload::D3d11`), and **encodes the capturer's texture in
//! place** — it registers each input texture with NVENC once (cached by pointer) and `encode_picture`s
//! it directly, with NO per-frame `CopyResource`. (That's safe because the host encode loop is
//! synchronous — capture → submit → poll, where `poll`/`lock_bitstream` blocks until the encode
//! finishes — so the capturer never overwrites the texture mid-encode; if that loop ever becomes
//! pipelined, the capturer must hand a ring of textures.) Mirrors the Linux NVENC config: CBR +
//! ultra-low-latency, infinite GOP, P-frames only, forced-IDR for RFI, in-band SPS/PPS each keyframe.
//!
//! Needs a real NVIDIA GPU at runtime (session creation fails otherwise) — compiles GPU-less and
//! **starts driver-less** (the DLL resolves at runtime; on an AMD/Intel box [`try_api`] fails
//! cleanly and the AMF/QSV/software backends carry the session). The software encoder
//! (`super::sw`) is the fallback.
//!
//! **Two-thread async retrieve** (`PUNKTFUNK_NVENC_ASYNC=1`, opt-in until on-glass validated —
//! gpu-contention plan §5.B): the NVENC guide mandates that the main thread only *submit*
//! (`nvEncEncodePicture`) while a **secondary thread** waits on per-buffer completion events and
//! does `nvEncLockBitstream`. Today's sync mode does both on one thread, so under a GPU-saturating
//! game the whole pipeline serializes on the WDDM scheduling wait (`1000/17ms ≈ 59 fps` — the
//! depth-1 collapse). In async mode the session is opened `enableEncodeAsync=1`, each output
//! bitstream gets a registered auto-reset event, `submit` returns immediately, and an internal
//! retrieve thread waits + locks + copies + unlocks, handing finished AUs back through a channel
//! that `poll` drains without blocking. All input-resource calls (register/map/unmap) stay on the
//! encode thread; the retrieve thread touches ONLY the event + lock/unlock — the exact split the
//! guide blesses. Backpressure: `submit` blocks on the oldest completion when `POOL - 1` encodes
//! are in flight, so an output buffer is never reused mid-encode. Latency cost when idle ≈ 0 (the
//! AU completes within the same tick and `poll` picks it up); under contention completed frames
//! queue instead of stalling capture — throughput recovers up to the scheduler-granted share.

// UNSAFE-LINT EXEMPTION (rationale + exit criteria: `unsafe_op_in_unsafe_fn` in the workspace
// Cargo.toml). This body is raw `nvEncodeAPI` entry-table + D3D11 calls almost line for line;
// narrowing it would add one `unsafe {}` plus one SAFETY comment per call that could only restate
// the signature. Clearing this file means DELETING the markers that carry no caller contract, not
// wrapping the calls — until then the lint is off HERE and enforced everywhere else.
#![allow(unsafe_op_in_unsafe_fn)]

use super::nvenc_core::{
    apply_low_latency_config, build_init_params, cached_ceiling, codec_guid, plan_range_recovery,
    resolve_slices, resolve_split_subframe, resolve_subframe, store_ceiling, subframe_env_forced,
    CeilingKey, LowLatencyConfig, NvStatusExt, RangePlan,
};
// Moved to `codec.rs` (WP4) so the libav path, which builds without the `nvenc` feature, can share
// one split policy instead of keeping the copy that had already drifted.
use super::nvenc_core::{
    cached_split_verdict, store_split_verdict, ArbAction, SplitArbiter, SplitKey,
};
use super::nvenc_status;
use super::{max_forced_split_mode, resolve_split_mode};
use super::{AuChunk, ChromaFormat, Codec, EncodedFrame, Encoder, EncoderCaps};
use anyhow::{anyhow, bail, Context, Result};
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
use std::collections::{HashMap, VecDeque};
use std::ffi::c_void;
use std::ptr;
use std::sync::mpsc;
use windows::core::{Interface, PCWSTR};
use windows::Win32::Foundation::{CloseHandle, HANDLE, WAIT_OBJECT_0};
use windows::Win32::Graphics::Direct3D11::{ID3D11Device, ID3D11Texture2D};
use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject};

use nvidia_video_codec_sdk::sys::nvEncodeAPI as nv;

// ---------------------------------------------------------------------------------------------
// Runtime-loaded NVENC entry table.
//
// The NVENC entry points live in `nvEncodeAPI64.dll`, which exists ONLY where the NVIDIA driver
// is installed. They must be resolved at runtime (`LoadLibraryExW` + `GetProcAddress`), never as
// a link-time import: the shipped host binary compiles the `nvenc` feature in unconditionally,
// and a load-time DLL import makes the Windows loader refuse to start the process on every
// AMD/Intel-only box ("nvencodeapi64.dll was not found", before `main`) — `encode.rs` never gets
// the chance to dispatch to AMF/QSV. This is the Windows analogue of the Linux host's dlopen'd
// libcuda. Only the two real DLL exports are resolved by name; the rest of the table comes back
// through `NvEncodeAPICreateInstance`.
// ---------------------------------------------------------------------------------------------

/// The `NV_ENCODE_API_FUNCTION_LIST` entries this encoder uses, unwrapped once at load so call
/// sites stay `(api().encode_picture)(…)`. Field names mirror the sdk crate's `EncodeAPI`, whose
/// lazy static must NOT be referenced — it calls the statically-declared externs, which is what
/// demanded the import lib at link time.
struct EncodeApi {
    open_encode_session_ex: unsafe extern "C" fn(
        *mut nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS,
        *mut *mut c_void,
    ) -> nv::NVENCSTATUS,
    initialize_encoder:
        unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_INITIALIZE_PARAMS) -> nv::NVENCSTATUS,
    reconfigure_encoder:
        unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_RECONFIGURE_PARAMS) -> nv::NVENCSTATUS,
    destroy_encoder: unsafe extern "C" fn(*mut c_void) -> nv::NVENCSTATUS,
    get_encode_caps: unsafe extern "C" fn(
        *mut c_void,
        nv::GUID,
        *mut nv::NV_ENC_CAPS_PARAM,
        *mut core::ffi::c_int,
    ) -> nv::NVENCSTATUS,
    // The two entry points behind [`probe_codec_support`] — the driver's own list of encode GUIDs
    // this chip exposes. Mandatory like every other entry: both have existed since NVENC 1.0, so a
    // driver missing them is broken in ways the rest of this table would not survive either.
    get_encode_guid_count: unsafe extern "C" fn(*mut c_void, *mut u32) -> nv::NVENCSTATUS,
    get_encode_guids:
        unsafe extern "C" fn(*mut c_void, *mut nv::GUID, u32, *mut u32) -> nv::NVENCSTATUS,
    get_encode_preset_config_ex: unsafe extern "C" fn(
        *mut c_void,
        nv::GUID,
        nv::GUID,
        nv::NV_ENC_TUNING_INFO,
        *mut nv::NV_ENC_PRESET_CONFIG,
    ) -> nv::NVENCSTATUS,
    create_bitstream_buffer: unsafe extern "C" fn(
        *mut c_void,
        *mut nv::NV_ENC_CREATE_BITSTREAM_BUFFER,
    ) -> nv::NVENCSTATUS,
    destroy_bitstream_buffer:
        unsafe extern "C" fn(*mut c_void, nv::NV_ENC_OUTPUT_PTR) -> nv::NVENCSTATUS,
    lock_bitstream:
        unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_LOCK_BITSTREAM) -> nv::NVENCSTATUS,
    unlock_bitstream: unsafe extern "C" fn(*mut c_void, nv::NV_ENC_OUTPUT_PTR) -> nv::NVENCSTATUS,
    register_resource:
        unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_REGISTER_RESOURCE) -> nv::NVENCSTATUS,
    unregister_resource:
        unsafe extern "C" fn(*mut c_void, nv::NV_ENC_REGISTERED_PTR) -> nv::NVENCSTATUS,
    map_input_resource:
        unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_MAP_INPUT_RESOURCE) -> nv::NVENCSTATUS,
    unmap_input_resource:
        unsafe extern "C" fn(*mut c_void, nv::NV_ENC_INPUT_PTR) -> nv::NVENCSTATUS,
    encode_picture:
        unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_PIC_PARAMS) -> nv::NVENCSTATUS,
    register_async_event:
        unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_EVENT_PARAMS) -> nv::NVENCSTATUS,
    unregister_async_event:
        unsafe extern "C" fn(*mut c_void, *mut nv::NV_ENC_EVENT_PARAMS) -> nv::NVENCSTATUS,
    invalidate_ref_frames: unsafe extern "C" fn(*mut c_void, u64) -> nv::NVENCSTATUS,
}

/// Resolve the table once per process. `Err` = NVENC genuinely unavailable on this machine (no
/// NVIDIA driver/DLL, or a driver older than our headers) — the entry points
/// ([`NvencD3d11Encoder::open`], [`probe_can_encode_444`]) gate on it and the AMF/QSV/software
/// backends carry on.
fn try_api() -> std::result::Result<&'static EncodeApi, &'static str> {
    static TABLE: std::sync::OnceLock<std::result::Result<EncodeApi, String>> =
        std::sync::OnceLock::new();
    TABLE
        .get_or_init(|| {
            let table = load_api();
            if let Err(e) = &table {
                // Once per process. Only reachable when something resolved to NVENC on this box
                // (backend misdetect or a forced PUNKTFUNK_ENCODER=nvenc) — say why it will fail.
                tracing::warn!(error = %e, "NVENC API unavailable");
            }
            table
        })
        .as_ref()
        .map_err(|e| e.as_str())
}

/// The loaded table, for call sites past a [`try_api`] gate — a live session (or the probe's own
/// gate) implies the load succeeded, and the table lives for the process lifetime.
fn api() -> &'static EncodeApi {
    try_api().expect("NVENC call before a successful try_api() gate")
}

fn load_api() -> std::result::Result<EncodeApi, String> {
    use windows::core::{s, w};
    use windows::Win32::System::LibraryLoader::{
        GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32,
    };
    // SAFETY: `LoadLibraryExW`/`GetProcAddress` take static NUL-terminated names; the
    // System32-only search path keeps a planted DLL out of the SYSTEM-service process. The two
    // transmutes cast the resolved exports to their documented prototypes (nvEncodeAPI.h), the
    // same contract the C SDK's own loader applies. `NvEncodeAPIGetMaxSupportedVersion` writes
    // one u32 through a live pointer; `NvEncodeAPICreateInstance` fills `list`, a stack-local
    // `#[repr(C)]` function list with `version` set, only during the call. The module is never
    // freed, so every extracted function pointer stays valid for the process lifetime.
    unsafe {
        let module = LoadLibraryExW(w!("nvEncodeAPI64.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32)
            .map_err(|e| format!("nvEncodeAPI64.dll not loadable (no NVIDIA driver?): {e}"))?;
        let get_version = GetProcAddress(module, s!("NvEncodeAPIGetMaxSupportedVersion"))
            .ok_or("nvEncodeAPI64.dll exports no NvEncodeAPIGetMaxSupportedVersion")?;
        let create_instance = GetProcAddress(module, s!("NvEncodeAPICreateInstance"))
            .ok_or("nvEncodeAPI64.dll exports no NvEncodeAPICreateInstance")?;
        let get_version: unsafe extern "C" fn(*mut u32) -> nv::NVENCSTATUS =
            std::mem::transmute(get_version);
        let create_instance: unsafe extern "C" fn(
            *mut nv::NV_ENCODE_API_FUNCTION_LIST,
        ) -> nv::NVENCSTATUS = std::mem::transmute(create_instance);

        let mut version = 0u32;
        get_version(&mut version)
            .nv_ok()
            .map_err(|e| format!("NvEncodeAPIGetMaxSupportedVersion: {e:?}"))?;
        // The sdk's assert_versions_match, minus the panic: an older driver is a clean Err.
        let (major, minor) = (version >> 4, version & 0xf);
        if (major, minor) < (nv::NVENCAPI_MAJOR_VERSION, nv::NVENCAPI_MINOR_VERSION) {
            return Err(format!(
                "driver NVENC API {major}.{minor} is older than the host's headers {}.{} — \
                 update the NVIDIA driver",
                nv::NVENCAPI_MAJOR_VERSION,
                nv::NVENCAPI_MINOR_VERSION
            ));
        }

        let mut list = nv::NV_ENCODE_API_FUNCTION_LIST {
            version: nv::NV_ENCODE_API_FUNCTION_LIST_VER,
            ..Default::default()
        };
        create_instance(&mut list)
            .nv_ok()
            .map_err(|e| format!("NvEncodeAPICreateInstance: {e:?}"))?;
        const MISSING: &str = "NvEncodeAPICreateInstance left an entry point unfilled";
        Ok(EncodeApi {
            open_encode_session_ex: list.nvEncOpenEncodeSessionEx.ok_or(MISSING)?,
            initialize_encoder: list.nvEncInitializeEncoder.ok_or(MISSING)?,
            reconfigure_encoder: list.nvEncReconfigureEncoder.ok_or(MISSING)?,
            destroy_encoder: list.nvEncDestroyEncoder.ok_or(MISSING)?,
            get_encode_caps: list.nvEncGetEncodeCaps.ok_or(MISSING)?,
            get_encode_guid_count: list.nvEncGetEncodeGUIDCount.ok_or(MISSING)?,
            get_encode_guids: list.nvEncGetEncodeGUIDs.ok_or(MISSING)?,
            get_encode_preset_config_ex: list.nvEncGetEncodePresetConfigEx.ok_or(MISSING)?,
            create_bitstream_buffer: list.nvEncCreateBitstreamBuffer.ok_or(MISSING)?,
            destroy_bitstream_buffer: list.nvEncDestroyBitstreamBuffer.ok_or(MISSING)?,
            lock_bitstream: list.nvEncLockBitstream.ok_or(MISSING)?,
            unlock_bitstream: list.nvEncUnlockBitstream.ok_or(MISSING)?,
            register_resource: list.nvEncRegisterResource.ok_or(MISSING)?,
            unregister_resource: list.nvEncUnregisterResource.ok_or(MISSING)?,
            map_input_resource: list.nvEncMapInputResource.ok_or(MISSING)?,
            unmap_input_resource: list.nvEncUnmapInputResource.ok_or(MISSING)?,
            encode_picture: list.nvEncEncodePicture.ok_or(MISSING)?,
            register_async_event: list.nvEncRegisterAsyncEvent.ok_or(MISSING)?,
            unregister_async_event: list.nvEncUnregisterAsyncEvent.ok_or(MISSING)?,
            invalidate_ref_frames: list.nvEncInvalidateRefFrames.ok_or(MISSING)?,
        })
    }
}

// Output bitstream buffers = max in-flight encodes. The helper deep-pipelines (submits several frames
// before locking the oldest) so per-frame GPU-scheduling waits OVERLAP instead of serializing under a
// GPU-saturating game; this must be ≥ the helper's `PUNKTFUNK_ENCODE_DEPTH` (default 4, clamped ≤ 6).
const POOL: usize = 8;

/// Live NVENC hardware-session units held by THIS host process (a plain session = 1; a forced
/// split-encode session occupies one session per engine = 2–3) — the Stage-W3 encoder budget
/// (`design/windows-parallel-virtual-displays.md` §4.5). Kept in ONE place so admitting a parallel
/// display consults the same accounting every open/teardown maintains; other processes' sessions
/// aren't visible here, but our own consumption is the deterministic part we can enforce
/// fail-closed at admission.
static LIVE_SESSION_UNITS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);

/// The NVENC concurrent-session cap to budget against: GeForce (consumer) drivers allow 8
/// concurrent encode sessions since R550 (pro cards are effectively unlimited).
/// `PUNKTFUNK_NVENC_MAX_SESSIONS` overrides for older drivers / known-different cards.
fn session_cap() -> u32 {
    std::env::var("PUNKTFUNK_NVENC_MAX_SESSIONS")
        .ok()
        .and_then(|s| s.parse().ok())
        .unwrap_or(8)
}

/// Whether one more (plain, non-split) encode session fits the NVENC budget — consulted by
/// admission before admitting a parallel display (`vdisplay::admission`). On a box that never
/// opened NVENC (AMD/Intel/none) the count is 0 and this always passes — the budget seam is
/// NVENC-only until the AMF/QSV equivalents grow their own accounting.
pub(crate) fn can_open_another_session() -> bool {
    LIVE_SESSION_UNITS.load(std::sync::atomic::Ordering::Relaxed) < session_cap()
}

/// Session-unit weight of a chosen split-encode mode (one hardware session per engine).
fn split_mode_units(split_mode: u32) -> u32 {
    match split_mode {
        m if m == nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_THREE_FORCED_MODE as u32 => 3,
        m if m == nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32
            || m == nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_FORCED_MODE as u32 =>
        {
            2
        }
        _ => 1,
    }
}

/// Serializes every `nvEncOpenEncodeSessionEx` in this process against [`reap_parked_sessions`].
/// The reap retry-destroys handles whose driver-side state is unknown; if it overlapped an open,
/// the driver could hand the NEW session the recycled address of the zombie being destroyed and
/// the reap would destroy a live session. Held across `init_session`'s whole open sequence
/// (including its caps probe and bitrate-clamp search) and around the standalone caps probes.
/// Admission (`can_open_another_session`) never takes it — that stays a lock-free atomic load.
static DRIVER_SESSION_GATE: std::sync::Mutex<()> = std::sync::Mutex::new(());

/// A session whose `destroy_encoder` failed with an AMBIGUOUS status (see
/// [`nvenc_status::destroy_proves_no_session`]): the driver may still hold its concurrent-session
/// slot, so its units stay charged in [`LIVE_SESSION_UNITS`] (fail-closed for admission) and the
/// handle is parked here for [`reap_parked_sessions`] to retry. The retry is what keeps a
/// TRANSIENT failure (wedge episode a TDR later cleared) from poisoning the budget until a host
/// restart, while a genuine driver leak keeps failing the retry and stays correctly charged.
struct ParkedSession {
    enc: usize,
    units: u32,
    /// Pins the D3D11 device the session was opened against. Teardown drops the texture
    /// registrations (the only other refs), and the reinit-on-device-change path parks exactly
    /// when the old device is on its way out — without this, the reap's late `destroy_encoder`
    /// could touch a freed device.
    _device: Option<ID3D11Device>,
}

// SAFETY: the COM ref is the only non-Send field. The D3D11 device is free-threaded (capture
// creates it without `D3D11_CREATE_DEVICE_SINGLETHREADED` — see pf-frame/src/dxgi.rs), we never
// call methods on it from here, and dropping (Release) a free-threaded COM object from another
// thread is sound. The raw `enc` handle is only ever passed back to `destroy_encoder` under
// [`PARKED`]'s lock with [`DRIVER_SESSION_GATE`] held — single-threaded access in practice.
unsafe impl Send for ParkedSession {}

static PARKED: std::sync::Mutex<Vec<ParkedSession>> = std::sync::Mutex::new(Vec::new());

/// Park a session whose destroy failed ambiguously. Units are NOT refunded — they keep counting
/// against admission until a reap proves the slot free. Bounded: once parked units would exceed
/// the session cap the oldest entry ages out WITH a refund (a repeated wedge-rebuild loop would
/// otherwise grow this without limit, and by then the driver has almost certainly been through
/// the reset that reclaims the early handles anyway).
fn park_session(enc: usize, units: u32, device: Option<ID3D11Device>) {
    let mut parked = PARKED.lock().unwrap_or_else(|p| p.into_inner());
    while !parked.is_empty() && parked.iter().map(|z| z.units).sum::<u32>() + units > session_cap()
    {
        let old = parked.remove(0);
        LIVE_SESSION_UNITS.fetch_sub(old.units, std::sync::atomic::Ordering::Relaxed);
        tracing::warn!(
            enc = old.enc,
            units = old.units,
            "NVENC parked-session graveyard full — aging out the oldest entry (units refunded)"
        );
    }
    parked.push(ParkedSession {
        enc,
        units,
        _device: device,
    });
}

/// Retry `destroy_encoder` on every parked session and refund the units of each one that now
/// succeeds (or fails with a session-gone status — same proof). Runs from `init_session` on the
/// encode thread, NEVER from admission (a wedged driver would block the admission registry lock).
///
/// # Safety
/// Caller must hold [`DRIVER_SESSION_GATE`] and there must be NO live NVENC session in the
/// process (checked here: live units == parked units). Under those two conditions no live session
/// can alias a recycled handle address, and no concurrent open can be handed one mid-reap. The
/// residual assumption — documented, unprovable from the SDK — is that a FAILED destroy leaves
/// the handle itself intact for a later retry; NVENC documents no retry semantics either way.
unsafe fn reap_parked_sessions() {
    let mut parked = PARKED.lock().unwrap_or_else(|p| p.into_inner());
    if parked.is_empty() {
        return;
    }
    let parked_units: u32 = parked.iter().map(|z| z.units).sum();
    if LIVE_SESSION_UNITS.load(std::sync::atomic::Ordering::Relaxed) != parked_units {
        return; // a live session exists somewhere — its address space is off limits
    }
    parked.retain(
        |z| match (api().destroy_encoder)(z.enc as *mut c_void).nv_ok() {
            Ok(()) => {
                tracing::info!(
                    enc = z.enc,
                    units = z.units,
                    "NVENC parked session reclaimed — retry-destroy succeeded, budget refunded"
                );
                LIVE_SESSION_UNITS.fetch_sub(z.units, std::sync::atomic::Ordering::Relaxed);
                false
            }
            Err(e) if nvenc_status::destroy_proves_no_session(e) => {
                tracing::info!(
                    enc = z.enc,
                    units = z.units,
                    status = ?e,
                    "NVENC parked session gone on the driver side — budget refunded"
                );
                LIVE_SESSION_UNITS.fetch_sub(z.units, std::sync::atomic::Ordering::Relaxed);
                false
            }
            Err(e) => {
                tracing::debug!(enc = z.enc, status = ?e,
                    "NVENC parked session still refuses destroy — units stay charged");
                true
            }
        },
    );
}

/// Whether the operator asked for the two-thread async retrieve (`PUNKTFUNK_NVENC_ASYNC` truthy).
/// Combined with the GPU's `NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT` in `init_session`. Opt-in until
/// on-glass validated; note an async-rejecting config surfaces as a failed session open — unset
/// the env in that case.
fn async_retrieve_requested() -> bool {
    std::env::var("PUNKTFUNK_NVENC_ASYNC")
        .map(|v| matches!(v.trim(), "1" | "true" | "yes" | "on"))
        .unwrap_or(false)
}

/// Max encodes in flight in async mode (`PUNKTFUNK_NVENC_ASYNC_DEPTH`, default 4, clamped
/// `2..=POOL-1`). Two independent ceilings meet here: the output-bitstream pool (hard, `POOL-1` —
/// a buffer must never be reused mid-encode) and the capturer's texture ring (soft — NVENC encodes
/// the ring textures in place, so in-flight depth beyond the ring lets the capturer overwrite a
/// frame mid-encode: visual corruption, not UB). IDD-push rings are sized around
/// `PUNKTFUNK_IDD_DEPTH`; raise both together if deeper pipelining is needed.
///
/// Read from the environment **once per process** (WP6.1): `submit` consults this on EVERY frame —
/// both arms of the `cap` match, in sync mode too, where the result is then discarded because the
/// backpressure loop short-circuits on `async_rt`. Memoizing rather than latching into a session
/// field is deliberate: the composed `cap` also folds in `input_ring_depth`, which
/// `set_input_ring_depth` may change after open, and freezing that half would reintroduce the
/// in-place-overwrite bug the ring term exists to prevent. Nothing in the workspace mutates this
/// variable at runtime, so memoizing a process-constant read is behaviour-preserving by
/// construction.
fn async_inflight_cap() -> usize {
    static CAP: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
    *CAP.get_or_init(|| {
        std::env::var("PUNKTFUNK_NVENC_ASYNC_DEPTH")
            .ok()
            .and_then(|s| s.trim().parse::<usize>().ok())
            .unwrap_or(4)
            .clamp(2, POOL - 1)
    })
}

/// One in-flight encode handed to the retrieve thread: the output bitstream to lock once its
/// completion `event` signals. Raw pointers travel as `usize` (the addresses are process-global
/// driver handles; the thread is joined before the session they belong to is destroyed).
struct RetrieveJob {
    bs: usize,
    event: usize,
}

/// A finished retrieve: the locked-and-copied AU (or the retrieve-side error) for the oldest
/// in-flight bitstream. `bs` lets the encode thread cross-check FIFO pairing with `pending`.
struct RetrieveDone {
    bs: usize,
    result: std::result::Result<(Vec<u8>, bool), String>,
}

/// The async-retrieve runtime: the job channel feeding the retrieve thread, the completion channel
/// back, the thread handle (joined in `teardown` BEFORE the session is destroyed), and AUs already
/// absorbed by backpressure that `poll` hands out first.
struct AsyncRetrieve {
    work_tx: Option<mpsc::SyncSender<RetrieveJob>>,
    done_rx: mpsc::Receiver<RetrieveDone>,
    join: Option<std::thread::JoinHandle<()>>,
    ready: VecDeque<EncodedFrame>,
}

/// The retrieve-thread body (gpu-contention plan §5.B): for each submitted frame, wait on its
/// completion event, lock the bitstream, copy the AU out, unlock, and send it back. Exits when the
/// job channel closes (teardown drops the sender and joins BEFORE destroying the session, so
/// `enc`/`bs`/`event` outlive every use here). Touches ONLY the event wait + lock/unlock — the
/// NVENC threading model's sanctioned secondary-thread surface.
fn retrieve_loop(
    enc: usize,
    work_rx: mpsc::Receiver<RetrieveJob>,
    done_tx: mpsc::Sender<RetrieveDone>,
) {
    pf_frame::thread_qos::boost_thread_priority(false);
    // Wedged-vs-routine drain (WP5.2b). Teardown drops the job sender and joins this thread, so
    // every queued job is WAITED for (never abandoned — abandoning converts the routine drain
    // into destroy/unmap/close-while-encoding). But on a wedged session that used to cost the
    // full 5 s PER queued job: up to `cap x 5 s` on the encode thread, and the stall-recovery
    // ladder rebuilds the session several times per episode, paying it each rebuild. The wedge
    // evidence lives right here — a completion wait that burned its full budget — so after one
    // full-budget timeout later jobs get a short slice instead. A first timeout is already
    // encoder-fatal (its Err reaches `absorb_done` before any later completion is handed out,
    // and the host tears the session down), so short-slicing behind an established wedge changes
    // only how long teardown blocks, never an outcome. A successful wait resets the latch, which
    // keeps a transient GPU stall from cascading short slices into false timeouts. (NVENC does
    // not document completion ordering across in-flight frames; nothing here relies on it —
    // FIFO only sharpens the latency bound.)
    const WEDGED_DRAIN_WAIT_MS: u32 = 250;
    let mut wedged = false;
    while let Ok(job) = work_rx.recv() {
        let wait_ms = if wedged { WEDGED_DRAIN_WAIT_MS } else { 5000 };
        // SAFETY: `job.event` is one of the auto-reset events `init_session` created and
        // registered for exactly this session, and `job.bs` one of its pool bitstreams; both stay
        // valid until `teardown`, which joins this thread first. `WaitForSingleObject` takes the
        // handle by value. On WAIT_OBJECT_0 the driver has completed the encode into `job.bs`, so
        // `lock_bitstream` (version set, struct a live stack local for the synchronous call)
        // yields a CPU-readable `bitstreamBufferPtr`/`bitstreamSizeInBytes` valid until
        // `unlock_bitstream`; the slice is copied (`to_vec`) before the unlock on the same buffer.
        // Lock/unlock from a secondary thread while the encode thread submits is the NVENC
        // guide's documented threading model.
        let result = unsafe {
            if WaitForSingleObject(HANDLE(job.event as *mut c_void), wait_ms) != WAIT_OBJECT_0 {
                wedged = true;
                Err(format!(
                    "NVENC completion event timeout ({wait_ms} ms) — encoder wedged?"
                ))
            } else {
                wedged = false;
                let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
                    version: nv::NV_ENC_LOCK_BITSTREAM_VER,
                    outputBitstream: job.bs as *mut c_void,
                    ..Default::default()
                };
                match (api().lock_bitstream)(enc as *mut c_void, &mut lock).nv_ok() {
                    Ok(()) => {
                        let data = std::slice::from_raw_parts(
                            lock.bitstreamBufferPtr as *const u8,
                            lock.bitstreamSizeInBytes as usize,
                        )
                        .to_vec();
                        let keyframe = matches!(
                            lock.pictureType,
                            nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR
                                | nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I
                        );
                        let _ = (api().unlock_bitstream)(enc as *mut c_void, job.bs as *mut c_void);
                        Ok((data, keyframe))
                    }
                    Err(e) => Err(format!(
                        "lock_bitstream (async): {e:?} — {}",
                        nvenc_status::explain(e)
                    )),
                }
            }
        };
        if done_tx.send(RetrieveDone { bs: job.bs, result }).is_err() {
            break; // encoder side gone (teardown drains us via join)
        }
    }
}

pub struct NvencD3d11Encoder {
    encoder: *mut c_void,
    codec: Codec,
    codec_guid: nv::GUID,
    width: u32,
    height: u32,
    fps: u32,
    bitrate_bps: u64,
    buffer_fmt: nv::NV_ENC_BUFFER_FORMAT,
    /// Encoded bit depth (8 or 10). 10 → HEVC Main10 (NVENC upconverts the 8-bit ARGB input).
    bit_depth: u8,
    /// Full-chroma 4:4:4 (HEVC Range Extensions, `chroma_format_idc = 3`) requested for this session.
    /// NVENC ingests the RGB (ARGB/ABGR10) input and CSCs it to YUV444 internally; the `FREXT` profile
    /// and `chromaFormatIDC = 3` in the encode config carry the chroma. Gated on the GPU's
    /// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` (cleared in `query_caps` on a card that lacks it) and on an
    /// RGB input format (NV12/P010 capture can't reconstruct 4:4:4). HEVC-only.
    chroma_444: bool,
    /// What the SESSION NEGOTIATED, before any per-init downgrade. `chroma_444` above is the
    /// EFFECTIVE value for the session currently open and is cleared whenever the capturer hands us
    /// subsampled YUV — which used to overwrite the negotiation itself, so a capturer that went
    /// back to RGB (HDR toggle, capture-path switch) could never recover 4:4:4 for the rest of the
    /// stream. Keeping the request separate lets every re-init recompute the effective value.
    chroma_444_requested: bool,
    /// `NV_ENC_CAPS_SUPPORT_YUV444_ENCODE` from the caps probe — whether this GPU can 4:4:4 encode at
    /// all. `chroma_444` is forced off when this is false (graceful downgrade to 4:2:0).
    yuv444_supported: bool,
    /// HDR: the capturer is delivering BT.2020 PQ 10-bit (`PixelFormat::Rgb10a2`) frames. Sets the
    /// `ABGR10` input format + the BT.2020/PQ colour VUI. Derived per-frame from the capture format
    /// (HDR can toggle mid-session); a change re-inits the session.
    hdr: bool,
    /// The HDR state the CAPTURE FORMAT asks for, independent of whether this GPU can serve it.
    ///
    /// `hdr` above is the effective value and `query_caps` clears it on a card without 10-bit
    /// encode. `submit` re-derives the requested value from every frame's pixel format, so
    /// comparing that against the post-downgrade `hdr` made them disagree permanently: a P010
    /// capturer on such a GPU reported "HDR changed" on EVERY frame and tore down and rebuilt the
    /// whole session each time. The re-init trigger compares against THIS instead.
    hdr_requested: bool,
    /// Latched when `query_caps` finds no 10-bit encode support, so the downgrade is remembered
    /// across re-inits instead of being rediscovered (and re-warned) every session.
    hdr_unsupported: bool,
    /// The source's static HDR mastering metadata (from the capturer's `GetDesc1`), emitted as
    /// in-band SEI (`mastering_display_colour_volume` + `content_light_level_info`) on each keyframe
    /// when `hdr`. `None` = unknown → no SEI (the VUI still signals BT.2020 PQ). Set per-frame via
    /// [`Encoder::set_hdr_meta`], so a mid-session regrade is picked up on the next keyframe.
    hdr_meta: Option<punktfunk_core::quic::HdrMeta>,
    /// Registrations of the capturer's input textures, cached by texture raw pointer — NVENC encodes
    /// them in place (no per-frame copy). The cloned `ID3D11Texture2D` keeps each alive until we
    /// unregister it (the capturer may drop its copy on a device recreate before our teardown runs).
    regs: HashMap<isize, (nv::NV_ENC_REGISTERED_PTR, ID3D11Texture2D)>,
    next: usize,
    bitstreams: Vec<nv::NV_ENC_OUTPUT_PTR>,
    /// Async mode: the registered completion event per pool bitstream (raw `HANDLE` as `usize`,
    /// parallel to `bitstreams`); empty in sync mode. Unregistered + closed in `teardown`.
    events: Vec<usize>,
    /// Async mode: the retrieve thread + its channels (`None` = classic same-thread sync retrieve).
    async_rt: Option<AsyncRetrieve>,
    /// The capturer's `pipeline_depth` (`set_input_ring_depth`). This backend encodes the
    /// capturer's textures IN PLACE, so it is a HARD ceiling on async in-flight depth: the
    /// capturer rotates its ring per delivered frame regardless of encode completion, so
    /// pipelining deeper lets it overwrite a texture mid-encode (torn frames). `None` until the
    /// session glue reports it — treated as "unknown, don't pipeline past the env cap".
    input_ring_depth: Option<usize>,
    /// `NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT` from the caps probe — gates the async retrieve mode.
    async_supported: bool,
    /// `NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK` from the caps probe — gates the DEFAULT-on
    /// sub-frame readback (the Linux backend's rule since its Phase 3; Windows joined after the
    /// 2026-07-31 on-glass A/B), so a GPU without it never has sub-frame forced by default.
    subframe_cap: bool,
    /// `NV_ENC_CAPS_NUM_ENCODER_ENGINES` — how many NVENC engines this GPU has, probed in
    /// [`query_caps`](Self::query_caps). `0` = not probed / unreadable. The split-encode ceiling:
    /// the driver accepts a split wider than the hardware and silently encodes narrower, so this
    /// is the only honest source for how wide we may go (see `codec::max_forced_split_mode`).
    encoder_engines: u32,
    /// Submit stamp for the split arbiter's per-frame cost (sync depth-1 path only).
    last_submit_at: Option<std::time::Instant>,
    /// Whole-AU paced-send time (µs) the host last reported. `0` = never reported, which keeps
    /// the arbiter out of the sub-frame trade it cannot otherwise price.
    send_spread_us: u32,
    /// Sub-frame state the session was OPENED able to run, so a return to a non-forced split can
    /// restore it without ever turning it on for a session that never had it.
    subframe_opened_with: bool,
    /// The live split-mode experiment, when one is running.
    arbiter: Option<SplitArbiter>,
    /// (bitstream, mapped input resource to unmap after retrieval, pts_ns, recovery-anchor) per
    /// in-flight encode. The fourth field tags the first frame encoded after a successful
    /// [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) — the clean re-anchor P-frame the
    /// client lifts its post-loss freeze on (see [`EncodedFrame::recovery_anchor`]).
    pending: VecDeque<(nv::NV_ENC_OUTPUT_PTR, nv::NV_ENC_INPUT_PTR, u64, bool, bool)>,
    /// The frame number of the NEXT submission (also its `inputTimeStamp`). Pinned per frame by
    /// [`Encoder::submit_indexed`] to the WIRE frame index the AU will carry, so the DPB timestamps
    /// `invalidate_ref_frames` compares client frame numbers against stay 1:1 with the wire across
    /// encoder rebuilds/resets (an internal counter desyncs on the first adaptive-bitrate rebuild —
    /// RFI then never matches again). Self-increments as a fallback for un-indexed callers (tests).
    frame_idx: i64,
    force_kf: bool,
    /// A successful [`invalidate_ref_frames`](Encoder::invalidate_ref_frames) arms this; the next
    /// `submit` consumes it into `pending` so that AU ships as the recovery anchor. NVENC applies
    /// the invalidation at the next `encode_picture`, so that frame is by construction the first
    /// one coded against only-valid references — without tagging it the client's freeze can only
    /// lift on an IDR, which the session glue suppresses after an RFI success (the cooldown):
    /// a ~1 s frozen stall per loss event on NVIDIA hosts.
    pending_anchor: bool,
    inited: bool,
    /// GPU capabilities probed once via `nvEncGetEncodeCaps` before configuring (Apollo's
    /// `get_encoder_cap`): gates 10-bit/custom-VBV/RFI on what this card actually supports instead
    /// of failing later as an opaque `InvalidParam`. Set by [`query_caps`](Self::query_caps).
    rfi_supported: bool,
    custom_vbv: bool,
    /// The split-encode mode + async-retrieve flag the live session was initialized with —
    /// `reconfigure_bitrate` must present the SAME init params as the open (only the config's
    /// rate fields may move). Meaningless while `inited` is false.
    split_mode: u32,
    /// The session's arbitrated sub-frame state ([`resolve_split_subframe`] at open) — recorded
    /// so reconfigure presents EXACTLY the init params the session opened with (an env re-read
    /// there could flip `enableSubFrameWrite` mid-session, and would re-log the arbitration on
    /// every ABR retarget).
    subframe_on: bool,
    /// Slice count the live session was configured with ([`resolve_slices`] over the
    /// negotiated ceiling, latched by `init_session` and consumed by `build_config` so an
    /// in-place reconfigure presents the same slicing). Chunked poll needs ≥ 2.
    slices: u32,
    /// Client-decoder slice ceiling from negotiation (see [`NvencD3d11Encoder::open`]).
    max_slices: u32,
    /// Sub-frame chunked poll armed for the live session (`slices ≥ 2` ∧ sub-frame write ∧
    /// SYNC retrieve — the async retrieve owns the bitstream from its thread, a doNotWait
    /// sampler here would race it).
    subframe_chunks: bool,
    /// In-progress chunked readback of the FRONT `pending` AU (see [`ChunkState`]).
    chunk: Option<ChunkState>,
    session_async: bool,
    /// The last reference-frame range we invalidated — dedupes repeated RFI requests for the same
    /// loss event (the client resends until it sees recovery).
    last_rfi_range: Option<(i64, i64)>,
    /// Raw ptr of the D3D11 device this session was initialized with. The capturer recreates the
    /// device on a desktop switch (normal ↔ Winlogon secure); when a frame carries a new device we
    /// tear down and re-init NVENC against it.
    init_device: *mut c_void,
    /// COM ref pinning that device while the session lives. `init_device` alone pins nothing, and
    /// `teardown` releases the texture registrations (the only other refs) BEFORE destroying the
    /// session — worse, a failed destroy parks the session handle for a later retry, which must
    /// never outlive the device it references. Moved into the [`ParkedSession`] on that path.
    init_device_com: Option<ID3D11Device>,
    /// The hardware-session units THIS encoder holds against [`LIVE_SESSION_UNITS`] (1 plain, 2–3
    /// under forced split-encode — a split session occupies one session per engine). `0` while no
    /// session is open; set by `init_session`, returned by `teardown`.
    session_units: u32,
}

// SAFETY: the `!Send` fields are the raw NVENC session/device handles (`encoder`, `init_device`),
// the raw NVENC bitstream/registered/mapped pointers carried in `bitstreams`/`regs`/`pending`, and
// the `ID3D11Texture2D` COM refs — none of which may be touched concurrently from two threads
// EXCEPT along the NVENC guide's sanctioned split. The encoder object is owned by exactly one
// thread: it is moved onto the host encode thread once at construction, and every method
// (`submit`/`poll`/`invalidate_ref_frames`/`Drop`) runs there. In async mode the internal retrieve
// thread additionally calls `WaitForSingleObject`/`lock_bitstream`/`unlock_bitstream` on the same
// session — the exact two-thread model the NVENC API documents as thread-safe (submit-side vs
// output-side); it never touches registrations, mappings, or D3D11. `teardown` joins that thread
// BEFORE destroying the session, so no retrieve call can outlive the handles. Moving the encoder
// across its single ownership-transfer boundary is sound because no NVENC/D3D11 call is in flight
// during the move — so `Send` introduces no data race on the non-`Send` fields.
unsafe impl Send for NvencD3d11Encoder {}

/// Sampling cadence of the sub-frame chunked poll's doNotWait lock — mirrors the Linux twin
/// (slice completions land ~0.5-1 ms apart; 50 µs keeps the added per-chunk delivery delay
/// well under one slice time without hammering the driver).
const CHUNK_SAMPLE_INTERVAL: std::time::Duration = std::time::Duration::from_micros(50);

/// Progress of a sub-frame chunked readback (P2f — the Windows twin of the Linux LN1 state)
/// for the FRONT in-flight AU: how much of the bitstream has already been handed out as
/// chunks. `Some` from an AU's first emitted chunk until its `last` — [`Encoder::poll`]
/// refuses to run while it exists (a plain poll would re-emit the already-shipped prefix).
struct ChunkState {
    /// Bytes emitted so far — also the next chunk's start offset (always a slice boundary).
    emitted: usize,
    /// Completed slices already covered by emitted chunks.
    slices_out: u32,
    /// The AU-opening chunk (`AuChunk::first`) has been handed out.
    opened: bool,
    /// Debug-build shadow of every emitted byte, cross-checked against the finishing blocking
    /// lock's full AU — a mis-cut chunk fails loudly in debug instead of silently corrupting
    /// the wire. Compiled out of release builds.
    #[cfg(debug_assertions)]
    shadow: Vec<u8>,
}

impl ChunkState {
    fn new() -> Self {
        ChunkState {
            emitted: 0,
            slices_out: 0,
            opened: false,
            #[cfg(debug_assertions)]
            shadow: Vec::new(),
        }
    }
}

impl NvencD3d11Encoder {
    #[allow(clippy::too_many_arguments)]
    pub fn open(
        codec: Codec,
        _format: PixelFormat,
        width: u32,
        height: u32,
        fps: u32,
        bitrate_bps: u64,
        bit_depth: u8,
        chroma: ChromaFormat,
        // Client-decoder slice ceiling from negotiation (`VIDEO_CAP_MULTI_SLICE`, or
        // GameStream's `videoEncoderSlicesPerFrame`); 1 = single-slice only — the safe shape
        // toward decoders that never asked (Amlogic TV SoCs wedge on multi-slice AUs).
        max_slices: u32,
    ) -> Result<Self> {
        // The runtime DLL load is the real "is NVENC possible here" gate: fail the open with a
        // clear reason (backend misdetect / forced PUNKTFUNK_ENCODER=nvenc on a non-NVIDIA box)
        // instead of an opaque session error on the first frame. Every later NVENC call in this
        // file sits behind this gate (or the probe's), so the infallible `api()` is sound.
        try_api().map_err(|e| anyhow!("NVENC unavailable: {e}"))?;
        Ok(Self {
            encoder: ptr::null_mut(),
            codec,
            codec_guid: codec_guid(codec),
            width,
            height,
            fps,
            bitrate_bps,
            buffer_fmt: nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
            bit_depth,
            // 4:4:4 is HEVC-only; the GPU-support gate is applied in `query_caps`.
            chroma_444: chroma.is_444() && codec == Codec::H265,
            chroma_444_requested: chroma.is_444() && codec == Codec::H265,
            hdr_requested: false,
            hdr_unsupported: false,
            yuv444_supported: false,
            hdr: false,
            hdr_meta: None,
            regs: HashMap::new(),
            next: 0,
            bitstreams: Vec::new(),
            events: Vec::new(),
            async_rt: None,
            input_ring_depth: None,
            async_supported: false,
            subframe_cap: false,
            encoder_engines: 0,
            last_submit_at: None,
            send_spread_us: 0,
            subframe_opened_with: false,
            arbiter: None,
            pending: VecDeque::new(),
            frame_idx: 0,
            force_kf: false,
            pending_anchor: false,
            inited: false,
            rfi_supported: false,
            custom_vbv: false,
            split_mode: nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32,
            subframe_on: false,
            slices: 1,
            max_slices: max_slices.max(1),
            subframe_chunks: false,
            chunk: None,
            session_async: false,
            last_rfi_range: None,
            init_device: ptr::null_mut(),
            init_device_com: None,
            session_units: 0,
        })
    }

    /// Tear down the encode session + pooled resources. Reused on a capture-device change (desktop
    /// switch) and at Drop.
    unsafe fn teardown(&mut self) {
        if self.encoder.is_null() {
            return;
        }
        // Async mode: retire the retrieve thread FIRST — drop the job sender so it finishes every
        // queued job (each references the still-live session) and exits, then join. Only after the
        // join is it sound to unmap/destroy anything the thread might have been touching.
        if let Some(mut rt) = self.async_rt.take() {
            drop(rt.work_tx.take());
            if let Some(j) = rt.join.take() {
                let _ = j.join();
            }
            // Completions the thread produced that poll() never absorbed — their AUs are dropped
            // (the session is going away), but the FIFO pairing stands, so nothing extra to do
            // beyond the pending unmap below.
            while rt.done_rx.try_recv().is_ok() {}
        }
        // Unmap any in-flight inputs, then unregister every cached texture and destroy the bitstreams.
        for (_, map, _, _, _) in &self.pending {
            if !map.is_null() {
                let _ = (api().unmap_input_resource)(self.encoder, *map);
            }
        }
        for (reg, _tex) in self.regs.values() {
            let _ = (api().unregister_resource)(self.encoder, *reg);
        }
        // Async events: unregister from the session, then close the Win32 handles.
        for &ev in &self.events {
            let mut ep = nv::NV_ENC_EVENT_PARAMS {
                version: nv::NV_ENC_EVENT_PARAMS_VER,
                completionEvent: ev as *mut c_void,
                ..Default::default()
            };
            let _ = (api().unregister_async_event)(self.encoder, &mut ep);
            let _ = CloseHandle(HANDLE(ev as *mut c_void));
        }
        self.events.clear();
        for &bs in &self.bitstreams {
            let _ = (api().destroy_bitstream_buffer)(self.encoder, bs);
        }
        // Session-budget truth (see LIVE_SESSION_UNITS): refund only on PROOF the driver released
        // the slot — a successful destroy, or a failure whose status says the session/device no
        // longer exists on the driver side (a TDR reclaims every session with the context). The
        // old unconditional refund made the counter drift low on real leaks (over-admitting
        // parallel displays); the opposite extreme — a permanent charge on ANY failure — let one
        // transient wedge episode poison admission until a host restart. Ambiguous failures
        // instead PARK the handle with its units still charged (fail-closed), and
        // `reap_parked_sessions` retries the destroy once no session is live, so the charge lasts
        // exactly as long as the driver keeps refusing — which is the definition of still-leaked.
        let dev_pin = self.init_device_com.take();
        match (api().destroy_encoder)(self.encoder).nv_ok() {
            Ok(()) => {
                LIVE_SESSION_UNITS
                    .fetch_sub(self.session_units, std::sync::atomic::Ordering::Relaxed);
            }
            Err(e) if nvenc_status::destroy_proves_no_session(e) => {
                tracing::warn!(
                    status = ?e,
                    "NVENC destroy_encoder failed, but the status proves the driver holds no \
                     session (device gone/reset) — budget refunded"
                );
                LIVE_SESSION_UNITS
                    .fetch_sub(self.session_units, std::sync::atomic::Ordering::Relaxed);
            }
            Err(e) => {
                tracing::warn!(
                    status = ?e,
                    units = self.session_units,
                    "NVENC destroy_encoder failed ambiguously — the driver may still hold this \
                     session's slot; parking the handle (units stay charged until a reap-destroy \
                     proves the slot free)"
                );
                park_session(self.encoder as usize, self.session_units, dev_pin);
            }
        }
        self.session_units = 0;
        self.regs.clear(); // drops the texture clones, releasing our refs
        self.bitstreams.clear();
        self.pending.clear();
        self.chunk = None;
        self.subframe_chunks = false;
        self.encoder = ptr::null_mut();
        self.inited = false;
        self.next = 0;
        // The new session starts with an empty DPB (its first frame is an IDR), so any prior
        // invalidation range is meaningless against it — and the IDR is itself the re-anchor,
        // so a pending anchor tag from a pre-teardown RFI is stale too.
        self.last_rfi_range = None;
        self.pending_anchor = false;
    }

    /// Query one `NV_ENC_CAPS` value for this codec on an open session; 0 on any error (treat an
    /// unqueryable cap as "unsupported", the conservative choice).
    unsafe fn get_cap(&self, enc: *mut c_void, which: nv::NV_ENC_CAPS) -> i32 {
        let mut param = nv::NV_ENC_CAPS_PARAM {
            version: nv::NV_ENC_CAPS_PARAM_VER,
            capsToQuery: which,
            reserved: [0; 62],
        };
        let mut val: i32 = 0;
        match (api().get_encode_caps)(enc, self.codec_guid, &mut param, &mut val).nv_ok() {
            Ok(()) => val,
            Err(_) => 0,
        }
    }

    /// Probe this GPU's real capabilities once (Apollo's `get_encoder_cap`) before the bitrate-probe
    /// loop configures the session: opens a throwaway session, queries the codec's max dimensions +
    /// 10-bit / custom-VBV / ref-pic-invalidation support, destroys it. Rejects an out-of-range mode
    /// up front with a clear error, downgrades 10-bit→8-bit when unsupported, and records the
    /// RFI/custom-VBV flags the config + [`invalidate_ref_frames`](Encoder::invalidate_ref_frames)
    /// gate on. Without this, an unsupported config surfaces only as an opaque `InvalidParam` that
    /// the bitrate-clamp search misreads as "bitrate too high" and binary-searches into the floor.
    unsafe fn query_caps(&mut self, device: &ID3D11Device) -> Result<()> {
        let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
            version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
            deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_DIRECTX,
            device: device.as_raw(),
            apiVersion: nv::NVENCAPI_VERSION,
            ..Default::default()
        };
        let mut enc: *mut c_void = ptr::null_mut();
        if let Err(e) = (api().open_encode_session_ex)(&mut params, &mut enc).nv_ok() {
            // The NVENC docs require NvEncDestroyEncoder even after a FAILED open (the driver may
            // have allocated the session slot before erroring) — without it, every failed open in
            // a retry loop leaks a slot toward the concurrent-session cap, turning a transient
            // failure into permanent exhaustion that only a host restart clears.
            if !enc.is_null() {
                let _ = (api().destroy_encoder)(enc);
            }
            return Err(nvenc_status::call_err(
                "open_encode_session_ex (caps probe)",
                e,
            ));
        }
        // The handshake with the kernel-mode driver just succeeded — from here on, an
        // `NV_ENC_ERR_INVALID_VERSION` in this process cannot be a driver version skew.
        nvenc_status::note_session_opened();
        let wmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_WIDTH_MAX);
        let hmax = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_HEIGHT_MAX);
        let ten_bit = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE);
        let yuv444 = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE);
        let rfi = self.get_cap(
            enc,
            nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_REF_PIC_INVALIDATION,
        );
        let custom_vbv = self.get_cap(
            enc,
            nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_CUSTOM_VBV_BUF_SIZE,
        );
        let async_enc = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_ASYNC_ENCODE_SUPPORT);
        let subframe = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_SUBFRAME_READBACK);
        // How many NVENC engines this GPU has — the split-encode ceiling. Must be probed rather
        // than inferred from a rejection: the driver ACCEPTS a split wider than the hardware and
        // silently encodes narrower (measured on `.21`, see `max_forced_split_mode`).
        let engines = self.get_cap(enc, nv::NV_ENC_CAPS::NV_ENC_CAPS_NUM_ENCODER_ENGINES);
        let _ = (api().destroy_encoder)(enc);

        // Reject an over-range mode with a clear message instead of an opaque InvalidParam.
        if wmax > 0 && hmax > 0 && (self.width as i32 > wmax || self.height as i32 > hmax) {
            bail!(
                "this GPU's NVENC max encode size for {:?} is {wmax}x{hmax}; client requested \
                 {}x{} (lower the client resolution or use a codec/GPU that supports it)",
                self.codec,
                self.width,
                self.height
            );
        }
        // Degrade gracefully rather than fail: no 10-bit encode on this card → 8-bit SDR.
        if self.bit_depth >= 10 && ten_bit == 0 {
            if !self.hdr_unsupported {
                tracing::warn!("NVENC: this GPU can't 10-bit encode — falling back to 8-bit SDR");
            }
            // Latched: `submit` compares the frame's REQUESTED hdr against `hdr_requested`, not
            // against this cleared value, so a 10-bit capturer no longer re-inits every frame.
            self.hdr_unsupported = true;
            self.bit_depth = 8;
            self.hdr = false;
        }
        // Same for 4:4:4: a card without YUV444 encode falls back to 4:2:0. (The host already probed
        // this via `probe_can_encode_444` before the Welcome, so this is a belt-and-braces guard.)
        self.yuv444_supported = yuv444 != 0;
        if self.chroma_444 && !self.yuv444_supported {
            tracing::warn!("NVENC: this GPU can't 4:4:4 encode — falling back to 4:2:0");
            self.chroma_444 = false;
        }
        self.rfi_supported = rfi != 0;
        self.custom_vbv = custom_vbv != 0;
        self.async_supported = async_enc != 0;
        self.subframe_cap = subframe != 0;
        self.encoder_engines = engines.max(0) as u32;
        tracing::info!(
            rfi = self.rfi_supported,
            custom_vbv = self.custom_vbv,
            async_encode = self.async_supported,
            subframe_readback = self.subframe_cap,
            max = %format!("{wmax}x{hmax}"),
            ten_bit = ten_bit != 0,
            "NVENC capabilities probed"
        );
        Ok(())
    }

    /// Author the session's `NV_ENC_CONFIG` at `bitrate` (bps): the P1/ULL preset (queried on
    /// `enc`) seeded with the RC/tier/chroma/VUI/DPB shape this backend always runs. ONE builder
    /// shared by [`try_open_session`] and [`Encoder::reconfigure_bitrate`], so an in-place rate
    /// retarget re-authors the exact same config with only the bitrate + derived VBV moved.
    unsafe fn build_config(&self, enc: *mut c_void, bitrate: u64) -> Result<nv::NV_ENC_CONFIG> {
        // Seed the P1 + ultra-low-latency preset config.
        let mut preset = nv::NV_ENC_PRESET_CONFIG {
            version: nv::NV_ENC_PRESET_CONFIG_VER,
            presetCfg: nv::NV_ENC_CONFIG {
                version: nv::NV_ENC_CONFIG_VER,
                ..Default::default()
            },
            ..Default::default()
        };
        (api().get_encode_preset_config_ex)(
            enc,
            self.codec_guid,
            nv::NV_ENC_PRESET_P1_GUID,
            nv::NV_ENC_TUNING_INFO::NV_ENC_TUNING_INFO_ULTRA_LOW_LATENCY,
            &mut preset,
        )
        .nv_ok()
        .map_err(|e| nvenc_status::call_err("get_encode_preset_config_ex", e))?;
        let mut cfg = preset.presetCfg;

        // Steps 3-7 (RC/VBV, tier+level, chroma+bit-depth, colour VUI, RFI DPB) are the shared
        // low-latency contract. On Windows the full-chroma input is a packed-RGB surface (NVENC
        // CSCs it internally under FREXT), and AV1's input-depth follows the surface format — 10-bit
        // for an ABGR10 / YUV420_10BIT input, else 8-bit.
        let rgb_input = matches!(
            self.buffer_fmt,
            nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB
                | nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
        );
        let ten_bit_in = matches!(
            self.buffer_fmt,
            nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
                | nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV420_10BIT
        );
        apply_low_latency_config(
            &mut cfg,
            LowLatencyConfig {
                codec: self.codec,
                bitrate,
                fps: self.fps,
                custom_vbv: self.custom_vbv,
                chroma_444: self.chroma_444,
                full_chroma_input: rgb_input,
                bit_depth: self.bit_depth,
                av1_input_depth_minus8: if ten_bit_in { 2 } else { 0 },
                hdr: self.hdr,
                rfi_supported: self.rfi_supported,
                // Latched by `init_session` from the negotiated ceiling (P2f) — a later
                // reconfigure re-presents the same slicing.
                slices: self.slices,
            },
        );
        Ok(cfg)
    }

    /// The config identity this session's split verdict is cached under.
    fn split_key(&self) -> SplitKey {
        // Same GPU identity as `ceiling_key`: the selected render adapter's LUID, `0` when
        // unresolved. Advisory either way.
        let gpu = pf_gpu::resolve_render_adapter_luid()
            .map(|l| ((l.HighPart as u32 as u64) << 32) | l.LowPart as u64)
            .unwrap_or(0);
        SplitKey {
            gpu,
            codec: self.codec,
            width: self.width,
            height: self.height,
            fps: self.fps,
            bit_depth: self.bit_depth,
            chroma_444: self.chroma_444,
        }
    }

    /// Move the LIVE session to `mode` without an IDR. Windows twin of the Linux method; S1 on
    /// D3D11 proved `nvEncReconfigureEncoder` takes a changed `splitEncodeMode` with
    /// `resetEncoder=0` and emits no keyframe on this device type too.
    fn apply_split_mode(&mut self, mode: u32) -> bool {
        let (prev_mode, prev_sub) = (self.split_mode, self.subframe_on);
        let (mode, subframe) = resolve_split_subframe(
            self.codec,
            mode,
            self.subframe_opened_with,
            subframe_env_forced(),
        );
        self.split_mode = mode;
        self.subframe_on = subframe;
        if self.reconfigure_bitrate(self.bitrate_bps) {
            true
        } else {
            tracing::warn!(
                from = prev_mode,
                to = mode,
                "NVENC split arbitration: driver refused the in-place split change — staying put"
            );
            self.split_mode = prev_mode;
            self.subframe_on = prev_sub;
            false
        }
    }

    /// Feed one frame's encode cost to the split arbiter and act on its verdict.
    fn feed_split_arbiter(&mut self, encode_us: u64) {
        let Some(arb) = self.arbiter.as_mut() else {
            return;
        };
        let action = arb.on_frame(encode_us);
        let done = arb.is_done();
        match action {
            Some(ArbAction::SwitchTo(mode)) => {
                if !self.apply_split_mode(mode) {
                    self.arbiter = None;
                    return;
                }
            }
            Some(ArbAction::Settled(mode)) => store_split_verdict(self.split_key(), mode),
            None => {}
        }
        if done {
            store_split_verdict(self.split_key(), self.split_mode);
            self.arbiter = None;
        }
    }

    /// Decide whether this session may run a live split experiment. Same gates as the Linux
    /// backend — see its `arm_split_arbiter` for why each one is a correctness condition rather
    /// than a preference; the only Windows difference is that `async_rt` is a real possibility
    /// here (opt-in two-thread retrieve), and under it the submit→AU span includes queue depth,
    /// so the comparison would be noise.
    fn arm_split_arbiter(&mut self) {
        if !matches!(
            std::env::var("PUNKTFUNK_NVENC_SPLIT_ARBITRATE").as_deref(),
            Ok("1")
        ) {
            return;
        }
        if std::env::var_os("PUNKTFUNK_SPLIT_ENCODE").is_some()
            || cached_split_verdict(&self.split_key()).is_some()
            || self.async_rt.is_some()
            || self.encoder_engines < 2
            || self.codec == Codec::H264
        {
            return;
        }
        let handicap_us = if self.subframe_on && self.codec != Codec::Av1 {
            if self.send_spread_us == 0 || self.slices < 2 {
                return;
            }
            let slices = self.slices as u64;
            self.send_spread_us as u64 * (slices - 1) / slices
        } else {
            0
        };
        let disable = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
        let widest = max_forced_split_mode(self.encoder_engines);
        let challenger = if self.split_mode == widest {
            disable
        } else {
            widest
        };
        if challenger == self.split_mode {
            return;
        }
        tracing::info!(
            incumbent = self.split_mode,
            challenger,
            handicap_us,
            "NVENC split arbitration armed (Windows) — measuring both arms live (no IDR)"
        );
        self.arbiter = Some(SplitArbiter::with_handicap(
            self.split_mode,
            challenger,
            handicap_us,
        ));
    }

    /// This session config's identity in the process-lifetime bitrate-ceiling cache
    /// (`nvenc_core::{cached_ceiling, store_ceiling}`). GPU identity is the selected render
    /// adapter's LUID — the adapter the capturer's device (and so this session) lives on; `0`
    /// when unresolved. Best effort by design: the cache is advisory, a colliding identity costs
    /// one failed open + re-search, never a wrong session.
    fn ceiling_key(&self, split_mode: u32) -> CeilingKey {
        let gpu = pf_gpu::resolve_render_adapter_luid()
            .map(|l| ((l.HighPart as u32 as u64) << 32) | l.LowPart as u64)
            .unwrap_or(0);
        CeilingKey {
            gpu,
            codec: self.codec,
            width: self.width,
            height: self.height,
            fps: self.fps,
            bit_depth: self.bit_depth,
            chroma_444: self.chroma_444,
            split_mode,
        }
    }

    /// Open + configure + initialize ONE NVENC session at `bitrate` (bps) and `split_mode`. Returns
    /// the session handle, or destroys it and returns the error. NVENC has no re-init after a failed
    /// `initialize_encoder`, so the bitrate-clamp search in `init_session` calls this once per probe.
    unsafe fn try_open_session(
        &self,
        device: &ID3D11Device,
        bitrate: u64,
        split_mode: u32,
        enable_async: bool,
        subframe: bool,
    ) -> Result<*mut c_void> {
        let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
            version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
            deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_DIRECTX,
            device: device.as_raw(),
            apiVersion: nv::NVENCAPI_VERSION,
            ..Default::default()
        };
        let mut enc: *mut c_void = ptr::null_mut();
        if let Err(e) = (api().open_encode_session_ex)(&mut params, &mut enc).nv_ok() {
            // Destroy-on-failed-open, as in `query_caps`: a failed open may still hold a session
            // slot that must be released.
            if !enc.is_null() {
                let _ = (api().destroy_encoder)(enc);
            }
            return Err(nvenc_status::call_err("open_encode_session_ex", e));
        }
        nvenc_status::note_session_opened();

        let mut cfg = match self.build_config(enc, bitrate) {
            Ok(cfg) => cfg,
            Err(e) => {
                let _ = (api().destroy_encoder)(enc);
                return Err(e);
            }
        };
        let mut init = build_init_params(
            self.codec_guid,
            self.width,
            self.height,
            self.fps,
            &mut cfg,
            split_mode,
            enable_async,
            // Windows: env opt-in only ("1"), never a default; arbitrated against split by the
            // caller (resolve_split_subframe) — and build_init_params additionally refuses it
            // on an async session.
            subframe,
        );

        match (api().initialize_encoder)(enc, &mut init).nv_ok() {
            Ok(()) => Ok(enc),
            Err(e) => {
                let _ = (api().destroy_encoder)(enc);
                Err(nvenc_status::call_err("initialize_encoder", e))
            }
        }
    }

    /// Lazily create the session on the first frame's D3D11 device (so capture + encode share it).
    fn init_session(&mut self, device: &ID3D11Device) -> Result<()> {
        // Serialize this whole open sequence (caps probe + bitrate-clamp search + charge) against
        // every other open and against the zombie reap — see DRIVER_SESSION_GATE. Cold path:
        // sessions open on a stream start or a device-change rebuild, never per frame.
        let _gate = DRIVER_SESSION_GATE
            .lock()
            .unwrap_or_else(|p| p.into_inner());
        // SAFETY: gate held (no open can be handed a recycled zombie address mid-reap) and the
        // reap itself re-checks that no live session exists before touching any parked handle.
        unsafe { reap_parked_sessions() };
        // SAFETY: every call below goes through a function pointer resolved once from the
        // runtime-loaded [`EncodeApi`] table (`api()`, gated in `open`), or through this type's own
        // `unsafe fn`s whose contract is met here. `query_caps`/`try_open_session` receive `device`,
        // the live `ID3D11Device` the caller pulled off the first frame; each returns either a valid
        // open NVENC session handle or an `Err`. `destroy_encoder` is only ever called on a handle a
        // `try_open_session` just returned (and `best` only when `!best.is_null()`), so it never frees
        // a dangling or null session. `create_bitstream_buffer` is passed `enc` — the one chosen live
        // session — and `&mut cb`, a `#[repr(C)] NV_ENC_CREATE_BITSTREAM_BUFFER` whose `version` is set
        // to `NV_ENC_CREATE_BITSTREAM_BUFFER_VER`; `cb` lives across the synchronous call and its
        // returned `bitstreamBuffer` is copied into `self.bitstreams` before `cb` drops. No handle
        // escapes the encode thread.
        unsafe {
            // Probe real GPU caps first (max dims / 10-bit / custom-VBV / RFI) so the config below is
            // gated on what this card supports and an out-of-range mode fails with a clear error
            // rather than being misread as a too-high bitrate by the clamp search.
            self.query_caps(device)?;
            // Bitrate clamp (see the search below): NVENC rejects `initialize_encoder` when the bitrate
            // exceeds the GPU's max codec level. We try the requested rate, then binary-search down to
            // the MAX the level accepts and clamp to it — so an over-asking client (e.g. 1 Gbps on HEVC)
            // gets the highest the GPU can actually do, not a coarse fraction of it.
            const FLOOR_BPS: u64 = 10_000_000;
            let requested_bps = self.bitrate_bps;
            // 2-way NVENC split-frame encoding (Ada dual-NVENC) — the high-pixel-rate throughput lever.
            // A single Ada NVENC session tops out ~0.8-1 Gpix/s, so at high motion a 5K@240
            // (1.77 Gpix/s) frame takes ~8 ms to encode and the rate caps ~125 fps; splitting across
            // both engines roughly halves that. Shared selector — see [`resolve_split_mode`] for the
            // precedence (env override / the measured Main10 don't-split rule / pixel rate).
            // The init-failure fallback below disables it if a codec/config rejects it.
            let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64;
            let split_mode: u32 =
                resolve_split_mode(self.codec, self.bit_depth, pixel_rate, self.encoder_engines);
            // Negotiated multi-slice (P2f): the direct-NVENC default of 4, clamped by the
            // client's ceiling — a single-slice client keeps today's shape, a
            // VIDEO_CAP_MULTI_SLICE / Moonlight slices-per-frame client gets real slices.
            // `PUNKTFUNK_NVENC_SLICES` stays the operator override in both directions.
            self.slices = resolve_slices(self.codec, 4.min(self.max_slices));
            // Split × sub-frame arbitration (Phase 8) before the ladder/ceiling key. Sub-frame
            // defaults ON where the GPU advertises SUBFRAME_READBACK (Linux parity; validated by
            // the 2026-07-31 .173 on-glass A/B — no regression, and slice-progressive clients
            // gain the encode/wire overlap); `PUNKTFUNK_NVENC_SUBFRAME` stays the tri-state
            // operator escape in both directions.
            let subframe_req = resolve_subframe(self.subframe_cap);
            let (split_mode, subframe_req) =
                resolve_split_subframe(self.codec, split_mode, subframe_req, subframe_env_forced());
            // Find the highest bitrate the GPU's codec LEVEL accepts and CLAMP to it. NVENC rejects
            // `initialize_encoder` (InvalidParam) when the bitrate exceeds the level ceiling (e.g. a
            // 1 Gbps request on HEVC). Strategy: try the requested rate; if the only problem is a forced
            // split-encode mode the codec doesn't support, disable split and retry; if the bitrate
            // itself is too high, binary-search [FLOOR, requested] for the MAX accepted rate and clamp
            // to THAT (don't undershoot — the old ×¾ step-down landed well below the real ceiling).
            const CLAMP_TOL_BPS: u64 = 20_000_000; // stop bisecting within ~20 Mbps of the ceiling

            // Two-thread async retrieve: operator opt-in AND the GPU reports async-encode support
            // (query_caps above). Threaded into every session-open probe so the chosen session is
            // built in the right mode from the start.
            let use_async = self.async_supported && async_retrieve_requested();

            // Ceiling cache (process lifetime, `nvenc_core`): a prior clamp search already found
            // this config's max accepted rate — open straight AT the ceiling instead of paying
            // the ~6-open binary search (and its session churn) on every ABR overshoot.
            let mut target_bps = requested_bps;
            if let Some(ceiling) = cached_ceiling(&self.ceiling_key(split_mode)) {
                if requested_bps > ceiling {
                    tracing::info!(
                        requested_mbps = requested_bps / 1_000_000,
                        ceiling_mbps = ceiling / 1_000_000,
                        "NVENC: requested bitrate above the cached codec-level ceiling — opening \
                         at the ceiling"
                    );
                    target_bps = ceiling;
                }
            }

            let mut probe =
                self.try_open_session(device, target_bps, split_mode, use_async, subframe_req);
            // The cache is advisory: a stale entry (driver change, identity collision) must not
            // wedge the open — retry the requested rate and let the search below rediscover.
            if probe.is_err() && target_bps < requested_bps {
                target_bps = requested_bps;
                probe = self.try_open_session(
                    device,
                    requested_bps,
                    split_mode,
                    use_async,
                    subframe_req,
                );
            }
            // Disambiguate a forced-split rejection from a bitrate-cap rejection: retry once at the
            // requested rate with split disabled — if THAT succeeds, split was the problem, not bitrate.
            // ANY non-disabled mode can be the rejection — AUTO included: AV1 rejects the whole
            // init with INVALID_PARAM on drivers/configs where auto split isn't valid for it,
            // which then masqueraded as a bitrate cap and failed "even at the floor".
            // `used_split` tracks the mode sessions ACTUALLY open with from here on — it feeds
            // `self.split_mode` (a reconfigure must re-present it) and the ceiling-cache key.
            let mut used_split = split_mode;
            let split_on =
                split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
            if probe.is_err() && split_on {
                let no_split = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
                if let Ok(e) =
                    self.try_open_session(device, target_bps, no_split, use_async, subframe_req)
                {
                    tracing::warn!("NVENC: split-encode rejected by codec/config — disabled");
                    used_split = no_split;
                    probe = Ok(e);
                }
            }

            let enc = match probe {
                Ok(enc) => {
                    self.bitrate_bps = target_bps;
                    enc
                }
                // Only a parameter/caps rejection means "the bitrate is above the codec-level
                // ceiling". A transient failure (busy engine, session limit, OOM, device loss,
                // version skew) must propagate — a search steered by it would discover, and
                // cache, a bogus ceiling.
                Err(e) if !nvenc_status::is_param_rejection(&e) => return Err(e),
                Err(_) => {
                    // Requested bitrate exceeds the codec-level ceiling — binary-search the max accepted.
                    // `lo` is the highest known-good rate (FLOOR is assumed to fit), `hi` the lowest
                    // rejected; `best` holds the live session at `lo` so we end up with the clamped one.
                    let mut lo = FLOOR_BPS;
                    let mut hi = target_bps;
                    let mut best: *mut c_void = ptr::null_mut();
                    let mut best_bps = 0u64;
                    while hi > lo + CLAMP_TOL_BPS {
                        let mid = lo + (hi - lo) / 2;
                        match self.try_open_session(
                            device,
                            mid,
                            used_split,
                            use_async,
                            subframe_req,
                        ) {
                            Ok(e) => {
                                if !best.is_null() {
                                    let _ = (api().destroy_encoder)(best);
                                }
                                best = e;
                                best_bps = mid;
                                lo = mid;
                            }
                            Err(e) if nvenc_status::is_param_rejection(&e) => hi = mid,
                            Err(e) => {
                                // Environmental mid-search failure: don't let it shrink the
                                // search — release the partial result and propagate.
                                if !best.is_null() {
                                    let _ = (api().destroy_encoder)(best);
                                }
                                return Err(e);
                            }
                        }
                    }
                    if best.is_null() {
                        // Nothing in (FLOOR, requested] accepted — fall back to the floor itself, also
                        // trying split-disabled in case a forced split (not the bitrate) is the blocker.
                        let no_split =
                            nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
                        best = match self.try_open_session(
                            device,
                            FLOOR_BPS,
                            used_split,
                            use_async,
                            subframe_req,
                        ) {
                            Ok(e) => e,
                            Err(_) => {
                                let e = self
                                    .try_open_session(
                                        device,
                                        FLOOR_BPS,
                                        no_split,
                                        use_async,
                                        subframe_req,
                                    )
                                    .context(
                                    "NVENC initialize_encoder rejected even at the floor bitrate",
                                )?;
                                used_split = no_split;
                                e
                            }
                        };
                        best_bps = FLOOR_BPS;
                    }
                    tracing::warn!(
                        requested_mbps = requested_bps / 1_000_000,
                        clamped_mbps = best_bps / 1_000_000,
                        "NVENC: requested bitrate above the GPU codec-level ceiling — clamped to the max accepted"
                    );
                    store_ceiling(self.ceiling_key(used_split), best_bps);
                    self.bitrate_bps = best_bps;
                    best
                }
            };
            self.encoder = enc;
            // Pin the device for the session's lifetime — and for a parked afterlife if the
            // eventual destroy fails (see `ParkedSession::_device`).
            self.init_device_com = Some(device.clone());
            // Session init params a later `reconfigure_bitrate` must re-present verbatim.
            self.split_mode = used_split;
            self.subframe_on = subframe_req;
            self.session_async = use_async;
            // Sub-frame chunked poll (P2f, the Windows leg of the slice pipeline): sync
            // retrieve only — chunked poll is a depth-1 sync feature; the async retrieve's
            // thread owns the bitstream lock.
            self.subframe_chunks = self.slices >= 2 && subframe_req && !use_async;
            if self.subframe_chunks {
                tracing::info!(
                    slices = self.slices,
                    "NVENC sub-frame chunked poll armed (poll_chunk emits slice-boundary AU chunks)"
                );
            }
            // Session-budget accounting (Stage W3): record what this open holds so admission can
            // decline a parallel display the hardware can't afford. Weighted by the FINAL split
            // mode (a split session occupies one hardware session per engine).
            self.session_units = split_mode_units(used_split);
            LIVE_SESSION_UNITS.fetch_add(self.session_units, std::sync::atomic::Ordering::Relaxed);
            // (The clamp path above already logs the requested→clamped bitrate at warn; no second
            // info line for the same event here.)

            // 5. one output bitstream per in-flight slot. There is NO encoder-owned input pool: the
            // capturer's textures are registered on demand in `submit` and encoded in place.
            for _ in 0..POOL {
                let mut cb = nv::NV_ENC_CREATE_BITSTREAM_BUFFER {
                    version: nv::NV_ENC_CREATE_BITSTREAM_BUFFER_VER,
                    ..Default::default()
                };
                (api().create_bitstream_buffer)(enc, &mut cb)
                    .nv_ok()
                    .map_err(|e| nvenc_status::call_err("create_bitstream_buffer", e))?;
                self.bitstreams.push(cb.bitstreamBuffer);
            }
            // Async retrieve: one auto-reset completion event per pool bitstream, registered with
            // the session, plus the retrieve thread the events signal. The thread only ever sees
            // raw addresses; `teardown` joins it before any of them die.
            if use_async {
                for _ in 0..POOL {
                    let ev = CreateEventW(None, false, false, PCWSTR::null())
                        .context("CreateEvent (NVENC completion)")?;
                    // Push BEFORE registering: a failed registration propagates out of
                    // `init_session` into submit's teardown call, and only handles that made it
                    // into `self.events` get closed there — pushing after the fallible call
                    // leaked the Win32 event on every registration failure. Teardown's
                    // unregister of the one never-registered event is a harmless error return.
                    self.events.push(ev.0 as usize);
                    let mut ep = nv::NV_ENC_EVENT_PARAMS {
                        version: nv::NV_ENC_EVENT_PARAMS_VER,
                        completionEvent: ev.0,
                        ..Default::default()
                    };
                    (api().register_async_event)(enc, &mut ep)
                        .nv_ok()
                        .map_err(|e| nvenc_status::call_err("register_async_event", e))?;
                }
                let (work_tx, work_rx) = mpsc::sync_channel::<RetrieveJob>(POOL);
                let (done_tx, done_rx) = mpsc::channel::<RetrieveDone>();
                let enc_addr = enc as usize;
                let join = std::thread::Builder::new()
                    .name("punktfunk-nvenc-out".into())
                    .spawn(move || retrieve_loop(enc_addr, work_rx, done_tx))
                    .context("spawn NVENC retrieve thread")?;
                self.async_rt = Some(AsyncRetrieve {
                    work_tx: Some(work_tx),
                    done_rx,
                    join: Some(join),
                    ready: VecDeque::new(),
                });
                tracing::info!(
                    pool = POOL,
                    "NVENC async retrieve active (two-thread encode: submit here, \
                     lock_bitstream on the retrieve thread)"
                );
            }
            self.inited = true;
            tracing::info!(
                // Parity with the Linux session-ready line. `split_mode` is the FINAL mode (post
                // any rejection fallback) and `engines` the ceiling it was chosen from — the mode
                // alone is ambiguous between "used every engine" and "left one idle", and the
                // driver honours an over-wide request without complaint, so neither number means
                // much without the other. `subframe` because AUTO + sub-frame is a measurably
                // single-engine combination that reads like a split in a log.
                split_mode = self.split_mode,
                engines = self.encoder_engines,
                subframe = self.subframe_on,
                "NVENC D3D11 session: {}x{}@{} {}-bit{} {} Mbps {:?}",
                self.width,
                self.height,
                self.fps,
                self.bit_depth,
                if self.hdr { " HDR(BT.2020 PQ)" } else { "" },
                self.bitrate_bps / 1_000_000,
                self.codec_guid
            );
            self.subframe_opened_with = self.subframe_on;
            self.arm_split_arbiter();
            Ok(())
        }
    }

    /// Fold one retrieve-thread completion back into encoder state ON THE ENCODE THREAD: pop the
    /// oldest `pending` entry (completions are FIFO — one retrieve thread, in-order jobs), verify
    /// the bitstream pairing, unmap the input resource, and queue the AU for `poll`. A retrieve
    /// error surfaces AFTER the unmap (the resource is retired either way) so the session glue's
    /// rebuild path starts from clean state.
    fn absorb_done(&mut self, done: RetrieveDone) -> Result<()> {
        let Some((bs, map, pts_ns, anchor, _idr_hint)) = self.pending.pop_front() else {
            bail!("NVENC async: completion with no in-flight frame (pairing bug)");
        };
        if bs as usize != done.bs {
            bail!("NVENC async: completion out of order (pairing bug)");
        }
        // SAFETY: `map` is the mapped input `submit` recorded for exactly this now-completed
        // encode; the session is live (`async_rt` exists only between `init_session` and
        // `teardown`) and this runs on the encode thread — the single unmap here mirrors the sync
        // path's poll-side unmap, exactly once per mapping.
        unsafe {
            if !map.is_null() {
                let _ = (api().unmap_input_resource)(self.encoder, map);
            }
        }
        let (data, keyframe) = done.result.map_err(|e| anyhow!("{e}"))?;
        self.async_rt
            .as_mut()
            .expect("absorb_done is only reachable in async mode")
            .ready
            .push_back(EncodedFrame {
                data,
                pts_ns,
                keyframe,
                recovery_anchor: anchor,
                chunk_aligned: false,
            });
        Ok(())
    }
}

impl Encoder for NvencD3d11Encoder {
    fn submit(&mut self, captured: &CapturedFrame) -> Result<()> {
        let frame = match &captured.payload {
            FramePayload::D3d11(f) => f,
            FramePayload::Cpu(_) => {
                bail!(
                    "NVENC D3D11 encoder needs a GPU texture frame (use the software encoder for CPU frames)"
                )
            }
        };
        // The capturer recreates its D3D11 device on a desktop switch (secure/Winlogon) and may come
        // back at a different resolution (user session applies its own mode on login). Re-init when the
        // frame arrives on a different device OR at a different size than our session was built on.
        // HDR (BT.2020 PQ 10-bit) when the capturer hands us a 10-bit R10G10B10A2 frame. This can flip
        // mid-session when the user toggles HDR (which arrives as a capture device recreate anyway).
        // HDR (BT.2020 PQ) when the capturer hands a 10-bit frame — either R10G10B10A2 (the legacy
        // shader path) or P010 (the video-processor path). 8-bit NV12/ARGB → SDR.
        let hdr = matches!(captured.format, PixelFormat::Rgb10a2 | PixelFormat::P010);
        let dev_raw = frame.device.as_raw();
        let size_changed =
            self.inited && (self.width != captured.width || self.height != captured.height);
        // Compare against what was REQUESTED last init, not the effective `self.hdr`: on a GPU
        // without 10-bit encode `query_caps` clears `self.hdr`, so comparing it against a P010
        // capturer's perpetual `hdr = true` reported a change on every single frame and tore the
        // session down and rebuilt it each time.
        let hdr_changed = self.inited && self.hdr_requested != hdr;
        if self.inited && (self.init_device != dev_raw || size_changed || hdr_changed) {
            tracing::info!(
                device_changed = self.init_device != dev_raw,
                size_changed,
                hdr_changed,
                hdr,
                new = format!("{}x{}", captured.width, captured.height),
                "NVENC: capture device/size/HDR changed — re-initializing session"
            );
            // SAFETY: `teardown` (an `unsafe fn`) requires the encode thread with no NVENC call in
            // flight and a session whose cached regs/bitstreams/pending all belong to `self.encoder`.
            // All hold: this is the synchronous encode thread, `self.inited` so `self.encoder` is the
            // live session every cached resource was created against, and the previous frame's encode
            // has already been polled (synchronous submit→poll), so nothing is mid-encode.
            unsafe { self.teardown() };
        }
        if !self.inited {
            // Adopt the current frame size + colour so the encoder always matches the capturer output.
            self.width = captured.width;
            self.height = captured.height;
            self.hdr_requested = hdr;
            // Effective until `query_caps` says otherwise (it clears this on a card without 10-bit).
            self.hdr = hdr;
            // Recompute the EFFECTIVE 4:4:4 from the negotiation on every init. This used to read
            // (and overwrite) `chroma_444` itself, so the first subsampled-YUV frame permanently
            // demoted the session — 4:4:4 never returned even when the capturer went back to RGB.
            self.chroma_444 = self.chroma_444_requested;
            // Pick the NVENC input format from the captured pixel format. YUV (NV12/P010) is the
            // video-processor path — NVENC encodes it natively (no internal RGB→YUV, which is a hidden
            // 3D/compute step that would fight a GPU-saturating game). RGB (ARGB/ABGR10) is the legacy
            // shader path. 10-bit (P010/ABGR10) forces HEVC Main10 + the BT.2020 PQ VUI.
            self.buffer_fmt = match captured.format {
                PixelFormat::P010 => {
                    self.bit_depth = 10;
                    nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_YUV420_10BIT
                }
                PixelFormat::Rgb10a2 => {
                    self.bit_depth = 10;
                    nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
                }
                PixelFormat::Nv12 => {
                    // NV12 is 8-bit 4:2:0. Force 8-bit so a transition from a prior P010 (10-bit) session
                    // — or a 10-bit-negotiated client on an SDR display — re-inits at the matching depth.
                    // Unlike ARGB (which NVENC upconverts to Main10), NV12 cannot feed a 10-bit session:
                    // `register_resource` rejects it as InvalidParam (the HDR→SDR-toggle stream drop).
                    self.bit_depth = 8;
                    nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_NV12
                }
                _ => nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
            };
            // 4:4:4 honesty: the FREXT/chromaFormatIDC=3 config engages only on an RGB input (a
            // subsampled NV12/P010 source can't reconstruct full chroma). If the capturer handed
            // native YUV despite a 4:4:4 negotiation, THIS session encodes 4:2:0 — clear the
            // effective flag now so `caps().chroma_444` (and native's post-open cross-check)
            // reports what the stream really carries instead of silently claiming full chroma.
            // Only the effective value is cleared: `chroma_444_requested` keeps the negotiation, so
            // a later re-init on an RGB capture recovers 4:4:4 instead of being stuck at 4:2:0.
            if self.chroma_444
                && !matches!(
                    self.buffer_fmt,
                    nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB
                        | nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10
                )
            {
                tracing::warn!(
                    format = ?captured.format,
                    "4:4:4 negotiated but the capturer delivered subsampled YUV — encoding 4:2:0"
                );
                self.chroma_444 = false;
            }
            let device = frame.device.clone();
            // `init_session` publishes `self.encoder` (and charges LIVE_SESSION_UNITS) BEFORE its
            // last fallible steps, so a failure there leaves a live session with `inited == false`.
            // Every guard on the re-init path keys off `inited`, so without this the next submit
            // would skip teardown and overwrite `self.encoder` — leaking the session permanently
            // (toward the driver's per-process cap) along with its session-budget units.
            // `teardown` keys off `encoder.is_null()`, not `inited`, so it cleans up exactly this
            // half-built state and is a no-op when nothing was opened.
            if let Err(e) = self.init_session(&device) {
                // SAFETY: same contract as the teardown above — the encode thread owns the session,
                // and a failed init leaves nothing mid-encode to race with.
                unsafe { self.teardown() };
                return Err(e);
            }
            self.init_device = dev_raw;
        }
        // The session's opening frame — NVENC emits it as an IDR regardless of pic flags, so the
        // in-band HDR SEI must ride it too. Detected via the still-empty output slot counter
        // (`teardown` zeroes it), NOT via `pts == 0`: `submit_indexed` pins pts to the wire frame
        // index, which is non-zero on a mid-session encoder rebuild's first frame.
        let opening = self.next == 0;
        // Async backpressure: never hand NVENC an output bitstream that is still in flight, and
        // keep in-flight depth within the capturer's texture ring. At the cap, block on the OLDEST
        // completion (the retrieve thread is already waiting on its event) before submitting more —
        // bounding depth exactly like the sync path's per-tick blocking poll, just `cap` deep
        // instead of 1.
        //
        // The ring term is the one that matters for correctness: `async_inflight_cap()` is only the
        // output-bitstream-pool ceiling plus an env knob, and consults NOTHING about the capturer,
        // despite this comment previously claiming otherwise. Since this backend encodes the
        // capturer's textures in place, exceeding the capturer's declared `pipeline_depth` lets it
        // rotate a texture out from under a live encode — torn frames, silently.
        // FAIL SAFE when nobody told us. `set_input_ring_depth` is a defaulted trait method, so a
        // caller that forgets it fails SILENTLY — and one did: the GameStream loop opens an encoder
        // (`gamestream/stream.rs`) and never calls it, while the Windows IDD-push capturer declares a
        // ring of 2 and `async_inflight_cap()` defaults to 4. That let a Moonlight session pipeline
        // four encodes against a two-texture ring, i.e. exactly the in-place overwrite this cap
        // exists to prevent, as torn/mixed frames and never an error.
        //
        // Guarding at the single point of CONSUMPTION rather than at each call site is deliberate:
        // it covers every present and future caller, including ones that have not been written yet,
        // whereas plumbing the setter into N loops only fixes the N sites someone remembered. An
        // unknown ring is treated as the shallowest one any capturer in this tree declares, so the
        // unconfigured path degrades to less pipelining — a latency cost, not corruption. Callers
        // that DO configure it are unaffected.
        const UNCONFIGURED_RING_DEPTH: usize = 2;
        let cap = match self.input_ring_depth {
            Some(d) => async_inflight_cap().min(d.max(1)),
            None => async_inflight_cap().min(UNCONFIGURED_RING_DEPTH),
        };
        while self.async_rt.is_some() && self.pending.len() >= cap {
            let done = {
                let rt = self.async_rt.as_mut().expect("checked in loop condition");
                rt.done_rx
                    .recv_timeout(std::time::Duration::from_secs(5))
                    .map_err(|_| anyhow!("NVENC async retrieve stalled (5s) — encoder wedged?"))?
            };
            self.absorb_done(done)?;
        }
        let slot = self.next % POOL;
        self.next += 1;
        // SAFETY: every NVENC call goes through a function pointer from the runtime-loaded `EncodeApi` table
        // and takes `self.encoder`, the live session `init_session` just established (non-null on the
        // path that reaches here). `NV_ENC_REGISTER_RESOURCE rr` has `version =
        // NV_ENC_REGISTER_RESOURCE_VER` and registers `frame.texture` — a D3D11 texture from
        // `frame.device`, which is the SAME device the session was opened against (any device change
        // tears down and re-inits above, so `init_device == frame.device.as_raw()` here); the cloned
        // `ID3D11Texture2D` is kept alive in `regs` so NVENC's registration never outlives the texture.
        // `mp` (`NV_ENC_MAP_INPUT_RESOURCE`, version set) maps that registration and the map is recorded
        // in `pending` to be unmapped exactly once in `poll`/`teardown`. `pic` (`NV_ENC_PIC_PARAMS`,
        // version set) points `inputBuffer` at `mp.mappedResource` and `outputBitstream` at the live
        // pool bitstream `bitstreams[slot]`; the optional SEI scratch (`mastering_sei`/`cll_sei` and the
        // `sei` Vec whose `as_mut_ptr()` is written into the codec union) are stack locals that outlive
        // the synchronous `encode_picture`. Every `#[repr(C)]` param is a live local borrowed `&mut`
        // for the duration of its one synchronous call. (In-place encode without `CopyResource` is
        // sound because the encode loop is synchronous, as the module docs state.)
        unsafe {
            // Register the capturer's texture with NVENC once (cached by raw pointer), then encode it
            // IN PLACE — no `CopyResource` into an encoder-owned pool. This is the zero-copy win: the
            // capturer already produced a stable GPU texture; we just register + map + encode it.
            let key = frame.texture.as_raw() as isize;
            if !self.regs.contains_key(&key) {
                let mut rr = nv::NV_ENC_REGISTER_RESOURCE {
                    version: nv::NV_ENC_REGISTER_RESOURCE_VER,
                    resourceType:
                        nv::NV_ENC_INPUT_RESOURCE_TYPE::NV_ENC_INPUT_RESOURCE_TYPE_DIRECTX,
                    width: self.width,
                    height: self.height,
                    pitch: 0,
                    resourceToRegister: frame.texture.as_raw(),
                    bufferFormat: self.buffer_fmt,
                    bufferUsage: nv::NV_ENC_BUFFER_USAGE::NV_ENC_INPUT_IMAGE,
                    ..Default::default()
                };
                (api().register_resource)(self.encoder, &mut rr)
                    .nv_ok()
                    .map_err(|e| nvenc_status::call_err("register_resource", e))?;
                self.regs
                    .insert(key, (rr.registeredResource, frame.texture.clone()));
            }
            let reg = self.regs[&key].0;

            let mut mp = nv::NV_ENC_MAP_INPUT_RESOURCE {
                version: nv::NV_ENC_MAP_INPUT_RESOURCE_VER,
                registeredResource: reg,
                ..Default::default()
            };
            (api().map_input_resource)(self.encoder, &mut mp)
                .nv_ok()
                .map_err(|e| nvenc_status::call_err("map_input_resource", e))?;

            let pts = self.frame_idx as u64;
            self.frame_idx += 1;
            let flags = if std::mem::take(&mut self.force_kf) {
                nv::NV_ENC_PIC_FLAGS::NV_ENC_PIC_FLAG_FORCEIDR as u32
                    | nv::NV_ENC_PIC_FLAGS::NV_ENC_PIC_FLAG_OUTPUT_SPSPPS as u32
            } else {
                0
            };
            // Recovery anchor (armed by a successful invalidate_ref_frames): THIS frame is the
            // first one encoded after the invalidation — the clean re-anchor. A simultaneous
            // forced IDR is itself the re-anchor, so the tag is dropped in that case.
            let anchor = std::mem::take(&mut self.pending_anchor) && flags == 0;
            // Submit-time IDR intent: chunked poll must flag an AU's EARLY chunks before the
            // driver reports `pictureType` (only the finishing lock sees it). Exact under
            // P-only + infinite GOP: IDRs happen only when forced — or on the session-opening
            // frame, which NVENC emits as an IDR regardless of pic flags (the Linux twin's
            // `is_idr`; without the `opening` term frame 1's early chunks went out unflagged
            // and the divergence WARN fired at every session start).
            let idr_hint = flags != 0 || opening;
            let mut pic = nv::NV_ENC_PIC_PARAMS {
                version: nv::NV_ENC_PIC_PARAMS_VER,
                inputWidth: self.width,
                inputHeight: self.height,
                inputPitch: 0,
                inputBuffer: mp.mappedResource,
                bufferFmt: mp.mappedBufferFmt,
                outputBitstream: self.bitstreams[slot],
                pictureStruct: nv::NV_ENC_PIC_STRUCT::NV_ENC_PIC_STRUCT_FRAME,
                inputTimeStamp: pts,
                encodePicFlags: flags as u32,
                // Async mode: the event the driver signals when this encode completes (the
                // retrieve thread waits on it). Null in sync mode (`events` is empty).
                completionEvent: self
                    .events
                    .get(slot)
                    .map(|&e| e as *mut c_void)
                    .unwrap_or(ptr::null_mut()),
                ..Default::default()
            };

            // In-band HDR10 SEI on every IDR (a forced keyframe, or the first frame NVENC opens with):
            // `mastering_display_colour_volume` (ST.2086) + `content_light_level_info` (CEA-861.3),
            // built from the source display's metadata. Any decoder — incl. stock Moonlight — then
            // tone-maps from the real grade. HEVC/H.264 carry SEI; AV1 uses metadata OBUs (follow-up).
            // The scratch buffers must outlive `encode_picture`, so they live in this scope.
            let is_idr = flags != 0 || opening;
            let mastering_sei = self
                .hdr_meta
                .map(|m| pf_frame::hdr::hevc_mastering_display_sei(&m));
            let cll_sei = self
                .hdr_meta
                .map(|m| pf_frame::hdr::hevc_content_light_level_sei(&m));
            let mut sei: Vec<nv::NV_ENC_SEI_PAYLOAD> = Vec::new();
            if is_idr && self.hdr {
                if let Some(p) = mastering_sei.as_ref() {
                    sei.push(nv::NV_ENC_SEI_PAYLOAD {
                        payloadSize: p.len() as u32,
                        payloadType: pf_frame::hdr::SEI_TYPE_MASTERING_DISPLAY_COLOUR_VOLUME,
                        payload: p.as_ptr() as *mut u8,
                    });
                }
                if let Some(p) = cll_sei.as_ref() {
                    sei.push(nv::NV_ENC_SEI_PAYLOAD {
                        payloadSize: p.len() as u32,
                        payloadType: pf_frame::hdr::SEI_TYPE_CONTENT_LIGHT_LEVEL_INFO,
                        payload: p.as_ptr() as *mut u8,
                    });
                }
            }
            if !sei.is_empty() {
                // Writing a union field is safe; the pointers/len are read during encode_picture.
                match self.codec {
                    Codec::H265 => {
                        pic.codecPicParams.hevcPicParams.seiPayloadArray = sei.as_mut_ptr();
                        pic.codecPicParams.hevcPicParams.seiPayloadArrayCnt = sei.len() as u32;
                    }
                    Codec::H264 => {
                        pic.codecPicParams.h264PicParams.seiPayloadArray = sei.as_mut_ptr();
                        pic.codecPicParams.h264PicParams.seiPayloadArrayCnt = sei.len() as u32;
                    }
                    // AV1 mastering/CLL ride METADATA OBUs, not SEI — separate follow-up.
                    Codec::Av1 => {}
                    Codec::PyroWave => {
                        unreachable!("PyroWave never opens the direct-NVENC backend")
                    }
                }
            }
            (api().encode_picture)(self.encoder, &mut pic)
                .nv_ok()
                .map_err(|e| nvenc_status::call_err("encode_picture", e))?;
            self.pending.push_back((
                self.bitstreams[slot],
                mp.mappedResource,
                captured.pts_ns,
                anchor,
                idr_hint,
            ));
            // Split-arbiter cost stamp; only meaningful on the sync depth-1 path, which is the
            // only path `arm_split_arbiter` allows an experiment on.
            self.last_submit_at = Some(std::time::Instant::now());
            // Async: hand the in-flight encode to the retrieve thread (channel capacity = POOL ≥
            // in-flight, so this send never blocks). The pending entry above pairs with its
            // completion FIFO in `absorb_done`.
            if let Some(rt) = &self.async_rt {
                let job = RetrieveJob {
                    bs: self.bitstreams[slot] as usize,
                    event: self.events[slot],
                };
                if rt.work_tx.as_ref().is_none_or(|tx| tx.send(job).is_err()) {
                    bail!("NVENC retrieve thread gone — rebuilding the session");
                }
            }
        }
        Ok(())
    }

    /// Pin this submission's frame number (= its `inputTimeStamp`) to the wire frame index the AU
    /// will carry, so the DPB timestamps `invalidate_ref_frames` matches client frame numbers
    /// against are the wire's — 1:1 across every rebuild/reset (see the trait doc). Within a
    /// session the loop's prediction is nondecreasing; a repeat after a reset lands on a fresh
    /// session (teardown cleared the DPB and `last_rfi_range`), so re-pinning is always sound.
    fn submit_indexed(&mut self, frame: &CapturedFrame, wire_index: u32) -> Result<()> {
        self.frame_idx = wire_index as i64;
        self.submit(frame)
    }

    fn set_input_ring_depth(&mut self, depth: usize) {
        // This backend registers and encodes the capturer's textures in place (no CopyResource),
        // so the capturer's ring depth is a hard ceiling on how deep async may pipeline.
        self.input_ring_depth = Some(depth);
        tracing::debug!(
            depth,
            env_cap = async_inflight_cap(),
            "NVENC: capturer input-ring depth reported — async in-flight bounded by the smaller"
        );
    }

    fn request_keyframe(&mut self) {
        self.force_kf = true;
    }

    fn caps(&self) -> EncoderCaps {
        // RFI is probed once at open (`rfi_supported`) — the real capability the session glue
        // routes on. (In-band HDR SEI needs no cap: it rides keyframes on HEVC/H.264 HDR
        // sessions — see `submit` — and every first-party client reads the grade out-of-band
        // via the 0xCE datagram regardless.)
        EncoderCaps {
            // The Windows capture path composites the pointer; this backend never reads `frame.cursor`.
            blends_cursor: false,
            supports_rfi: self.rfi_supported,
            // Reflects what the session actually configured (cleared in `query_caps` if the GPU lacks
            // YUV444 encode), so the glue can confirm 4:4:4 vs the negotiated request.
            chroma_444: self.chroma_444,
            // The direct-NVENC path recovers via real RFI (or a forced IDR), not the Linux
            // libavcodec intra-refresh mode.
            intra_refresh: false,
            intra_refresh_recovery: false,
            intra_refresh_period: 0,
        }
    }

    fn set_hdr_meta(&mut self, meta: Option<punktfunk_core::quic::HdrMeta>) {
        // Stored and emitted as in-band SEI on the next keyframe (see `submit`). Cheap to call every
        // frame; only changes when the source is regraded or HDR toggles.
        self.hdr_meta = meta;
    }

    fn invalidate_ref_frames(&mut self, first: i64, last: i64) -> bool {
        // No live session or the GPU can't invalidate → caller forces a full IDR. (NVENC handles
        // are single-threaded; this runs on the encode thread, like submit/poll.) Everything else
        // — range validity, covering-range dedup, the DPB window, the clamp — is
        // `nvenc_core::plan_range_recovery`, one policy for both direct-NVENC backends.
        if self.encoder.is_null() || !self.rfi_supported {
            return false;
        }
        match plan_range_recovery(first, last, self.frame_idx, self.last_rfi_range) {
            // Already invalidated a covering range for this loss event — no new driver calls
            // needed, no IDR. RE-ARM the anchor though: the client re-asking means the previous
            // recovery anchor AU may itself have been lost, and the next frame is just as clean a
            // re-anchor (it too references only valid frames).
            RangePlan::Covered => {
                self.pending_anchor = true;
                true
            }
            RangePlan::Decline => false,
            RangePlan::Invalidate { first, last } => {
                // Each input's `inputTimeStamp` is `frame_idx`, which `submit_indexed` pins to the
                // WIRE frame index the AU carries — so the client's lost-frame range maps 1:1 onto
                // the timestamps NVENC invalidates here, and stays 1:1 across encoder
                // rebuilds/resets (an internal counter would desync on the first adaptive-bitrate
                // rebuild and RFI would then clamp every range into first > last, silently
                // degrading to IDR-only forever).
                // SAFETY: `invalidate_ref_frames` is a function pointer from the runtime-loaded
                // `EncodeApi` table. `self.encoder` was checked non-null at the top of this fn and
                // is the live session; this runs on the encode thread (like submit/poll), so there
                // is no concurrent NVENC use. The plan clamped each `ts` to
                // `[oldest_in_dpb, frame_idx - 1]`, so it names a frame still in the session's
                // DPB; the call passes only that `u64` timestamp (no struct), so there is no
                // struct-size or lifetime concern.
                unsafe {
                    for ts in first..=last {
                        if (api().invalidate_ref_frames)(self.encoder, ts as u64)
                            .nv_ok()
                            .is_err()
                        {
                            return false; // any failure → fall back to IDR
                        }
                    }
                }
                self.last_rfi_range = Some((first, last));
                // The next submitted frame is the first one encoded after the invalidation — the
                // clean re-anchor P-frame. Arm the tag so its AU ships with `recovery_anchor` and
                // the client lifts its post-loss freeze on it (instead of waiting ~1 s for the
                // cooldown-suppressed IDR fallback).
                self.pending_anchor = true;
                true
            }
        }
    }

    fn poll(&mut self) -> Result<Option<EncodedFrame>> {
        // A partially-chunked AU must be finished through `poll_chunk`: its emitted prefix is
        // already with the caller, so a whole-AU poll here would double-emit those bytes.
        if self.chunk.is_some() {
            bail!("NVENC poll() called mid-chunked-AU — drain it via poll_chunk (caller bug)");
        }
        // Async mode: drain whatever the retrieve thread has finished (non-blocking) and hand out
        // the oldest ready AU. `None` = nothing completed yet — the session loop keeps the frame
        // in flight and re-polls next tick, capture never blocks on the WDDM scheduling wait.
        if self.async_rt.is_some() {
            while let Ok(done) = self
                .async_rt
                .as_mut()
                .expect("checked just above")
                .done_rx
                .try_recv()
            {
                self.absorb_done(done)?;
            }
            return Ok(self
                .async_rt
                .as_mut()
                .expect("checked just above")
                .ready
                .pop_front());
        }
        let Some((bs, map, pts_ns, anchor, _idr_hint)) = self.pending.pop_front() else {
            return Ok(None);
        };
        // SAFETY: a non-empty `pending` implies `submit` ran, so `self.encoder` is the live session
        // (`teardown` clears `pending` whenever it nulls the handle); all calls below use function
        // pointers from the runtime-loaded `EncodeApi` table on the encode thread. `NV_ENC_LOCK_BITSTREAM lock`
        // (version = `NV_ENC_LOCK_BITSTREAM_VER`) locks `bs`, a pool bitstream a prior `encode_picture`
        // targeted; `lock_bitstream` blocks until that encode finishes, so on success
        // `lock.bitstreamBufferPtr` is non-null and points at `lock.bitstreamSizeInBytes` bytes of
        // NVENC-owned, CPU-readable output valid until `unlock_bitstream`. The `from_raw_parts` slice is
        // only read (copied via `to_vec()`) BEFORE `unlock_bitstream(bs)` — lock and unlock pair on the
        // same buffer — so it never outlives the lock. `map` (the input resource paired with `bs` in
        // `pending`) is unmapped here, after the encode completed, exactly once.
        unsafe {
            let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
                version: nv::NV_ENC_LOCK_BITSTREAM_VER,
                outputBitstream: bs,
                ..Default::default()
            };
            (api().lock_bitstream)(self.encoder, &mut lock)
                .nv_ok()
                .map_err(|e| nvenc_status::call_err("lock_bitstream", e))?;
            let data = std::slice::from_raw_parts(
                lock.bitstreamBufferPtr as *const u8,
                lock.bitstreamSizeInBytes as usize,
            )
            .to_vec();
            let keyframe = matches!(
                lock.pictureType,
                nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR | nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I
            );
            (api().unlock_bitstream)(self.encoder, bs)
                .nv_ok()
                .map_err(|e| nvenc_status::call_err("unlock_bitstream", e))?;
            if !map.is_null() {
                let _ = (api().unmap_input_resource)(self.encoder, map);
            }
            let encode_us = self
                .last_submit_at
                .take()
                .map(|t| t.elapsed().as_micros() as u64);
            if let Some(us) = encode_us {
                self.feed_split_arbiter(us);
            }
            Ok(Some(EncodedFrame {
                data,
                pts_ns,
                keyframe,
                recovery_anchor: anchor,
                chunk_aligned: false,
            }))
        }
    }

    fn supports_chunked_poll(&self) -> bool {
        // Dynamic on purpose: a rebuild can land in a different mode (async opt-in, slice
        // fallback) and `teardown` drops the latch — a caller re-querying per AU sees the mode
        // fall away instead of chunk-polling a session that can't serve it.
        self.subframe_chunks && self.async_rt.is_none()
    }

    fn poll_chunk(&mut self) -> Result<Option<AuChunk>> {
        // Not a chunked session (knobs off, escalated to async): degrade to a single whole-AU
        // chunk so a chunk-driven caller works against every session shape. The
        // `chunk.is_none()` arm is defensive — a mid-AU state must always finish below.
        if !self.supports_chunked_poll() && self.chunk.is_none() {
            return Ok(self.poll()?.map(AuChunk::whole));
        }
        let Some(&(bs, _, pts_ns, anchor, idr_hint)) = self.pending.front() else {
            return Ok(None);
        };
        // Sampling budget: if this driver branch never publishes intermediate slices, stop
        // burning CPU after ~2 frame intervals and finish through the blocking lock — worst
        // case poll_chunk behaves like sync `poll` plus a few failed doNotWait attempts.
        let budget = std::time::Duration::from_micros(2_000_000 / self.fps.max(1) as u64);
        let t0 = std::time::Instant::now();
        let mut offsets = [0u32; 32];
        loop {
            let emitted = self.chunk.as_ref().map_or(0, |c| c.emitted);
            let slices_out = self.chunk.as_ref().map_or(0, |c| c.slices_out);
            // SAFETY: `bs` is the front `pending` entry's pool bitstream (a prior
            // `encode_picture` targeted it, and `teardown` clears `pending` whenever it nulls
            // the session), the session is live, and this runs on the encode thread. `lock`
            // (version set, doNotWait) and `offsets` are live stack locals across the
            // synchronous call; `reportSliceOffsets` was armed at init so the driver may write
            // up to `numSlices` ≤ 32 offsets (`sliceModeData` is clamped 2..=32 by
            // `resolve_slices`). On a successful sub-frame lock `bitstreamBufferPtr` holds
            // `bitstreamSizeInBytes` readable bytes of COMPLETED slices (enableSubFrameWrite
            // publishes them mid-encode) valid until the matching unlock; the emitted range is
            // copied out BEFORE the unlock. Every successful lock is unlocked exactly once on
            // all paths through the body.
            unsafe {
                let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
                    version: nv::NV_ENC_LOCK_BITSTREAM_VER,
                    outputBitstream: bs,
                    sliceOffsets: offsets.as_mut_ptr(),
                    ..Default::default()
                };
                lock.set_doNotWait(1);
                if (api().lock_bitstream)(self.encoder, &mut lock)
                    .nv_ok()
                    .is_ok()
                {
                    let n = lock.numSlices;
                    let bytes = lock.bitstreamSizeInBytes as usize;
                    if n >= self.slices {
                        // Every slice is readable — fall through to the finishing blocking
                        // lock (the completion authority; `numSlices` alone is not trusted
                        // across driver branches).
                        let _ = (api().unlock_bitstream)(self.encoder, bs);
                        break;
                    }
                    if n > slices_out && bytes > emitted {
                        // New completed slice(s): cut `[emitted..bytes)`. `bytes` with `n`
                        // reported slices is the end of slice n (slices are contiguous
                        // Annex-B), so the cut lands on a NAL boundary.
                        let data =
                            std::slice::from_raw_parts(lock.bitstreamBufferPtr as *const u8, bytes)
                                [emitted..]
                                .to_vec();
                        (api().unlock_bitstream)(self.encoder, bs)
                            .nv_ok()
                            .map_err(|e| nvenc_status::call_err("unlock_bitstream (chunk)", e))?;
                        let cs = self.chunk.get_or_insert_with(ChunkState::new);
                        #[cfg(debug_assertions)]
                        cs.shadow.extend_from_slice(&data);
                        let first = !cs.opened;
                        cs.opened = true;
                        cs.emitted = bytes;
                        cs.slices_out = n;
                        return Ok(Some(AuChunk {
                            data,
                            pts_ns,
                            keyframe: idr_hint,
                            recovery_anchor: anchor,
                            chunk_aligned: false,
                            first,
                            last: false,
                        }));
                    }
                    let _ = (api().unlock_bitstream)(self.encoder, bs);
                }
                // Non-SUCCESS (LOCK_BUSY) = not ready — never an error here; the finishing
                // blocking lock below owns real failures.
            }
            if t0.elapsed() > budget {
                break;
            }
            std::thread::sleep(CHUNK_SAMPLE_INTERVAL);
        }

        // Finish: ONE blocking lock — the completion authority, exactly like sync `poll` (so
        // the final chunk blocks and the AU tail never rides a +1 tick — the depth-1 pump
        // contract). Emits whatever the sampler hadn't handed out.
        let (bs, map, pts_ns, anchor, idr_hint) =
            self.pending.pop_front().expect("front() checked above");
        // SAFETY: same contract as `poll`'s blocking lock: `bs` is the popped in-flight pool
        // bitstream on the live session (encode thread); the blocking `lock_bitstream`
        // (version set) returns when the encode finished, yielding `bitstreamSizeInBytes`
        // CPU-readable bytes at `bitstreamBufferPtr` valid until `unlock_bitstream` — every
        // read (tail copy + debug prefix check) happens BEFORE the unlock. `map` (paired with
        // `bs` in `pending`) is unmapped here, after completion, exactly once.
        unsafe {
            let mut lock = nv::NV_ENC_LOCK_BITSTREAM {
                version: nv::NV_ENC_LOCK_BITSTREAM_VER,
                outputBitstream: bs,
                ..Default::default()
            };
            (api().lock_bitstream)(self.encoder, &mut lock)
                .nv_ok()
                .map_err(|e| nvenc_status::call_err("lock_bitstream (chunk finish)", e))?;
            let total = lock.bitstreamSizeInBytes as usize;
            let full = std::slice::from_raw_parts(lock.bitstreamBufferPtr as *const u8, total);
            let cs = self.chunk.take().unwrap_or_else(ChunkState::new);
            if cs.emitted > total {
                let _ = (api().unlock_bitstream)(self.encoder, bs);
                bail!(
                    "NVENC chunked poll: {} bytes already emitted but the finished AU is only \
                     {} — sub-frame readback reported bytes the final lock disowns",
                    cs.emitted,
                    total
                );
            }
            #[cfg(debug_assertions)]
            if cs.shadow.as_slice() != &full[..cs.emitted] {
                let _ = (api().unlock_bitstream)(self.encoder, bs);
                bail!("NVENC chunked poll: emitted chunks diverge from the finished AU prefix");
            }
            let data = full[cs.emitted..].to_vec();
            let keyframe = matches!(
                lock.pictureType,
                nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_IDR | nv::NV_ENC_PIC_TYPE::NV_ENC_PIC_TYPE_I
            );
            (api().unlock_bitstream)(self.encoder, bs)
                .nv_ok()
                .map_err(|e| nvenc_status::call_err("unlock_bitstream (chunk finish)", e))?;
            if !map.is_null() {
                let _ = (api().unmap_input_resource)(self.encoder, map);
            }
            if cs.opened && keyframe != idr_hint {
                // Can't happen under P-only + infinite GOP; if a driver branch ever proves
                // otherwise, the earlier chunks carried the wrong flag — make it visible.
                tracing::warn!(
                    predicted = idr_hint,
                    actual = keyframe,
                    "NVENC chunked poll: picture type diverged from the submit-time prediction"
                );
            }
            Ok(Some(AuChunk {
                data,
                pts_ns,
                keyframe,
                recovery_anchor: anchor,
                chunk_aligned: false,
                first: !cs.opened,
                last: true,
            }))
        }
    }

    /// Encode-stall recovery: tear the whole session down (the same teardown a capture-device
    /// change uses) and let the next `submit` rebuild it lazily on the current device — the owed
    /// AUs are forfeited and the fresh session opens on an IDR. Gives the encode-stall watchdog a
    /// healing lever on NVENC instead of ending the session. Caveat: the SYNC retrieve mode blocks
    /// inside `lock_bitstream`, so a driver wedge that hangs the lock never returns to the loop
    /// for the watchdog to fire — this lever fully protects the async retrieve mode (5 s event
    /// timeouts surface as poll errors) and the submit-side failure paths.
    fn reset(&mut self) -> bool {
        // SAFETY: `teardown` (an `unsafe fn`) requires the encode thread with no NVENC call in
        // flight and a session whose cached resources belong to `self.encoder` — all hold here
        // (reset is called from the session loop between submit/poll, like every other method),
        // and it early-returns on an already-null session.
        unsafe { self.teardown() };
        self.force_kf = true;
        true
    }

    fn reconfigure_bitrate(&mut self, bps: u64) -> bool {
        if !self.inited {
            // No live session yet — the lazy init simply opens at the new rate.
            self.bitrate_bps = bps;
            return true;
        }
        // Cached codec-level ceiling: clamp the target BEFORE the driver call, so a known
        // overshoot retargets to the ceiling IN PLACE instead of bouncing off the driver into
        // the caller's full-rebuild fallback (an IDR plus ~half a second of session churn per
        // ABR overshoot on the pre-cache path). The caller reads the clamp back through
        // [`Encoder::applied_bitrate_bps`].
        let bps = match cached_ceiling(&self.ceiling_key(self.split_mode)) {
            Some(ceiling) => bps.min(ceiling),
            None => bps,
        };
        // SAFETY: `inited` ⟹ `self.encoder` is the live session and this runs on the encode
        // thread between submit/poll (`nvEncReconfigureEncoder` is a submit-side call, the
        // sanctioned side of the two-thread async split — the retrieve thread only ever locks
        // bitstreams). `build_config` only queries the preset on that session; `cfg` outlives the
        // synchronous reconfigure call whose `reInitEncodeParams.encodeConfig` points at it.
        unsafe {
            let mut cfg = match self.build_config(self.encoder, bps) {
                Ok(cfg) => cfg,
                Err(e) => {
                    tracing::warn!(error = %format!("{e:#}"),
                        "NVENC reconfigure: config re-author failed — falling back to a rebuild");
                    return false;
                }
            };
            let mut params = nv::NV_ENC_RECONFIGURE_PARAMS {
                version: nv::NV_ENC_RECONFIGURE_PARAMS_VER,
                reInitEncodeParams: build_init_params(
                    self.codec_guid,
                    self.width,
                    self.height,
                    self.fps,
                    &mut cfg,
                    self.split_mode,
                    self.session_async,
                    // The SESSION's recorded state, not a fresh env read: reconfigure must
                    // present exactly the init params the open arbitrated (an env re-read could
                    // flip enableSubFrameWrite mid-session — the Linux latch invariant).
                    self.subframe_on,
                ),
                ..Default::default()
            };
            // Keep the encoder's RC state and reference chain: no reset, no IDR — the in-flight
            // frames and the caller's wire-index prediction survive the retarget.
            params.set_resetEncoder(0);
            params.set_forceIDR(0);
            match (api().reconfigure_encoder)(self.encoder, &mut params).nv_ok() {
                Ok(()) => {
                    self.bitrate_bps = bps;
                    true
                }
                Err(e) => {
                    // E.g. the new rate is above the codec-level ceiling — the caller's rebuild
                    // fallback owns the clamp search.
                    tracing::warn!(status = ?e, mbps = bps / 1_000_000,
                        "nvEncReconfigureEncoder rejected — falling back to a rebuild");
                    false
                }
            }
        }
    }

    fn set_send_spread_us(&mut self, us: u32) {
        self.send_spread_us = us;
    }

    fn applied_bitrate_bps(&self) -> Option<u64> {
        // `bitrate_bps` is the post-clamp truth: the open path's ceiling search and the
        // reconfigure path's cache clamp both write what the session ACTUALLY targets.
        Some(self.bitrate_bps)
    }

    fn flush(&mut self) -> Result<()> {
        Ok(()) // P1/ULL + frameIntervalP=1: each submit yields its AU; no internal queue to drain.
    }
}

impl Drop for NvencD3d11Encoder {
    fn drop(&mut self) {
        // SAFETY: `teardown` (an `unsafe fn`) needs the owning thread with no NVENC call in flight and
        // a session whose cached resources all belong to `self.encoder`. At Drop this encoder is owned
        // exclusively (no other reference can exist), runs on the encode thread it was confined to, and
        // `teardown` early-returns when `self.encoder` is null; otherwise every cached reg/bitstream/
        // pending was created against that live session. It runs exactly once (here).
        unsafe { self.teardown() };
    }
}

/// Probe whether the active NVIDIA GPU can encode HEVC **4:4:4** (`NV_ENC_CAPS_SUPPORT_YUV444_ENCODE`).
/// HEVC-only; the result is cached by the caller ([`crate::can_encode_444`]) and read *before*
/// the Welcome so the host advertises the chroma it can really encode (honest downgrade to 4:2:0 on a
/// card without it). See [`probe_encode_cap`] for the throwaway-session mechanics.
pub fn probe_can_encode_444(codec: Codec) -> bool {
    if codec != Codec::H265 {
        return false;
    }
    probe_encode_cap(codec, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_YUV444_ENCODE)
}

/// Probe whether the active NVIDIA GPU can encode `codec` at **10-bit**
/// (`NV_ENC_CAPS_SUPPORT_10BIT_ENCODE` against the codec's own GUID — HEVC Main10 / AV1 10-bit).
/// The result is cached by the caller ([`crate::can_encode_10bit`]) and read *before* the
/// Welcome so the negotiated bit depth — and the HDR label derived from it — matches what NVENC
/// will really emit. The session-open path re-checks the same cap as a belt-and-braces guard
/// ([`NvencD3d11Encoder::probe_caps`]'s 8-bit fallback).
pub fn probe_can_encode_10bit(codec: Codec) -> bool {
    if !codec.supports_10bit() {
        return false;
    }
    probe_encode_cap(codec, nv::NV_ENC_CAPS::NV_ENC_CAPS_SUPPORT_10BIT_ENCODE)
}

/// Query ONE NVENC capability for `codec` on a throwaway session (see [`with_probe_session`]).
/// `false` on any failure (no loadable NVENC, no device, failed open) — the honest answer for a
/// capability that couldn't be confirmed.
fn probe_encode_cap(codec: Codec, cap: nv::NV_ENC_CAPS) -> bool {
    with_probe_session(|enc| {
        let mut param = nv::NV_ENC_CAPS_PARAM {
            version: nv::NV_ENC_CAPS_PARAM_VER,
            capsToQuery: cap,
            reserved: [0; 62],
        };
        let mut val: i32 = 0;
        // SAFETY: `get_encode_caps` reads one scalar cap into `val` (live locals) for the live
        // session `enc` via the loaded API table (`with_probe_session` sits past `try_api`).
        unsafe {
            (api().get_encode_caps)(enc, codec_guid(codec), &mut param, &mut val)
                .nv_ok()
                .is_ok()
                && val != 0
        }
    })
    .unwrap_or(false)
}

/// Which codecs **this GPU's** NVENC can actually encode, asked of the driver itself
/// (`nvEncGetEncodeGUIDs`) on a throwaway session — the Windows twin of the Linux
/// `nvenc_cuda::probe_support` codec half, probing the **selected render adapter** (the GPU the
/// session will really encode on) rather than CUDA device 0. Same field bug on both OSes: the
/// static `H.264 | HEVC | AV1` superset advertised HEVC on a 1st-gen Maxwell, and a client that
/// negotiated it got a dead session instead of a stream.
///
/// Every failure path returns "nothing probed", which [`crate::CodecSupport::wire_mask`] turns
/// into `None` so the caller keeps the old static superset — a broken probe must never be able to
/// narrow an NVIDIA host's advertisement to nothing. Cached per selected GPU by the caller
/// ([`crate::windows_codec_support`]).
pub(crate) fn probe_codec_support() -> crate::CodecSupport {
    let unknown = crate::CodecSupport {
        h264: false,
        h265: false,
        av1: false,
    };
    with_probe_session(|enc| {
        // SAFETY: all NVENC calls go through the loaded API table against the live session `enc`;
        // `count`/`written` are live locals, and `guids` is sized to the count the driver just
        // reported, its pointer valid for that many `GUID`s (matching `guidArraySize`).
        unsafe {
            let mut count = 0u32;
            let counted = (api().get_encode_guid_count)(enc, &mut count)
                .nv_ok()
                .is_ok();
            let mut guids = vec![nv::GUID::default(); count as usize];
            let mut written = 0u32;
            let listed = counted
                && count > 0
                && (api().get_encode_guids)(enc, guids.as_mut_ptr(), count, &mut written)
                    .nv_ok()
                    .is_ok();
            if !listed {
                tracing::warn!(
                    "NVENC codec probe: driver listed no encode GUIDs — keeping the static \
                     advertisement"
                );
                return unknown;
            }
            guids.truncate(written as usize);
            crate::CodecSupport {
                h264: guids.contains(&codec_guid(Codec::H264)),
                h265: guids.contains(&codec_guid(Codec::H265)),
                av1: guids.contains(&codec_guid(Codec::Av1)),
            }
        }
    })
    .unwrap_or(unknown)
}

/// Open a throwaway NVENC session on a fresh hardware D3D11 device, hand it to `f`, and tear
/// everything down. `None` = no loadable NVENC / no device / failed open — the caller supplies
/// the honest "couldn't confirm" answer. Shared by [`probe_encode_cap`] and
/// [`probe_codec_support`], so every advertisement probe opens sessions exactly one way.
fn with_probe_session<T>(f: impl FnOnce(*mut c_void) -> T) -> Option<T> {
    // Same exclusion as `init_session`: this opens a real (throwaway) session, so it must never
    // overlap a zombie reap that could be destroying the very address the driver hands us.
    let _gate = DRIVER_SESSION_GATE
        .lock()
        .unwrap_or_else(|p| p.into_inner());
    use windows::Win32::Foundation::HMODULE;
    use windows::Win32::Graphics::Direct3D::{
        D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN, D3D_FEATURE_LEVEL_11_0,
    };
    use windows::Win32::Graphics::Direct3D11::{
        D3D11CreateDevice, D3D11_CREATE_DEVICE_BGRA_SUPPORT, D3D11_SDK_VERSION,
    };
    use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
    // No loadable NVENC on this box (non-NVIDIA / no driver) → nothing to confirm.
    // This is also the `api()` gate for every NVENC call below and inside `f`.
    if try_api().is_err() {
        return None;
    }
    // SAFETY: a self-contained probe owning every handle it creates. `CreateDXGIFactory1`/
    // `EnumAdapterByLuid` return owned COM objects or err (→ default-adapter fallback).
    // `D3D11CreateDevice` (explicit adapter + UNKNOWN driver type, or NULL adapter + HARDWARE)
    // fills `device` or returns Err (→ None). `open_encode_session_ex` opens an NVENC session
    // against that device's raw pointer (valid while `device` is held) or errors (→ None, after
    // destroying any residue session the failed open left — the docs require it).
    // `destroy_encoder` frees the session exactly once, after `f` returns (so `enc` is live for
    // the whole closure call); `device`/its context drop with the COM wrappers. No handle
    // escapes this call and nothing runs concurrently (the gate above).
    unsafe {
        // Probe on the SELECTED render adapter — the GPU the session will actually encode on
        // (web-console preference / PUNKTFUNK_RENDER_ADAPTER / max VRAM). The OS default adapter
        // (NULL) can be the *other* GPU on a hybrid box, answering for hardware we won't use.
        let adapter: Option<IDXGIAdapter1> =
            pf_gpu::resolve_render_adapter_luid().and_then(|luid| {
                let factory: IDXGIFactory4 = CreateDXGIFactory1().ok()?;
                factory.EnumAdapterByLuid(luid).ok()
            });
        let mut device: Option<ID3D11Device> = None;
        let created = match &adapter {
            Some(a) => D3D11CreateDevice(
                a,
                D3D_DRIVER_TYPE_UNKNOWN,
                HMODULE::default(),
                D3D11_CREATE_DEVICE_BGRA_SUPPORT,
                Some(&[D3D_FEATURE_LEVEL_11_0]),
                D3D11_SDK_VERSION,
                Some(&mut device),
                None,
                None,
            ),
            None => D3D11CreateDevice(
                None,
                D3D_DRIVER_TYPE_HARDWARE,
                HMODULE::default(),
                D3D11_CREATE_DEVICE_BGRA_SUPPORT,
                Some(&[D3D_FEATURE_LEVEL_11_0]),
                D3D11_SDK_VERSION,
                Some(&mut device),
                None,
                None,
            ),
        };
        if created.is_err() {
            return None;
        }
        let device = device?;
        let mut params = nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS {
            version: nv::NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER,
            deviceType: nv::NV_ENC_DEVICE_TYPE::NV_ENC_DEVICE_TYPE_DIRECTX,
            device: device.as_raw(),
            apiVersion: nv::NVENCAPI_VERSION,
            ..Default::default()
        };
        let mut enc: *mut c_void = ptr::null_mut();
        if (api().open_encode_session_ex)(&mut params, &mut enc)
            .nv_ok()
            .is_err()
        {
            // Destroy-on-failed-open: a failed open may still hold a session slot.
            if !enc.is_null() {
                let _ = (api().destroy_encoder)(enc);
            }
            return None;
        }
        // Availability probe, but a real session open all the same: it proves the driver accepted
        // this build's version word, which is what rules a skew out later (see `nvenc_status`).
        nvenc_status::note_session_opened();
        let out = f(enc);
        let _ = (api().destroy_encoder)(enc);
        Some(out)
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use pf_frame::{dxgi::D3d11Frame, CapturedFrame, FramePayload};
    use windows::Win32::Graphics::Direct3D11::{
        D3D11_BIND_RENDER_TARGET, D3D11_SUBRESOURCE_DATA, D3D11_TEXTURE2D_DESC, D3D11_USAGE_DEFAULT,
    };
    use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_SAMPLE_DESC};
    use windows::Win32::Graphics::Dxgi::{
        CreateDXGIFactory1, IDXGIFactory1, DXGI_ADAPTER_FLAG_SOFTWARE,
    };

    /// The 8 fully-saturated colour bars the matrix analysis samples (RGB). Saturated primaries
    /// separate BT.601 from BT.709 by tens of code points (e.g. pure-green luma 145 vs 173).
    const BARS: [(u8, u8, u8); 8] = [
        (255, 255, 255), // white
        (255, 255, 0),   // yellow
        (0, 255, 255),   // cyan
        (0, 255, 0),     // green
        (255, 0, 255),   // magenta
        (255, 0, 0),     // red
        (0, 0, 255),     // blue
        (0, 0, 0),       // black
    ];

    /// BGRA probe pattern: left half = the 8 colour bars (flat patches → matrix measurement),
    /// right half = alternating 1-px red/blue columns (the chroma-resolution litmus: true 4:4:4
    /// keeps adjacent columns' chroma distinct; an internally-subsampled encode blends them).
    fn probe_pattern(w: usize, h: usize) -> Vec<u8> {
        let mut px = vec![0u8; w * h * 4];
        let bar_w = (w / 2) / BARS.len();
        for y in 0..h {
            for x in 0..w {
                let (r, g, b) = if x < w / 2 {
                    BARS[(x / bar_w).min(BARS.len() - 1)]
                } else if x % 2 == 0 {
                    (255, 0, 0) // red column
                } else {
                    (0, 0, 255) // blue column
                };
                let o = (y * w + x) * 4;
                px[o] = b;
                px[o + 1] = g;
                px[o + 2] = r;
                px[o + 3] = 255;
            }
        }
        px
    }

    /// Encode 30 static pattern frames through the real NVENC session (ARGB input, the exact
    /// production configuration) at the given chroma and write the Annex-B stream to `path`.
    fn encode_pattern(chroma: ChromaFormat, path: &str) {
        const W: u32 = 1280;
        const H: u32 = 720;
        // SAFETY: (test-only) straight-line D3D11/DXGI COM calls on one thread; every out-pointer
        // is checked before use; the texture/device outlive the encoder (dropped at scope end).
        unsafe {
            let factory: IDXGIFactory1 = CreateDXGIFactory1().expect("DXGI factory");
            let mut adapter = None;
            for i in 0.. {
                let Ok(a) = factory.EnumAdapters1(i) else {
                    break;
                };
                let desc = a.GetDesc1().expect("adapter desc");
                if desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32 == 0 {
                    adapter = Some(a);
                    break;
                }
            }
            let adapter = adapter.expect("no hardware DXGI adapter");
            let (device, _ctx) = pf_frame::dxgi::make_device(&adapter).expect("make_device");

            let bytes = probe_pattern(W as usize, H as usize);
            let init = D3D11_SUBRESOURCE_DATA {
                pSysMem: bytes.as_ptr() as *const _,
                SysMemPitch: W * 4,
                SysMemSlicePitch: 0,
            };
            let desc = D3D11_TEXTURE2D_DESC {
                Width: W,
                Height: H,
                MipLevels: 1,
                ArraySize: 1,
                Format: DXGI_FORMAT_B8G8R8A8_UNORM,
                SampleDesc: DXGI_SAMPLE_DESC {
                    Count: 1,
                    Quality: 0,
                },
                Usage: D3D11_USAGE_DEFAULT,
                // NVENC registration requires RENDER_TARGET on D3D11 input textures.
                BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
                CPUAccessFlags: 0,
                MiscFlags: 0,
            };
            let mut tex = None;
            device
                .CreateTexture2D(&desc, Some(&init), Some(&mut tex))
                .expect("pattern texture");
            let tex = tex.expect("null pattern texture");

            let mut enc = NvencD3d11Encoder::open(
                Codec::H265,
                PixelFormat::Bgra,
                W,
                H,
                60,
                100_000_000, // high rate: the 1-px stripes must survive quantization
                8,
                chroma,
                1,
            )
            .expect("NVENC open");
            let mut out = Vec::new();
            for i in 0..30u64 {
                let frame = CapturedFrame {
                    width: W,
                    height: H,
                    pts_ns: i * 16_666_667,
                    format: PixelFormat::Bgra,
                    payload: FramePayload::D3d11(D3d11Frame {
                        texture: tex.clone(),
                        device: device.clone(),
                        pyro: None,
                    }),
                    cursor: None,
                };
                enc.submit(&frame).expect("submit");
                while let Some(au) = enc.poll().expect("poll") {
                    out.extend_from_slice(&au.data);
                }
            }
            enc.flush().ok();
            while let Ok(Some(au)) = enc.poll() {
                out.extend_from_slice(&au.data);
            }
            assert!(!out.is_empty(), "no AUs produced");
            let caps444 = enc.caps().chroma_444;
            std::fs::write(path, &out).expect("write bitstream");
            println!(
                "wrote {path}: {} bytes, requested {chroma:?}, caps.chroma_444={caps444}",
                out.len()
            );
        }
    }

    /// ON-HARDWARE (RTX box `.173`): the Phase 3.2 in-place rate retarget — encode a few frames,
    /// `reconfigure_bitrate` mid-stream (up AND down), keep encoding, and assert every
    /// post-reconfigure AU is a P-frame (`nvEncReconfigureEncoder` with `resetEncoder=0` /
    /// `forceIDR=0` must NOT restart the stream). The Windows twin of the Linux backend's
    /// `nvenc_cuda_reconfigure_no_idr`. Run:
    ///   cargo test -p punktfunk-host --features nvenc -- --ignored nvenc_reconfigure --nocapture
    #[test]
    #[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.173)"]
    fn nvenc_reconfigure_no_idr() {
        let _ = tracing_subscriber::fmt().with_test_writer().try_init();
        const W: u32 = 1280;
        const H: u32 = 720;
        // SAFETY: (test-only) same straight-line D3D11/DXGI setup as `encode_pattern`.
        unsafe {
            let factory: IDXGIFactory1 = CreateDXGIFactory1().expect("DXGI factory");
            let mut adapter = None;
            for i in 0.. {
                let Ok(a) = factory.EnumAdapters1(i) else {
                    break;
                };
                let desc = a.GetDesc1().expect("adapter desc");
                if desc.Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32 == 0 {
                    adapter = Some(a);
                    break;
                }
            }
            let adapter = adapter.expect("no hardware DXGI adapter");
            let (device, _ctx) = pf_frame::dxgi::make_device(&adapter).expect("make_device");

            let bytes = probe_pattern(W as usize, H as usize);
            let init = D3D11_SUBRESOURCE_DATA {
                pSysMem: bytes.as_ptr() as *const _,
                SysMemPitch: W * 4,
                SysMemSlicePitch: 0,
            };
            let desc = D3D11_TEXTURE2D_DESC {
                Width: W,
                Height: H,
                MipLevels: 1,
                ArraySize: 1,
                Format: DXGI_FORMAT_B8G8R8A8_UNORM,
                SampleDesc: DXGI_SAMPLE_DESC {
                    Count: 1,
                    Quality: 0,
                },
                Usage: D3D11_USAGE_DEFAULT,
                BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
                CPUAccessFlags: 0,
                MiscFlags: 0,
            };
            let mut tex = None;
            device
                .CreateTexture2D(&desc, Some(&init), Some(&mut tex))
                .expect("pattern texture");
            let tex = tex.expect("null pattern texture");

            let mut enc = NvencD3d11Encoder::open(
                Codec::H265,
                PixelFormat::Bgra,
                W,
                H,
                60,
                20_000_000,
                8,
                ChromaFormat::Yuv420,
                1,
            )
            .expect("NVENC open");

            let submit_and_poll = |enc: &mut NvencD3d11Encoder, range: std::ops::Range<u64>| {
                let mut keyframes = 0usize;
                let mut aus = 0usize;
                for i in range {
                    let frame = CapturedFrame {
                        width: W,
                        height: H,
                        pts_ns: i * 16_666_667,
                        format: PixelFormat::Bgra,
                        payload: FramePayload::D3d11(D3d11Frame {
                            texture: tex.clone(),
                            device: device.clone(),
                            pyro: None,
                        }),
                        cursor: None,
                    };
                    enc.submit_indexed(&frame, i as u32).expect("submit");
                    while let Some(au) = enc.poll().expect("poll") {
                        aus += 1;
                        keyframes += au.keyframe as usize;
                    }
                }
                enc.flush().ok();
                while let Ok(Some(au)) = enc.poll() {
                    aus += 1;
                    keyframes += au.keyframe as usize;
                }
                (aus, keyframes)
            };

            let (aus, kfs) = submit_and_poll(&mut enc, 0..4);
            assert!(aus > 0, "no AUs before the reconfigure");
            assert_eq!(kfs, 1, "exactly the opening IDR before the reconfigure");

            assert!(
                enc.reconfigure_bitrate(60_000_000),
                "in-place reconfigure to 60 Mbps must succeed on RTX NVENC"
            );
            let (aus, kfs) = submit_and_poll(&mut enc, 4..8);
            assert!(aus > 0, "no AUs after the up-reconfigure");
            assert_eq!(kfs, 0, "an in-place rate retarget must not emit an IDR");

            assert!(
                enc.reconfigure_bitrate(10_000_000),
                "in-place reconfigure down to 10 Mbps must succeed"
            );
            let (aus, kfs) = submit_and_poll(&mut enc, 8..12);
            assert!(aus > 0, "no AUs after the down-reconfigure");
            assert_eq!(kfs, 0, "an in-place rate retarget must not emit an IDR");

            println!("nvenc (Windows) reconfigure smoke: 20→60→10 Mbps in place, zero IDRs");
        }
    }

    /// ON-HARDWARE — **S1 on WINDOWS/D3D11**, the question that gates Windows split arbitration.
    ///
    /// Everything the split-encode programme rests on was proven on **Linux/CUDA**: that
    /// `nvEncReconfigureEncoder` accepts a changed `splitEncodeMode` with `resetEncoder=0`, emits
    /// **no IDR**, and actually takes effect. The Windows backend drives a different device type
    /// (`NV_ENC_DEVICE_TYPE_DIRECTX`), so none of that transfers by assumption — and if the driver
    /// refuses it here, Windows arbitration is simply not buildable and should not be attempted.
    ///
    /// Also checks the two things WP1.1 added, on real Windows hardware rather than by inference
    /// from Linux: that `query_caps` latches `NUM_ENCODER_ENGINES`, and that the driver **honours
    /// an over-ask** (asking for a 3-way split on a 2-engine card) — the behaviour that makes the
    /// clamp necessary rather than defensive.
    ///
    /// Reports rather than asserts the verdict: both outcomes are legitimate findings. Run:
    ///   cargo test -p pf-encode --features nvenc -- --ignored --test-threads=1 \
    ///     nvenc_split_reconfigure_in_place --nocapture
    #[test]
    #[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX Windows box"]
    fn nvenc_split_reconfigure_in_place() {
        let _ = tracing_subscriber::fmt().with_test_writer().try_init();
        const W: u32 = 1920;
        const H: u32 = 1080;
        const BPS: u64 = 40_000_000;
        let disable = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32;
        let two = nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_TWO_FORCED_MODE as u32;

        // Isolate the split variable exactly as the Linux spike does.
        // SAFETY: this `#[ignore]`d hardware spike is run alone (manual RTX-box run, one test),
        // so no other thread exists to read or write the environment concurrently.
        unsafe {
            std::env::set_var("PUNKTFUNK_NVENC_SUBFRAME", "0");
            std::env::set_var("PUNKTFUNK_SPLIT_ENCODE", "0");
        }

        // SAFETY: (test-only) the same straight-line D3D11/DXGI setup as `nvenc_reconfigure_no_idr`.
        unsafe {
            let factory: IDXGIFactory1 = CreateDXGIFactory1().expect("DXGI factory");
            let mut adapter = None;
            for i in 0.. {
                let Ok(a) = factory.EnumAdapters1(i) else {
                    break;
                };
                if a.GetDesc1().expect("adapter desc").Flags & DXGI_ADAPTER_FLAG_SOFTWARE.0 as u32
                    == 0
                {
                    adapter = Some(a);
                    break;
                }
            }
            let adapter = adapter.expect("no hardware DXGI adapter");
            let (device, _ctx) = pf_frame::dxgi::make_device(&adapter).expect("make_device");
            let bytes = probe_pattern(W as usize, H as usize);
            let init = D3D11_SUBRESOURCE_DATA {
                pSysMem: bytes.as_ptr() as *const _,
                SysMemPitch: W * 4,
                SysMemSlicePitch: 0,
            };
            let desc = D3D11_TEXTURE2D_DESC {
                Width: W,
                Height: H,
                MipLevels: 1,
                ArraySize: 1,
                Format: DXGI_FORMAT_B8G8R8A8_UNORM,
                SampleDesc: DXGI_SAMPLE_DESC {
                    Count: 1,
                    Quality: 0,
                },
                Usage: D3D11_USAGE_DEFAULT,
                BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
                CPUAccessFlags: 0,
                MiscFlags: 0,
            };
            let mut tex = None;
            device
                .CreateTexture2D(&desc, Some(&init), Some(&mut tex))
                .expect("pattern texture");
            let tex = tex.expect("null pattern texture");

            let mut enc = NvencD3d11Encoder::open(
                Codec::H265,
                PixelFormat::Bgra,
                W,
                H,
                60,
                BPS,
                8,
                ChromaFormat::Yuv420,
                1,
            )
            .expect("NVENC open");

            let submit_and_poll = |enc: &mut NvencD3d11Encoder, range: std::ops::Range<u64>| {
                let (mut aus, mut keyframes) = (0usize, 0usize);
                for i in range {
                    let frame = CapturedFrame {
                        width: W,
                        height: H,
                        pts_ns: i * 16_666_667,
                        format: PixelFormat::Bgra,
                        payload: FramePayload::D3d11(D3d11Frame {
                            texture: tex.clone(),
                            device: device.clone(),
                            pyro: None,
                        }),
                        cursor: None,
                    };
                    enc.submit_indexed(&frame, i as u32).expect("submit");
                    while let Some(au) = enc.poll().expect("poll") {
                        aus += 1;
                        keyframes += au.keyframe as usize;
                    }
                }
                (aus, keyframes)
            };

            let (aus, kfs) = submit_and_poll(&mut enc, 0..6);
            assert!(aus > 0 && kfs == 1, "opening IDR then steady P-frames");
            println!(
                "S1(win): engines={} (latched by query_caps), opened split_mode={}",
                enc.encoder_engines, enc.split_mode
            );
            assert!(
                enc.encoder_engines >= 2,
                "this GPU reports {} NVENC engine(s) — S1 is not interpretable here",
                enc.encoder_engines
            );
            assert_eq!(enc.split_mode, disable, "must open split-disabled");

            // THE SPIKE: change ONLY splitEncodeMode, in place, same bitrate.
            enc.split_mode = two;
            let accepted = enc.reconfigure_bitrate(BPS);
            println!("S1(win): reconfigure DISABLE→TWO_FORCED accepted = {accepted}");
            if accepted {
                let (aus, kfs) = submit_and_poll(&mut enc, 6..12);
                assert!(aus > 0, "no AUs after the accepted reconfigure");
                println!(
                    "S1(win) VERDICT: {}",
                    if kfs == 0 {
                        "PASS — accepted with NO IDR on D3D11: Windows arbitration is buildable"
                    } else {
                        "FAIL — accepted but forced an IDR, which is the same as a rejection"
                    }
                );
                enc.split_mode = disable;
                let back = enc.reconfigure_bitrate(BPS);
                println!("S1(win): reverse accepted = {back}");
            } else {
                enc.split_mode = disable;
                println!(
                    "S1(win) VERDICT: FAIL — the D3D11 path REFUSES an in-place split change. \
                     Windows arbitration is not buildable; the Linux result does not transfer."
                );
            }
            enc.flush().ok();
        }

        // SAFETY: as the set above — single-threaded manual test run, no concurrent env access.
        unsafe {
            std::env::remove_var("PUNKTFUNK_SPLIT_ENCODE");
            std::env::remove_var("PUNKTFUNK_NVENC_SUBFRAME");
        }
    }

    /// ON-GLASS (RTX box): the measurement gating the AYUV 4:4:4 work — encodes the probe
    /// pattern through the REAL ARGB-input NVENC session once with `chromaFormatIDC=3`/FREXT
    /// and once as plain 4:2:0, so offline analysis of the two bitstreams answers (1) whether
    /// the FREXT stream is truly full-chroma and (2) which matrix NVENC's internal RGB→YUV CSC
    /// used (BT.601 vs BT.709 — saturated bars differ by tens of code points). Run with:
    ///   cargo test -p punktfunk-host --features nvenc -- --ignored nvenc_444_on_glass --nocapture
    #[test]
    #[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box"]
    fn nvenc_444_on_glass_probe() {
        encode_pattern(
            ChromaFormat::Yuv444,
            "C:\\Users\\Public\\nvenc444_probe.h265",
        );
        encode_pattern(
            ChromaFormat::Yuv420,
            "C:\\Users\\Public\\nvenc420_probe.h265",
        );
    }

    /// ON-HARDWARE: the codec-advertisement probe against the real driver — the Windows twin of
    /// the Linux `nvenc_codec_probe_reports_real_gpu_support` test, same invariants: every
    /// NVENC-capable GPU ever made encodes H.264 (a `false` means the enumeration is broken and
    /// would silently narrow the advertisement), and the answer must be stable across calls since
    /// one cached answer drives every negotiation. Prints the mask so a run on an OLD card
    /// (Maxwell GM107 = h264 only, the GPU this probe exists for) is self-documenting. Run:
    ///   cargo test -p pf-encode --features nvenc --release -- --ignored nvenc_codec_probe --nocapture
    /// (`--release` is REQUIRED on Windows, and pre-dates this test: the debug lib-test link
    /// fails LNK2019 because the sdk crate's unused lazy loader references the NvEncodeAPI
    /// imports that runtime loading deliberately avoids — debug `/OPT:NOREF` keeps the dead
    /// COMDAT, release strips it. Windows CI gates pf-encode with clippy for the same reason.)
    #[test]
    #[ignore = "requires an NVIDIA GPU + driver — run manually on the RTX box (.173)"]
    fn nvenc_codec_probe_reports_real_gpu_support() {
        let caps = probe_codec_support();
        eprintln!(
            "NVENC (Windows) probe: h264={} h265={} av1={}",
            caps.h264, caps.h265, caps.av1
        );
        assert!(
            caps.h264,
            "every NVENC generation encodes H.264 — a false here means the GUID enumeration \
             failed, which would narrow the host's codec advertisement"
        );
        let again = probe_codec_support();
        assert_eq!(
            (caps.h264, caps.h265, caps.av1),
            (again.h264, again.h265, again.av1),
            "the probe must be stable — it is cached once and drives every later negotiation"
        );
    }
}
