diff --git a/agent/src/bin/dump_stream.rs b/agent/src/bin/dump_stream.rs index 0101a0b..baff8af 100644 --- a/agent/src/bin/dump_stream.rs +++ b/agent/src/bin/dump_stream.rs @@ -114,6 +114,14 @@ fn arg(name: &str) -> Option { .cloned() } +/// v5 观测列:帧头光标字段的稳定文本形态(proof 脚本按 `cursor=(x,y)` / `cursor=None` 解析) +fn cursor_cell(c: &Option<(i16, i16)>) -> String { + match c { + Some((x, y)) => format!("cursor=({x},{y})"), + None => "cursor=None".into(), + } +} + #[tokio::main] async fn main() -> Result<()> { rustls::crypto::ring::default_provider() @@ -184,8 +192,8 @@ async fn main() -> Result<()> { // 累积裸码流,用于 --save let mut saved: Vec = Vec::new(); - println!("\n序号 seq kf 载荷字节 NAL 结构 解码结果"); - println!("{}", "-".repeat(72)); + println!("\n序号 seq kf 载荷字节 NAL 结构 光标 解码结果"); + println!("{}", "-".repeat(86)); for i in 0..total { let (hdr, payload) = match tokio::time::timeout( @@ -221,11 +229,12 @@ async fn main() -> Result<()> { // 模拟主控端积压跳帧:读出但不解码 if Some(i) == skip_at { println!( - "[{i:2}] {:3} {:<5} {:8} {:<14} ⏭ 跳过解码(模拟积压跳帧)", + "[{i:2}] {:3} {:<5} {:8} {:<14} {:14} ⏭ 跳过解码(模拟积压跳帧)", hdr.seq, hdr.keyframe, payload.len(), - describe(&types) + describe(&types), + cursor_cell(&hdr.cursor) ); continue; } @@ -237,11 +246,12 @@ async fn main() -> Result<()> { }; println!( - "[{i:2}] {:3} {:<5} {:8} {:<14} {}", + "[{i:2}] {:3} {:<5} {:8} {:<14} {:14} {}", hdr.seq, hdr.keyframe, payload.len(), describe(&types), + cursor_cell(&hdr.cursor), res ); diff --git a/agent/src/capture.rs b/agent/src/capture.rs index 65439b4..0219d30 100644 --- a/agent/src/capture.rs +++ b/agent/src/capture.rs @@ -36,6 +36,7 @@ use windows::Win32::Graphics::Dxgi::{ IDXGIDevice, IDXGIOutput1, IDXGIOutputDuplication, IDXGIResource, DXGI_ERROR_WAIT_TIMEOUT, DXGI_OUTDUPL_DESC, DXGI_OUTDUPL_FRAME_INFO, }; +use windows::Win32::UI::WindowsAndMessaging::{GetCursorInfo, CURSORINFO, CURSOR_SHOWING}; use std::time::{Duration, Instant}; @@ -50,6 +51,31 @@ use std::time::{Duration, Instant}; /// 又不会因过密的整帧内编码抬高码率。 pub const INTRA_PERIOD_FRAMES: u32 = 60; +/// v5:光标可见性 + 采集边界折叠的**纯逻辑**(无 Win32 调用,可单测)。 +/// +/// 输入是 `GetCursorInfo` 的原始读数与采集画面尺寸;输出协议 `cursor` 字段语义 +/// (见 `common::VideoFrameHeader::cursor`): +/// - 未 `CURSOR_SHOWING`(指针隐藏或被触控抑制)→ None +/// - 越出 `[0,w)×[0,h)`(副屏负坐标区、主屏外右侧等)→ None +/// ——采集画面里没有它,主控端画了位置也是错的(4b/Q5=A:语义单一来源在被控端) +/// - 越出 i16 正范围 → None 且第二返回 `true`(调用方打日志):协议用 i16 省字节, +/// 依赖「现实桌面 << 32767」前提;前提破了就折叠,**绝不截断出一个看起来合法的错坐标** +/// +/// 独立成函数的原因:这套判定是 4b 折叠规则的全部智力内容,而 Win32 读数在 +/// 有人用真鼠标的机器上不可复现——逻辑必须与采样解耦地被测试锁死。 +fn fold_cursor(showing: bool, x: i32, y: i32, w: i32, h: i32) -> (Option<(i16, i16)>, bool) { + if !showing { + return (None, false); + } + if x < 0 || y < 0 || x >= w || y >= h { + return (None, false); + } + if x > i16::MAX as i32 || y > i16::MAX as i32 { + return (None, true); + } + (Some((x as i16, y as i16)), false) +} + pub struct Duplication { pub device: ID3D11Device, context: ID3D11DeviceContext, @@ -182,6 +208,42 @@ impl Duplication { Ok(r) } } + + /// v5:采样当前远端光标位置(**采集显示器内**的物理像素,(0,0)=画面左上角)。 + /// + /// 折叠规则(隐藏/离屏/i16 越界 → None)的唯一真源是 [`fold_cursor`]—— + /// 那里是可单测的纯逻辑;本函数只负责 Win32 读数与失败留痕。 + /// + /// `GetCursorInfo` 失败 → None + 会话期只打一条日志(可见后果,但不逐帧刷屏)。 + /// + /// 前提:进程必须 per-monitor DPI 感知(`main.rs::init_dpi_awareness`,3b/D2 教训), + /// 否则 `ptScreenPos` 是逻辑坐标,与采集像素不同尺度。 + pub fn sample_cursor(&self) -> Option<(i16, i16)> { + static LOGGED_FAIL: AtomicBool = AtomicBool::new(false); + static LOGGED_OVERFLOW: AtomicBool = AtomicBool::new(false); + unsafe { + let mut ci = CURSORINFO::default(); + // Win32 契约:调用方必须先填 cbSize,否则 API 直接拒绝(MSDN GetCursorInfo) + ci.cbSize = std::mem::size_of::() as u32; + if GetCursorInfo(&mut ci).is_err() { + if !LOGGED_FAIL.swap(true, Ordering::Relaxed) { + eprintln!("[capture] GetCursorInfo 失败:帧头 cursor 将恒为 None"); + } + return None; + } + let (pos, overflow) = fold_cursor( + ci.flags.0 & CURSOR_SHOWING.0 != 0, + ci.ptScreenPos.x, + ci.ptScreenPos.y, + self.width as i32, + self.height as i32, + ); + if overflow && !LOGGED_OVERFLOW.swap(true, Ordering::Relaxed) { + eprintln!("[capture] 光标坐标超出 i16 协议范围,已折叠为 None(桌面尺寸异常?)"); + } + pos + } + } } /// NVENC 硬编封装:H.264 High、CBR 码率控制、UltraLowLatency 调优、无 B 帧。 @@ -364,6 +426,10 @@ pub fn run(tx: Sender>>, keyreq: Arc, congestion: Arc // `$kf` 由调用点显式给出:NVENC 路径知道 `force_key`,软编路径同理。 // 不要在这里猜——首帧和 KeyFrameReq 都会产生 IDR,猜错会让解码端错过重同步点。 + // + // `cursor` 在**这里**采样(不在循环顶部):帧头语义是「本帧发送时刻的位置」。 + // emit! 是唯一的发送入口,静止保活重发也走它(500ms 分支),所以新帧 / + // 静止重发 / 强制 IDR 三种发送路径天然全覆盖,无需各自补采样。 macro_rules! emit { ($payload:expr, $kf:expr) => {{ let header = VideoFrameHeader { @@ -372,8 +438,7 @@ pub fn run(tx: Sender>>, keyreq: Arc, congestion: Arc ts_ms: start.elapsed().as_millis() as u64, seq, keyframe: $kf, - // v5 光标字段:占位 None,真实采样(GetCursorInfo)在下一步接入 - cursor: None, + cursor: dup.sample_cursor(), }; let payload_vec: Vec = $payload; let frame = encode_video_frame(&header, &payload_vec); @@ -587,3 +652,61 @@ pub fn run(tx: Sender>>, keyreq: Arc, congestion: Arc std::thread::sleep(Duration::from_millis(300)); } } + +#[cfg(test)] +mod tests { + use super::fold_cursor; + + const W: i32 = 1920; + const H: i32 = 1080; + + /// 界内可见 → 原样返回。中心与 (0,0) 都要覆盖:(0,0) 与 None 在协议里是两种语义, + /// 若实现把「左上角」误折叠成 None,光标到左上角就「消失」了(这是最容易写错的一条)。 + #[test] + fn inside_bounds_pass_through_including_origin() { + assert_eq!(fold_cursor(true, 960, 540, W, H), (Some((960, 540)), false)); + assert_eq!(fold_cursor(true, 0, 0, W, H), (Some((0, 0)), false)); + assert_eq!( + fold_cursor(true, W - 1, H - 1, W, H), + (Some((1919, 1079)), false) + ); + } + + /// 隐藏/触控抑制(CURSOR_SHOWING 未置位)→ None,**即使坐标界内**。 + /// 顺序很关键:坐标合法但不可见时也不能画标记。 + #[test] + fn hidden_cursor_folds_to_none_even_when_in_bounds() { + assert_eq!(fold_cursor(false, 100, 100, W, H), (None, false)); + assert_eq!(fold_cursor(false, 0, 0, W, H), (None, false)); + } + + /// 越出采集画面 → None:负坐标(副屏在左,本机实测 -1920..-1)、 + /// 右/下边界外、以及**恰好在边界上**(坐标 w 对应第 w+1 列,画面里没有)。 + #[test] + fn out_of_capture_bounds_folds_to_none() { + assert_eq!(fold_cursor(true, -960, 500, W, H), (None, false)); // 副屏(负 X) + assert_eq!(fold_cursor(true, 100, -1, W, H), (None, false)); // 负 Y + assert_eq!(fold_cursor(true, W, 500, W, H), (None, false)); // 右边界外一列 + assert_eq!(fold_cursor(true, 500, H, W, H), (None, false)); // 下边界外一行 + assert_eq!(fold_cursor(true, 2610, 1300, W, H), (None, false)); // 主屏外右侧 + } + + /// 越出 i16 正范围 → None + 第二返回 true(调用方据此打日志)。 + /// 注意:常规采集尺寸(≤1920)下越界 x 永远先被边界检查拦下,i16 分支不可达—— + /// 必须用超宽采集(假想带鱼屏)才能测到;它证明「界内但超 i16」会被折叠 + /// 而**不是截断成看似合法的错坐标**。i16::MAX 恰好合法,不误报。 + #[test] + fn beyond_i16_folds_with_overflow_flag() { + let huge_w = 40_000; // 假想的超宽采集,越过 i16::MAX + assert_eq!( + fold_cursor(true, i16::MAX as i32, 0, huge_w, H), + (Some((i16::MAX, 0)), false), + "i16::MAX 恰好界内合法,不该误报 overflow" + ); + assert_eq!( + fold_cursor(true, 33_000, 10, huge_w, H), + (None, true), + "界内但超 i16 → overflow=true 的 None,而非截断成错误坐标" + ); + } +}