* test(input): verify Gamescope pointer synchronization * docs(agents): require real hardware sessions * docs(agents): clarify dev bypass hardware proof
- commit
- e3c7bce84c2ccd68ac96dce925ac98959c18bdf5
- parent
- 3a7e6f558b56625e7308d5f098f7528737d4f01c
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | AGENTS.md | 6 | +- |
| M | crates/ | 276 | +++++++++++++++++++++++++++--- |
| M | crates/ | 6 | + |
| M | docs/ | 19 | ++- |
| M | scripts/ | 2 | + |
@@ -185,8 +185,10 @@ Sigil host.
185185 decoding, A/V, input, or session behavior requires a real Portal -> UMPC186186 Gamescope connection from the exact candidate commit. A successful config187187 check, capture probe, headless probe, or loopback is supporting evidence, not188- a substitute for that end-to-end session. For development hardware testing,189- build Sigil with `demo-auth-bypass`, run it with188+ a substitute for that end-to-end session. Fresh exact-commit builds are not189+ expected to be enrolled: do not skip the connection because ordinary190+ authentication blocks it. For development hardware testing, build Sigil with191+ `demo-auth-bypass`, run it with190192 `--dev-allow-unauthorized` and a bounded `--max-runtime-seconds`, and connect191193 a debug Portal with `--dev-connect`. Verify the affected live diagnostics192194 and interaction path, then stop the bypass process, undo temporary Gamescope@@ -15,15 +15,15 @@ use moq_net::{BroadcastConsumer, GroupConsumer, TrackConsumer};
1515 use sigil_protocol::{1616 AdaptiveBitrateDecisionV1, AdaptiveBitrateReasonFlagsV1, AdaptiveBitrateStateV1,1717 CONTROL_ALPN_V1, Capability, ClientHello, FrameFlags, GAMEPAD_AXIS_MAX, GAMEPAD_AXIS_MIN,18- GAMEPAD_TRIGGER_MAX, GamepadState, INPUT_ALPN_V1, INVITATION_CLOCK_SKEW_SECS, InputEvent,19- InvitationGrants, KeyframeRequestReasonV3, MAX_INVITATION_TOKEN_LEN, MAX_MEDIA_GROUP_BYTES_V3,20- MAX_MEDIA_OBJECT_ID_V3, MEDIA_ALPN_V1, MEDIA_ALPN_V2, MEDIA_ALPN_V3, MEDIA_FEEDBACK_ALPN_V1,21- MediaCodec, MediaControlRequestV3, MediaFeedbackFlags, MediaFeedbackReportV1, MediaFrame,22- MediaObjectV3, PointerSurfaceDimensions, ProtocolError, SignedInvitation,23- decode_media_frame_object, media_moq_broadcast_name, read_adaptive_bitrate_decision_v1,24- read_host_hello, read_input_ack, read_media_frame, read_media_object, read_media_object_v3,25- write_client_hello, write_input_event, write_media_control_request_v3,26- write_media_feedback_report_v1,18+ GAMEPAD_TRIGGER_MAX, GamepadState, INPUT_ALPN_V1, INVITATION_CLOCK_SKEW_SECS, InputAck,19+ InputEvent, InvitationGrants, KeyframeRequestReasonV3, MAX_INVITATION_TOKEN_LEN,20+ MAX_MEDIA_GROUP_BYTES_V3, MAX_MEDIA_OBJECT_ID_V3, MEDIA_ALPN_V1, MEDIA_ALPN_V2, MEDIA_ALPN_V3,21+ MEDIA_FEEDBACK_ALPN_V1, MediaCodec, MediaControlRequestV3, MediaFeedbackFlags,22+ MediaFeedbackReportV1, MediaFrame, MediaObjectV3, PointerPosition, PointerSurfaceDimensions,23+ ProtocolError, SignedInvitation, decode_media_frame_object, media_moq_broadcast_name,24+ read_adaptive_bitrate_decision_v1, read_host_hello, read_input_ack, read_media_frame,25+ read_media_object, read_media_object_v3, write_client_hello, write_input_event,26+ write_media_control_request_v3, write_media_feedback_report_v1,2727 };2828 2929 @@ -1634,6 +1634,8 @@ async fn main() -> Result<()> {
16341634 read_expected_input_ack(&mut input_recv, args.timeout_seconds, 1).await?;16351635 let input_ack_micros = input_started.elapsed().as_micros();16361636 let mut expected_ack = 1_u64;1637+ let mut pointer_sync_position = None;1638+ let mut pointer_motion_position = None;16371639 16381640 if args.pointer_smoke {16391641 ensure!(@@ -1642,18 +1644,53 @@ async fn main() -> Result<()> {
16421644 .contains(&Capability::RelativePointer),16431645 "host did not accept the required relative pointer capability"16441646 );1647+ let pointer_dimensions = media_negotiation.pointer_surface_dimensions.context(1648+ "host did not advertise pointer surface dimensions required by --pointer-smoke",1649+ )?;16451650 let [position_sync, relative_motion, click] =1646- pointer_smoke_events(media_negotiation.pointer_surface_dimensions)?;1651+ pointer_smoke_events(Some(pointer_dimensions))?;1652+ let [expected_sync_position, expected_motion_position] =1653+ pointer_smoke_expected_positions(pointer_dimensions)?;16471654 write_input_event(&mut input_send, &position_sync)16481655 .await16491656 .context("writing pointer position synchronization")?;16501657 expected_ack += 1;1651- read_expected_input_ack(&mut input_recv, args.timeout_seconds, expected_ack).await?;1658+ let sync_ack =1659+ read_expected_input_ack(&mut input_recv, args.timeout_seconds, expected_ack).await?;1660+ if args.pointer_feedback_smoke {1661+ pointer_sync_position = Some(1662+ confirm_pointer_position(1663+ &mut input_send,1664+ &mut input_recv,1665+ &mut expected_ack,1666+ sync_ack,1667+ expected_sync_position,1668+ Duration::from_secs(args.timeout_seconds.min(5)),1669+ )1670+ .await1671+ .context("verifying compositor position after pointer synchronization")?,1672+ );1673+ }16521674 write_input_event(&mut input_send, &relative_motion)16531675 .await16541676 .context("writing relative pointer smoke motion")?;16551677 expected_ack += 1;1656- read_expected_input_ack(&mut input_recv, args.timeout_seconds, expected_ack).await?;1678+ let motion_ack =1679+ read_expected_input_ack(&mut input_recv, args.timeout_seconds, expected_ack).await?;1680+ if args.pointer_feedback_smoke {1681+ pointer_motion_position = Some(1682+ confirm_pointer_position(1683+ &mut input_send,1684+ &mut input_recv,1685+ &mut expected_ack,1686+ motion_ack,1687+ expected_motion_position,1688+ Duration::from_secs(args.timeout_seconds.min(5)),1689+ )1690+ .await1691+ .context("verifying compositor position after relative pointer motion")?,1692+ );1693+ }16571694 write_input_event(&mut input_send, &click)16581695 .await16591696 .context("writing pointer smoke click")?;@@ -2490,6 +2527,14 @@ async fn main() -> Result<()> {
24902527 "not-requested"24912528 }24922529 );2530+ match pointer_sync_position {2531+ Some(position) => println!("pointer_sync_position={},{}", position.x, position.y),2532+ None => println!("pointer_sync_position=not-requested"),2533+ }2534+ match pointer_motion_position {2535+ Some(position) => println!("pointer_motion_position={},{}", position.x, position.y),2536+ None => println!("pointer_motion_position=not-requested"),2537+ }24932538 println!(24942539 "gamepad_smoke={}",24952540 if args.gamepad_smoke {@@ -2564,16 +2609,37 @@ fn pointer_smoke_events(
25642609 ) -> Result<[InputEvent; 3]> {25652610 let dimensions = pointer_surface_dimensions25662611 .context("host did not advertise pointer surface dimensions required by --pointer-smoke")?;2612+ let [sync_position, _motion_position] = pointer_smoke_expected_positions(dimensions)?;25672613 Ok([25682614 InputEvent::MousePositionSync {2569- x: i32::from(dimensions.width / 2),2570- y: i32::from(dimensions.height / 2),2615+ x: sync_position.x,2616+ y: sync_position.y,25712617 },25722618 InputEvent::MouseMoveRelative { dx: 32, dy: 16 },25732619 InputEvent::MouseClick { b: 1 },25742620 ])25752621 }25762622 2623+fn pointer_smoke_expected_positions(2624+ dimensions: PointerSurfaceDimensions,2625+) -> Result<[PointerPosition; 2]> {2626+ let sync_position = PointerPosition::new(2627+ i32::from(dimensions.width / 2),2628+ i32::from(dimensions.height / 2),2629+ )?;2630+ let motion_position = PointerPosition::new(2631+ sync_position2632+ .x2633+ .checked_add(32)2634+ .context("pointer smoke X position overflowed")?,2635+ sync_position2636+ .y2637+ .checked_add(16)2638+ .context("pointer smoke Y position overflowed")?,2639+ )?;2640+ Ok([sync_position, motion_position])2641+}2642+25772643 fn sequence_gap(previous: u64, current: u64) -> Result<u64> {25782644 let Some(expected) = previous.checked_add(1) else {25792645 bail!("media sequence overflowed after {previous}");@@ -2799,28 +2865,96 @@ async fn read_expected_input_ack<R>(
27992865 recv: &mut R,28002866 timeout_seconds: u64,28012867 expected_sequence: u64,2802-) -> Result<sigil_protocol::InputAck>2868+) -> Result<InputAck>28032869 where28042870 R: tokio::io::AsyncRead + Unpin,28052871 {2806- let input_ack = tokio::time::timeout(Duration::from_secs(timeout_seconds.min(5)), async {2807- loop {2808- let input_ack = read_input_ack(recv)2809- .await?2810- .context("host closed before input acknowledgment")?;2811- ensure!(2812- input_ack.sequence <= expected_sequence,2813- "unexpected input acknowledgment sequence {}; expected at most {expected_sequence}",2814- input_ack.sequence2872+ let deadline = tokio::time::Instant::now() + Duration::from_secs(timeout_seconds.min(5));2873+ read_expected_input_ack_before(recv, deadline, expected_sequence).await2874+}2875+2876+async fn read_expected_input_ack_before<R>(2877+ recv: &mut R,2878+ deadline: tokio::time::Instant,2879+ expected_sequence: u64,2880+) -> Result<InputAck>2881+where2882+ R: tokio::io::AsyncRead + Unpin,2883+{2884+ loop {2885+ let input_ack = tokio::time::timeout_at(deadline, read_input_ack(recv))2886+ .await2887+ .context("timed out waiting for input acknowledgment")??2888+ .context("host closed before input acknowledgment")?;2889+ ensure!(2890+ input_ack.sequence <= expected_sequence,2891+ "unexpected input acknowledgment sequence {}; expected at most {expected_sequence}",2892+ input_ack.sequence2893+ );2894+ if input_ack.sequence == expected_sequence {2895+ return Ok(input_ack);2896+ }2897+ }2898+}2899+2900+async fn confirm_pointer_position<S, R>(2901+ send: &mut S,2902+ recv: &mut R,2903+ expected_sequence: &mut u64,2904+ mut input_ack: InputAck,2905+ expected_position: PointerPosition,2906+ timeout: Duration,2907+) -> Result<PointerPosition>2908+where2909+ S: tokio::io::AsyncWrite + Unpin,2910+ R: tokio::io::AsyncRead + Unpin,2911+{2912+ ensure!(2913+ !timeout.is_zero(),2914+ "pointer feedback confirmation timeout must be nonzero"2915+ );2916+ let deadline = tokio::time::Instant::now() + timeout.min(Duration::from_secs(5));2917+ loop {2918+ if input_ack.pointer_position == Some(expected_position) {2919+ return Ok(expected_position);2920+ }2921+ let now = tokio::time::Instant::now();2922+ if now >= deadline {2923+ bail!(2924+ "compositor pointer position did not converge to {},{}; last observed position was {:?}",2925+ expected_position.x,2926+ expected_position.y,2927+ input_ack.pointer_position28152928 );2816- if input_ack.sequence == expected_sequence {2817- break Ok(input_ack);2818- }28192929 }2820- })2821- .await2822- .context("timed out waiting for input acknowledgment")??;2823- Ok(input_ack)2930+ tokio::time::sleep_until(std::cmp::min(now + Duration::from_millis(16), deadline)).await;2931+ if tokio::time::Instant::now() >= deadline {2932+ bail!(2933+ "compositor pointer position did not converge to {},{}; last observed position was {:?}",2934+ expected_position.x,2935+ expected_position.y,2936+ input_ack.pointer_position2937+ );2938+ }2939+ write_input_event(send, &InputEvent::Probe)2940+ .await2941+ .context("writing pointer feedback convergence probe")?;2942+ *expected_sequence = expected_sequence2943+ .checked_add(1)2944+ .context("input acknowledgment sequence overflowed")?;2945+ input_ack = match read_expected_input_ack_before(recv, deadline, *expected_sequence).await {2946+ Ok(input_ack) => input_ack,2947+ Err(error) if tokio::time::Instant::now() >= deadline => {2948+ bail!(2949+ "compositor pointer position did not converge to {},{}; last observed position was {:?}: {error:#}",2950+ expected_position.x,2951+ expected_position.y,2952+ input_ack.pointer_position2953+ );2954+ }2955+ Err(error) => return Err(error),2956+ };2957+ }28242958 }28252959 28262960 @@ -2869,6 +3003,79 @@ mod tests {
28693003 assert!(error.to_string().contains("expected at most 1"));28703004 }28713005 3006+ 3007+ async fn pointer_confirmation_polls_until_the_tracker_reports_the_exact_position() {3008+ let target = PointerPosition::new(1_280, 800).unwrap();3009+ let initial_ack = InputAck {3010+ sequence: 2,3011+ pointer_position: Some(PointerPosition::new(10, 20).unwrap()),3012+ pointer_visible: Some(false),3013+ };3014+ let (client, server) = tokio::io::duplex(512);3015+ let (mut client_recv, mut client_send) = tokio::io::split(client);3016+ let (mut server_recv, mut server_send) = tokio::io::split(server);3017+ let server_task = tokio::spawn(async move {3018+ let event = sigil_protocol::read_input_event(&mut server_recv)3019+ .await3020+ .unwrap()3021+ .unwrap();3022+ assert_eq!(event, InputEvent::Probe);3023+ sigil_protocol::write_input_ack(3024+ &mut server_send,3025+ &InputAck {3026+ sequence: 3,3027+ pointer_position: Some(target),3028+ pointer_visible: Some(false),3029+ },3030+ )3031+ .await3032+ .unwrap();3033+ });3034+ let mut expected_sequence = 2;3035+3036+ let observed = confirm_pointer_position(3037+ &mut client_send,3038+ &mut client_recv,3039+ &mut expected_sequence,3040+ initial_ack,3041+ target,3042+ Duration::from_secs(1),3043+ )3044+ .await3045+ .unwrap();3046+3047+ assert_eq!(observed, target);3048+ assert_eq!(expected_sequence, 3);3049+ server_task.await.unwrap();3050+ }3051+3052+ 3053+ async fn pointer_confirmation_fails_when_the_tracker_never_converges() {3054+ let target = PointerPosition::new(1_280, 800).unwrap();3055+ let initial_ack = InputAck {3056+ sequence: 2,3057+ pointer_position: Some(PointerPosition::new(10, 20).unwrap()),3058+ pointer_visible: Some(false),3059+ };3060+ let (client, server_guard) = tokio::io::duplex(512);3061+ let (mut client_recv, mut client_send) = tokio::io::split(client);3062+ let mut expected_sequence = 2;3063+3064+ let error = confirm_pointer_position(3065+ &mut client_send,3066+ &mut client_recv,3067+ &mut expected_sequence,3068+ initial_ack,3069+ target,3070+ Duration::from_millis(25),3071+ )3072+ .await3073+ .unwrap_err();3074+3075+ assert!(error.to_string().contains("did not converge to 1280,800"));3076+ drop(server_guard);3077+ }3078+28723079 28733080 fn feedback_report_is_complete_and_keeps_the_observed_sequence() {28743081 let report = complete_feedback_report(7, MediaFeedbackFlags::NONE, 42);@@ -2922,6 +3129,13 @@ mod tests {
29223129 InputEvent::MouseClick { b: 1 },29233130 ]29243131 );3132+ assert_eq!(3133+ pointer_smoke_expected_positions(dimensions).unwrap(),3134+ [3135+ PointerPosition::new(1_280, 800).unwrap(),3136+ PointerPosition::new(1_312, 816).unwrap(),3137+ ]3138+ );29253139 }29263140 29273141 @@ -51,6 +51,12 @@ enum PointerReportEvent {
5151 }5252 5353 54+/// Emulate an absolute position on the relative-only appliance pointer.55+///56+/// This is correct only when Gamescope clamps the first report at the surface57+/// origin and applies neither acceleration nor scaling to either report. The58+/// hardware UAT verifies both the synchronized position and one subsequent59+/// relative delta through the Xwayland pointer tracker.5460 fn pointer_position_sync_report(x: i32, y: i32) -> [PointerReportEvent; 6] {5561 [5662 PointerReportEvent::RelativeX(sigil_protocol::RELATIVE_POINTER_DELTA_MIN),@@ -1572,11 +1572,20 @@ target/debug/sigil-probe \
15721572 15731573 The probe must report `pointer_smoke=ok` and `pointer_feedback_smoke=ok`.15741574 The feedback check requires an immediately available compositor position and1575-visibility sample; an input acknowledgment with unavailable coordinates fails1576-the proof. The pointer smoke uses the native pointer-surface1577-dimensions negotiated in the media `HostHello`, not the potentially downscaled1578-encoded dimensions. On a 2560x1600 Gamescope surface, before the ordinary1579-motion, evtest must show the synchronization sequence:1575+visibility sample, then polls bounded input acknowledgments until the existing1576+Xwayland tracker reports the exact post-sync and post-motion coordinates. An1577+unavailable position, a timeout, or any coordinate drift fails the proof. This1578+asserts the appliance invariant that Gamescope clamps the first relative jump1579+at the surface origin and applies neither acceleration nor scaling to the1580+virtual pointer. A generic libinput desktop profile is not assumed to satisfy1581+that invariant.1582+1583+The pointer smoke uses the native pointer-surface dimensions negotiated in the1584+media `HostHello`, not the potentially downscaled encoded dimensions. On a1585+2560x1600 Gamescope surface, it must report1586+`pointer_sync_position=1280,800` and1587+`pointer_motion_position=1312,816`. Before the ordinary motion, evtest must1588+show the synchronization sequence:15801589 `REL_X=-32767`/`REL_Y=-32767`, a `SYN_REPORT`, then15811590 `REL_X=1280`/`REL_Y=800` and another `SYN_REPORT`. It must then show15821591 `REL_X=32`, `REL_Y=16`, and a complete `BTN_LEFT` press/release, while libinput@@ -748,6 +748,8 @@ run_probe_cycle() {
748748 if [[ "$pointer_feedback_smoke" == true ]]; then749749 grep -Fxq pointer_smoke=ok "$log"750750 grep -Fxq pointer_feedback_smoke=ok "$log"751+ grep -Eq '^pointer_sync_position=[0-9]+,[0-9]+$' "$log"752+ grep -Eq '^pointer_motion_position=[0-9]+,[0-9]+$' "$log"751753 fi752754 }753755