fix(pointer): support portable Gamescope feedback
- commit
- f41e3896d871edfe59c7f0a13c2f41658800268e
- parent
- 964ce0a422f5b5e233dfd88b24f7b3aa135a4358
- parent
- 271b029a3d1b3dd39561f9d50ab977150384544b
- author
- FelineStateMachine <dami.etoile@gmail.com>
| M | AGENTS.md | 3 | + |
| M | crates/ | 6 | +- |
| M | crates/ | 345 | ++++++++++++++++++++++++------ |
| M | docs/ | 43 | +++- |
@@ -134,6 +134,9 @@ Sigil host.
134134 measured end to end. Do not select codecs by compression ratio alone.135135 - Preserve native pointer coordinates independently from encoded resolution or136136 client window size.137+- Map a differently shaped focused Xwayland root through a centered aspect-fit138+ region in the native pointer surface; never stretch pointer axes139+ independently.137140 - Session teardown must release all held input transitions and emit a neutral138141 gamepad state even after an error or disconnect.139142 @@ -134,8 +134,10 @@ pub struct GamescopePipewireConfig {
134134 /// Optional additional exact PipeWire property matches, for example a stable GPU identity.135135 136136 pub match_properties: BTreeMap<String, String>,137- /// Bootstrap Gamescope Xwayland display used to discover the compositor's138- /// current mouse-focus display. Absence disables cursor feedback.137+ /// Bootstrap Gamescope Xwayland display. When Gamescope exposes its138+ /// mouse-focus property Sigil follows the active Xwayland; otherwise139+ /// pointer feedback remains bounded to this configured display. Absence140+ /// disables cursor feedback.139141 140142 pub xwayland_display: Option<String>,141143 /// Absolute paths avoid PATH-dependent executable selection in the daemon.@@ -162,7 +162,7 @@ impl PointerPositionTracker {
162162 .context("neither gamescope_pipewire.xwayland_display nor DISPLAY is configured")?,163163 };164164 validate_display_name(&bootstrap_display)?;165- let bootstrap = connect_bootstrap_display(&bootstrap_display)?;165+ let mut bootstrap = connect_bootstrap_display(&bootstrap_display)?;166166 anyhow::ensure!(167167 bootstrap.pointer_surface_dimensions == pointer_surface_dimensions,168168 "bootstrap Gamescope pointer surface is {}x{}, but capture preflight resolved {}x{}",@@ -171,10 +171,10 @@ impl PointerPositionTracker {
171171 pointer_surface_dimensions.width,172172 pointer_surface_dimensions.height173173 );174- let focus_display =175- query_focus_display(&bootstrap.connection, bootstrap.root, bootstrap.focus_atom)176- .context("querying initial Gamescope mouse-focus display")?;177- let active = connect_active_display(&focus_display)174+ let focus_display = bootstrap175+ .query_focus_display()176+ .context("querying initial Gamescope mouse-focus display")?;177+ let mut active = connect_active_display(&focus_display)178178 .context("connecting to active Gamescope Xwayland")?;179179 let initial_position = query_pointer_position(180180 &active.connection,@@ -182,7 +182,8 @@ impl PointerPositionTracker {
182182 bootstrap.pointer_surface_dimensions,183183 )184184 .context("querying initial Gamescope Xwayland pointer position")?;185- let initial = available_pointer_state(initial_position, query_cursor_visible(&active).ok());185+ let initial =186+ available_pointer_state(initial_position, active.query_cursor_visible_bounded());186187 let (latest, _) = tokio::sync::watch::channel(initial);187188 let stop = Arc::new(AtomicBool::new(false));188189 let thread_latest = latest.clone();@@ -203,24 +204,20 @@ impl PointerPositionTracker {
203204 continue;204205 }205206 match connect_bootstrap_display(&bootstrap_display) {206- Ok(reconnected) => {207- let focus_display = query_focus_display(208- &reconnected.connection,209- reconnected.root,210- reconnected.focus_atom,211- );207+ Ok(mut reconnected) => {208+ let focus_display = reconnected.query_focus_display();212209 let recovered = focus_display.and_then(|focus_display| {213- let active = connect_active_display(&focus_display)?;210+ let mut active = connect_active_display(&focus_display)?;214211 let position = query_pointer_position(215212 &active.connection,216213 active.root,217214 reconnected.pointer_surface_dimensions,218215 )?;219- Ok((focus_display, active, position))216+ let visible = active.query_cursor_visible_bounded();217+ Ok((focus_display, active, position, visible))220218 });221219 match recovered {222- Ok((focus_display, active, position)) => {223- let visible = query_cursor_visible(&active).ok();220+ Ok((focus_display, active, position, visible)) => {224221 let pointer_surface_dimensions =225222 reconnected.pointer_surface_dimensions;226223 bootstrap = Some(reconnected);@@ -267,13 +264,9 @@ impl PointerPositionTracker {
267264 continue;268265 }269266 let bootstrap_connection = bootstrap270- .as_ref()267+ .as_mut()271268 .expect("bootstrap connection was checked above");272- let focus_display = match query_focus_display(273- &bootstrap_connection.connection,274- bootstrap_connection.root,275- bootstrap_connection.focus_atom,276- ) {269+ let focus_display = match bootstrap_connection.query_focus_display() {277270 Ok(display) => display,278271 Err(error) => {279272 tracing::warn!(@@ -294,7 +287,7 @@ impl PointerPositionTracker {
294287 active_connection = connect_active_display(&active_display).ok();295288 activity_visibility.reset();296289 }297- let Some(active) = active_connection.as_ref() else {290+ let Some(active) = active_connection.as_mut() else {298291 publish_unavailable(&thread_latest);299292 continue;300293 };@@ -306,7 +299,7 @@ impl PointerPositionTracker {
306299 Ok(position) => {307300 let visible = activity_visibility.resolve(308301 position,309- query_cursor_visible(active).ok(),302+ active.query_cursor_visible_bounded(),310303 Instant::now(),311304 );312305 let state = available_pointer_state(position, Some(visible));@@ -367,9 +360,11 @@ fn connect_display(display: &str) -> Result<(x11rb::rust_connection::RustConnect
367360 368361 369362 struct BootstrapXwayland {363+ display: String,370364 connection: x11rb::rust_connection::RustConnection,371365 root: u32,372- focus_atom: u32,366+ focus_atom: Option<u32>,367+ focus_fallback_reported: bool,373368 pointer_surface_dimensions: PointerSurfaceDimensions,374369 }375370 @@ -380,7 +375,10 @@ fn connect_bootstrap_display(display: &str) -> Result<BootstrapXwayland> {
380375 let (connection, root) =381376 connect_display(display).context("connecting to bootstrap Gamescope Xwayland")?;382377 let focus_atom = connection383- .intern_atom(false, FOCUS_DISPLAY_PROPERTY)378+ // SteamOS and older Gamescope builds may not publish this Bazzite-era379+ // extension. Do not create the atom and mistake its existence for380+ // compositor support.381+ .intern_atom(true, FOCUS_DISPLAY_PROPERTY)384382 .context("interning Gamescope mouse-focus property")?385383 .reply()386384 .context("receiving Gamescope mouse-focus atom")?@@ -393,13 +391,54 @@ fn connect_bootstrap_display(display: &str) -> Result<BootstrapXwayland> {
393391 let pointer_surface_dimensions = PointerSurfaceDimensions::new(geometry.width, geometry.height)394392 .context("validating bootstrap Gamescope pointer surface dimensions")?;395393 Ok(BootstrapXwayland {394+ display: display.to_owned(),396395 connection,397396 root,398- focus_atom,397+ focus_atom: (focus_atom != x11rb::NONE).then_some(focus_atom),398+ focus_fallback_reported: false,399399 pointer_surface_dimensions,400400 })401401 }402402 403+404+impl BootstrapXwayland {405+ fn query_focus_display(&mut self) -> Result<String> {406+ let discovered = match self.focus_atom {407+ Some(focus_atom) => query_focus_display(&self.connection, self.root, focus_atom)?,408+ None => None,409+ };410+ let (focused_display, used_fallback) = select_focus_display(&self.display, discovered);411+ if !used_fallback {412+ if self.focus_fallback_reported {413+ tracing::info!(414+ display = %self.display,415+ focus_display = %focused_display,416+ "Gamescope mouse-focus discovery recovered"417+ );418+ }419+ self.focus_fallback_reported = false;420+ } else if !self.focus_fallback_reported {421+ tracing::warn!(422+ display = %self.display,423+ "Gamescope mouse-focus property is unavailable; pointer feedback is bounded to the configured Xwayland display"424+ );425+ self.focus_fallback_reported = true;426+ }427+ Ok(focused_display)428+ }429+}430+431+432+fn select_focus_display(433+ configured_display: &str,434+ discovered_display: Option<String>,435+) -> (String, bool) {436+ match discovered_display {437+ Some(discovered_display) => (discovered_display, false),438+ None => (configured_display.to_owned(), true),439+ }440+}441+403442 404443 fn publish_unavailable(latest: &tokio::sync::watch::Sender<PointerState>) {405444 latest.send_if_modified(|latest| {@@ -414,9 +453,11 @@ fn publish_unavailable(latest: &tokio::sync::watch::Sender<PointerState>) {
414453 415454 416455 struct ActiveXwayland {456+ display: String,417457 connection: x11rb::rust_connection::RustConnection,418458 root: u32,419- cursor_visible_atom: u32,459+ cursor_visible_atom: Option<u32>,460+ visibility_fallback_reported: bool,420461 }421462 422463 @@ -425,32 +466,73 @@ fn connect_active_display(display: &str) -> Result<ActiveXwayland> {
425466 426467 let (connection, root) = connect_display(display)?;427468 let cursor_visible_atom = connection428- .intern_atom(false, CURSOR_VISIBLE_PROPERTY)469+ .intern_atom(true, CURSOR_VISIBLE_PROPERTY)429470 .context("interning Gamescope cursor-visibility property")?430471 .reply()431472 .context("receiving Gamescope cursor-visibility atom")?432473 .atom;433474 Ok(ActiveXwayland {475+ display: display.to_owned(),434476 connection,435477 root,436- cursor_visible_atom,478+ cursor_visible_atom: (cursor_visible_atom != x11rb::NONE).then_some(cursor_visible_atom),479+ visibility_fallback_reported: false,437480 })438481 }439482 440483 441-fn query_cursor_visible(active: &ActiveXwayland) -> Result<bool> {484+impl ActiveXwayland {485+ fn query_cursor_visible_bounded(&mut self) -> Option<bool> {486+ match self487+ .cursor_visible_atom488+ .map(|atom| query_cursor_visible(&self.connection, self.root, atom))489+ .transpose()490+ {491+ Ok(Some(visible)) => {492+ if self.visibility_fallback_reported {493+ tracing::info!(494+ display = %self.display,495+ "Gamescope cursor-visibility feedback recovered"496+ );497+ }498+ self.visibility_fallback_reported = false;499+ Some(visible)500+ }501+ Ok(None) => {502+ if !self.visibility_fallback_reported {503+ tracing::warn!(504+ display = %self.display,505+ "Gamescope cursor-visibility property is unavailable; visibility will follow bounded pointer activity"506+ );507+ self.visibility_fallback_reported = true;508+ }509+ None510+ }511+ Err(error) => {512+ if !self.visibility_fallback_reported {513+ tracing::warn!(514+ display = %self.display,515+ %error,516+ "Gamescope cursor-visibility property is invalid; visibility will follow bounded pointer activity"517+ );518+ self.visibility_fallback_reported = true;519+ }520+ None521+ }522+ }523+ }524+}525+526+527+fn query_cursor_visible(528+ connection: &x11rb::rust_connection::RustConnection,529+ root: u32,530+ cursor_visible_atom: u32,531+) -> Result<bool> {442532 use x11rb::protocol::xproto::{AtomEnum, ConnectionExt};443533 444- let reply = active445- .connection446- .get_property(447- false,448- active.root,449- active.cursor_visible_atom,450- AtomEnum::CARDINAL,451- 0,452- 1,453- )534+ let reply = connection535+ .get_property(false, root, cursor_visible_atom, AtomEnum::CARDINAL, 0, 1)454536 .context("sending Gamescope cursor-visibility property query")?455537 .reply()456538 .context("receiving Gamescope cursor-visibility property")?;@@ -485,7 +567,7 @@ fn query_focus_display(
485567 connection: &x11rb::rust_connection::RustConnection,486568 root: u32,487569 focus_atom: u32,488-) -> Result<String> {570+) -> Result<Option<String>> {489571 use x11rb::protocol::xproto::{AtomEnum, ConnectionExt};490572 491573 let reply = connection@@ -493,11 +575,19 @@ fn query_focus_display(
493575 .context("sending Gamescope mouse-focus property query")?494576 .reply()495577 .context("receiving Gamescope mouse-focus property")?;578+ parse_optional_focus_display(reply.format, &reply.value)579+}580+581+582+fn parse_optional_focus_display(format: u8, value: &[u8]) -> Result<Option<String>> {583+ if format == 0 && value.is_empty() {584+ return Ok(None);585+ }496586 anyhow::ensure!(497- reply.format == 32,587+ format == 32,498588 "Gamescope mouse-focus property is not CARDINAL/32"499589 );500- parse_focus_display(&reply.value)590+ parse_focus_display(value).map(Some)501591 }502592 503593 @@ -535,6 +625,10 @@ fn query_pointer_position(
535625 .context("sending X11 QueryPointer")?536626 .reply()537627 .context("receiving X11 QueryPointer reply")?;628+ anyhow::ensure!(629+ reply.same_screen && reply.root == root,630+ "Xwayland pointer is not on the queried active root"631+ );538632 map_pointer_position(539633 geometry.width,540634 geometry.height,@@ -546,11 +640,20 @@ fn query_pointer_position(
546640 }547641 548642 549-fn validate_root_geometry(643+644+struct PointerSurfaceRegion {645+ offset_x: u16,646+ offset_y: u16,647+ width: u16,648+ height: u16,649+}650+651+652+fn pointer_surface_region(550653 width: u16,551654 height: u16,552655 pointer_surface_dimensions: PointerSurfaceDimensions,553-) -> Result<()> {656+) -> Result<PointerSurfaceRegion> {554657 pointer_surface_dimensions555658 .validate()556659 .context("validating captured pointer surface dimensions")?;@@ -559,19 +662,46 @@ fn validate_root_geometry(
559662 "active Xwayland root dimensions must be non-zero"560663 );561664 562- // Widen before cross multiplication so every pair of u16 dimensions is563- // overflow-safe. Exact aspect equality prevents a focus switch from564- // silently stretching pointer coordinates into a differently shaped565- // captured surface.566- let root_aspect = u64::from(width) * u64::from(pointer_surface_dimensions.height);567- let surface_aspect = u64::from(height) * u64::from(pointer_surface_dimensions.width);665+ // Gamescope centers a focused game surface while preserving its aspect666+ // ratio. Reproduce that transform in the compositor's native pointer667+ // coordinate space: independent axis stretching would make the overlay668+ // disagree with the game whenever the active Xwayland root is letterboxed.669+ let root_width = u64::from(width);670+ let root_height = u64::from(height);671+ let surface_width = u64::from(pointer_surface_dimensions.width);672+ let surface_height = u64::from(pointer_surface_dimensions.height);673+ let root_is_wider = root_width * surface_height >= root_height * surface_width;674+ let (fitted_width, fitted_height) = if root_is_wider {675+ (676+ surface_width,677+ root_height678+ .checked_mul(surface_width)679+ .context("pointer surface height scaling overflowed")?680+ / root_width,681+ )682+ } else {683+ (684+ root_width685+ .checked_mul(surface_height)686+ .context("pointer surface width scaling overflowed")?687+ / root_height,688+ surface_height,689+ )690+ };568691 anyhow::ensure!(569- root_aspect == surface_aspect,570- "active Xwayland root is {width}x{height}, but the captured pointer surface {}x{} has a different aspect ratio",571- pointer_surface_dimensions.width,572- pointer_surface_dimensions.height692+ fitted_width > 0 && fitted_height > 0,693+ "active Xwayland root aspect cannot be represented on the captured pointer surface"573694 );574- Ok(())695+ let fitted_width =696+ u16::try_from(fitted_width).context("fitted pointer surface width exceeds u16")?;697+ let fitted_height =698+ u16::try_from(fitted_height).context("fitted pointer surface height exceeds u16")?;699+ Ok(PointerSurfaceRegion {700+ offset_x: (pointer_surface_dimensions.width - fitted_width) / 2,701+ offset_y: (pointer_surface_dimensions.height - fitted_height) / 2,702+ width: fitted_width,703+ height: fitted_height,704+ })575705 }576706 577707 @@ -582,9 +712,9 @@ fn map_pointer_position(
582712 root_y: i32,583713 pointer_surface_dimensions: PointerSurfaceDimensions,584714 ) -> Result<PointerPosition> {585- validate_root_geometry(root_width, root_height, pointer_surface_dimensions)?;586- let x = map_pointer_axis(root_x, root_width, pointer_surface_dimensions.width, "x")?;587- let y = map_pointer_axis(root_y, root_height, pointer_surface_dimensions.height, "y")?;715+ let region = pointer_surface_region(root_width, root_height, pointer_surface_dimensions)?;716+ let x = map_pointer_axis(root_x, root_width, region.offset_x, region.width, "x")?;717+ let y = map_pointer_axis(root_y, root_height, region.offset_y, region.height, "y")?;588718 PointerPosition::new(x, y).context("scaled pointer position is outside the protocol range")589719 }590720 @@ -592,6 +722,7 @@ fn map_pointer_position(
592722 fn map_pointer_axis(593723 coordinate: i32,594724 root_extent: u16,725+ surface_offset: u16,595726 surface_extent: u16,596727 axis: &str,597728 ) -> Result<i32> {@@ -603,12 +734,15 @@ fn map_pointer_axis(
603734 coordinate < root_extent,604735 "Xwayland {axis} pointer coordinate is outside the active root"605736 );606- let scaled = coordinate737+ let scaled_within_region = coordinate607738 .checked_mul(surface_extent)608739 .context("pointer coordinate scaling overflowed")?609740 / root_extent;741+ let scaled = u64::from(surface_offset)742+ .checked_add(scaled_within_region)743+ .context("pointer surface offset overflowed")?;610744 anyhow::ensure!(611- scaled < surface_extent,745+ scaled_within_region < surface_extent,612746 "scaled {axis} pointer coordinate is outside the captured surface"613747 );614748 i32::try_from(scaled).context("scaled pointer coordinate exceeds the protocol integer range")@@ -626,6 +760,13 @@ mod tests {
626760 627761 628762 fn parses_gamescope_cardinal_string_abi_fail_closed() {763+ assert_eq!(parse_optional_focus_display(0, &[]).unwrap(), None);764+ assert_eq!(765+ parse_optional_focus_display(32, &[b':', b'0', 0, 0]).unwrap(),766+ Some(":0".to_owned())767+ );768+ assert!(parse_optional_focus_display(8, b":0").is_err());769+ assert!(parse_optional_focus_display(0, b":0").is_err());629770 assert_eq!(parse_focus_display(&[b':', b'0', 0, 0]).unwrap(), ":0");630771 assert_eq!(631772 parse_focus_display(&[b':', b'1', 0, 0, 78, 0, 0, 0]).unwrap(),@@ -636,11 +777,28 @@ mod tests {
636777 assert!(parse_focus_display(b":0").is_err());637778 }638779 780+ 781+ fn missing_gamescope_focus_extension_is_bounded_to_configured_display() {782+ assert_eq!(select_focus_display(":0", None), (":0".to_owned(), true));783+ assert_eq!(784+ select_focus_display(":0", Some(":1".to_owned())),785+ (":1".to_owned(), false)786+ );787+ }788+639789 640790 fn xwayland_pointer_mapping_is_identity_for_equal_surfaces() {641791 let expected = PointerSurfaceDimensions::new(2_560, 1_600).unwrap();642792 643- validate_root_geometry(2_560, 1_600, expected).unwrap();793+ assert_eq!(794+ pointer_surface_region(2_560, 1_600, expected).unwrap(),795+ PointerSurfaceRegion {796+ offset_x: 0,797+ offset_y: 0,798+ width: 2_560,799+ height: 1_600,800+ }801+ );644802 assert_eq!(645803 map_pointer_position(2_560, 1_600, 212, 140, expected).unwrap(),646804 PointerPosition::new(212, 140).unwrap()@@ -671,12 +829,71 @@ mod tests {
671829 assert_eq!(logical, native_root);672830 }673831 832+ 833+ fn wide_xwayland_root_maps_into_centered_letterbox_region() {834+ let native = PointerSurfaceDimensions::new(2_560, 1_600).unwrap();835+836+ assert_eq!(837+ pointer_surface_region(1_280, 720, native).unwrap(),838+ PointerSurfaceRegion {839+ offset_x: 0,840+ offset_y: 80,841+ width: 2_560,842+ height: 1_440,843+ }844+ );845+ assert_eq!(846+ map_pointer_position(1_280, 720, 0, 0, native).unwrap(),847+ PointerPosition::new(0, 80).unwrap()848+ );849+ assert_eq!(850+ map_pointer_position(1_280, 720, 640, 360, native).unwrap(),851+ PointerPosition::new(1_280, 800).unwrap()852+ );853+ assert_eq!(854+ map_pointer_position(1_280, 720, 1_279, 719, native).unwrap(),855+ PointerPosition::new(2_558, 1_518).unwrap()856+ );857+ }858+859+ 860+ fn narrow_xwayland_root_maps_into_centered_pillarbox_region() {861+ let native = PointerSurfaceDimensions::new(2_560, 1_600).unwrap();862+863+ assert_eq!(864+ pointer_surface_region(1_024, 768, native).unwrap(),865+ PointerSurfaceRegion {866+ offset_x: 213,867+ offset_y: 0,868+ width: 2_133,869+ height: 1_600,870+ }871+ );872+ assert_eq!(873+ map_pointer_position(1_024, 768, 0, 0, native).unwrap(),874+ PointerPosition::new(213, 0).unwrap()875+ );876+ assert_eq!(877+ map_pointer_position(1_024, 768, 512, 384, native).unwrap(),878+ PointerPosition::new(1_279, 800).unwrap()879+ );880+ }881+674882 675883 fn pointer_mapping_rejects_invalid_geometry_and_coordinates() {676884 let native = PointerSurfaceDimensions::new(2_560, 1_600).unwrap();677885 678886 assert!(map_pointer_position(0, 800, 0, 0, native).is_err());679- assert!(map_pointer_position(1_280, 720, 0, 0, native).is_err());887+ assert!(888+ map_pointer_position(889+ u16::MAX,890+ 1,891+ 0,892+ 0,893+ PointerSurfaceDimensions::new(64, 64).unwrap(),894+ )895+ .is_err()896+ );680897 assert!(map_pointer_position(1_280, 800, -1, 0, native).is_err());681898 assert!(map_pointer_position(1_280, 800, 1_280, 0, native).is_err());682899 assert!(map_pointer_position(1_280, 800, 0, 800, native).is_err());@@ -1205,21 +1205,36 @@ acknowledged only after exact property readback. CQP remains on the external
12051205 compatibility backend until its in-process force-keyframe path is implemented12061206 and proven separately.12071207 1208-The configured `:0` is a bootstrap connection, not a fixed input target.1209-Gamescope may move mouse focus between its `:0` and `:1` Xwayland servers.1210-Sigil reads `GAMESCOPE_MOUSE_FOCUS_DISPLAY` from the bootstrap root, reconnects1211-to the active local display, and samples `QueryPointer` at no more than 60 Hz.1212-It also reads `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK` from that active root so the1213-client overlay disappears when Gamescope hides its cursor. Missing, malformed,1214-or unreachable Xwayland state during startup disables the separately1215-negotiated pointer feedback capability. After successful startup, losing1216-Gamescope publishes pointer feedback as unavailable, then reconnects the1208+The configured `:0` is the bootstrap connection. Gamescope builds that publish1209+`GAMESCOPE_MOUSE_FOCUS_DISPLAY` may move mouse focus between their `:0` and1210+`:1` Xwayland servers; Sigil follows that property and reconnects to the active1211+local display. Upstream SteamOS and older Gamescope builds need not ship this1212+extension. Sigil detects that absence without creating the atom, logs the1213+degraded discovery mode, and safely samples only the explicitly configured1214+display instead of disabling pointer feedback.1215+1216+Sigil samples `QueryPointer` at no more than 60 Hz. If the active Xwayland root1217+has a different aspect ratio than the native captured surface, Sigil maps it1218+into a centered aspect-fit region (letterbox or pillarbox) so coordinates stay1219+in the compositor's native pointer space without independent-axis stretching.1220+Zero-sized, unrepresentable, off-root, or cross-screen geometry still fails1221+closed.1222+1223+Gamescope builds that publish `GAMESCOPE_CURSOR_VISIBLE_FEEDBACK` provide the1224+compositor's visibility sample. Authoritative pointer motion deliberately keeps1225+the client cursor visible for a bounded three seconds even if that sample is1226+temporarily false, recovering Gamescope's virtual cursor after remote input.1227+When the optional extension is absent or malformed, Sigil logs the fallback and1228+uses that same bounded motion window without inventing longer-lived visibility.1229+Missing or unreachable Xwayland state during startup still disables the1230+separately negotiated pointer feedback capability. After successful startup,1231+losing Gamescope publishes pointer feedback as unavailable, then reconnects the12171232 bootstrap display with a bounded 100 ms to 2 second backoff. A complete1218-reconnect re-interns both Gamescope atoms, discovers the replacement native1219-surface, and validates one active-display sample before publishing coordinates1220-again. Relative uinput remains available without guessed cursor coordinates.1221-The service `DISPLAY=:0` line is retained as an explicit fallback for1222-configurations created before `xwayland_display` was added.1233+reconnect re-detects both optional Gamescope properties, validates one1234+active-display sample, and only then publishes coordinates again. Relative1235+uinput remains available without guessed cursor coordinates. The service1236+`DISPLAY=:0` line is retained as an explicit fallback for configurations1237+created before `xwayland_display` was added.12231238 12241239 Audio is optional and must resolve one exact PipeWire sink, never a microphone.12251240 The appliance owns a persistent 48 kHz stereo null sink so capture does not