Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
17 changes: 8 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,8 @@ tests/
├── test_deswizzle.c # Tiling format conversion (unit, no GPU)
├── test_connector_names.c # Connector naming
├── test_convert.c # Pixel conversion paths
├── test_cursor.c # Cursor helper-fallback classification (unit)
├── test_refresh.c # Exact CRTC refresh fraction (unit, no GPU)
├── test_hdr.c # HDR metadata
├── test_outbuf.c # Output buffer growth and caps
├── test_scanout.c # Scanout geometry
Expand All @@ -227,20 +229,17 @@ tests/
### Running Tests

```bash
# Unit tests (no hardware needed): formats, deswizzle, helper
# Unit tests (no hardware needed)
meson test -C build --suite unit

# Integration tests (need a DRM device): enumerate, capture
# vkms gives a synthetic scanout for CI-friendly testing
# Integration tests (need a DRM device): enumerate, capture. CONTRIBUTING.md has
# which card they open and when the capture test really grabs.
sudo modprobe vkms
DRM_DEVICE=/dev/dri/card1 meson test -C build --suite integration

# Same integration suite against real hardware (Intel/Nvidia/AMD/virtio)
DRM_DEVICE=/dev/dri/card0 meson test -C build --suite integration
meson test -C build --suite integration
```

There are two suites only: `unit` and `integration`. There is no separate
`gpu` suite — point the integration suite at a real GPU via `DRM_DEVICE`.
`gpu` suite.

### Writing Tests

Expand Down Expand Up @@ -409,7 +408,7 @@ libdrmtap/
│ ├── test_formats.c ← unit suite
│ ├── test_helper.c ← unit suite
│ ├── test_deswizzle.c ← unit suite
│ ├── ... ← six more unit suites, see the Testing section
│ ├── ... ← eight more unit suites, see the Testing section
│ └── lsan.supp ← LeakSanitizer suppressions
├── examples/
│ ├── screenshot.c ← Capture one frame → PPM on stdout
Expand Down
41 changes: 40 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,44 @@ 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 whole-hertz `vrefresh` of the mode, so on Linux 5.9
and later 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 current mode of the CRTC 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.

### Fixed: the wrapper compared errno by its x86 number

`DrmTap::plane_rotation()` (0.5.8) recognised "no rotation property" as `-95`, which is
`ENOTSUP` on x86 and ARM but not on SPARC, MIPS or PA-RISC, so there a plane without
the property came back as an `Err` instead of `Ok(None)`. The wrapper now depends on
`libc` and compares `ENOTSUP` and `ENODATA` by name.

## [0.5.8] - 2026-09-25

### Added: `drmtap_plane_rotation()`, the rotation property of the plane
Expand Down Expand Up @@ -958,13 +996,14 @@ entry point is additive and would not on its own have justified more than a patc
- amdgpu EGL detile fix, privileged-helper hardening, and a batch of full-audit
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
[0.5.5]: https://github.com/fxd0h/libdrmtap/releases/tag/v0.5.5
[0.5.4]: https://github.com/fxd0h/libdrmtap/releases/tag/v0.5.4
[0.5.3]: https://github.com/fxd0h/libdrmtap/releases/tag/v0.5.3
[0.5.2]: https://github.com/fxd0h/libdrmtap/releases/tag/v0.5.2
[0.5.1]: https://github.com/fxd0h/libdrmtap/releases/tag/v0.5.1
[0.5.0]: https://github.com/fxd0h/libdrmtap/releases/tag/v0.5.0
[0.4.15]: https://github.com/fxd0h/libdrmtap/releases/tag/v0.4.15
Expand Down
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ meson test -C build --suite integration
# DRM_DEVICE the library auto-detects the card driving the MOST active CRTCs,
# which can be the wrong one on a multi-GPU system:
DRM_DEVICE=/dev/dri/card0 ./build/test_capture

# test_capture can pass without grabbing a frame, so read its output: it prints
# SKIP when the card has no connected display, and PASS on -EACCES (no
# CAP_SYS_ADMIN and no working drmtap-helper) or on -ENODEV (no active plane, e.g.
# vkms with no compositor). As root, DRM_DEVICE is ignored and the library picks
# the card driving the most active CRTCs.
```

Running the suites under the sanitizer build (`build-asan` above) is the recommended pre-submit check.
Expand Down
10 changes: 6 additions & 4 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 Expand Up @@ -295,8 +296,9 @@ A self-contained example of the crate-based backend, which does link
# Unit tests (no hardware needed)
meson test -C build --suite unit

# Integration tests (needs DRM device)
sudo DRM_DEVICE=/dev/dri/card0 meson test -C build --suite integration
# Integration tests (need a DRM device; CONTRIBUTING.md has which card they open
# and when the capture test really grabs)
meson test -C build --suite integration
```

### Environment variables
Expand Down Expand Up @@ -356,7 +358,7 @@ Every existing project is either a complete application, a plugin, or PipeWire-b
│ (RustDesk, Sunshine, VNC, custom) │
├─────────────────────────────────────────────────┤
│ libdrmtap.h │
│ Public API — ~20 functions │
│ Public API — 27 functions │
├─────────────────────────────────────────────────┤
│ │
│ ┌────────────┐ ┌─────────┐ ┌──────────────┐ │
Expand Down Expand Up @@ -409,7 +411,7 @@ libdrmtap uses a **dual-path** approach for GPU-tiled framebuffers:

That live position is what makes the hotspot recoverable, and it is why there are two ways out on bare metal rather than one:

- **Measure it**, if the consumer injects the pointer itself (remote desktop, test automation). It knows where it put the pointer, the plane sits at that point minus the hotspot, so once both settle `hotspot = injected_position - plane_position` — mind the coordinate spaces, the plane is in the CRTC's physical pixels while an injected point is usually in the compositor's logical layout. RustDesk does this in [rustdesk/rustdesk#15897](https://github.com/rustdesk/rustdesk/pull/15897).
- **Measure it**, if the consumer injects the pointer itself (remote desktop, test automation). It knows where it put the pointer, the plane sits at that point minus the hotspot, so once both settle `hotspot = injected_position - plane_position` — mind the coordinate spaces, the plane is in the CRTC's physical pixels while an injected point is usually in the compositor's logical layout. RustDesk does this in [rustdesk/rustdesk#16122](https://github.com/rustdesk/rustdesk/pull/16122).
- **Approximate it from the image** (top-left of an arrow's bounding box, centre of a tall/narrow I-beam) when there is no injected pointer to compare against. Fine for an arrow, and off by roughly half the glyph for a wide centre-hotspot shape: on a horizontal resize arrow the bounding-box guess gave `(4,16)` where the real hotspot was `(26,23)`, i.e. 19 px out horizontally.

Those same hotspot properties are why, since kernel 6.6, a para-virtualized driver (`virtio-gpu`, `vmwgfx`, `qxl`, `vboxvideo`) **hides its cursor plane entirely** from a client that has enabled `DRM_CLIENT_CAP_ATOMIC` and has not also enabled `DRM_CLIENT_CAP_CURSOR_PLANE_HOTSPOT`: the second cap is how a client declares it honors them. libdrmtap needs the atomic cap to read a connector's `CRTC_ID`, so it enables both (since 0.5.4 — before it, the cursor read found no plane on those drivers and reported the cursor hidden forever). The hotspot cap is refused with `EOPNOTSUPP` on bare metal, which is harmless and is also a cheap way to tell the two kinds of driver apart.
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
45 changes: 39 additions & 6 deletions bindings/rust/libdrmtap-sys/csrc/drmtap.h
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,13 @@ extern "C" {
/* Version */
/* ========================================================================= */

/* Version of the C library. Kept equal to the libdrmtap-sys crate version
* (the C sources packaged for Rust are the same code) and to the meson
* project version; the unit tests cross-check all three. The higher-level
* `libdrmtap` Rust wrapper crate carries its own, separate version line. */
/* Version of the C library. Since 0.5.1 the C library, the meson project, the
* libdrmtap-sys crate (the same C sources, packaged for Rust) and the libdrmtap
* wrapper crate share this one version; tools/check-version.sh verifies every
* site. */
#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 mode's whole-hertz
vrefresh: on Linux 5.9 and later 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
Loading
Loading