diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d86655c..14c178e 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -14,6 +14,7 @@ env:
RUST_MIN_VER_PKGS: >-
-p frameclock
-p frameclock_apple
+ -p frameclock_wayland
-p frameclock_web
-p subduction_backend_android
-p subduction_backend_apple
@@ -28,6 +29,7 @@ env:
RUST_STD_ONLY_PKGS: >-
--exclude frameclock_apple
--exclude frameclock_simulated
+ --exclude frameclock_wayland
--exclude macos_layers
--exclude macos_lotta_layers
--exclude macos_wgpu
@@ -56,6 +58,7 @@ env:
RUST_NO_WASIP1_PKGS: >-
--exclude frameclock_web
--exclude frameclock_apple
+ --exclude frameclock_wayland
--exclude macos_layers
--exclude macos_lotta_layers
--exclude macos_wgpu
@@ -79,6 +82,7 @@ env:
# List of packages that don't support wasm32.
RUST_NO_WASM_PKGS: >-
--exclude frameclock_apple
+ --exclude frameclock_wayland
--exclude macos_layers
--exclude macos_lotta_layers
--exclude macos_wgpu
@@ -106,6 +110,7 @@ env:
--exclude windows_lotta_layers
# Packages excluded on non-Linux runners (Linux-only).
RUST_LINUX_EXCLUDE_PKGS: >-
+ --exclude frameclock_wayland
--exclude subduction_backend_wayland
--exclude wayland_example_common
--exclude wayland_layers
diff --git a/Cargo.lock b/Cargo.lock
index c04deb1..d1d6c25 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -474,6 +474,14 @@ dependencies = [
"frameclock",
]
+[[package]]
+name = "frameclock_wayland"
+version = "0.0.1"
+dependencies = [
+ "frameclock",
+ "rustix 1.1.4",
+]
+
[[package]]
name = "frameclock_web"
version = "0.0.1"
diff --git a/Cargo.toml b/Cargo.toml
index f27e8d1..52925ba 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,6 +4,7 @@ members = [
"frameclock",
"frameclock_web",
"frameclock_apple",
+ "frameclock_wayland",
"subduction_backend_apple",
"subduction_backend_web",
"subduction_backend_wgpu",
@@ -64,6 +65,7 @@ repository = "https://github.com/forest-rs/subduction"
[workspace.dependencies]
frameclock = { version = "0.0.1", path = "frameclock" }
frameclock_apple = { version = "0.0.1", path = "frameclock_apple", default-features = false }
+frameclock_wayland = { version = "0.0.1", path = "frameclock_wayland" }
frameclock_web = { version = "0.0.1", path = "frameclock_web" }
subduction_core = { path = "subduction_core" }
subduction_sync_harness = { path = "subduction_sync_harness" }
diff --git a/frameclock_wayland/CHANGELOG.md b/frameclock_wayland/CHANGELOG.md
new file mode 100644
index 0000000..6a4357b
--- /dev/null
+++ b/frameclock_wayland/CHANGELOG.md
@@ -0,0 +1,23 @@
+
+
+# Changelog
+
+The latest published `frameclock_wayland` release is [0.0.1](#001-XXXX-XX-XX), which was released on
+XXXX-XX-XX. You can find its changes [documented below](#001-XXXX-XX-XX).
+
+## [Unreleased]
+
+This release has an [MSRV][] of 1.92.
+
+This is the initial release.
+
+[Unreleased]: https://github.com/forest-rs/subduction/compare/frameclock_wayland-v0.0.1...HEAD
+[0.0.1]: https://github.com/forest-rs/subduction/releases/tag/frameclock_wayland-v0.0.1
+
+[MSRV]: README.md#minimum-supported-rust-version-msrv
diff --git a/frameclock_wayland/Cargo.toml b/frameclock_wayland/Cargo.toml
new file mode 100644
index 0000000..4002ee7
--- /dev/null
+++ b/frameclock_wayland/Cargo.toml
@@ -0,0 +1,18 @@
+[package]
+name = "frameclock_wayland"
+description = "Wayland timing adapters for frameclock"
+readme = "README.md"
+version.workspace = true
+edition.workspace = true
+rust-version.workspace = true
+license.workspace = true
+repository.workspace = true
+keywords = ["frame-pacing", "timing", "wayland", "presentation-time", "linux"]
+categories = ["os::linux-apis", "rendering::engine"]
+
+[lints]
+workspace = true
+
+[dependencies]
+frameclock = { workspace = true }
+rustix = { version = "1.1.3", default-features = false, features = ["time"] }
diff --git a/frameclock_wayland/LICENSE-APACHE b/frameclock_wayland/LICENSE-APACHE
new file mode 100644
index 0000000..d9a10c0
--- /dev/null
+++ b/frameclock_wayland/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_wayland/LICENSE-MIT b/frameclock_wayland/LICENSE-MIT
new file mode 100644
index 0000000..9cf1062
--- /dev/null
+++ b/frameclock_wayland/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_wayland/README.md b/frameclock_wayland/README.md
new file mode 100644
index 0000000..9aff2a1
--- /dev/null
+++ b/frameclock_wayland/README.md
@@ -0,0 +1,89 @@
+
+
+# Frameclock Wayland
+
+**Wayland timing adapters for `frameclock`.**
+
+
+
+`frameclock_wayland` connects Wayland frame timing to `frameclock`. It selects
+and reads the compositor-aligned presentation clock as `HostTime`, converts
+`wl_surface.frame` callback completions into `FrameTick` values, and carries
+`wp_presentation` feedback facts as `PresentEvent` values for scheduler
+feedback.
+
+The crate intentionally does not own `wl_surface` objects, event queues,
+buffers, registries, or protocol dispatch. Protocol I/O belongs to hosts and
+backend crates such as `subduction_backend_wayland`; this crate owns the
+timing bookkeeping those hosts feed and poll. Present-hint computation and a
+retained `FrameDriver` wrapper are left to future implementation here.
+
+## Core Flow
+
+```text
+wl_surface.frame done -> TickerState -> FrameTick
+wp_presentation_feedback events -> PresentEvent -> PresentEventQueue
+wp_presentation.clock_id -> Clock -> HostTime reads
+```
+
+Use `TickerState` as the frame-callback bookkeeping for one surface: call
+`mark_callback_requested` to claim the single in-flight slot before sending a
+`wl_surface.frame` request (it returns `false` if a callback is already in
+flight), call `on_callback_done` when the matching `wl_callback.done` event
+arrives, and drain resulting ticks with `poll_tick`. A `TickerState` models a single paced
+surface/output stream — create one per `wl_surface`, pass it a stable
+`OutputId`, and feed it only that surface's presentation feedback. Hosts that
+multiplex several surfaces on one queue should keep a `TickerState` per stream
+and correlate feedback to the right stream by `SubmissionId` themselves.
+
+Use `presentation_time_to_host_time` to convert
+`wp_presentation_feedback.presented` timestamps, store the most recent value
+via `TickerState::set_last_observed_actual_present` so the next tick carries
+`FrameTick::prev_actual_present`, and queue per-commit `PresentEvent`s in a
+`PresentEventQueue` correlated by `SubmissionId`.
+
+Use `Clock::from_presentation_clock_id` to map the `wp_presentation.clock_id`
+event to a `Clock`, and read all timing facts from that clock so feedback
+timestamps and tick times stay in one time domain.
+
+## Timing Model
+
+`now`, `Clock::now`, and all converted timestamps are nanosecond ticks;
+`timebase` returns the identity nanosecond `Timebase`.
+
+Wayland frame callbacks carry no predicted present time or refresh interval,
+so emitted `FrameTick`s are pacing-only facts. Actual presentation evidence
+arrives separately through `wp_presentation` feedback: the previous frame's
+actual present time is surfaced as `FrameTick::prev_actual_present`, and the
+full per-commit event stream is available as `PresentEvent` values for hosts
+that resolve feedback by `SubmissionId`.
+
+When the compositor advertises `wp_presentation`, its `clock_id` event names
+the clock domain of all feedback timestamps. Hosts should switch their reads
+to that clock (`Clock::Presentation`) so `HostTime` comparisons remain valid.
+`Clock::Monotonic` is the fallback when `wp_presentation` is missing or the
+advertised clock is unknown.
+
+Compositors stop delivering frame callbacks while a surface is occluded or
+minimised, so the tick stream can stall. Hosts should treat tick starvation
+as normal Wayland behaviour: idle, apply a timeout, or fall back to a
+timer-based tick source.
+
+## 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_wayland/src/lib.rs b/frameclock_wayland/src/lib.rs
new file mode 100644
index 0000000..4b09cfe
--- /dev/null
+++ b/frameclock_wayland/src/lib.rs
@@ -0,0 +1,73 @@
+// Copyright 2026 the Frameclock Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! Wayland timing adapters for [`frameclock`].
+//!
+//! This crate owns Wayland-specific timing adaptation. It selects and reads
+//! the compositor-aligned presentation clock as [`HostTime`], converts
+//! `wl_surface.frame` callback completions into [`FrameTick`] values via
+//! [`TickerState`], and carries `wp_presentation` feedback facts as
+//! [`PresentEvent`] values.
+//!
+//! It intentionally does not own `wl_surface` objects, event queues, buffers,
+//! registries, or protocol dispatch. Protocol I/O belongs to hosts and backend
+//! crates; this crate owns the timing bookkeeping those hosts feed and poll.
+//!
+//! # Core Flow
+//!
+//! ```text
+//! wl_surface.frame done -> TickerState -> FrameTick
+//! wp_presentation_feedback events -> PresentEvent -> PresentEventQueue
+//! wp_presentation.clock_id -> Clock -> HostTime reads
+//! ```
+//!
+//! A host's frame-callback dispatch path has this shape:
+//!
+//! ```rust,ignore
+//! use frameclock::OutputId;
+//! use frameclock_wayland::{Clock, TickerState};
+//!
+//! let mut ticker = TickerState::new();
+//! let clock = Clock::Monotonic;
+//!
+//! // Claim the single in-flight slot before sending a wl_surface.frame request:
+//! if ticker.mark_callback_requested() {
+//! // send the wl_surface.frame request
+//! }
+//!
+//! // When the matching wl_callback.done event arrives:
+//! ticker.on_callback_done(clock, OutputId(0));
+//!
+//! // After dispatch, drain the queued ticks:
+//! while let Some(tick) = ticker.poll_tick() {
+//! // Build a FrameOpportunity and plan the frame.
+//! _ = tick;
+//! }
+//! ```
+//!
+//! All `HostTime` values are nanosecond ticks. When the compositor advertises
+//! `wp_presentation`, map its `clock_id` event to a [`Clock`] with
+//! [`Clock::from_presentation_clock_id`] and read timing facts from that clock
+//! so feedback timestamps and tick times stay in one time domain.
+//!
+//! This crate keeps its implementation `no_std` (with `alloc`), but reading
+//! clocks requires an operating system. It is intended to be validated on
+//! Linux targets, not on generic no-std targets such as `x86_64-unknown-none`.
+//!
+//! [`HostTime`]: frameclock::HostTime
+//! [`FrameTick`]: frameclock::FrameTick
+
+#![no_std]
+
+extern crate alloc;
+
+mod presentation;
+mod queue;
+mod tick;
+mod time;
+
+pub use presentation::{
+ PresentEvent, PresentEventQueue, SubmissionId, presentation_time_to_host_time,
+};
+pub use tick::TickerState;
+pub use time::{Clock, now, timebase};
diff --git a/frameclock_wayland/src/presentation.rs b/frameclock_wayland/src/presentation.rs
new file mode 100644
index 0000000..f376f02
--- /dev/null
+++ b/frameclock_wayland/src/presentation.rs
@@ -0,0 +1,243 @@
+// Copyright 2026 the Frameclock Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! Presentation feedback contracts and queueing.
+
+use crate::queue::BoundedQueue;
+use frameclock::{HostTime, OutputId};
+
+/// Unique identity for one `wl_surface.commit` submission.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
+pub struct SubmissionId(pub u64);
+
+/// Converts a `wp_presentation_feedback.presented` timestamp to [`HostTime`].
+///
+/// `tv_nsec` is clamped to `≤999_999_999` to prevent overflow. Arithmetic is
+/// saturating so edge-case compositor data never causes a panic.
+#[must_use]
+pub fn presentation_time_to_host_time(tv_sec_hi: u32, tv_sec_lo: u32, tv_nsec: u32) -> HostTime {
+ let seconds = u64::from(tv_sec_hi) << 32 | u64::from(tv_sec_lo);
+ let nanos = seconds
+ .saturating_mul(1_000_000_000)
+ .saturating_add(u64::from(tv_nsec.min(999_999_999)));
+ HostTime(nanos)
+}
+
+/// Per-commit presentation feedback event.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
+pub enum PresentEvent {
+ /// The submission was presented.
+ Presented {
+ /// Identity of the commit this event corresponds to.
+ id: SubmissionId,
+ /// Actual presentation timestamp in host-time ticks.
+ actual_present: HostTime,
+ /// Observed refresh interval in host ticks, if known.
+ refresh_interval: Option,
+ /// Output where the frame was shown, if known.
+ output: Option,
+ /// Raw protocol flags.
+ flags: u32,
+ },
+ /// The compositor discarded the submission.
+ Discarded {
+ /// Identity of the commit this event corresponds to.
+ id: SubmissionId,
+ },
+}
+
+/// Bounded FIFO queue for [`PresentEvent`] values.
+///
+/// Overflow policy is `drop_oldest`: when full, pushing a new event removes
+/// the oldest queued event first. This keeps newest feedback available to the
+/// host 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 presentation 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, presentation_time_to_host_time};
+ use frameclock::HostTime;
+ use frameclock::OutputId;
+
+ #[test]
+ fn queue_overflow_drops_oldest_event() {
+ let mut queue = PresentEventQueue::with_capacity(2);
+ queue.push(PresentEvent::Discarded {
+ id: SubmissionId(1),
+ });
+ queue.push(PresentEvent::Discarded {
+ id: SubmissionId(2),
+ });
+ queue.push(PresentEvent::Discarded {
+ id: SubmissionId(3),
+ });
+
+ assert_eq!(
+ queue.pop(),
+ Some(PresentEvent::Discarded {
+ id: SubmissionId(2)
+ })
+ );
+ assert_eq!(
+ queue.pop(),
+ Some(PresentEvent::Discarded {
+ id: SubmissionId(3)
+ })
+ );
+ assert_eq!(queue.pop(), None);
+ assert_eq!(queue.dropped_count(), 1);
+ }
+
+ #[test]
+ fn presented_event_round_trips_payload() {
+ let event = PresentEvent::Presented {
+ id: SubmissionId(9),
+ actual_present: HostTime(123),
+ refresh_interval: Some(16_666_667),
+ output: Some(OutputId(4)),
+ flags: 7,
+ };
+
+ let mut queue = PresentEventQueue::with_capacity(4);
+ queue.push(event);
+ assert_eq!(queue.pop(), Some(event));
+ }
+
+ #[test]
+ fn zero_capacity_is_promoted_to_one() {
+ let mut queue = PresentEventQueue::with_capacity(0);
+ queue.push(PresentEvent::Discarded {
+ id: SubmissionId(1),
+ });
+ queue.push(PresentEvent::Discarded {
+ id: SubmissionId(2),
+ });
+
+ assert_eq!(queue.len(), 1);
+ assert_eq!(
+ queue.pop(),
+ Some(PresentEvent::Discarded {
+ id: SubmissionId(2)
+ })
+ );
+ assert_eq!(queue.dropped_count(), 1);
+ }
+
+ // --- presentation_time_to_host_time tests ---
+
+ #[test]
+ fn timestamp_packing_normal_values() {
+ // 1 second + 500_000_000 ns
+ let t = presentation_time_to_host_time(0, 1, 500_000_000);
+ assert_eq!(t, HostTime(1_500_000_000));
+ }
+
+ #[test]
+ fn timestamp_packing_large_tv_sec_hi() {
+ // tv_sec_hi = 1 means seconds = 1 << 32 = 4_294_967_296
+ let t = presentation_time_to_host_time(1, 0, 0);
+ assert_eq!(t, HostTime(4_294_967_296 * 1_000_000_000));
+ }
+
+ #[test]
+ fn timestamp_packing_zero() {
+ let t = presentation_time_to_host_time(0, 0, 0);
+ assert_eq!(t, HostTime(0));
+ }
+
+ #[test]
+ fn timestamp_packing_saturates_on_overflow() {
+ // u32::MAX across all fields should saturate rather than panic.
+ let t = presentation_time_to_host_time(u32::MAX, u32::MAX, u32::MAX);
+ assert_eq!(t, HostTime(u64::MAX));
+ }
+
+ #[test]
+ fn tv_nsec_clamped_above_max() {
+ // tv_nsec > 999_999_999 is clamped.
+ let clamped = presentation_time_to_host_time(0, 0, 1_500_000_000);
+ let expected = presentation_time_to_host_time(0, 0, 999_999_999);
+ assert_eq!(clamped, expected);
+ }
+
+ #[test]
+ fn tv_nsec_exact_max_is_not_clamped() {
+ let t = presentation_time_to_host_time(0, 0, 999_999_999);
+ assert_eq!(t, HostTime(999_999_999));
+ }
+
+ #[test]
+ fn refresh_zero_produces_none() {
+ // Verify the conversion convention: refresh == 0 → None.
+ let refresh: u32 = 0;
+ let interval: Option = if refresh == 0 {
+ None
+ } else {
+ Some(u64::from(refresh))
+ };
+ assert_eq!(interval, None);
+ }
+
+ #[test]
+ fn refresh_nonzero_produces_some() {
+ let refresh: u32 = 16_666_667;
+ let interval: Option = if refresh == 0 {
+ None
+ } else {
+ Some(u64::from(refresh))
+ };
+ assert_eq!(interval, Some(16_666_667));
+ }
+}
diff --git a/frameclock_wayland/src/queue.rs b/frameclock_wayland/src/queue.rs
new file mode 100644
index 0000000..158fd16
--- /dev/null
+++ b/frameclock_wayland/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_wayland/src/tick.rs b/frameclock_wayland/src/tick.rs
new file mode 100644
index 0000000..a89b1fa
--- /dev/null
+++ b/frameclock_wayland/src/tick.rs
@@ -0,0 +1,356 @@
+// Copyright 2026 the Frameclock Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! Frame-tick queueing primitives and ticker state machine.
+
+use crate::queue::BoundedQueue;
+use crate::time::Clock;
+use frameclock::{FrameTick, HostTime, OutputId};
+
+/// 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()
+ }
+
+ #[allow(dead_code, reason = "used by tests and future diagnostics")]
+ pub(crate) fn len(&self) -> usize {
+ self.inner.len()
+ }
+
+ #[allow(dead_code, reason = "used by tests and future diagnostics")]
+ pub(crate) fn is_empty(&self) -> bool {
+ self.inner.is_empty()
+ }
+
+ #[allow(dead_code, reason = "used by tests and future diagnostics")]
+ pub(crate) fn dropped_count(&self) -> u64 {
+ self.inner.dropped_count()
+ }
+}
+
+impl Default for TickQueue {
+ fn default() -> Self {
+ Self::with_capacity(Self::DEFAULT_CAPACITY)
+ }
+}
+
+/// Pure-logic state machine for frame callback tick generation.
+///
+/// Tracks the in-flight callback state, builds [`FrameTick`]s when callbacks
+/// complete, and queues them for polling. Protocol I/O is handled externally;
+/// this type contains only the bookkeeping. Hosts call
+/// [`mark_callback_requested`](Self::mark_callback_requested) when sending a
+/// `wl_surface.frame` request, [`on_callback_done`](Self::on_callback_done)
+/// when the matching `wl_callback.done` event arrives, and drain ticks with
+/// [`poll_tick`](Self::poll_tick).
+///
+/// # One stream per surface
+///
+/// A `TickerState` models a single paced surface/output stream. Create one
+/// instance per `wl_surface` you pace, drive it only with that surface's frame
+/// callbacks, and pass a stable [`OutputId`] for the stream to
+/// [`on_callback_done`](Self::on_callback_done).
+///
+/// The ticker keeps a single most-recent actual-present timestamp (see
+/// [`set_last_observed_actual_present`](Self::set_last_observed_actual_present))
+/// and stamps it onto the next tick's [`FrameTick::prev_actual_present`]. Feed
+/// it only presentation feedback for the same surface/output stream: mixing in
+/// feedback from an unrelated surface or output would attribute one surface's
+/// presentation to another. Hosts that multiplex several surfaces on one event
+/// queue should keep a `TickerState` per stream and correlate presentation
+/// feedback to the right stream themselves — for example by the
+/// [`SubmissionId`](crate::SubmissionId) carried on each
+/// [`PresentEvent`](crate::PresentEvent).
+#[derive(Debug)]
+pub struct TickerState {
+ queue: TickQueue,
+ tick_index: u64,
+ callback_in_flight: bool,
+ last_observed_actual_present: Option,
+}
+
+impl TickerState {
+ /// Creates an empty ticker with no callback in flight.
+ #[must_use]
+ pub fn new() -> Self {
+ Self {
+ queue: TickQueue::default(),
+ tick_index: 0,
+ callback_in_flight: false,
+ last_observed_actual_present: None,
+ }
+ }
+
+ /// Records that a `wl_callback.done` event has arrived.
+ ///
+ /// If a callback is in flight, builds a pacing-only [`FrameTick`] for
+ /// `output` with the current time read from `clock`, enqueues it,
+ /// increments the tick index, and clears the in-flight flag. If no
+ /// callback is in flight, debug-asserts and returns.
+ ///
+ /// `output` should identify this stream's current target output and stay
+ /// stable for the stream's lifetime; refresh it only when the surface
+ /// actually moves between outputs.
+ pub fn on_callback_done(&mut self, clock: Clock, output: OutputId) {
+ debug_assert!(
+ self.callback_in_flight,
+ "on_callback_done called without an in-flight callback"
+ );
+ if !self.callback_in_flight {
+ return;
+ }
+
+ let now = clock.now();
+ let prev_actual_present = self.last_observed_actual_present;
+
+ let tick = FrameTick {
+ now,
+ predicted_present: None,
+ refresh_interval: None,
+ frame_index: self.tick_index,
+ output,
+ prev_actual_present,
+ };
+
+ self.queue.push(tick);
+ self.tick_index += 1;
+ self.callback_in_flight = false;
+ }
+
+ /// Pops the next queued [`FrameTick`], if any.
+ pub fn poll_tick(&mut self) -> Option {
+ self.queue.pop()
+ }
+
+ /// Returns whether a frame callback is currently in flight.
+ #[must_use]
+ pub fn is_callback_in_flight(&self) -> bool {
+ self.callback_in_flight
+ }
+
+ /// Claims the single in-flight callback slot before a `wl_surface.frame`
+ /// request is sent.
+ ///
+ /// Only one frame callback may be in flight at a time. Returns `true` when
+ /// the slot was newly claimed and the caller should send the
+ /// `wl_surface.frame` request. Returns `false` when a callback is already
+ /// in flight; in that case the ticker state is left unchanged and the
+ /// caller must not request another callback. The slot is released when the
+ /// matching [`on_callback_done`](Self::on_callback_done) runs.
+ #[must_use = "a false return means a callback is already in flight and no new frame request should be sent"]
+ pub fn mark_callback_requested(&mut self) -> bool {
+ if self.callback_in_flight {
+ return false;
+ }
+ self.callback_in_flight = true;
+ true
+ }
+
+ /// Stores the most recent actual present time for propagation into the
+ /// next [`FrameTick::prev_actual_present`].
+ ///
+ /// Feed this only with presentation feedback for the same surface/output
+ /// stream this ticker paces (see the [type-level contract](Self#one-stream-per-surface)).
+ pub fn set_last_observed_actual_present(&mut self, t: HostTime) {
+ self.last_observed_actual_present = Some(t);
+ }
+}
+
+impl Default for TickerState {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::{TickQueue, TickerState};
+ use crate::time::Clock;
+ use frameclock::FrameTick;
+ use frameclock::HostTime;
+ use frameclock::OutputId;
+
+ fn test_tick(frame_index: u64) -> FrameTick {
+ FrameTick {
+ now: HostTime(frame_index),
+ predicted_present: None,
+ refresh_interval: None,
+ frame_index,
+ output: OutputId(0),
+ prev_actual_present: None,
+ }
+ }
+
+ // --- TickQueue tests ---
+
+ #[test]
+ fn overflow_drops_oldest_tick() {
+ let mut queue = TickQueue::with_capacity(2);
+ queue.push(test_tick(1));
+ queue.push(test_tick(2));
+ queue.push(test_tick(3));
+
+ assert_eq!(queue.pop().map(|tick| tick.frame_index), Some(2));
+ assert_eq!(queue.pop().map(|tick| tick.frame_index), Some(3));
+ assert_eq!(queue.pop(), None);
+ assert_eq!(queue.dropped_count(), 1);
+ }
+
+ #[test]
+ fn empty_state_tracks_push_and_pop() {
+ let mut queue = TickQueue::default();
+ assert!(queue.is_empty());
+ assert_eq!(queue.len(), 0);
+
+ queue.push(test_tick(7));
+ assert!(!queue.is_empty());
+ assert_eq!(queue.len(), 1);
+
+ let _ = queue.pop();
+ assert!(queue.is_empty());
+ assert_eq!(queue.len(), 0);
+ }
+
+ // --- TickerState tests ---
+
+ #[test]
+ fn on_callback_done_enqueues_tick_with_correct_fields() {
+ let mut ticker = TickerState::new();
+
+ assert!(ticker.mark_callback_requested());
+ ticker.on_callback_done(Clock::Monotonic, OutputId(0));
+
+ let tick = ticker.poll_tick().expect("should have a tick");
+ assert!(tick.now.ticks() > 0);
+ assert_eq!(tick.predicted_present, None);
+ assert_eq!(tick.refresh_interval, None);
+ assert_eq!(tick.frame_index, 0);
+ assert_eq!(tick.output, OutputId(0));
+ assert_eq!(tick.prev_actual_present, None);
+ }
+
+ #[test]
+ fn on_callback_done_uses_caller_output() {
+ let mut ticker = TickerState::new();
+
+ assert!(ticker.mark_callback_requested());
+ ticker.on_callback_done(Clock::Monotonic, OutputId(3));
+
+ let tick = ticker.poll_tick().expect("should have a tick");
+ assert_eq!(tick.output, OutputId(3));
+ }
+
+ #[test]
+ fn poll_tick_returns_none_when_empty() {
+ let mut ticker = TickerState::new();
+ assert!(ticker.poll_tick().is_none());
+ }
+
+ #[test]
+ fn tick_index_increments_monotonically() {
+ let mut ticker = TickerState::new();
+
+ for expected in 0..5 {
+ assert!(ticker.mark_callback_requested());
+ ticker.on_callback_done(Clock::Monotonic, OutputId(0));
+ let tick = ticker.poll_tick().unwrap();
+ assert_eq!(tick.frame_index, expected);
+ }
+ }
+
+ #[test]
+ fn callback_in_flight_transitions() {
+ let mut ticker = TickerState::new();
+
+ assert!(!ticker.is_callback_in_flight());
+ assert!(ticker.mark_callback_requested());
+ assert!(ticker.is_callback_in_flight());
+ ticker.on_callback_done(Clock::Monotonic, OutputId(0));
+ assert!(!ticker.is_callback_in_flight());
+ }
+
+ #[test]
+ fn mark_callback_requested_rejects_double_request() {
+ let mut ticker = TickerState::new();
+
+ // First request claims the in-flight slot.
+ assert!(ticker.mark_callback_requested());
+ assert!(ticker.is_callback_in_flight());
+
+ // A second request while one is in flight is rejected and leaves the
+ // state unchanged.
+ assert!(!ticker.mark_callback_requested());
+ assert!(ticker.is_callback_in_flight());
+
+ // After the callback completes, the slot can be claimed again.
+ ticker.on_callback_done(Clock::Monotonic, OutputId(0));
+ assert!(ticker.mark_callback_requested());
+ }
+
+ #[test]
+ fn last_observed_actual_present_propagates() {
+ let mut ticker = TickerState::new();
+
+ // First tick: no previous actual present.
+ assert!(ticker.mark_callback_requested());
+ ticker.on_callback_done(Clock::Monotonic, OutputId(0));
+ let tick0 = ticker.poll_tick().unwrap();
+ assert_eq!(tick0.prev_actual_present, None);
+
+ // Record an actual present time.
+ ticker.set_last_observed_actual_present(HostTime(42_000));
+
+ // Second tick: should carry the observed time.
+ assert!(ticker.mark_callback_requested());
+ ticker.on_callback_done(Clock::Monotonic, OutputId(0));
+ let tick1 = ticker.poll_tick().unwrap();
+ assert_eq!(tick1.prev_actual_present, Some(HostTime(42_000)));
+ }
+
+ #[test]
+ fn done_when_not_in_flight_is_ignored() {
+ let mut ticker = TickerState::new();
+
+ // Calling on_callback_done without mark_callback_requested is guarded
+ // by debug_assert, so in release builds it returns without enqueuing.
+ // We verify the initial state reflects no enqueue path.
+ assert!(!ticker.is_callback_in_flight());
+ assert!(ticker.poll_tick().is_none());
+ }
+
+ #[test]
+ fn queue_overflow_drops_oldest_through_ticker() {
+ // TickQueue default capacity is 8; push 9 ticks and verify the first
+ // is dropped.
+ let mut ticker = TickerState::new();
+
+ for _ in 0..9 {
+ assert!(ticker.mark_callback_requested());
+ ticker.on_callback_done(Clock::Monotonic, OutputId(0));
+ }
+
+ // First available tick should be index 1 (index 0 was dropped).
+ let tick = ticker.poll_tick().unwrap();
+ assert_eq!(tick.frame_index, 1);
+ }
+}
diff --git a/frameclock_wayland/src/time.rs b/frameclock_wayland/src/time.rs
new file mode 100644
index 0000000..86520c0
--- /dev/null
+++ b/frameclock_wayland/src/time.rs
@@ -0,0 +1,162 @@
+// Copyright 2026 the Frameclock Authors
+// SPDX-License-Identifier: Apache-2.0 OR MIT
+
+//! Wayland host clock selection and reads.
+
+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;
+
+/// Clock source used for Wayland timing facts.
+#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
+pub enum Clock {
+ /// `CLOCK_MONOTONIC` fallback clock.
+ #[default]
+ Monotonic,
+ /// Clock selected from `wp_presentation.clock_id`.
+ Presentation(PosixClockId),
+}
+
+impl Clock {
+ /// Attempts to map a `wp_presentation.clock_id` value to a [`Clock`].
+ ///
+ /// Returns `Some(Clock::Presentation(...))` if the raw id maps to a POSIX
+ /// clock recognized by the platform. Returns `None` for unknown or
+ /// out-of-range values, in which case the caller should keep the current
+ /// clock and degrade gracefully.
+ #[must_use]
+ pub fn from_presentation_clock_id(clk_id: u32) -> Option {
+ // `PosixClockId` is `u32` on Apple platforms, `i32` elsewhere
+ #[cfg(not(target_vendor = "apple"))]
+ let posix_id = {
+ let raw = i32::try_from(clk_id).ok()?;
+ PosixClockId::try_from(raw).ok()?
+ };
+ #[cfg(target_vendor = "apple")]
+ let posix_id = PosixClockId::try_from(clk_id).ok()?;
+ Some(Self::Presentation(posix_id))
+ }
+
+ /// 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,
+ Self::Presentation(clock_id) => clock_id,
+ }
+ }
+}
+
+/// Returns the Wayland [`Timebase`]: host ticks are nanoseconds.
+#[must_use]
+pub const fn timebase() -> Timebase {
+ Timebase::NANOS
+}
+
+/// Returns the current monotonic host time in nanoseconds.
+///
+/// Hosts tracking a `wp_presentation` clock domain should prefer
+/// [`Clock::now`] on the selected clock so all timing facts stay comparable.
+#[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};
+ use frameclock::HostTime;
+ use frameclock::time::Timebase;
+ use rustix::time::{ClockId as PosixClockId, 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 presentation_clock_variant_is_usable() {
+ let tick = Clock::Presentation(PosixClockId::Monotonic).now();
+ assert!(
+ tick.ticks() > 0,
+ "clock_gettime(monotonic) should be positive"
+ );
+ }
+
+ #[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));
+ }
+
+ #[test]
+ fn clock_from_known_monotonic_id() {
+ let clk_id = PosixClockId::Monotonic as u32;
+ let clock = Clock::from_presentation_clock_id(clk_id).unwrap();
+ assert_eq!(clock, Clock::Presentation(PosixClockId::Monotonic));
+ // The returned clock must be readable.
+ assert!(clock.now().ticks() > 0);
+ }
+
+ #[cfg(any(target_os = "linux", target_os = "android"))]
+ #[test]
+ fn clock_from_known_monotonic_raw_id() {
+ let clk_id = PosixClockId::MonotonicRaw as u32;
+ let clock = Clock::from_presentation_clock_id(clk_id).unwrap();
+ assert_eq!(clock, Clock::Presentation(PosixClockId::MonotonicRaw));
+ assert!(clock.now().ticks() > 0);
+ }
+
+ #[test]
+ fn clock_from_unknown_in_range_id() {
+ // A value that fits in i32 but is not a recognized POSIX clock.
+ assert!(Clock::from_presentation_clock_id(12345).is_none());
+ }
+
+ #[test]
+ fn clock_from_overflow_id() {
+ // u32::MAX overflows i32, should be rejected.
+ assert!(Clock::from_presentation_clock_id(u32::MAX).is_none());
+ }
+}