diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a4dbed..7c0aab9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,7 @@ env: -p frameclock_wayland -p frameclock_web -p frameclock_windows + -p frameclock_x11 -p mediaclock -p subduction_backend_android -p subduction_backend_apple @@ -33,6 +34,7 @@ env: --exclude frameclock_simulated --exclude frameclock_wayland --exclude frameclock_windows + --exclude frameclock_x11 --exclude macos_layers --exclude macos_lotta_layers --exclude macos_wgpu @@ -63,6 +65,7 @@ env: --exclude frameclock_apple --exclude frameclock_wayland --exclude frameclock_windows + --exclude frameclock_x11 --exclude macos_layers --exclude macos_lotta_layers --exclude macos_wgpu @@ -71,6 +74,7 @@ env: --exclude subduction_backend_web --exclude subduction_backend_wgpu --exclude subduction_backend_windows + --exclude subduction_backend_x11 --exclude wayland_example_common --exclude wayland_layers --exclude wayland_lotta_layers @@ -88,12 +92,14 @@ env: --exclude frameclock_apple --exclude frameclock_wayland --exclude frameclock_windows + --exclude frameclock_x11 --exclude macos_layers --exclude macos_lotta_layers --exclude macos_wgpu --exclude subduction_backend_apple --exclude subduction_backend_windows --exclude subduction_backend_wayland + --exclude subduction_backend_x11 --exclude trace_demo --exclude wayland_example_common --exclude wayland_layers @@ -117,7 +123,9 @@ env: # Packages excluded on non-Linux runners (Linux-only). RUST_LINUX_EXCLUDE_PKGS: >- --exclude frameclock_wayland + --exclude frameclock_x11 --exclude subduction_backend_wayland + --exclude subduction_backend_x11 --exclude wayland_example_common --exclude wayland_layers --exclude wayland_lotta_layers @@ -332,6 +340,15 @@ jobs: with: save-if: ${{ github.event_name != 'merge_group' }} + - name: install libxcb (Linux) + # `subduction_backend_x11` links the system libxcb through `x11rb`'s + # `XCBConnection`, so linking its test binaries needs the dev package's + # `libxcb.so`. + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libxcb1-dev + - name: cargo nextest (Linux) if: runner.os == 'Linux' run: cargo nextest run ${{ env.RUST_MAC_EXCLUDE_PKGS }} ${{ env.RUST_WIN_EXCLUDE_PKGS }} --workspace --locked --all-features --no-fail-fast diff --git a/Cargo.lock b/Cargo.lock index e1a6688..2b92b56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -498,6 +498,14 @@ dependencies = [ "windows", ] +[[package]] +name = "frameclock_x11" +version = "0.0.1" +dependencies = [ + "frameclock", + "rustix 1.1.4", +] + [[package]] name = "futures-core" version = "0.3.32" @@ -1894,7 +1902,13 @@ dependencies = [ name = "subduction_backend_x11" version = "0.0.1" dependencies = [ + "frameclock", + "frameclock_x11", + "pollster", + "subduction_backend_wgpu", "subduction_core", + "wgpu", + "x11rb 0.14.0", ] [[package]] @@ -2883,7 +2897,7 @@ dependencies = [ "web-time", "windows-sys 0.52.0", "x11-dl", - "x11rb", + "x11rb 0.13.2", "xkbcommon-dl", ] @@ -2935,7 +2949,20 @@ dependencies = [ "libloading", "once_cell", "rustix 1.1.4", - "x11rb-protocol", + "x11rb-protocol 0.13.2", +] + +[[package]] +name = "x11rb" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a8885a854a8bfdf87a301e53e41b17c5f8f33639903131338b997b1eb614f44" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "rustix 1.1.4", + "x11rb-protocol 0.14.0", ] [[package]] @@ -2944,6 +2971,12 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" +[[package]] +name = "x11rb-protocol" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acf4d1bc32aa46eec18caa634ec3cf4c05bfa151f12b93b510b15190f69a1ca8" + [[package]] name = "xcursor" version = "0.3.10" diff --git a/Cargo.toml b/Cargo.toml index 6bbc617..df371c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "frameclock_web", "frameclock_apple", "frameclock_wayland", + "frameclock_x11", "frameclock_windows", "subduction_backend_apple", "subduction_backend_web", @@ -70,9 +71,11 @@ frameclock = { version = "0.0.1", path = "frameclock" } mediaclock = { version = "0.0.1", path = "mediaclock" } frameclock_apple = { version = "0.0.1", path = "frameclock_apple", default-features = false } frameclock_wayland = { version = "0.0.1", path = "frameclock_wayland" } +frameclock_x11 = { version = "0.0.1", path = "frameclock_x11" } frameclock_windows = { version = "0.0.1", path = "frameclock_windows" } frameclock_web = { version = "0.0.1", path = "frameclock_web" } subduction_core = { path = "subduction_core" } +subduction_backend_wgpu = { path = "subduction_backend_wgpu" } subduction_sync_harness = { path = "subduction_sync_harness" } invalidation = "0.2.0" kurbo = { version = "0.13", default-features = false, features = ["libm"] } diff --git a/frameclock_x11/CHANGELOG.md b/frameclock_x11/CHANGELOG.md new file mode 100644 index 0000000..bb32ac3 --- /dev/null +++ b/frameclock_x11/CHANGELOG.md @@ -0,0 +1,17 @@ + + +# Changelog + +## [Unreleased] + +This release has an [MSRV][] of 1.92. + +This is the initial release. + +[MSRV]: README.md#minimum-supported-rust-version-msrv diff --git a/frameclock_x11/Cargo.toml b/frameclock_x11/Cargo.toml new file mode 100644 index 0000000..0ec1af0 --- /dev/null +++ b/frameclock_x11/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "frameclock_x11" +description = "X11 timing adapters for frameclock (Present extension, MSC/UST)" +readme = "README.md" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +keywords = ["frame-pacing", "timing", "x11", "present-extension", "linux"] +categories = ["os::unix-apis", "rendering::engine"] + +[lints] +workspace = true + +[dependencies] +frameclock = { workspace = true } +rustix = { version = "1.1.3", default-features = false, features = ["time"] } diff --git a/frameclock_x11/LICENSE-APACHE b/frameclock_x11/LICENSE-APACHE new file mode 100644 index 0000000..d9a10c0 --- /dev/null +++ b/frameclock_x11/LICENSE-APACHE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/frameclock_x11/LICENSE-MIT b/frameclock_x11/LICENSE-MIT new file mode 100644 index 0000000..9cf1062 --- /dev/null +++ b/frameclock_x11/LICENSE-MIT @@ -0,0 +1,19 @@ +MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/frameclock_x11/README.md b/frameclock_x11/README.md new file mode 100644 index 0000000..b00aeff --- /dev/null +++ b/frameclock_x11/README.md @@ -0,0 +1,49 @@ +
+ +# Frameclock X11 + +**X11 timing adapters for `frameclock`.** + +
+ +`frameclock_x11` connects X11 frame timing to `frameclock`, built on the +Present extension with no pre-Present fallbacks. A `PresentCompleteNotify` +event carries both a vblank tick and its `ust`/`msc` (unadjusted system time +in microseconds, and the media stream counter); this crate turns those events +into `frameclock::FrameTick` values and carries per-pixmap Present feedback as +`PresentEvent` values. + +The crate owns only the timing bookkeeping. It references no `x11rb` type and +performs no protocol I/O: a host or backend decodes Present events and feeds +the `ust`/`msc` values in. + +## Core Flow + +```text +PresentNotifyMSC request -> PresentTicker (claims the in-flight slot) +PresentCompleteNotify (msc) -> PresentTicker -> FrameTick +PresentCompleteNotify (pixmap) -> PresentEvent -> PresentEventQueue +``` + +See the crate-level documentation for the `ust` clock domain, the +`PresentNotifyMSC` arming loop, and how pacing behaves while a swapchain owner +(for example wgpu/Vulkan) presents. + +## no_std + +This crate keeps its implementation `no_std` (with `alloc`), but reading +clocks requires an operating system. It is validated on Linux targets instead +of the workspace's generic `x86_64-unknown-none` no-std target. + +## Minimum Supported Rust Version (MSRV) + +This crate has been verified to compile with **Rust 1.92** and later. + +## License + +Licensed under either of + +- Apache License, Version 2.0, or +- MIT license, + +at your option. diff --git a/frameclock_x11/src/lib.rs b/frameclock_x11/src/lib.rs new file mode 100644 index 0000000..ddfd738 --- /dev/null +++ b/frameclock_x11/src/lib.rs @@ -0,0 +1,288 @@ +// Copyright 2026 the Frameclock Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! X11 timing adapters for [`frameclock`], built on the Present extension. +//! +//! The crate assumes a server with the Present extension and has no +//! pre-Present fallbacks. It reads the Present unadjusted-system-time clock as +//! [`HostTime`], converts `PresentCompleteNotify` completions into +//! [`FrameTick`] values via [`PresentTicker`], and carries per-pixmap Present +//! feedback as [`PresentEvent`] values. +//! +//! It intentionally does not own X11 windows, connections, event queues, or +//! protocol dispatch. Protocol I/O belongs to hosts and backend crates; this +//! crate owns the timing bookkeeping those hosts feed and poll, and references +//! no `x11rb` type. +//! +//! # Core flow +//! +//! ```text +//! PresentNotifyMSC request -> PresentTicker (claims the in-flight slot) +//! PresentCompleteNotify -> PresentTicker -> FrameTick +//! PresentCompleteNotify(px) -> PresentEvent -> PresentEventQueue (backend path) +//! ``` +//! +//! A host's frame loop has this shape: +//! +//! ```rust,ignore +//! use frameclock::OutputId; +//! use frameclock_x11::{Clock, PresentTicker, frame_opportunity}; +//! +//! let mut ticker = PresentTicker::new(); +//! +//! // Arm the next vblank notify before sending a PresentNotifyMSC request: +//! if ticker.mark_notify_requested() { +//! // present_notify_msc(window, serial, 0, 1, 0) -- notify at the next msc +//! } +//! +//! // When the matching PresentCompleteNotify (kind NotifyMSC) arrives: +//! ticker.on_complete_notify(Clock::Monotonic, event.ust, event.msc); +//! +//! // After dispatch, drain the queued ticks and build opportunities: +//! while let Some(tick) = ticker.poll_tick() { +//! let opportunity = frame_opportunity(tick, fallback_refresh_interval); +//! _ = opportunity; +//! } +//! ``` +//! +//! # The `ust` clock domain +//! +//! Present reports `ust` (unadjusted system time) in microseconds. On Linux with +//! DRI3/Present that is `CLOCK_MONOTONIC`, which [`Clock::Monotonic`] reads in +//! nanoseconds, so `ust`-derived timestamps and clock reads share one domain — +//! the same domain a host should stamp input events in. All [`HostTime`] values +//! are nanosecond ticks. +//! +//! # Pacing while a swapchain presents +//! +//! `PresentNotifyMSC` reads the window's vblank clock without presenting +//! anything, so the tick stream stays vsync-aligned even when a swapchain +//! owner such as wgpu/Vulkan performs the actual scan-out. Per-submission +//! feedback — which vblank a frame landed on, flip vs. copy, buffer idleness — +//! only reaches a presenter that itself submits `PresentPixmap`; the +//! [`PresentEvent`] queue models that feedback for such a presenter. + +#![no_std] + +extern crate alloc; + +mod presentation; +mod queue; +mod tick; +mod time; + +pub use presentation::{PresentEvent, PresentEventQueue, SubmissionId}; +pub use tick::PresentTicker; +pub use time::{Clock, now, timebase, ust_to_host_time}; + +use frameclock::{DisplayTiming, Duration, FrameOpportunity, FrameTick, HostTime, PresentHints}; + +/// Returns the default commit lead for a refresh interval. +/// +/// A predicted present time describes a vblank slot, not a promise that app work +/// can be committed at the last possible tick. Use a small platform-side lead so +/// [`PresentHints::latest_commit`] remains a commit boundary, while `frameclock` +/// still owns learned app build margins. +#[must_use] +pub const fn default_commit_lead(refresh_interval: Duration) -> Duration { + refresh_interval.div_u64(4) +} + +fn refresh_interval_for_tick(tick: &FrameTick, fallback_refresh_interval: Duration) -> Duration { + tick.refresh_interval + .filter(|ticks| *ticks > 0) + .map(Duration) + .unwrap_or(fallback_refresh_interval) +} + +fn commit_boundary(target: HostTime, lead: Duration, floor: HostTime) -> HostTime { + target.checked_sub(lead).unwrap_or(floor).max(floor) +} + +/// Computes [`PresentHints`] from an X11 [`FrameTick`] using the default commit +/// lead. +/// +/// Use [`present_hints_with_commit_lead`] when a host has a platform-specific +/// commit lead estimate. +#[must_use] +pub fn present_hints(tick: &FrameTick, fallback_refresh_interval: Duration) -> PresentHints { + let refresh_interval = refresh_interval_for_tick(tick, fallback_refresh_interval); + present_hints_with_commit_lead( + tick, + fallback_refresh_interval, + default_commit_lead(refresh_interval), + ) +} + +/// Computes [`PresentHints`] from an X11 [`FrameTick`]. +/// +/// A predicted present time derived from Present `ust`/`msc` (see +/// [`PresentTicker`]) is a client-side extrapolation of the vblank grid, so it +/// is reported as estimated timing (`PresentationTiming::Estimated`) rather than +/// predictive. If the prediction is missing or stale, the hint falls back to +/// pacing-only timing with a one-refresh commit boundary. The scheduler applies +/// its own learned build margin later. +#[must_use] +pub fn present_hints_with_commit_lead( + tick: &FrameTick, + fallback_refresh_interval: Duration, + commit_lead: Duration, +) -> PresentHints { + let refresh_interval = refresh_interval_for_tick(tick, fallback_refresh_interval); + if let Some(predicted_present) = tick + .predicted_present + .filter(|predicted_present| *predicted_present >= tick.now) + { + return PresentHints::estimated( + predicted_present, + commit_boundary(predicted_present, commit_lead, tick.now), + ); + } + + let pacing_target = tick + .now + .checked_add(refresh_interval) + .unwrap_or(HostTime(u64::MAX)); + PresentHints::pacing_only(commit_boundary(pacing_target, commit_lead, tick.now)) +} + +/// Returns display timing for an X11 [`FrameTick`]. +/// +/// Prefers [`FrameTick::refresh_interval`] when present (the cadence measured +/// from consecutive `msc` deltas), falling back to the predicted-present delta +/// and finally to `fallback_interval`. The Present extension does not expose a +/// variable-refresh range here, so this always produces fixed-rate timing. +#[must_use] +pub fn display_timing(tick: &FrameTick, fallback_interval: Duration) -> DisplayTiming { + DisplayTiming::from_tick(tick, fallback_interval) +} + +/// Builds a [`FrameOpportunity`] from an X11 Present tick. +/// +/// This pairs the estimated/pacing present hints with display timing derived +/// from the same tick, falling back to `fallback_interval` until a second +/// completion has established a cadence. +#[must_use] +pub fn frame_opportunity(tick: FrameTick, fallback_interval: Duration) -> FrameOpportunity { + let refresh_interval = refresh_interval_for_tick(&tick, fallback_interval); + frame_opportunity_with_commit_lead( + tick, + fallback_interval, + default_commit_lead(refresh_interval), + ) +} + +/// Builds a [`FrameOpportunity`] with an explicit platform commit lead. +#[must_use] +pub fn frame_opportunity_with_commit_lead( + tick: FrameTick, + fallback_interval: Duration, + commit_lead: Duration, +) -> FrameOpportunity { + let hints = present_hints_with_commit_lead(&tick, fallback_interval, commit_lead); + let display_timing = display_timing(&tick, fallback_interval); + FrameOpportunity::new(tick, hints, display_timing) +} + +#[cfg(test)] +mod tests { + use super::{ + default_commit_lead, display_timing, frame_opportunity, present_hints, + present_hints_with_commit_lead, + }; + use frameclock::OutputId; + use frameclock::timing::PresentationTiming; + use frameclock::{DisplayTiming, Duration, FrameTick, HostTime}; + + fn tick(predicted_present: Option) -> FrameTick { + FrameTick { + now: HostTime(1_000_000), + predicted_present, + refresh_interval: Some(16_666_667), + output: OutputId(0), + prev_actual_present: None, + } + } + + #[test] + fn present_hints_with_prediction_are_estimated() { + let hints = present_hints(&tick(Some(HostTime(20_000_000))), Duration(16_666_667)); + + assert_eq!(hints.presentation_timing(), PresentationTiming::Estimated); + assert_eq!(hints.desired_present(), Some(HostTime(20_000_000))); + assert_eq!(hints.latest_commit(), HostTime(15_833_334)); + } + + #[test] + fn present_hints_respect_explicit_commit_lead() { + let hints = present_hints_with_commit_lead( + &tick(Some(HostTime(20_000_000))), + Duration(16_666_667), + Duration(2_000_000), + ); + + assert_eq!(hints.presentation_timing(), PresentationTiming::Estimated); + assert_eq!(hints.desired_present(), Some(HostTime(20_000_000))); + assert_eq!(hints.latest_commit(), HostTime(18_000_000)); + } + + #[test] + fn present_hints_without_prediction_are_pacing_only() { + let hints = present_hints(&tick(None), Duration(16_666_667)); + + assert_eq!(hints.presentation_timing(), PresentationTiming::PacingOnly); + assert_eq!(hints.desired_present(), None); + assert_eq!(hints.latest_commit(), HostTime(13_500_001)); + } + + #[test] + fn present_hints_ignore_stale_prediction() { + let stale_tick = FrameTick { + now: HostTime(2_000_000), + predicted_present: Some(HostTime(1_900_000)), + refresh_interval: Some(16_666_667), + output: OutputId(0), + prev_actual_present: None, + }; + let hints = present_hints(&stale_tick, Duration(16_666_667)); + + assert_eq!(hints.presentation_timing(), PresentationTiming::PacingOnly); + assert_eq!(hints.desired_present(), None); + } + + #[test] + fn default_commit_lead_is_quarter_refresh() { + assert_eq!( + default_commit_lead(Duration(16_666_667)), + Duration(4_166_666) + ); + } + + #[test] + fn display_timing_prefers_reported_refresh_interval() { + assert_eq!( + display_timing(&tick(Some(HostTime(2_000_000))), Duration(8_333_333)), + DisplayTiming::fixed(Duration(16_666_667)) + ); + } + + #[test] + fn frame_opportunity_pairs_tick_hints_and_display_timing() { + let tick = tick(Some(HostTime(20_000_000))); + let opportunity = frame_opportunity(tick, Duration(8_333_333)); + + assert_eq!(opportunity.tick, tick); + assert_eq!( + opportunity.hints.presentation_timing(), + PresentationTiming::Estimated + ); + assert_eq!( + opportunity.hints.desired_present(), + Some(HostTime(20_000_000)) + ); + assert_eq!( + opportunity.display_timing, + DisplayTiming::fixed(Duration(16_666_667)) + ); + } +} diff --git a/frameclock_x11/src/presentation.rs b/frameclock_x11/src/presentation.rs new file mode 100644 index 0000000..0d3f678 --- /dev/null +++ b/frameclock_x11/src/presentation.rs @@ -0,0 +1,199 @@ +// Copyright 2026 the Frameclock Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Present feedback contracts and queueing. +//! +//! This path is for a backend that owns presentation — one that issues +//! `PresentPixmap` and correlates the resulting `PresentCompleteNotify` +//! (kind `Pixmap`) and `PresentIdleNotify` events back to its submissions. A +//! host that lets a swapchain owner (for example wgpu/Vulkan) present does not +//! receive these per-pixmap completions on its own connection and instead paces +//! from the [`PresentTicker`](crate::PresentTicker) vblank stream alone. + +use crate::queue::BoundedQueue; +use crate::time::ust_to_host_time; +use frameclock::{HostTime, OutputId}; + +/// Unique identity for one `PresentPixmap` submission. +/// +/// A backend chooses the `serial` it passes to `PresentPixmap` and matches it +/// against the `serial` echoed by the `PresentCompleteNotify` and +/// `PresentIdleNotify` events. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct SubmissionId(pub u32); + +/// Per-submission Present feedback event. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum PresentEvent { + /// The submitted pixmap was presented (`PresentCompleteNotify`, kind + /// `Pixmap`). + Presented { + /// Identity of the submission this event corresponds to. + id: SubmissionId, + /// Actual presentation timestamp in host-time ticks. + actual_present: HostTime, + /// Media stream counter (vblank count) at presentation. + msc: u64, + /// Output where the frame was shown, if known. + output: Option, + /// Raw `CompleteMode` value (`Copy`, `Flip`, `Skip`, …). + mode: u8, + }, + /// A submitted buffer became idle and can be reused + /// (`PresentIdleNotify`). + Idle { + /// Identity of the submission whose buffer is now idle. + id: SubmissionId, + }, +} + +impl PresentEvent { + /// Builds a [`PresentEvent::Presented`] from raw `PresentCompleteNotify` + /// (kind `Pixmap`) fields, converting `ust` microseconds to host ticks. + #[must_use] + pub const fn presented( + id: SubmissionId, + ust_micros: u64, + msc: u64, + output: Option, + mode: u8, + ) -> Self { + Self::Presented { + id, + actual_present: ust_to_host_time(ust_micros), + msc, + output, + mode, + } + } +} + +/// Bounded FIFO queue for [`PresentEvent`] values. +/// +/// Overflow policy is `drop_oldest`: when full, pushing a new event removes the +/// oldest queued event first, keeping the newest feedback available to the +/// backend under backpressure. +#[derive(Debug, Clone)] +pub struct PresentEventQueue { + inner: BoundedQueue, +} + +impl PresentEventQueue { + /// Default queue capacity used by [`Default`]. + pub const DEFAULT_CAPACITY: usize = 64; + + /// Creates a queue with an explicit capacity. + /// + /// `capacity == 0` is promoted to `1`. + #[must_use] + pub fn with_capacity(capacity: usize) -> Self { + Self { + inner: BoundedQueue::with_capacity(capacity), + } + } + + /// Enqueues one Present feedback event. + pub fn push(&mut self, event: PresentEvent) { + self.inner.push(event); + } + + /// Pops the oldest queued event, if any. + pub fn pop(&mut self) -> Option { + self.inner.pop() + } + + /// Returns the current queue length. + #[must_use] + pub fn len(&self) -> usize { + self.inner.len() + } + + /// Returns `true` when no events are queued. + #[must_use] + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + /// Number of events dropped due to queue overflow. + #[must_use] + pub fn dropped_count(&self) -> u64 { + self.inner.dropped_count() + } +} + +impl Default for PresentEventQueue { + fn default() -> Self { + Self::with_capacity(Self::DEFAULT_CAPACITY) + } +} + +#[cfg(test)] +mod tests { + use super::{PresentEvent, PresentEventQueue, SubmissionId}; + use crate::time::ust_to_host_time; + use frameclock::OutputId; + + #[test] + fn presented_helper_converts_ust_to_host_ticks() { + let event = PresentEvent::presented(SubmissionId(9), 1_500_000, 42, Some(OutputId(1)), 1); + assert_eq!( + event, + PresentEvent::Presented { + id: SubmissionId(9), + actual_present: ust_to_host_time(1_500_000), + msc: 42, + output: Some(OutputId(1)), + mode: 1, + } + ); + } + + #[test] + fn queue_overflow_drops_oldest_event() { + let mut queue = PresentEventQueue::with_capacity(2); + queue.push(PresentEvent::Idle { + id: SubmissionId(1), + }); + queue.push(PresentEvent::Idle { + id: SubmissionId(2), + }); + queue.push(PresentEvent::Idle { + id: SubmissionId(3), + }); + + assert_eq!( + queue.pop(), + Some(PresentEvent::Idle { + id: SubmissionId(2) + }) + ); + assert_eq!( + queue.pop(), + Some(PresentEvent::Idle { + id: SubmissionId(3) + }) + ); + assert_eq!(queue.pop(), None); + assert_eq!(queue.dropped_count(), 1); + } + + #[test] + fn zero_capacity_is_promoted_to_one() { + let mut queue = PresentEventQueue::with_capacity(0); + queue.push(PresentEvent::Idle { + id: SubmissionId(1), + }); + queue.push(PresentEvent::Idle { + id: SubmissionId(2), + }); + + assert_eq!(queue.len(), 1); + assert_eq!( + queue.pop(), + Some(PresentEvent::Idle { + id: SubmissionId(2) + }) + ); + assert_eq!(queue.dropped_count(), 1); + } +} diff --git a/frameclock_x11/src/queue.rs b/frameclock_x11/src/queue.rs new file mode 100644 index 0000000..158fd16 --- /dev/null +++ b/frameclock_x11/src/queue.rs @@ -0,0 +1,92 @@ +// Copyright 2026 the Frameclock Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Internal bounded queue utilities. + +use alloc::collections::VecDeque; + +/// Bounded FIFO queue with a `drop_oldest` overflow policy. +/// +/// Once full, new pushes remove the oldest item before inserting the newest. +#[derive(Debug, Clone)] +pub(crate) struct BoundedQueue { + items: VecDeque, + capacity: usize, + dropped_count: u64, +} + +impl BoundedQueue { + pub(crate) fn with_capacity(capacity: usize) -> Self { + let capacity = capacity.max(1); + Self { + items: VecDeque::with_capacity(capacity), + capacity, + dropped_count: 0, + } + } + + pub(crate) fn push(&mut self, item: T) { + if self.items.len() == self.capacity { + let _ = self.items.pop_front(); + self.dropped_count += 1; + } + self.items.push_back(item); + } + + pub(crate) fn pop(&mut self) -> Option { + self.items.pop_front() + } + + pub(crate) fn len(&self) -> usize { + self.items.len() + } + + pub(crate) fn is_empty(&self) -> bool { + self.items.is_empty() + } + + pub(crate) fn dropped_count(&self) -> u64 { + self.dropped_count + } +} + +#[cfg(test)] +mod tests { + use super::BoundedQueue; + + #[test] + fn zero_capacity_is_promoted_to_one() { + let mut queue = BoundedQueue::with_capacity(0); + queue.push(10_u32); + queue.push(11_u32); + + assert_eq!(queue.len(), 1); + assert_eq!(queue.pop(), Some(11_u32)); + assert_eq!(queue.dropped_count(), 1); + } + + #[test] + fn push_over_capacity_drops_oldest() { + let mut queue = BoundedQueue::with_capacity(2); + queue.push(1_u32); + queue.push(2_u32); + queue.push(3_u32); + + assert_eq!(queue.pop(), Some(2_u32)); + assert_eq!(queue.pop(), Some(3_u32)); + assert_eq!(queue.pop(), None); + assert_eq!(queue.dropped_count(), 1); + } + + #[test] + fn empty_queue_reports_is_empty() { + let mut queue = BoundedQueue::with_capacity(2); + assert!(queue.is_empty()); + + queue.push(1_u32); + assert!(!queue.is_empty()); + + let _ = queue.pop(); + assert!(queue.is_empty()); + } +} diff --git a/frameclock_x11/src/tick.rs b/frameclock_x11/src/tick.rs new file mode 100644 index 0000000..a4fc735 --- /dev/null +++ b/frameclock_x11/src/tick.rs @@ -0,0 +1,428 @@ +// Copyright 2026 the Frameclock Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Present-driven frame-tick queueing and ticker state machine. + +use crate::queue::BoundedQueue; +use crate::time::{Clock, ust_to_host_time}; +use frameclock::{FrameTick, HostTime, OutputId}; + +const MICROS_TO_NANOS: u64 = 1_000; + +/// Internal bounded queue for frame ticks. +/// +/// Overflow policy is `drop_oldest` to retain the freshest pacing signal. +#[derive(Debug, Clone)] +pub(crate) struct TickQueue { + inner: BoundedQueue, +} + +impl TickQueue { + pub(crate) const DEFAULT_CAPACITY: usize = 8; + + pub(crate) fn with_capacity(capacity: usize) -> Self { + Self { + inner: BoundedQueue::with_capacity(capacity), + } + } + + pub(crate) fn push(&mut self, tick: FrameTick) { + self.inner.push(tick); + } + + pub(crate) fn pop(&mut self) -> Option { + self.inner.pop() + } + + #[cfg(test)] + pub(crate) fn dropped_count(&self) -> u64 { + self.inner.dropped_count() + } +} + +impl Default for TickQueue { + fn default() -> Self { + Self::with_capacity(Self::DEFAULT_CAPACITY) + } +} + +/// One observed Present completion: the vblank timestamp and its counter. +#[derive(Clone, Copy, Debug)] +struct PresentSample { + /// Unadjusted system time of the vblank, in microseconds. + ust_micros: u64, + /// Media stream counter (vblank count) of the vblank. + msc: u64, +} + +/// Pure-logic state machine that turns Present completions into [`FrameTick`]s. +/// +/// The Present extension answers a `PresentNotifyMSC` request with a +/// `PresentCompleteNotify` event carrying the vblank's `ust` (unadjusted +/// system time, microseconds) and `msc` (media stream counter). One such event +/// is both the frame-start wake and the actual-present timestamp, so each +/// completion emits a fully-populated tick. +/// +/// Protocol I/O is handled externally; this type contains only the bookkeeping. +/// Hosts call [`mark_notify_requested`](Self::mark_notify_requested) before +/// sending a `PresentNotifyMSC` request, [`on_complete_notify`](Self::on_complete_notify) +/// when the matching `PresentCompleteNotify` event arrives, and drain ticks with +/// [`poll_tick`](Self::poll_tick). +/// +/// # One stream per window +/// +/// A `PresentTicker` models a single window's vblank stream. Create one instance +/// per presented window, drive it only with that window's Present completions, +/// and pass a stable [`OutputId`] for the stream via +/// [`with_output`](Self::with_output). +/// +/// # Refresh interval +/// +/// The refresh interval is derived from consecutive completions: +/// `(ust_now - ust_prev) / (msc_now - msc_prev)`, converted to nanoseconds. It +/// is `None` until a second completion establishes a delta, leaving early ticks +/// pacing-only. Because `msc` counts *every* vblank whether or not a frame was +/// presented into it, this yields the true display cadence even when the app +/// renders below the refresh rate. +#[derive(Debug)] +pub struct PresentTicker { + queue: TickQueue, + notify_in_flight: bool, + last_present: Option, + refresh_interval: Option, + output: OutputId, +} + +impl PresentTicker { + /// Creates an empty ticker for [`OutputId`] `0` with no notify in flight. + #[must_use] + pub fn new() -> Self { + Self::with_output(OutputId(0)) + } + + /// Creates an empty ticker whose ticks carry `output`. + #[must_use] + pub fn with_output(output: OutputId) -> Self { + Self { + queue: TickQueue::default(), + notify_in_flight: false, + last_present: None, + refresh_interval: None, + output, + } + } + + /// Returns the [`OutputId`] stamped on emitted ticks. + #[must_use] + pub fn output(&self) -> OutputId { + self.output + } + + /// Sets the [`OutputId`] stamped on future ticks. + /// + /// Update this only when the window actually moves between outputs; the + /// observed refresh interval is intentionally retained across the change, + /// since a stale cadence is a better predictor than none until the new + /// output reports its own. + pub fn set_output(&mut self, output: OutputId) { + self.output = output; + } + + /// Records that a `PresentCompleteNotify` event has arrived. + /// + /// If a notify is in flight, builds a [`FrameTick`] from the current time + /// read from `clock`, the vblank's `ust_micros`/`msc`, and the observed + /// refresh interval, enqueues it, and clears the in-flight flag. If no notify + /// is in flight, debug-asserts and returns. + /// + /// `ust_micros` and `msc` are the `ust` and `msc` fields of the + /// `PresentCompleteNotify` event. Only completions of kind `NotifyMSC` (the + /// answer to a `PresentNotifyMSC` request) should be fed here; per-pixmap + /// completions belong to a presentation-feedback path, not the tick stream. + pub fn on_complete_notify(&mut self, clock: Clock, ust_micros: u64, msc: u64) { + debug_assert!( + self.notify_in_flight, + "on_complete_notify called without an in-flight notify" + ); + if !self.notify_in_flight { + return; + } + + if let Some(prev) = self.last_present + && let Some(interval) = refresh_interval_from_samples(prev, ust_micros, msc) + { + self.refresh_interval = Some(interval); + } + + let actual_present = ust_to_host_time(ust_micros); + let now = clock.now(); + let predicted_present = predict_next_present(actual_present, self.refresh_interval, now); + + let tick = FrameTick { + now, + predicted_present, + refresh_interval: self.refresh_interval, + output: self.output, + prev_actual_present: Some(actual_present), + }; + + self.queue.push(tick); + self.last_present = Some(PresentSample { ust_micros, msc }); + self.notify_in_flight = false; + } + + /// Pops the next queued [`FrameTick`], if any. + pub fn poll_tick(&mut self) -> Option { + self.queue.pop() + } + + /// Returns whether a `PresentNotifyMSC` notify is currently in flight. + #[must_use] + pub fn is_notify_in_flight(&self) -> bool { + self.notify_in_flight + } + + /// Claims the single in-flight notify slot before a `PresentNotifyMSC` + /// request is sent. + /// + /// Only one notify may be in flight at a time. Returns `true` when the slot + /// was newly claimed and the caller should send the `PresentNotifyMSC` + /// request. Returns `false` when a notify is already in flight; in that case + /// the ticker state is left unchanged and the caller must not arm another. + /// The slot is released when the matching + /// [`on_complete_notify`](Self::on_complete_notify) runs. + #[must_use = "a false return means a notify is already in flight and no new request should be armed"] + pub fn mark_notify_requested(&mut self) -> bool { + if self.notify_in_flight { + return false; + } + self.notify_in_flight = true; + true + } + + /// Returns the most recently observed refresh interval in host ticks, if a + /// second completion has established the display cadence. + #[must_use] + pub fn observed_refresh_interval(&self) -> Option { + self.refresh_interval + } +} + +impl Default for PresentTicker { + fn default() -> Self { + Self::new() + } +} + +/// Computes the per-vblank refresh interval (host ticks) from two completions. +/// +/// Returns `None` when the counter did not advance (a duplicate or reordered +/// completion) or the resulting interval would be zero, so a bad sample never +/// overwrites a good cadence. +fn refresh_interval_from_samples(prev: PresentSample, ust_micros: u64, msc: u64) -> Option { + let msc_delta = msc.checked_sub(prev.msc).filter(|delta| *delta > 0)?; + let ust_delta_micros = ust_micros.checked_sub(prev.ust_micros)?; + let per_frame = ust_delta_micros.saturating_mul(MICROS_TO_NANOS) / msc_delta; + (per_frame > 0).then_some(per_frame) +} + +/// Predicts the next vblank at or after `now` from the last observed present. +/// +/// Returns `None` when no refresh interval has been established, leaving the +/// tick pacing-only. Otherwise it advances the last actual-present time by whole +/// refresh intervals until it reaches `now`, landing on the display's vblank +/// grid. +fn predict_next_present( + last_actual: HostTime, + refresh_interval: Option, + now: HostTime, +) -> Option { + let refresh = refresh_interval.filter(|interval| *interval > 0)?; + let elapsed = now.ticks().saturating_sub(last_actual.ticks()); + let intervals = elapsed.div_ceil(refresh); + let advance = intervals.checked_mul(refresh)?; + last_actual.ticks().checked_add(advance).map(HostTime) +} + +#[cfg(test)] +mod tests { + use super::{PresentTicker, TickQueue, predict_next_present}; + use crate::time::{Clock, ust_to_host_time}; + use frameclock::{HostTime, OutputId}; + + #[test] + fn poll_tick_returns_none_when_empty() { + let mut ticker = PresentTicker::new(); + assert!(ticker.poll_tick().is_none()); + } + + #[test] + fn first_completion_is_pacing_only_with_actual_present() { + let mut ticker = PresentTicker::new(); + + assert!(ticker.mark_notify_requested()); + ticker.on_complete_notify(Clock::Monotonic, 1_000_000, 100); + + let tick = ticker.poll_tick().expect("should have a tick"); + // No prior sample, so no cadence and no prediction yet. + assert_eq!(tick.refresh_interval, None); + assert_eq!(tick.predicted_present, None); + // The vblank's ust (microseconds) becomes the actual-present ticks. + assert_eq!(tick.prev_actual_present, Some(ust_to_host_time(1_000_000))); + assert_eq!(tick.output, OutputId(0)); + } + + #[test] + fn second_completion_establishes_refresh_and_prediction() { + let mut ticker = PresentTicker::new(); + + assert!(ticker.mark_notify_requested()); + ticker.on_complete_notify(Clock::Monotonic, 1_000_000, 100); + let _ = ticker.poll_tick(); + + // One vblank later: +16_667 µs, +1 msc -> ~16.667 ms refresh. + assert!(ticker.mark_notify_requested()); + ticker.on_complete_notify(Clock::Monotonic, 1_016_667, 101); + + let tick = ticker.poll_tick().expect("should have a tick"); + assert_eq!(tick.refresh_interval, Some(16_667_000)); + assert_eq!(ticker.observed_refresh_interval(), Some(16_667_000)); + // Prediction lands on the vblank grid at or after `now`. + let predicted = tick.predicted_present.expect("prediction available"); + assert!(predicted >= tick.now); + } + + #[test] + fn refresh_is_per_vblank_even_when_frames_are_skipped() { + let mut ticker = PresentTicker::new(); + + assert!(ticker.mark_notify_requested()); + ticker.on_complete_notify(Clock::Monotonic, 2_000_000, 200); + let _ = ticker.poll_tick(); + + // Three vblanks elapsed in one completion: +50_000 µs over +3 msc. + assert!(ticker.mark_notify_requested()); + ticker.on_complete_notify(Clock::Monotonic, 2_050_000, 203); + let tick = ticker.poll_tick().expect("should have a tick"); + + // 50_000 µs / 3 vblanks = 16_666_666 ns per vblank. + assert_eq!(tick.refresh_interval, Some(16_666_666)); + } + + #[test] + fn duplicate_msc_does_not_overwrite_cadence() { + let mut ticker = PresentTicker::new(); + + assert!(ticker.mark_notify_requested()); + ticker.on_complete_notify(Clock::Monotonic, 3_000_000, 300); + let _ = ticker.poll_tick(); + assert!(ticker.mark_notify_requested()); + ticker.on_complete_notify(Clock::Monotonic, 3_016_667, 301); + let _ = ticker.poll_tick(); + assert_eq!(ticker.observed_refresh_interval(), Some(16_667_000)); + + // A completion with the same msc yields no delta and is ignored. + assert!(ticker.mark_notify_requested()); + ticker.on_complete_notify(Clock::Monotonic, 3_016_667, 301); + let _ = ticker.poll_tick(); + assert_eq!(ticker.observed_refresh_interval(), Some(16_667_000)); + } + + #[test] + fn notify_in_flight_transitions() { + let mut ticker = PresentTicker::new(); + + assert!(!ticker.is_notify_in_flight()); + assert!(ticker.mark_notify_requested()); + assert!(ticker.is_notify_in_flight()); + ticker.on_complete_notify(Clock::Monotonic, 1_000_000, 1); + assert!(!ticker.is_notify_in_flight()); + } + + #[test] + fn mark_notify_requested_rejects_double_request() { + let mut ticker = PresentTicker::new(); + + assert!(ticker.mark_notify_requested()); + assert!(!ticker.mark_notify_requested()); + assert!(ticker.is_notify_in_flight()); + + ticker.on_complete_notify(Clock::Monotonic, 1_000_000, 1); + assert!(ticker.mark_notify_requested()); + } + + #[test] + fn output_is_stamped_on_ticks() { + let mut ticker = PresentTicker::with_output(OutputId(4)); + assert_eq!(ticker.output(), OutputId(4)); + + assert!(ticker.mark_notify_requested()); + ticker.on_complete_notify(Clock::Monotonic, 1_000_000, 1); + let tick = ticker.poll_tick().unwrap(); + assert_eq!(tick.output, OutputId(4)); + + ticker.set_output(OutputId(7)); + assert!(ticker.mark_notify_requested()); + ticker.on_complete_notify(Clock::Monotonic, 1_016_667, 2); + let tick = ticker.poll_tick().unwrap(); + assert_eq!(tick.output, OutputId(7)); + } + + #[test] + fn queue_overflow_drops_oldest_through_ticker() { + let mut ticker = PresentTicker::new(); + + // Default capacity is 8; push 9 ticks without polling and verify one drop. + for frame in 0..9 { + assert!(ticker.mark_notify_requested()); + ticker.on_complete_notify(Clock::Monotonic, 1_000_000 + frame * 16_667, 100 + frame); + } + + assert_eq!(ticker.queue.dropped_count(), 1); + } + + // --- Present prediction tests --- + + #[test] + fn predict_next_present_rounds_up_to_next_vblank() { + // last + 4*refresh = 1640 is the first vblank at or after now = 1500. + let predicted = predict_next_present(HostTime(1000), Some(160), HostTime(1500)); + assert_eq!(predicted, Some(HostTime(1640))); + } + + #[test] + fn predict_next_present_on_exact_vblank_returns_now() { + let predicted = predict_next_present(HostTime(1000), Some(160), HostTime(1480)); + assert_eq!(predicted, Some(HostTime(1480))); + } + + #[test] + fn predict_next_present_without_refresh_is_none() { + assert_eq!( + predict_next_present(HostTime(1000), None, HostTime(1500)), + None + ); + assert_eq!( + predict_next_present(HostTime(1000), Some(0), HostTime(1500)), + None + ); + } + + #[test] + fn tick_queue_overflow_drops_oldest() { + let mut queue = TickQueue::with_capacity(2); + for now in 1..=3 { + queue.push(frameclock::FrameTick { + now: HostTime(now), + predicted_present: None, + refresh_interval: None, + output: OutputId(0), + prev_actual_present: None, + }); + } + assert_eq!(queue.pop().map(|tick| tick.now), Some(HostTime(2))); + assert_eq!(queue.pop().map(|tick| tick.now), Some(HostTime(3))); + assert_eq!(queue.pop(), None); + assert_eq!(queue.dropped_count(), 1); + } +} diff --git a/frameclock_x11/src/time.rs b/frameclock_x11/src/time.rs new file mode 100644 index 0000000..3a30886 --- /dev/null +++ b/frameclock_x11/src/time.rs @@ -0,0 +1,135 @@ +// Copyright 2026 the Frameclock Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! X11 host clock reads and Present timestamp conversion. + +use frameclock::HostTime; +use frameclock::time::Timebase; +use rustix::time::{ClockId as PosixClockId, Timespec, clock_gettime}; + +const NANOS_PER_SECOND: u128 = 1_000_000_000; +const NANOS_PER_MICRO: u64 = 1_000; + +/// Clock source used for X11 timing facts. +/// +/// The Present extension reports `ust` (unadjusted system time) in the server's +/// monotonic clock domain. On Linux with DRI3/Present that is `CLOCK_MONOTONIC` +/// in microseconds, the same clock this reads in nanoseconds, so +/// [`ust_to_host_time`] and [`Clock::now`] produce comparable [`HostTime`] +/// values. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)] +pub enum Clock { + /// `CLOCK_MONOTONIC`, the domain Present `ust` timestamps are reported in. + #[default] + Monotonic, +} + +impl Clock { + /// Returns the current host time read from this clock in nanoseconds. + #[must_use] + pub fn now(self) -> HostTime { + let timespec = clock_gettime(self.posix_clock_id()); + timespec_to_host_time(timespec) + } + + #[must_use] + const fn posix_clock_id(self) -> PosixClockId { + match self { + Self::Monotonic => PosixClockId::Monotonic, + } + } +} + +/// Converts a Present `ust` value (microseconds) to [`HostTime`] nanoseconds. +/// +/// The Present extension reports `ust` in microseconds; host time is +/// nanoseconds. Arithmetic saturates so an out-of-range server value can never +/// panic. +#[must_use] +pub const fn ust_to_host_time(ust_micros: u64) -> HostTime { + HostTime(ust_micros.saturating_mul(NANOS_PER_MICRO)) +} + +/// Returns the X11 [`Timebase`]: host ticks are nanoseconds. +#[must_use] +pub const fn timebase() -> Timebase { + Timebase::NANOS +} + +/// Returns the current monotonic host time in nanoseconds. +#[must_use] +pub fn now() -> HostTime { + Clock::Monotonic.now() +} + +fn timespec_to_host_time(timespec: Timespec) -> HostTime { + let seconds = u64::try_from(timespec.tv_sec).unwrap_or(0); + let nanos = u64::try_from(timespec.tv_nsec) + .unwrap_or(0) + .min(999_999_999); + + let ticks_u128 = u128::from(seconds) + .saturating_mul(NANOS_PER_SECOND) + .saturating_add(u128::from(nanos)); + let ticks = u64::try_from(ticks_u128).unwrap_or(u64::MAX); + HostTime(ticks) +} + +#[cfg(test)] +mod tests { + use super::{Clock, now, timebase, timespec_to_host_time, ust_to_host_time}; + use frameclock::HostTime; + use frameclock::time::Timebase; + use rustix::time::Timespec; + + #[test] + fn timebase_is_nanos_identity() { + assert_eq!(timebase(), Timebase::NANOS); + } + + #[test] + fn now_is_monotonic_non_decreasing() { + let first = now(); + let second = now(); + assert!(second >= first, "monotonic clock should not go backwards"); + } + + #[test] + fn monotonic_clock_is_readable() { + assert!( + Clock::Monotonic.now().ticks() > 0, + "clock_gettime(monotonic) should be positive" + ); + } + + #[test] + fn ust_micros_scale_to_nanoseconds() { + assert_eq!(ust_to_host_time(0), HostTime(0)); + assert_eq!(ust_to_host_time(1), HostTime(1_000)); + assert_eq!(ust_to_host_time(1_500_000), HostTime(1_500_000_000)); + } + + #[test] + fn ust_conversion_saturates_on_overflow() { + assert_eq!(ust_to_host_time(u64::MAX), HostTime(u64::MAX)); + } + + #[test] + fn timespec_conversion_builds_nanosecond_ticks() { + let input = Timespec { + tv_sec: 12, + tv_nsec: 345_678_901, + }; + let expected = HostTime(12 * 1_000_000_000 + 345_678_901); + assert_eq!(timespec_to_host_time(input), expected); + } + + #[test] + fn timespec_conversion_saturates_on_large_values() { + let input = Timespec { + tv_sec: i64::MAX, + tv_nsec: 999_999_999, + }; + assert_eq!(timespec_to_host_time(input), HostTime(u64::MAX)); + } +} diff --git a/subduction_backend_x11/Cargo.toml b/subduction_backend_x11/Cargo.toml index 79d8d9c..979cb9a 100644 --- a/subduction_backend_x11/Cargo.toml +++ b/subduction_backend_x11/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "subduction_backend_x11" -description = "X11 backend for subduction (Present extension, OML sync)" +description = "X11 backend for subduction (Present extension, wgpu compositing)" readme = "README.md" version.workspace = true edition.workspace = true @@ -14,4 +14,26 @@ categories = ["rendering::engine"] workspace = true [dependencies] +# The present/timing source is pure logic (fed decoded Present fields), so it +# builds everywhere on `frameclock_x11` alone. subduction_core = { workspace = true, features = ["std"] } +frameclock = { workspace = true, features = ["std"] } +frameclock_x11 = { workspace = true } + +# The compositor owns an X11 window and a wgpu surface, so it is gated to the +# targets that have both. +[target.'cfg(all(unix, not(target_os = "macos")))'.dependencies] +subduction_backend_wgpu = { workspace = true } +wgpu = { workspace = true } +pollster = "0.4" +# The Present extension carries frame timing; `dri3`/`xfixes`/`sync`/`randr` +# back it and are not pulled transitively. `allow-unsafe-code` unlocks the +# libxcb-backed `XCBConnection`, whose raw pointer the wgpu surface needs. +x11rb = { version = "0.14", default-features = false, features = [ + "allow-unsafe-code", + "dri3", + "present", + "randr", + "sync", + "xfixes", +] } diff --git a/subduction_backend_x11/README.md b/subduction_backend_x11/README.md new file mode 100644 index 0000000..6a81cab --- /dev/null +++ b/subduction_backend_x11/README.md @@ -0,0 +1,42 @@ +
+ +# Subduction Backend X11 + +**X11 backend for subduction.** + +
+ +`subduction_backend_x11` presents the subduction layer tree to an X11 window. +It assumes a server with the Present extension. It presents directly to a +top-level window and reads that window's vblank clock through Present; a +compositing manager is not required. + +- **`X11PresentSource`** turns a window's Present completions into `frameclock` + ticks (via `frameclock_x11`) and per-submission feedback. It is pure + bookkeeping and references no `x11rb` type, so any event loop can drive it. +- **`X11Compositor`** is the presenting `Presenter`: it owns the X11 window and + a wgpu surface, and composites the subduction layer tree onto it with + `subduction_backend_wgpu`. Available on non-Apple Unix targets. + +The compositor presents through wgpu's own swapchain. Per-pixmap Present +feedback only flows to a presenter that submits `PresentPixmap` itself, so in +this configuration the `X11PresentSource` feedback queue stays empty. + +## Minimum supported Rust Version (MSRV) + +This crate has been verified to compile with **Rust 1.92** and later. + +## License + +Licensed under either of + +- Apache License, Version 2.0 ([LICENSE-APACHE] or ), or +- MIT license ([LICENSE-MIT] or ), + +at your option. + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, +as defined in the Apache-2.0 license, shall be dual licensed as above, without any additional terms or conditions. + +[LICENSE-APACHE]: https://github.com/forest-rs/subduction/blob/main/LICENSE-APACHE +[LICENSE-MIT]: https://github.com/forest-rs/subduction/blob/main/LICENSE-MIT diff --git a/subduction_backend_x11/src/compositor.rs b/subduction_backend_x11/src/compositor.rs new file mode 100644 index 0000000..ac81ee3 --- /dev/null +++ b/subduction_backend_x11/src/compositor.rs @@ -0,0 +1,453 @@ +// Copyright 2026 the Subduction Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Presenting X11 compositor: owns a top-level window and a wgpu surface, and +//! composites the subduction layer tree onto it by reusing the fallback +//! compositor in `subduction_backend_wgpu`. + +#![expect( + unsafe_code, + reason = "wgpu surface creation from raw X11 handles requires unsafe" +)] + +use std::error::Error; +use std::ffi::c_void; +use std::fmt; +use std::num::NonZeroU32; +use std::ptr::NonNull; + +use frameclock::FrameTick; +use subduction_backend_wgpu::{LayerRoot, WgpuPresenter, WgpuSurfaceTarget}; +use subduction_core::backend::Presenter; +use subduction_core::layer::{FrameChanges, LayerStore, SurfaceId}; +use x11rb::connection::Connection; +use x11rb::protocol::Event as X11Event; +use x11rb::protocol::present::{self, CompleteKind, ConnectionExt as _}; +use x11rb::protocol::xproto::{ + self, AtomEnum, ConnectionExt as _, CreateWindowAux, EventMask, PropMode, WindowClass, +}; +use x11rb::wrapper::ConnectionExt as _; +use x11rb::xcb_ffi::XCBConnection; + +use crate::present_source::X11PresentSource; + +/// Configuration for the compositor's window. +#[derive(Clone, Debug)] +pub struct X11WindowConfig { + /// The window title. + pub title: String, + /// The initial window size in physical pixels. + pub size: (u32, u32), +} + +impl Default for X11WindowConfig { + fn default() -> Self { + Self { + title: String::from("Subduction"), + size: (1024, 768), + } + } +} + +/// Errors from bringing up or driving the X11 compositor. +#[derive(Debug)] +pub enum X11BackendError { + /// A required X extension is absent — this no-legacy backend has no fallback. + MissingExtension(&'static str), + /// The X connection or a request failed. + X11(Box), + /// A wgpu adapter, device, or surface could not be created. + Wgpu(Box), +} + +impl fmt::Display for X11BackendError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::MissingExtension(name) => { + write!(f, "required X extension `{name}` is unavailable") + } + Self::X11(err) => write!(f, "X11 error: {err}"), + Self::Wgpu(err) => write!(f, "wgpu error: {err}"), + } + } +} + +impl Error for X11BackendError {} + +impl From for X11BackendError { + fn from(err: x11rb::errors::ConnectError) -> Self { + Self::X11(Box::new(err)) + } +} + +impl From for X11BackendError { + fn from(err: x11rb::errors::ConnectionError) -> Self { + Self::X11(Box::new(err)) + } +} + +impl From for X11BackendError { + fn from(err: x11rb::errors::ReplyError) -> Self { + Self::X11(Box::new(err)) + } +} + +impl From for X11BackendError { + fn from(err: x11rb::errors::ReplyOrIdError) -> Self { + Self::X11(Box::new(err)) + } +} + +type Result = core::result::Result; + +/// A presenting subduction backend for a top-level X11 window. +/// +/// It owns the X11 connection and window, the wgpu surface it presents to, the +/// [`WgpuPresenter`] that composites the layer tree, and the +/// [`X11PresentSource`] that paces frames from the window's vblank clock. +/// +/// Implement a frame loop by feeding decoded X11 events to +/// [`handle_event`](Self::handle_event), rendering app content into the surface +/// targets from [`target_for_surface`](Self::target_for_surface), calling +/// [`apply`](Presenter::apply) with the evaluated [`FrameChanges`], and then +/// [`present`](Self::present). +pub struct X11Compositor { + conn: XCBConnection, + window: u32, + wm_delete_window: xproto::Atom, + present_eid: u32, + present_serial: u32, + closed: bool, + configured_size: (u32, u32), + surface: wgpu::Surface<'static>, + surface_config: wgpu::SurfaceConfiguration, + presenter: WgpuPresenter, + source: X11PresentSource, +} + +impl fmt::Debug for X11Compositor { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("X11Compositor") + .field("window", &self.window) + .field("configured_size", &self.configured_size) + .field("closed", &self.closed) + .finish_non_exhaustive() + } +} + +impl X11Compositor { + /// Connects to the X server, opens a window, and builds the wgpu compositor. + pub fn new(config: &X11WindowConfig) -> Result { + let (conn, screen_num) = XCBConnection::connect(None)?; + + let (window, wm_delete_window, size) = create_window(&conn, screen_num, config)?; + let present_eid = configure_present(&conn, window)?; + + let (width, height) = size; + let mut descriptor = wgpu::InstanceDescriptor::new_without_display_handle(); + descriptor.backends = wgpu::Backends::VULKAN; + let instance = wgpu::Instance::new(descriptor); + let surface = create_surface(&instance, &conn, screen_num, window)?; + + let (device, queue, surface_config) = + configure_surface(&instance, &surface, width, height)?; + let root = LayerRoot::new(surface_config.format, (width, height)); + let presenter = WgpuPresenter::new(device, queue, root, (width, height)); + + Ok(Self { + conn, + window, + wm_delete_window, + present_eid, + present_serial: 0, + closed: false, + configured_size: (width, height), + surface, + surface_config, + presenter, + source: X11PresentSource::new(), + }) + } + + /// Returns the wgpu device the compositor renders with. + #[must_use] + pub fn device(&self) -> &wgpu::Device { + self.presenter.device() + } + + /// Returns the wgpu queue the compositor submits with. + #[must_use] + pub fn queue(&self) -> &wgpu::Queue { + self.presenter.queue() + } + + /// Returns the render target the app should draw `surface_id`'s content into. + /// + /// Targets are allocated when [`apply`](Presenter::apply) first observes the + /// surface attached as layer content. + #[must_use] + pub fn target_for_surface(&self, surface_id: SurfaceId) -> Option> { + self.presenter.target_for_surface(surface_id) + } + + /// Returns whether the window manager has asked the window to close. + #[must_use] + pub fn is_closed(&self) -> bool { + self.closed + } + + /// Claims the vblank-notify slot and arms `PresentNotifyMSC` if free. + /// + /// Call to keep the vblank stream alive while frames are wanted; it is a + /// no-op while a notify is already in flight. + pub fn arm_vblank_notify(&mut self) -> Result<()> { + if self.source.mark_notify_requested() { + self.present_serial = self.present_serial.wrapping_add(1); + self.conn + .present_notify_msc(self.window, self.present_serial, 0, 1, 0)?; + self.conn.flush()?; + } + Ok(()) + } + + /// Pops the next queued frame tick, if any. + pub fn poll_tick(&mut self) -> Option { + self.source.poll_tick() + } + + /// Routes one decoded X11 event into the present source and lifecycle state. + pub fn handle_event(&mut self, event: &X11Event) { + match event { + X11Event::PresentCompleteNotify(complete) if complete.event == self.present_eid => { + if complete.kind == CompleteKind::NOTIFY_MSC { + self.source.on_notify_complete(complete.ust, complete.msc); + } else { + // A per-pixmap completion: feed it as submission feedback, + // keyed by the serial the presenter stamped. + self.source.on_pixmap_complete( + complete.serial, + complete.ust, + complete.msc, + complete.mode.into(), + ); + } + } + X11Event::PresentIdleNotify(idle) if idle.event == self.present_eid => { + self.source.on_idle(idle.serial); + } + X11Event::ConfigureNotify(configure) => { + self.configured_size = (u32::from(configure.width), u32::from(configure.height)); + } + X11Event::ClientMessage(message) + if message.data.as_data32()[0] == self.wm_delete_window => + { + self.closed = true; + } + _ => {} + } + } + + /// Reconciles the surface with the latest configured window size. + /// + /// Returns whether the size changed and the surface was reconfigured. + pub fn reconcile_size(&mut self) -> bool { + let (width, height) = self.configured_size; + if width == 0 + || height == 0 + || (width, height) == (self.surface_config.width, self.surface_config.height) + { + return false; + } + self.surface_config.width = width; + self.surface_config.height = height; + self.surface + .configure(self.presenter.device(), &self.surface_config); + self.presenter.root_mut().resize(width, height); + true + } + + /// Composites the current layer tree onto the window and presents it. + /// + /// Call after rendering app content into the surface targets and after + /// [`apply`](Presenter::apply). Acquires the swapchain image, composites into + /// it, submits, and presents; a lost or outdated surface is reconfigured and + /// the frame skipped. + pub fn present(&mut self, store: &LayerStore) -> Result<()> { + let output = match self.surface.get_current_texture() { + wgpu::CurrentSurfaceTexture::Success(output) + | wgpu::CurrentSurfaceTexture::Suboptimal(output) => output, + wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { + self.surface + .configure(self.presenter.device(), &self.surface_config); + return Ok(()); + } + wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => { + return Ok(()); + } + wgpu::CurrentSurfaceTexture::Validation => { + return Err(X11BackendError::Wgpu( + "surface texture acquisition failed validation".into(), + )); + } + }; + + let view = output + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let command_buffer = self.presenter.composite(store, &view); + self.presenter.queue().submit([command_buffer]); + output.present(); + Ok(()) + } +} + +impl Presenter for X11Compositor { + fn apply(&mut self, store: &LayerStore, changes: &FrameChanges) { + self.presenter.apply(store, changes); + } +} + +/// Creates, titles, and maps the top-level window, wiring `WM_DELETE_WINDOW`. +fn create_window( + conn: &XCBConnection, + screen_num: usize, + config: &X11WindowConfig, +) -> Result<(u32, xproto::Atom, (u32, u32))> { + let screen = &conn.setup().roots[screen_num]; + let window = conn.generate_id()?; + + let width = u16::try_from(config.size.0).unwrap_or(1024).max(1); + let height = u16::try_from(config.size.1).unwrap_or(768).max(1); + + let aux = CreateWindowAux::new() + .background_pixel(screen.black_pixel) + .event_mask(EventMask::STRUCTURE_NOTIFY); + conn.create_window( + screen.root_depth, + window, + screen.root, + 0, + 0, + width, + height, + 0, + WindowClass::INPUT_OUTPUT, + screen.root_visual, + &aux, + )? + .check()?; + + let title = config.title.as_bytes(); + let net_wm_name = intern(conn, b"_NET_WM_NAME")?; + let utf8_string = intern(conn, b"UTF8_STRING")?; + let wm_protocols = intern(conn, b"WM_PROTOCOLS")?; + let wm_delete_window = intern(conn, b"WM_DELETE_WINDOW")?; + conn.change_property8( + PropMode::REPLACE, + window, + AtomEnum::WM_NAME, + AtomEnum::STRING, + title, + )?; + conn.change_property8(PropMode::REPLACE, window, net_wm_name, utf8_string, title)?; + conn.change_property32( + PropMode::REPLACE, + window, + wm_protocols, + AtomEnum::ATOM, + &[wm_delete_window], + )?; + conn.map_window(window)?; + conn.flush()?; + + Ok(( + window, + wm_delete_window, + (u32::from(width), u32::from(height)), + )) +} + +/// Negotiates the Present extension and selects completion and idle events. +fn configure_present(conn: &XCBConnection, window: u32) -> Result { + conn.present_query_version(1, 0)?.reply()?; + let eid = conn.generate_id()?; + conn.present_select_input( + eid, + window, + present::EventMask::COMPLETE_NOTIFY | present::EventMask::IDLE_NOTIFY, + )?; + conn.flush()?; + Ok(eid) +} + +/// Creates a `wgpu` surface from the raw `xcb_connection_t` and window id. +fn create_surface( + instance: &wgpu::Instance, + conn: &XCBConnection, + screen_num: usize, + window: u32, +) -> Result> { + let raw_connection = conn.get_raw_xcb_connection().cast::(); + let window = + NonZeroU32::new(window).ok_or_else(|| X11BackendError::X11("window id is zero".into()))?; + let display_handle = wgpu::rwh::XcbDisplayHandle::new( + NonNull::new(raw_connection), + i32::try_from(screen_num).unwrap_or(0), + ); + let window_handle = wgpu::rwh::XcbWindowHandle::new(window); + let target = wgpu::SurfaceTargetUnsafe::RawHandle { + raw_display_handle: Some(wgpu::rwh::RawDisplayHandle::Xcb(display_handle)), + raw_window_handle: wgpu::rwh::RawWindowHandle::Xcb(window_handle), + }; + // SAFETY: `raw_connection` and `window` come from the live `XCBConnection`, + // which this compositor keeps alive for at least as long as the surface. + let surface = unsafe { instance.create_surface_unsafe(target) } + .map_err(|err| X11BackendError::Wgpu(Box::new(err)))?; + Ok(surface) +} + +/// Selects an adapter and device, and configures the surface for rendering. +fn configure_surface( + instance: &wgpu::Instance, + surface: &wgpu::Surface<'static>, + width: u32, + height: u32, +) -> Result<(wgpu::Device, wgpu::Queue, wgpu::SurfaceConfiguration)> { + let width = width.max(1); + let height = height.max(1); + let (adapter, device, queue) = pollster::block_on(async { + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: Some(surface), + }) + .await + .map_err(|err| X11BackendError::Wgpu(Box::new(err)))?; + let limits = adapter.limits(); + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("subduction_backend_x11 device"), + required_features: wgpu::Features::empty(), + required_limits: limits, + memory_hints: wgpu::MemoryHints::default(), + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::default(), + }) + .await + .map_err(|err| X11BackendError::Wgpu(Box::new(err)))?; + Ok::<_, X11BackendError>((adapter, device, queue)) + })?; + + let mut surface_config = surface + .get_default_config(&adapter, width, height) + .ok_or_else(|| X11BackendError::Wgpu("the adapter cannot render to this surface".into()))?; + surface_config.usage |= wgpu::TextureUsages::RENDER_ATTACHMENT; + surface.configure(&device, &surface_config); + Ok((device, queue, surface_config)) +} + +/// Interns an atom, creating it if necessary. +fn intern(conn: &XCBConnection, name: &[u8]) -> Result { + Ok(conn.intern_atom(false, name)?.reply()?.atom) +} diff --git a/subduction_backend_x11/src/lib.rs b/subduction_backend_x11/src/lib.rs index 475374e..c51acc3 100644 --- a/subduction_backend_x11/src/lib.rs +++ b/subduction_backend_x11/src/lib.rs @@ -3,8 +3,30 @@ //! X11 backend for subduction. //! -//! This crate will provide integration with X11 display: +//! The backend assumes a server with the Present extension. It presents +//! directly to a top-level window and reads that window's vblank clock +//! through Present; a compositing manager is not required. //! -//! - Present extension / `GLX_OML_sync_control` for timing when available -//! - Timer-based pacing fallback -//! - Capability detection and graceful degradation +//! - [`X11PresentSource`] turns a window's Present completions into +//! `frameclock` ticks and per-submission feedback. It is pure bookkeeping +//! over [`frameclock_x11`] and references no `x11rb` type, so any event loop +//! can drive it. +//! - [`X11Compositor`] is the presenting [`Presenter`]: it owns the X11 window +//! and a wgpu surface, and composites the subduction layer tree onto it with +//! `subduction_backend_wgpu`. It is available on non-Apple Unix targets. +//! +//! The compositor presents through wgpu's own swapchain. Per-pixmap Present +//! feedback only flows to a presenter that submits `PresentPixmap` itself, so +//! in this configuration the [`X11PresentSource`] feedback queue stays empty. + +mod present_source; + +pub use frameclock_x11::{PresentEvent, SubmissionId}; +pub use present_source::X11PresentSource; +pub use subduction_core::backend::Presenter; + +#[cfg(all(unix, not(target_os = "macos")))] +mod compositor; + +#[cfg(all(unix, not(target_os = "macos")))] +pub use compositor::{X11BackendError, X11Compositor, X11WindowConfig}; diff --git a/subduction_backend_x11/src/present_source.rs b/subduction_backend_x11/src/present_source.rs new file mode 100644 index 0000000..32ae662 --- /dev/null +++ b/subduction_backend_x11/src/present_source.rs @@ -0,0 +1,203 @@ +// Copyright 2026 the Subduction Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Present-extension timing and feedback source. +//! +//! [`X11PresentSource`] turns a window's Present completions into `frameclock` +//! ticks and per-submission feedback. It is pure bookkeeping over +//! [`frameclock_x11`] and references no `x11rb` type; a host decodes the +//! Present events and feeds their fields in. +//! +//! Two Present event streams feed it: +//! +//! - **`NotifyMSC` completions** — the vblank stream armed with `PresentNotifyMSC` +//! (see [`mark_notify_requested`](X11PresentSource::mark_notify_requested) and +//! [`on_notify_complete`](X11PresentSource::on_notify_complete)). These become +//! [`FrameTick`]s. +//! - **Per-pixmap completions** — the feedback for buffers submitted with +//! `PresentPixmap` (see [`on_pixmap_complete`](X11PresentSource::on_pixmap_complete) +//! and [`on_idle`](X11PresentSource::on_idle)). These become [`PresentEvent`]s +//! correlated by the [`SubmissionId`] a presenter stamps on each submission. + +use frameclock::{FrameTick, OutputId}; +use frameclock_x11::{Clock, PresentEvent, PresentEventQueue, PresentTicker, SubmissionId}; + +/// Turns a window's Present completions into `frameclock` ticks and feedback. +/// +/// Keep one per presented window. Drive the vblank stream by calling +/// [`mark_notify_requested`](Self::mark_notify_requested) before arming a +/// `PresentNotifyMSC` request and [`on_notify_complete`](Self::on_notify_complete) +/// when the matching completion arrives, then drain [`poll_tick`](Self::poll_tick). +/// A presenter that issues `PresentPixmap` allocates a [`SubmissionId`] with +/// [`next_submission`](Self::next_submission), and reports the buffer's fate +/// through [`on_pixmap_complete`](Self::on_pixmap_complete) / +/// [`on_idle`](Self::on_idle), draining [`poll_present_event`](Self::poll_present_event). +#[derive(Debug)] +pub struct X11PresentSource { + ticker: PresentTicker, + feedback: PresentEventQueue, + clock: Clock, + output: OutputId, + next_serial: u32, +} + +impl X11PresentSource { + /// Creates a source for [`OutputId`] `0`. + #[must_use] + pub fn new() -> Self { + Self::with_output(OutputId(0)) + } + + /// Creates a source whose ticks and feedback carry `output`. + #[must_use] + pub fn with_output(output: OutputId) -> Self { + Self { + ticker: PresentTicker::with_output(output), + feedback: PresentEventQueue::default(), + clock: Clock::Monotonic, + output, + next_serial: 0, + } + } + + /// Claims the single in-flight vblank-notify slot before arming + /// `PresentNotifyMSC`. + /// + /// Returns `true` when the caller should send the request; `false` when a + /// notify is already in flight. Delegates to + /// [`PresentTicker::mark_notify_requested`]. + #[must_use = "a false return means a notify is already in flight and no new request should be armed"] + pub fn mark_notify_requested(&mut self) -> bool { + self.ticker.mark_notify_requested() + } + + /// Records a `NotifyMSC` completion, enqueuing a [`FrameTick`]. + /// + /// `ust` and `msc` are the fields of the `PresentCompleteNotify` event of + /// kind `NotifyMSC`. + pub fn on_notify_complete(&mut self, ust: u64, msc: u64) { + self.ticker.on_complete_notify(self.clock, ust, msc); + } + + /// Pops the next queued [`FrameTick`], if any. + pub fn poll_tick(&mut self) -> Option { + self.ticker.poll_tick() + } + + /// Allocates the next [`SubmissionId`] to stamp on a `PresentPixmap`. + /// + /// The returned value is the `serial` a presenter passes to `PresentPixmap` + /// and matches against the `serial` echoed by the completion and idle events. + pub fn next_submission(&mut self) -> SubmissionId { + let serial = self.next_serial; + self.next_serial = self.next_serial.wrapping_add(1); + SubmissionId(serial) + } + + /// Records a per-pixmap `PresentCompleteNotify` (kind `Pixmap`) as feedback. + /// + /// `serial` is the submission's [`SubmissionId`] serial; `mode` is the raw + /// `CompleteMode` (`Copy`, `Flip`, `Skip`, …). + pub fn on_pixmap_complete(&mut self, serial: u32, ust: u64, msc: u64, mode: u8) { + self.feedback.push(PresentEvent::presented( + SubmissionId(serial), + ust, + msc, + Some(self.output), + mode, + )); + } + + /// Records a `PresentIdleNotify`: the submission's buffer is reusable. + pub fn on_idle(&mut self, serial: u32) { + self.feedback.push(PresentEvent::Idle { + id: SubmissionId(serial), + }); + } + + /// Pops the next queued per-submission [`PresentEvent`], if any. + pub fn poll_present_event(&mut self) -> Option { + self.feedback.pop() + } + + /// Returns the most recently observed refresh interval in host ticks, once a + /// second `NotifyMSC` completion has established the cadence. + #[must_use] + pub fn observed_refresh_interval(&self) -> Option { + self.ticker.observed_refresh_interval() + } +} + +impl Default for X11PresentSource { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::X11PresentSource; + use frameclock_x11::{PresentEvent, SubmissionId}; + + #[test] + fn notify_completions_produce_ticks() { + let mut source = X11PresentSource::new(); + assert!(source.mark_notify_requested()); + source.on_notify_complete(1_000_000, 100); + let tick = source.poll_tick().expect("a notify completion is a tick"); + assert_eq!( + tick.prev_actual_present, + Some(frameclock::HostTime(1_000_000_000)) + ); + } + + #[test] + fn single_notify_in_flight() { + let mut source = X11PresentSource::new(); + assert!(source.mark_notify_requested()); + assert!(!source.mark_notify_requested()); + source.on_notify_complete(1_000_000, 1); + assert!(source.mark_notify_requested()); + } + + #[test] + fn submissions_get_monotonic_ids() { + let mut source = X11PresentSource::new(); + assert_eq!(source.next_submission(), SubmissionId(0)); + assert_eq!(source.next_submission(), SubmissionId(1)); + assert_eq!(source.next_submission(), SubmissionId(2)); + } + + #[test] + fn pixmap_completion_and_idle_are_feedback() { + let mut source = X11PresentSource::new(); + let id = source.next_submission(); + source.on_pixmap_complete(id.0, 2_000_000, 200, 1); + source.on_idle(id.0); + + let presented = source.poll_present_event().expect("a presented event"); + match presented { + PresentEvent::Presented { + id: got, msc, mode, .. + } => { + assert_eq!(got, id); + assert_eq!(msc, 200); + assert_eq!(mode, 1); + } + other => panic!("expected Presented, got {other:?}"), + } + assert_eq!(source.poll_present_event(), Some(PresentEvent::Idle { id })); + assert_eq!(source.poll_present_event(), None); + } + + #[test] + fn refresh_interval_emerges_after_second_completion() { + let mut source = X11PresentSource::new(); + assert!(source.mark_notify_requested()); + source.on_notify_complete(1_000_000, 100); + assert_eq!(source.observed_refresh_interval(), None); + assert!(source.mark_notify_requested()); + source.on_notify_complete(1_016_667, 101); + assert_eq!(source.observed_refresh_interval(), Some(16_667_000)); + } +}