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));
+ }
+}