A zero-dependency C library for DRM/KMS framebuffer capture on Linux
Capture any Linux display — no Wayland, no PipeWire, no user prompts.
Screen capture on modern Linux is a mess:
- Wayland prevents unprivileged screen capture by design
- PipeWire/xdg-desktop-portal require user interaction every time
- Login screens (GDM, SDDM) can't be captured at all — no compositor running
- RustDesk, Sunshine, and others have fought this for 4+ years with no clean solution
- No embeddable C library exists for direct framebuffer capture
libdrmtap goes below the compositor — directly to the kernel's DRM/KMS subsystem — to capture the framebuffer without any user interaction, compositor cooperation, or display server dependency.
#include <drmtap.h>
int main() {
drmtap_ctx *ctx = drmtap_open(NULL); // auto-detect GPU
drmtap_frame_info frame;
drmtap_grab_mapped(ctx, &frame); // capture screen → RGBA
// frame.data = linear RGBA pixels
// frame.width, frame.height, frame.stride
drmtap_frame_release(ctx, &frame);
drmtap_close(ctx);
}[dependencies]
libdrmtap = "0.5"This pulls in
libdrmtap-sys, which embeds and statically compiles the C sources (and the privilege helper). No systemlibdrmtapinstall, nomeson install, nopkg-configto find a shared library.
use libdrmtap::DrmTap;
let mut tap = DrmTap::open(None)?;
let frame = tap.grab_mapped()?;
println!("{}x{} pixels captured", frame.width(), frame.height());| Feature | Status |
|---|---|
| VMs (virtio_gpu, Parallels, QEMU) | ✅ Verified |
| Multi-monitor (per-CRTC) | ✅ Implemented |
| Multi-GPU (enumerate every card + bind convert to the exporting GPU) | ✅ Implemented |
| Split capture (privileged export + unprivileged convert) | ✅ Implemented |
| Zero-copy DMA-BUF output (V3) | ✅ Implemented |
| Mapped RGBA output | ✅ Verified |
| Continuous capture (polling loop) | ✅ Verified |
| Cursor capture (position + pixels) | ✅ Verified (on a para-virtualized driver, needs 0.5.4 — see Known Limitations) |
| Privileged helper (setcap, no root) | ✅ Verified |
| Security hardening (cap drop + seccomp) | ✅ Implemented |
| EGL/GLES2 GPU-universal detiling | ✅ Implemented (primary, all GPUs) |
| Intel (i915/xe) X/Y-tiled deswizzle | ✅ CPU fallback |
| AMD (amdgpu) deswizzle | ⛔ EGL only — no CPU decoder, fails closed |
| Nvidia (nvidia-drm) blocklinear deswizzle | ⛔ EGL only — there is no CPU decoder; the CPU path returns -ENOTSUP (0.5.3) |
| HDR10 → SDR tone-map (AR30/XR30/AB30/XB30, XR48/AR48/XB48/AB48) | ✅ Implemented (P010 not yet) |
| Frame differencing (dirty rects) | ✅ Implemented |
Thread-safe (one drmtap_ctx per thread) |
✅ By design |
| Coexists with NoMachine/Sunshine | ✅ By design |
| Rust bindings (crates.io) | ✅ Published |
| MIT License | ✅ |
⚠️ Testing status: every entry below is a machine someone captured from, and it says whose. A tester confirmation is one host on one day, not a support matrix.Verified here:
- Intel Meteor Lake-P (
i915, Core Ultra 7 155H) — multiple displays up to 3840x2160,4_TILED_MTL_RC_CCS_CCscanout detiled through EGL.- AMD RX560 (Polaris/gfx8) — 2880x1800
eDP-1, tiledXR30, EGL detile.- NVIDIA Jetson Orin Nano (
nvidia-drm, aarch64, Wayland).virtio_gpu(QEMU/Parallels VMs), V3 zero-copy path.Confirmed by outside testers, on their hardware and not ours:
- AMD RX Vega 64 (gfx9), X11, modifier
0x200000004801a01, unprivileged via the helper — GK-Gaming, 2026-07-29 (#26, #36).- Intel Raptor Lake with a hybrid NVIDIA GPU (ThinkBook 16p G5 IRX, Ubuntu 26.04, GNOME Wayland,
eDP-1,Y_TILED_GEN12_RC_CCS_CC) — huzhifeng, 2026-08-07 (#45).Whichever GPU you have, most of this depends on the EGL detile backend. The CPU path on its own still handles linear scanouts and the plain tilings (Intel X/Y/Yf-tiled), but a compressed scanout, or any tiled layout it has no deswizzler for, fails closed — it returns an error rather than passing the raw bytes off as linear pixels. Current Intel and AMD desktops scan out exactly those. The
eglMeson feature defaults toauto, which silently builds the CPU-only stub when the headers are missing, so configure with-Degl=enabledto fail at configure time instead — that is what #45 turned out to be.Multi-GPU render-node selection is verified on the Jetson Orin's two DRM devices (
tegra/renderD128 with no connectors +nvidia-drm/renderD129 driving the display): the converter now binds renderD129, the node that exported the scanout. Multi-card, multi-VENDOR hosts with more than one display-driving GPU are now verified on an Apple T2 MacBook Pro: three DRM cards —appletbdrm(the Touch Bar strip),i915(no connected output) andamdgpu(the panel) — where two different vendors are each scanning out a display at once, and each was captured from its own card. That machine also covers two cases nothing else here does:
- A display-only card with NO render node.
appletbdrmexposes an active CRTC and norenderD*, so a consumer cannot be told which GPU exported the scanout and must not guess: importing it on the wrong device can corrupt the image silently. Such a frame takes the CPU path instead.- A scanout framebuffer PADDED wider than the mode. That card drives a 60x2170 mode out of a 64x2170 framebuffer (256-byte pitch), and those four columns are alignment padding that is never scanned out. A grab reports the width the CRTC scans out, not the framebuffer's, so a consumer that enumerated the mode is not handed an image wider than the display it asked for. Worth knowing if you compare a frame's geometry against a display list. If you test on real hardware, please report results.
ℹ️ virgl note: Plain
virtio-gpuis captured with a direct linear map. A host-rendered virgl (3D) scanout cannot be read by the guest CPU (it comes out black, andTRANSFER_FROM_HOSTdoes not bring it back); it is captured via GPU-side EGL readback on the guest. Solved technically; production integration is in progress (#15).HDR10 capture is tone-mapped to SDR. When the connector advertises an HDR scanout (
HDR_OUTPUT_METADATA, PQ), libdrmtap applies the real transfer — PQ (SMPTE ST 2084) decode, BT.2020 → BT.709 gamut, a highlight-preserving tone-map, sRGB — and returns correct 8-bit SDR, instead of the washed-out top-8-of-10-bits result. This matches what consumers like RustDesk expect (their pipeline is 8-bit SDR; on other platforms the OS/compositor tone-maps before they see it — on DRM we do it). CoversAR30/XR30/AB30/XB30(10-bit) andXR48/AR48/XB48/AB48(16-bit), in both the CPU and the EGL (tiled) paths.P010(10-bit YUV, used for overlay-video planes rather than the primary desktop scanout) is not handled.FP16 half-float scanouts (
XRGB16161616Fand its BGR/alpha siblings) are reduced to 8-bit sRGB on the CPU fallback — linear-light decode through the sRGB OETF, not HDR tone-mapped (values above 1.0 clip to white).
The tiled/compressed framebuffer path (Intel/AMD/Nvidia modifiers) uses the DRM
GETFB2 ioctl, which needs Linux 4.20+ — i.e. Ubuntu 20.04 (5.4), 22.04
(5.15), 24.04 (6.8) or newer. Linear framebuffers (virtio-gpu and similar VMs)
work on older kernels.
# Dependencies: meson, gcc, libdrm-dev
sudo apt install meson gcc libdrm-dev pkg-config
# Optional (for EGL GPU-universal detiling):
sudo apt install libegl-dev libgles2-mesa-dev
# Build. -Degl=enabled is not optional in practice: the EGL backend is what
# detiles a modern scanout, and without it meson quietly builds a stub that
# fails on the first real frame. Passing it turns that into a configure error.
git clone https://github.com/fxd0h/libdrmtap.git
cd libdrmtap
meson setup build -Degl=enabled
meson compile -C buildsudo meson install -C buildA Meson install leaves the helper world-executable. Do not apply the
capability to it in that state: a file capability applies to every user who can
exec the binary, so cap_sys_admin+ep on a 0755 helper lets any local user
read the DRM scanout (login screen, lock screen, other users' sessions). Restrict
who can run it FIRST — root:<capture-group>, mode 0750 — then apply the
capability. The SO_PEERCRED check inside the helper is defense-in-depth, not an
access-control boundary; the file mode is. This mirrors the procedure in
SECURITY.md.
# Restrict execution to a trusted group, THEN grant the capability.
sudo groupadd -f drmtap-capture
sudo chown root:drmtap-capture /usr/local/libexec/drmtap-helper
sudo chmod 0750 /usr/local/libexec/drmtap-helper
sudo setcap cap_sys_admin+ep /usr/local/libexec/drmtap-helper
# Add each user who is allowed to capture to the group:
sudo usermod -aG drmtap-capture "$USER"# With sudo (direct capture):
sudo ./build/screenshot > screenshot.ppm
# Without sudo (via helper — after setcap above):
./build/screenshot > screenshot.ppmThe included VNC server example captures the screen and serves it over VNC with mouse and keyboard input:
# Build with libvncserver (optional dependency)
meson setup build -Degl=enabled && meson compile -C build
# Run (needs root for uinput access)
sudo ./build/vnc_server
# Connect from any VNC client:
# vnc://YOUR_IP:5900 (password: drmtap)libdrmtap is being upstreamed into RustDesk
via rustdesk/rustdesk#15420.
The integration adds a drm backend to scrap that dlopens libdrmtap.so.0
at runtime: it does not link libdrmtap at build time and does not depend on the
libdrmtap-sys crate, whose build.rs
would statically compile the whole C tree and a privileged helper. Loading the
shared object by soname is what lets a missing or too-old .so degrade to the
existing PipeWire path instead of breaking the build.
A self-contained example of the crate-based backend, which does link
libdrmtap-sys statically, lives in
contrib/integrations/rustdesk/.
Tested and verified on Ubuntu 24.04 with cargo build (zero errors).
# 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| Variable | Effect |
|---|---|
DRM_DEVICE |
DRM node to open (e.g. /dev/dri/card0). Ignored for privileged (root / CAP_SYS_ADMIN) callers — a privileged capture service uses its configured device_path or the KMS auto-scan, so the environment cannot redirect which device it opens. Honored only for unprivileged runs. |
DRMTAP_DEBUG |
Set to 1 for verbose debug logging on stderr. |
DRMTAP_NO_EGL |
Set to 1 to force the CPU deswizzle/convert path (skip EGL/GLES). |
DRMTAP_NO_IMAGE_CACHE |
Set to 1 to re-import the EGLImage every frame (disable the import-once cache). |
DRMTAP_FORCE_MMAP_FAIL |
Test hook — set to 1 to force the fast-path CPU mmap to fail so the EGL-detile fallback runs. |
The privilege model is described in SECURITY.md.
CPU cost of the capture loop, measured as a share of one core (approximate; on
Ubuntu 24.04). The library figure is the full grab + detile loop in the consuming
process; the helper figure is the privileged drmtap-helper process.
| Environment | Resolution | Capture CPU (1 core) | Helper CPU | Path |
|---|---|---|---|---|
Intel Meteor Lake (i915) |
dual 3840×2160 @ 60 | ~16% | ~0.4% | V3 zero-copy, EGL CCS detile |
NVIDIA Jetson Orin Nano (nvidia-drm) |
1920×1080 | ~11% | ~0.4% | V3 zero-copy, EGL detile |
| Plain virtio-gpu VM | typical desktop | ~1.2% | ~0.7% | V3 zero-copy, direct linear map |
Plain virtio dropped from ~14% on the old V2 copy path to ~1.2% once V3 stopped copying full frames across the socket.
How it works:
- V3 zero-copy (default) — the privileged helper exports the active scanout as a DMA-BUF and passes the fd back over a
socketpairviaSCM_RIGHTS. No full-frame copy crosses the socket. - V2 copy (fallback) — when DMA-BUF export is not possible, the helper dumb-maps the framebuffer and copies the pixels over the socket.
- GPU EGL readback — tiled/compressed scanouts (Intel CCS, AMD, Nvidia block-linear, vendor modifiers) and host-rendered virgl buffers are imported as an
EGLImageand read back linear withglReadPixels. Plain linear framebuffers are mapped directly with no detile. - Zero-copy DMA-BUF output — consumers that take the DMA-BUF output can import the buffer directly (e.g. as an
EGLImageor Vulkan texture) and skip the readback entirely.
Every existing project is either a complete application, a plugin, or PipeWire-based. None is an embeddable C library for DRM/KMS capture.
| Project | Embeddable lib? | DRM/KMS? | Privilege helper? | Multi-GPU? | Multi-monitor? |
|---|---|---|---|---|---|
| FFmpeg kmsgrab | ❌ Needs FFmpeg (40MB+) | ✅ | ❌ Root on binary | ✅ | ✅ |
| gpu-screen-recorder | ❌ CLI tool | ✅ Partial | ✅ gsr-kms-server | ✅ | ✅ |
| kmsvnc | ❌ VNC server | ✅ | ❌ Needs root | ✅ | |
| obs-kmsgrab | ❌ OBS plugin | ✅ | ✅ pkexec | ❌ First GPU only | ❌ |
| Sunshine | ❌ Built-in | ✅ | ❌ setcap on binary | ✅ | ✅ |
| ReFrame | ❌ VNC server | ✅ | ✅ Split process | ✅ | ✅ |
| libdrmtap | ✅ C library | ✅ | ✅ Hardened | ✅ | ✅ |
libdrmtap is the only project that is simultaneously: an embeddable C library, uses DRM/KMS direct capture, includes a hardened privilege helper, supports multiple GPUs, and handles multiple monitors — all under the MIT license.
┌─────────────────────────────────────────────────┐
│ YOUR APPLICATION │
│ (RustDesk, Sunshine, VNC, custom) │
├─────────────────────────────────────────────────┤
│ libdrmtap.h │
│ Public API — ~20 functions │
├─────────────────────────────────────────────────┤
│ │
│ ┌────────────┐ ┌─────────┐ ┌──────────────┐ │
│ │ enumerate │ │ grab │ │ pixel_convert│ │
│ │ Planes │ │ GetFB2 │ │ Deswizzle │ │
│ │ CRTCs │ │ Prime │ │ Format cvt │ │
│ │ Connectors │ │ DMA-BUF │ │ Diff frames │ │
│ └────────────┘ └─────────┘ └──────────────┘ │
│ │
│ ┌────────────┐ ┌────────────────────────────┐ │
│ │ gpu_backend│ │ privilege_helper │ │
│ │ EGL: all │ │ Auto-spawn drmtap-helper │ │
│ │ (primary) │ │ DMA-BUF fd via SCM_RIGHTS │ │
│ │ CPU detile │ │ V3 zero-copy / V2 copy │ │
│ │ (fallback) │ │ cap_drop + seccomp │ │
│ │ VM: linear │ │ Transparent to user │ │
│ └────────────┘ └────────────────────────────┘ │
├─────────────────────────────────────────────────┤
│ libdrm / kernel DRM/KMS │
└─────────────────────────────────────────────────┘
libdrmtap uses a dual-path approach for GPU-tiled framebuffers:
- EGL path (primary) — Imports DMA-BUF as EGLImage with modifier metadata, renders via
GL_TEXTURE_EXTERNAL_OES. The GPU driver handles its own tiling transparently. One code path for ALL GPUs. Based on ReFrame. - CPU path (fallback) — Per-GPU deswizzle when EGL is unavailable (headless servers, no GPU rendering).
- Linear path — VMs and linear framebuffers need no conversion.
| ✅ We do | ❌ We don't |
|---|---|
| Capture framebuffer pixels (RGBA / DMA-BUF) | Encode or compress frames |
| Handle GPU tiling → linear conversion | Transport frames over network |
| Manage permissions transparently | Inject keyboard/mouse input |
| Support Intel, AMD, Nvidia, VMs | Replace Wayland's screen sharing protocols |
| Provide cursor position + shape | Implement a VNC/RDP server |
| Enumerate displays and monitors across multiple GPUs | |
| Detect changed regions (dirty rects) |
Wayland positioning: For interactive desktop sharing where user consent matters, use Wayland protocols (ext-image-capture-source-v1, PipeWire). For unattended system-level capture (servers, kiosks, CI, remote management, login screens), use libdrmtap. We complement Wayland — we don't compete with it.
-
Cursor hotspot on bare-metal drivers. The cursor image and position are captured exactly, but the cursor hotspot (the click point inside the image, e.g. an arrow's tip or an I-beam's centre) is only exposed by the DRM cursor plane on virtualized drivers (
virtio-gpu,vmwgfx), via theHOTSPOT_X/HOTSPOT_Yplane properties. On bare-metal drivers (i915, amdgpu, nvidia) those properties are absent, and compositors such as Mutter move the cursor with the legacydrmModeMoveCursorAPI — which never updates the atomicCRTC_X/CRTC_Yplane position either. As a result, on bare metal the hotspot must be approximated from the captured image (e.g. the top-left of an arrow's bounding box, the centre of a tall/narrow I-beam), so the rendered cursor can land a few pixels off the true click point.drmtap_cursor_info.hot_x/hot_ycarry the real hotspot when the driver provides it and0otherwise; consumers should fall back to an image-derived estimate in that case. The exact position is available from the kernel's/sys/kernel/debug/dri/N/statedebug dump, but that interface is root-only, not a stable ABI, and driver-specific — so it is unsuitable for production use.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 enabledDRM_CLIENT_CAP_ATOMICand has not also enabledDRM_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'sCRTC_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 withEOPNOTSUPPon bare metal, which is harmless and is also a cheap way to tell the two kinds of driver apart.
This project was born from a real frustration: trying to get remote desktop working on Ubuntu Wayland. After RustDesk broke packages, Sunshine failed on KMS, and every solution required "just switch to X11" — we decided to solve it at the root.
We researched every existing project on GitHub, analyzed 25+ real-world issues, studied the source code of kmsvnc, gpu-screen-recorder, FFmpeg kmsgrab, ReFrame, and kms-screenshot. All findings are documented in docs/research/.
Read the full research: 9 documents covering the landscape, Wayland capture problems, DRM/KMS internals, permissions, GPU differences, API design, GitHub issues analysis, potential adopters, and the ReFrame EGL analysis.
This project is proudly developed with the assistance of AI coding agents. We don't hide it — we celebrate it.
The entire research phase — analyzing codebases, reading GitHub issues, studying kernel APIs, designing the architecture — was conducted as a collaboration between a human developer and AI agents. This includes:
- Antigravity by Google DeepMind — agentic AI coding assistant
- Gemini by Google DeepMind — powering research and analysis
- Claude by Anthropic — powering code generation and reasoning
We believe AI agents are a force multiplier for open source. A single developer with AI assistance can produce research and code that would traditionally require a team. This project is proof of that thesis.
Agent-written code is held to the same bar as any other contribution: every PR is reviewed by CodeRabbit, scanned by CodeQL and cppcheck, and validated under ASan/UBSan in CI — and the privileged helper survived a serious security audit during RustDesk upstreaming. The hardening was praised, not waved through. AI speeds up the work; it does not lower the standard.
See docs/AI_DEVELOPMENT.md for our full AI development philosophy, and AGENTS.md for the working agreement we hand to coding agents.
| Document | Description |
|---|---|
00_landscape.md |
GitHub landscape — 11 projects analyzed |
01_wayland_capture_problem.md |
Wayland capture problem — why every project struggles |
02_drm_kms_mechanism.md |
DRM/KMS capture mechanism deep dive |
03_permissions.md |
Permissions model & helper binary |
04_gpu_and_testing.md |
GPU differences & vkms testing |
05_api_and_architecture.md |
API design & architecture |
06_github_issues_analysis.md |
Analysis of 25+ real GitHub issues |
07_potential_adopters.md |
Integration targets — 243K+ ⭐ of potential adopters |
08_reframe_egl_analysis.md |
ReFrame analysis — EGL GPU-universal detiling |
We welcome contributions of all kinds! Whether you're fixing a bug, adding GPU support, improving docs, or just reporting an issue — you're helping solve a real problem for the Linux desktop community.
See CONTRIBUTING.md for guidelines.
Areas where we especially need help:
- 🐧 Testing on real hardware (Intel, AMD, Nvidia, Raspberry Pi) — validating EGL detiling and CPU fallback paths
- 🔧 Login screen capture testing — GDM/SDDM/LightDM on various distros
- 📖 Documentation and examples — tutorials, man pages
- 🌐 Integration patches — RustDesk, Sunshine, and other remote desktop projects
MIT License — Use it anywhere, in anything.
We chose MIT specifically so projects like RustDesk (AGPLv3), Sunshine, and proprietary software can all integrate libdrmtap without license conflicts.
libdrmtap — Framebuffer capture for the Linux kernel, no compositor required.
Built with ❤️, frustration, and AI agents.