Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,36 @@ the `libdrmtap` wrapper crate all share ONE version. 0.5.0 declared that move an
did not complete it - the wrapper still shipped 0.3.4 pinned to a `-sys` range that
could not reach 0.5.0 - so the shared line only actually holds from 0.5.1.

## [0.5.9] - 2026-10-01

### Added: `drmtap_crtc_refresh()`, the exact refresh of the captured CRTC

`drmtap_display.refresh_hz` is the kernel's `vrefresh`, rounded to whole hertz, so the
cinema and broadcast rates lose their fraction: 59.94 reads 60, 23.976 reads 24, and
timings that are not a whole number (1280x1024 at 60.02, a 3840x2160 mode at 29.98)
read as if they were. A consumer that paces capture to the panel needs the real rate.
`drmtap_crtc_refresh(ctx, &num, &den)` returns it as the reduced fraction `num/den`
hertz, computed from the CRTC's current mode the way the kernel computes vrefresh
(pixel clock over the horizontal and vertical totals, with interlace, doublescan and
vscan) but not rounded. It is the rate the mode is programmed to: the kernel keeps the
pixel clock in kHz, so a 1080p "59.94" mode at 148352 kHz reads `148352/2475`
(59.94020 Hz), a few parts per million away from the nominal `60000/1001`.

No connector is probed: once the CRTC is known it is one `DRM_IOCTL_MODE_GETCRTC`, so
it can be called again to follow a mode change. On a context opened with `crtc_id` 0
the first call also picks the CRTC a grab would pick, and keeps that choice: the grab
and this call now share one helper for the auto-selection. `-ENODATA` when the CRTC
has no mode (disabled); a CRTC that is only blanked keeps its mode and answers.
`DrmTap::crtc_refresh()` in the safe wrapper returns `Result<Option<(u64, u64)>>`,
`None` for a CRTC with no mode.

Measured on i915: `60/1` for 1920x1080@60, `6750000/112463` (60.0197 Hz) for
1280x1024 and `131375/4382` (29.9806 Hz) for 3840x2160@30, matching the mode timings
in debugfs; `-ENODATA` on a disabled pipe; the auto-selected CRTC on a `crtc_id` 0
context. The mode-to-fraction step is pure and unit-tested on CEA-861 timings (60,
59.94, 50, 29.97, 23.976, 119.88, 1080i, doublescan, vscan) and on the widest values
the mode fields allow.

## [0.5.8] - 2026-09-25

### Added: `drmtap_plane_rotation()`, the rotation property of the plane
Expand Down Expand Up @@ -959,6 +989,7 @@ entry point is additive and would not on its own have justified more than a patc
fixes.

[0.5.2]: https://github.com/fxd0h/libdrmtap/releases/tag/v0.5.2
[0.5.9]: https://github.com/fxd0h/libdrmtap/releases/tag/v0.5.9
[0.5.8]: https://github.com/fxd0h/libdrmtap/releases/tag/v0.5.8
[0.5.7]: https://github.com/fxd0h/libdrmtap/releases/tag/v0.5.7
[0.5.6]: https://github.com/fxd0h/libdrmtap/releases/tag/v0.5.6
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ println!("{}x{} pixels captured", frame.width(), frame.height());
| Mapped RGBA output | ✅ Verified |
| Continuous capture (polling loop) | ✅ Verified |
| Cursor capture (position + pixels) | ✅ Verified on bare metal (amdgpu, i915) and on a para-virtualized driver (needs 0.5.4 there — see Known Limitations). Hotspot provenance (`drmtap_cursor_hotspot_valid()`, 0.5.6) verified in all three of its states: absent on i915, present on virtio-gpu, unavailable through a pre-0.5.6 helper |
| Exact CRTC refresh as a fraction (`drmtap_crtc_refresh()`, 0.5.9) | ✅ Verified on i915 (`60/1`, `6750000/112463` for 1280x1024 at 60.02 Hz, `131375/4382` for 3840x2160 at 29.98 Hz; `-ENODATA` on a disabled pipe) |
| Plane rotation (`drmtap_plane_rotation()`, 0.5.8) | ✅ Verified on i915 (mutter rotates 180 in hardware: `rotate-180`), on amdgpu with KWin (software rotation: `rotate-0`, scanout upside down) and on appletbdrm (no property: `-ENOTSUP`) |
| Privileged helper (setcap, no root) | ✅ Verified |
| Security hardening (cap drop + seccomp) | ✅ Implemented |
Expand Down
2 changes: 1 addition & 1 deletion bindings/rust/libdrmtap-sys/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "libdrmtap-sys"
version = "0.5.8"
version = "0.5.9"
links = "drmtap"
edition = "2021"
authors = ["Mariano Abad <weimaraner@gmail.com>"]
Expand Down
34 changes: 34 additions & 0 deletions bindings/rust/libdrmtap-sys/csrc/drm_enumerate.c
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,40 @@ static const char *const connector_type_names[] = {
#endif
};

static uint64_t refresh_gcd(uint64_t a, uint64_t b) {
while (b != 0) {
uint64_t t = a % b;
a = b;
b = t;
}
return a;
}

/* drm_mode_vrefresh() of the kernel as an exact fraction instead of whole hertz: the
* same pixel clock over the same totals, with interlace, doublescan and vscan, reduced.
* A 59.94 Hz 1080p mode (clock 148352 kHz) reads 148352/2475 and 1280x1024 at
* 108000 kHz reads 6750000/112463 (60.02 Hz), where vrefresh reads 60 for both. */
int drmtap_mode_refresh(const drmModeModeInfo *mode, uint64_t *num, uint64_t *den) {
if (!mode || !num || !den || mode->clock == 0 || mode->htotal == 0 || mode->vtotal == 0) {
return -EINVAL;
}
uint64_t n = (uint64_t)mode->clock * 1000u; /* kHz -> Hz */
uint64_t d = (uint64_t)mode->htotal * mode->vtotal;
if (mode->flags & DRM_MODE_FLAG_INTERLACE) {
n *= 2;
}
if (mode->flags & DRM_MODE_FLAG_DBLSCAN) {
d *= 2;
}
if (mode->vscan > 1) {
d *= mode->vscan;
}
uint64_t g = refresh_gcd(n, d);
*num = n / g;
*den = d / g;
return 0;
}

const char *drmtap_connector_type_name(uint32_t connector_type) {
if (connector_type < sizeof(connector_type_names) / sizeof(*connector_type_names)
&& connector_type_names[connector_type]) {
Expand Down
75 changes: 58 additions & 17 deletions bindings/rust/libdrmtap-sys/csrc/drm_grab.c
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,32 @@ static void read_hdr_metadata_direct(drmtap_ctx *ctx, uint32_t crtc_id) {
}
}

/* A context opened with crtc_id 0 captures the first CRTC that has a mode. It is chosen
* once and kept in ctx->crtc_id, so every later call answers for the same CRTC. */
static uint32_t select_first_active_crtc(drmtap_ctx *ctx) {
drmModeRes *res = drmModeGetResources(ctx->drm_fd);
if (!res) {
return 0;
}
uint32_t chosen = 0;
for (int i = 0; i < res->count_crtcs && chosen == 0; i++) {
drmModeCrtc *crtc = drmModeGetCrtc(ctx->drm_fd, res->crtcs[i]);
if (!crtc) {
continue;
}
if (crtc->mode_valid) {
chosen = crtc->crtc_id;
}
drmModeFreeCrtc(crtc);
}
drmModeFreeResources(res);
if (chosen != 0) {
ctx->crtc_id = chosen;
drmtap_debug_log(ctx, "auto-selected CRTC %u", chosen);
}
return chosen;
}

// Find the primary plane attached to the target CRTC
// Returns the plane_id or 0 on failure
static uint32_t find_primary_plane(drmtap_ctx *ctx) {
Expand All @@ -245,23 +271,7 @@ static uint32_t find_primary_plane(drmtap_ctx *ctx) {

/* If no CRTC selected, pick the first active one */
if (target_crtc == 0) {
drmModeRes *res = drmModeGetResources(ctx->drm_fd);
if (res) {
for (int i = 0; i < res->count_crtcs; i++) {
drmModeCrtc *crtc = drmModeGetCrtc(ctx->drm_fd, res->crtcs[i]);
if (crtc) {
if (crtc->mode_valid) {
target_crtc = crtc->crtc_id;
ctx->crtc_id = target_crtc;
drmtap_debug_log(ctx, "auto-selected CRTC %u", target_crtc);
drmModeFreeCrtc(crtc);
break;
}
drmModeFreeCrtc(crtc);
}
}
drmModeFreeResources(res);
}
target_crtc = select_first_active_crtc(ctx);
}

if (target_crtc == 0) {
Expand Down Expand Up @@ -325,6 +335,37 @@ static uint32_t find_primary_plane(drmtap_ctx *ctx) {
return result;
}

/* The exact refresh of the captured CRTC, from its current mode. No connector probe:
* once the CRTC is known it is one GETCRTC, so a caller can ask again to follow a
* mode change. */
int drmtap_crtc_refresh(drmtap_ctx *ctx, uint64_t *num, uint64_t *den) {
if (!ctx || !num || !den) {
return -EINVAL;
}
if (ctx->is_render_only) {
drmtap_set_error(ctx, "crtc refresh: a render-only context has no CRTC");
return -ENOTSUP;
}
uint32_t crtc_id = ctx->crtc_id ? ctx->crtc_id : select_first_active_crtc(ctx);
if (crtc_id == 0) {
drmtap_set_error(ctx, "crtc refresh: no CRTC with a mode to pick");
return -ENOENT;
}
drmModeCrtc *crtc = drmModeGetCrtc(ctx->drm_fd, crtc_id);
if (!crtc) {
int err = errno ? errno : EIO;
drmtap_set_error(ctx, "crtc %u: %s", crtc_id, strerror(err));
return -err;
}
int rc = crtc->mode_valid ? drmtap_mode_refresh(&crtc->mode, num, den) : -ENODATA;
drmModeFreeCrtc(crtc);
if (rc != 0) {
drmtap_set_error(ctx, "crtc %u has no mode with timings", crtc_id);
return -ENODATA;
}
return 0;
}

/* The DRM "rotation" property of the plane the last grab read from. Read now,
* so a consumer calls it right after the grab it wants to describe. The plane is
* the one do_grab or the fast path recorded (before any grab: the one a grab would
Expand Down
37 changes: 35 additions & 2 deletions bindings/rust/libdrmtap-sys/csrc/drmtap.h
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ extern "C" {
* `libdrmtap` Rust wrapper crate carries its own, separate version line. */
#define DRMTAP_VERSION_MAJOR 0
#define DRMTAP_VERSION_MINOR 5
#define DRMTAP_VERSION_PATCH 8
#define DRMTAP_VERSION_PATCH 9

/**
* @brief Get the library version as a packed integer.
Expand Down Expand Up @@ -130,7 +130,9 @@ typedef struct {
uint32_t y; /**< Y offset in virtual FB (from CRTC) */
uint32_t width; /**< Current mode width in pixels */
uint32_t height; /**< Current mode height in pixels */
uint32_t refresh_hz; /**< Vertical refresh rate */
uint32_t refresh_hz; /**< Vertical refresh, the kernel's whole-hertz vrefresh:
59.94 reads 60. drmtap_crtc_refresh() has the
exact rate */
int active; /**< 1 = display is on, 0 = disabled */
} drmtap_display;

Expand Down Expand Up @@ -633,6 +635,37 @@ const char *drmtap_gpu_driver(drmtap_ctx *ctx);
*/
int drmtap_plane_rotation(drmtap_ctx *ctx, uint32_t *rotation);

/**
* @brief Exact refresh rate of the CRTC this context captures, as a fraction.
*
* The refresh in hertz is *num / *den, reduced. It is computed from the CRTC's
* current mode the way the kernel computes vrefresh (pixel clock over the
* horizontal and vertical totals, with interlace, doublescan and vscan) but is
* not rounded to whole hertz, so cinema and broadcast rates keep their value:
* a 1080p mode programmed at 148352 kHz reads 148352/2475 (59.94020 Hz) and one
* at 74176 kHz with a 2750 total 296704/12375 (23.97608 Hz), where
* drmtap_display.refresh_hz reads 60 and 24. This is the rate the mode is
* programmed to; the kernel stores the pixel clock in kHz, so it can differ from
* the nominal 60000/1001 or 24000/1001 by a few parts per million.
*
* No connector is probed: once the CRTC is known it is one
* DRM_IOCTL_MODE_GETCRTC, so it is cheap to call again to follow a mode change.
* On a context opened with crtc_id 0 the first call (unless a grab ran first)
* also picks the CRTC a grab would pick, and keeps that choice. With variable
* refresh (VRR) it is the mode's nominal rate, the fastest the panel goes. A
* CRTC that is only blanked (DPMS off) keeps its mode and still answers.
*
* @param ctx Capture context (not a drmtap_open_render() context)
* @param num Set to the numerator (hertz) on success
* @param den Set to the denominator on success, never 0
* @return 0 on success; -EINVAL for a NULL argument; -ENOTSUP for a render-only
* context; -ENOENT when there is no CRTC with a mode to pick (or the
* device resources cannot be read); -ENODATA when the CRTC has no mode
* (disabled) or its mode has no timings; another negative errno if the
* CRTC cannot be read. Added in 0.5.9.
*/
int drmtap_crtc_refresh(drmtap_ctx *ctx, uint64_t *num, uint64_t *den);

/**
* @brief Get the underlying DRM file descriptor.
*
Expand Down
6 changes: 6 additions & 0 deletions bindings/rust/libdrmtap-sys/csrc/drmtap_internal.h
Original file line number Diff line number Diff line change
Expand Up @@ -431,4 +431,10 @@ uint32_t drmtap_scanout_width_of(uint32_t fb_width,
* Pure, so the whole table is testable without the matching hardware. */
const char *drmtap_connector_type_name(uint32_t connector_type);

/* Refresh of a mode in hertz as the exact reduced fraction *num / *den, computed like the
* kernel's drm_mode_vrefresh() but not rounded to whole hertz. -EINVAL for a mode with no
* clock or totals. Pure, so it is unit-tested. */
struct _drmModeModeInfo; /* drmModeModeInfo, from xf86drmMode.h */
int drmtap_mode_refresh(const struct _drmModeModeInfo *mode, uint64_t *num, uint64_t *den);

#endif /* DRMTAP_INTERNAL_H */
6 changes: 6 additions & 0 deletions bindings/rust/libdrmtap-sys/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,12 @@ extern "C" {
/// `0` on success, `-ENOTSUP` when the plane has no such property, `-ENOENT`
/// when no primary plane is bound, `-EINVAL` on a null argument. Added in 0.5.8.
pub fn drmtap_plane_rotation(ctx: *mut drmtap_ctx, rotation: *mut u32) -> c_int;
/// The exact refresh of the captured CRTC in hertz, as the reduced fraction
/// `*num / *den` of its current mode (59.94 Hz reads `148352/2475` where
/// `refresh_hz` reads 60). `0` on success, `-ENODATA` when the CRTC has no mode,
/// `-ENOENT` when there is no CRTC with a mode to pick, `-ENOTSUP` for a render-only
/// context, `-EINVAL` on a null argument. Added in 0.5.9.
pub fn drmtap_crtc_refresh(ctx: *mut drmtap_ctx, num: *mut u64, den: *mut u64) -> c_int;

// Pixel conversion
pub fn drmtap_deswizzle(
Expand Down
4 changes: 2 additions & 2 deletions bindings/rust/libdrmtap/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "libdrmtap"
version = "0.5.8"
version = "0.5.9"
edition = "2021"
# std::os::fd (BorrowedFd/OwnedFd), which the frame's fd accessors are built on, is 1.66.
# Declared so a downstream on an older toolchain gets that sentence instead of a type error.
Expand All @@ -16,7 +16,7 @@ keywords = ["drm", "kms", "screen-capture", "wayland", "remote-desktop"]
categories = ["multimedia::video", "os::linux-apis"]

[dependencies]
libdrmtap-sys = { version = "0.5.8", path = "../libdrmtap-sys" }
libdrmtap-sys = { version = "0.5.9", path = "../libdrmtap-sys" }
# Optional on purpose: a third-party type in a public signature ties our semver to theirs. See the
# `drm-fourcc` impl block in lib.rs.
drm-fourcc = { version = "2.2", optional = true }
Expand Down
1 change: 1 addition & 0 deletions bindings/rust/libdrmtap/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
- **`list_displays()`** — enumerate connected monitors
- **`displays_changed()`** — hotplug detection
- **`plane_rotation()`** (since 0.5.8): the DRM `rotation` bitmask the primary plane scans out with, read now, for the plane the last grab read from. `Some(0x1)` is rotate-0, `Some(0x4)` rotate-180 and so on (a reflection adds `0x10`/`0x20`). `None` means the plane has no `rotation` property: it cannot have turned the scanout, so treat it as rotate-0. No plane bound, or a property set that could not be read, is an `Err`. A frame from a plane at rotate-0 (or `None`) arrives turned by the whole output transform and has to be turned back by it; a frame from a plane that rotated or reflected is already upright and is left alone. Measured: mutter on i915 turns 180 in hardware (`0x4`, the scanout is upright); KWin on amdgpu turns in software (`0x1`, the scanout is upside down).
- **`crtc_refresh()`** (since 0.5.9): the exact refresh of the captured CRTC, in hertz, as the reduced fraction `(num, den)` of its current mode. A 59.94 Hz 1080p mode reads `(148352, 2475)` and a 23.976 Hz one `(296704, 12375)`, where `Display::refresh_hz` rounds to 60 and 24. No connector probe: once the CRTC is known it is one mode read, so it can be called again to follow a mode change. `None` when the CRTC has no mode (disabled); an `Err` when it cannot be read. Measured on i915: `(60, 1)` at 1920x1080@60, `(6750000, 112463)` (60.0197 Hz) at 1280x1024, `(131375, 4382)` (29.9806 Hz) at 3840x2160@30.
- **`Error::io_error()`** (since 0.5.7) — the error as an `io::Error` when it really is an errno.
Not every negative return is one: `drmtap_drm_fd()` uses a bare `-1` as a sentinel, so that value
answers `None` rather than being rendered as `EPERM`, an error nothing reported. The `code` field
Expand Down
21 changes: 21 additions & 0 deletions bindings/rust/libdrmtap/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,27 @@ impl DrmTap {
}
}

/// The exact refresh of the captured CRTC, in hertz, as the reduced fraction
/// `(num, den)` of its current mode: a 59.94 Hz 1080p mode reads `(148352, 2475)`
/// and a 23.976 Hz one `(296704, 12375)`, where [`Display::refresh_hz`] reads
/// 60 and 24. No connector probe: once the CRTC is known it is one mode read, so
/// it can be called again to follow a mode change. `None` when the CRTC has no
/// mode (disabled); an `Err` when it cannot be read. See `drmtap_crtc_refresh()`
/// in the header.
///
/// Available since 0.5.9.
pub fn crtc_refresh(&mut self) -> Result<Option<(u64, u64)>> {
let (mut num, mut den) = (0u64, 0u64);
let rc = unsafe { ffi::drmtap_crtc_refresh(self.ctx, &mut num, &mut den) };
if rc == 0 {
Ok(Some((num, den)))
} else if rc == -61 {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Ok(None)
} else {
check(self.ctx, rc).map(|_| None)
}
}

/// Get the cursor state (position, image, visibility).
pub fn get_cursor(&mut self) -> Result<Cursor> {
let mut raw = unsafe { std::mem::zeroed::<ffi::drmtap_cursor_info>() };
Expand Down
6 changes: 6 additions & 0 deletions docs/research/05_api_and_architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ void drmtap_cursor_release(drmtap_ctx *ctx, drmtap_cursor_info *cursor);
* already turned it on scanout: that frame is left alone. */
int drmtap_plane_rotation(drmtap_ctx *ctx, uint32_t *rotation);

/* 0.5.9: the exact refresh of the captured CRTC in hertz, as the reduced fraction
* *num / *den of its current mode, so 59.94 and 23.976 keep their value where
* drmtap_display.refresh_hz rounds to 60 and 24. No connector probe: once the CRTC
* is known it is one GETCRTC. -ENODATA when the CRTC has no mode (disabled). */
int drmtap_crtc_refresh(drmtap_ctx *ctx, uint64_t *num, uint64_t *den);

// --- Display Hotplug Detection (v1) ---
// Call after drmtap_grab*() returns -ENODEV or periodically to detect changes.
// Returns 1 if display configuration changed since last call, 0 if unchanged.
Expand Down
Loading
Loading