Skip to content

Instantly share code, notes, and snippets.

@3v1n0
Last active September 26, 2026 00:07
Show Gist options
  • Select an option

  • Save 3v1n0/a175bbeabc8ff3740dd13c0aec8bb3eb to your computer and use it in GitHub Desktop.

Select an option

Save 3v1n0/a175bbeabc8ff3740dd13c0aec8bb3eb to your computer and use it in GitHub Desktop.
Direct-scanout (direct rendering) monitor for Wayland to check if compositor supports and is using direct rendering with zero-copy in real time
#!/usr/bin/env python3
"""Fullscreen direct-scanout (direct rendering) monitor for Wayland.
Maps a fullscreen GBM/DMABUF window and reports, per frame, whether the
compositor directly scans out the window (wp_presentation ZERO_COPY flag)
or composites it. Also tracks the linux-dmabuf feedback "scanout tranche",
which the compositor only advertises while it considers this surface a
scanout candidate: if that tranche disappears while the window is fullscreen,
something is inhibiting unredirection (e.g. Ubuntu Dock autohide).
Deps: python3-pywayland (or pip pywayland) and libgbm; the needed
wayland protocol definitions are embedded in this file.
Usage:
python3 scanout-monitor.py [--drm NODE] [--duration SEC]
Press Ctrl+C to stop; a summary and verdict are printed on exit.
"""
from __future__ import annotations
import argparse
import ctypes
import hashlib
import mmap
import os
import select
import shutil
import signal
import struct
import traceback
import sys
import tempfile
import time
DRM_FORMAT_XRGB8888 = 0x34325258 # 'XR24'
DRM_FORMAT_MOD_LINEAR = 0
DRM_FORMAT_MOD_INVALID = 0x00FFFFFF00000000
GBM_BO_USE_SCANOUT = 1 << 0
GBM_BO_USE_LINEAR = 1 << 2
GBM_BO_USE_RENDERING = 1 << 3
GBM_BO_TRANSFER_WRITE = 1 << 1
# wp_presentation_feedback.kind
VSYNC, HW_CLOCK, HW_COMPLETION, ZERO_COPY = 1, 2, 4, 8
FLAG_NAMES = ["vsync", "hw-clock", "hw-completion", "zero-copy"]
# Animation colors (XRGB8888 little-endian byte order: B, G, R, pad)
BG_PIXEL = bytes([0x24, 0x0A, 0x30, 0x00]) # #300A24 dark aubergine
DIRECT_PIXEL = bytes([0x20, 0x54, 0xE9, 0x00]) # #E95420 Ubuntu orange
COMPOSITED_PIXEL = bytes([0x9F, 0xA7, 0xAE, 0x00]) # #AEA79F warm grey
UNKNOWN_PIXEL = bytes([0x50, 0x27, 0x5E, 0x00]) # #5E2750 mid aubergine
BAR_PIXEL = bytes([0xF7, 0xF7, 0xF7, 0x00]) # #F7F7F7
SQUARE_SIZE = 256
BAR_HEIGHT = 16
# zwp_linux_dmabuf_feedback_v1 tranche flags
TRANCHE_SCANOUT = 1
# ---------------------------------------------------------------- GBM (ctypes)
class Gbm:
def __init__(self, node: str):
self.lib = ctypes.CDLL("libgbm.so.1")
self.lib.gbm_create_device.restype = ctypes.c_void_p
self.lib.gbm_create_device.argtypes = [ctypes.c_int]
self.lib.gbm_bo_create_with_modifiers2.restype = ctypes.c_void_p
self.lib.gbm_bo_create_with_modifiers2.argtypes = [
ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32,
ctypes.POINTER(ctypes.c_uint64), ctypes.c_uint, ctypes.c_uint]
self.lib.gbm_bo_create.restype = ctypes.c_void_p
self.lib.gbm_bo_create.argtypes = [
ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32,
ctypes.c_uint]
self.lib.gbm_bo_get_modifier.restype = ctypes.c_uint64
self.lib.gbm_bo_get_modifier.argtypes = [ctypes.c_void_p]
self.lib.gbm_bo_get_plane_count.restype = ctypes.c_int
self.lib.gbm_bo_get_plane_count.argtypes = [ctypes.c_void_p]
self.lib.gbm_bo_map.restype = ctypes.c_void_p
self.lib.gbm_bo_map.argtypes = [
ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint32,
ctypes.c_uint32, ctypes.c_uint, ctypes.POINTER(ctypes.c_uint32),
ctypes.POINTER(ctypes.c_void_p)]
self.lib.gbm_bo_unmap.restype = ctypes.c_int
self.lib.gbm_bo_unmap.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
for name, restype in [("gbm_bo_get_fd_for_plane", ctypes.c_int),
("gbm_bo_get_stride_for_plane", ctypes.c_uint32),
("gbm_bo_get_offset", ctypes.c_int32)]:
fn = getattr(self.lib, name, None)
if fn is None:
raise RuntimeError(f"libgbm too old: {name} missing")
fn.restype = restype
fn.argtypes = [ctypes.c_void_p, ctypes.c_uint]
self.fd = os.open(node, os.O_RDWR | os.O_CLOEXEC)
self.dev = self.lib.gbm_create_device(self.fd)
if not self.dev:
raise RuntimeError(f"gbm_create_device failed on {node}")
def create_bo(self, w, h, modifiers):
mods = [m for m in modifiers if m != DRM_FORMAT_MOD_INVALID]
bo = None
if mods:
array = (ctypes.c_uint64 * len(mods))(*mods)
bo = self.lib.gbm_bo_create_with_modifiers2(
self.dev, w, h, DRM_FORMAT_XRGB8888, array, len(mods),
GBM_BO_USE_SCANOUT | GBM_BO_USE_RENDERING)
if not bo:
bo = self.lib.gbm_bo_create(
self.dev, w, h, DRM_FORMAT_XRGB8888,
GBM_BO_USE_SCANOUT | GBM_BO_USE_LINEAR)
if not bo:
return None
return {"bo": bo,
"modifier": self.lib.gbm_bo_get_modifier(bo),
"planes": self.lib.gbm_bo_get_plane_count(bo),
"fd": self.lib.gbm_bo_get_fd_for_plane(bo, 0),
"stride": self.lib.gbm_bo_get_stride_for_plane(bo, 0),
"offset": self.lib.gbm_bo_get_offset(bo, 0),
"width": w, "height": h}
def paint(self, info, x, y, w, h, row_fn):
"""Write rows row_fn(r) (w*4 bytes each) into the given region.
gbm_bo_map() may hand out a linear staging copy (e.g. for tiled
buffers) that is only written back on gbm_bo_unmap(), so the
region is mapped, painted and unmapped every time.
"""
map_data = ctypes.c_void_p()
stride = ctypes.c_uint32(0)
ptr = self.lib.gbm_bo_map(info["bo"], x, y, w, h,
GBM_BO_TRANSFER_WRITE,
ctypes.byref(stride),
ctypes.byref(map_data))
if ptr:
try:
view = ctypes.cast(ptr, ctypes.POINTER(
ctypes.c_char * (stride.value * h))).contents
for r in range(h):
off = r * stride.value
view[off:off + w * 4] = row_fn(r)
finally:
self.lib.gbm_bo_unmap(info["bo"], map_data)
return True
# fallback: plain dmabuf mmap (works for linear buffers)
size = info["stride"] * info["height"]
try:
with mmap.mmap(info["fd"], size, mmap.MAP_SHARED,
mmap.PROT_READ | mmap.PROT_WRITE) as view:
for r in range(h):
off = (y + r) * info["stride"] + x * 4
view[off:off + w * 4] = row_fn(r)
return True
except (OSError, ValueError):
return False
# ------------------------------------------------------- protocol bindings
# Minimal protocol definitions, trimmed to what this tool uses (opcode
# order preserved, matching the real protocols). They are fed to
# pywayland's scanner at runtime, so the system wayland-protocols
# package is not needed. Core wayland comes from pywayland itself.
WAYLAND_STUB_XML = """<?xml version="1.0" encoding="UTF-8"?>
<protocol name="wayland">
<interface name="wl_surface" version="6"/>
<interface name="wl_output" version="4"/>
<interface name="wl_seat" version="7"/>
<interface name="wl_buffer" version="1"/>
</protocol>
"""
XDG_SHELL_XML = """<?xml version="1.0" encoding="UTF-8"?>
<protocol name="xdg_shell">
<interface name="xdg_wm_base" version="5">
<request name="destroy" type="destructor"/>
<request name="create_positioner">
<arg name="id" type="new_id" interface="xdg_positioner"/>
</request>
<request name="get_xdg_surface">
<arg name="id" type="new_id" interface="xdg_surface"/>
<arg name="surface" type="object" interface="wl_surface"/>
</request>
<request name="pong">
<arg name="serial" type="uint"/>
</request>
<event name="ping">
<arg name="serial" type="uint"/>
</event>
</interface>
<interface name="xdg_positioner" version="5">
<request name="destroy" type="destructor"/>
</interface>
<interface name="xdg_surface" version="5">
<request name="destroy" type="destructor"/>
<request name="get_toplevel">
<arg name="id" type="new_id" interface="xdg_toplevel"/>
</request>
<request name="get_popup">
<arg name="id" type="new_id" interface="xdg_popup"/>
<arg name="parent" type="object" interface="xdg_surface" allow-null="true"/>
<arg name="positioner" type="object" interface="xdg_positioner"/>
</request>
<request name="set_window_geometry">
<arg name="x" type="int"/>
<arg name="y" type="int"/>
<arg name="width" type="int"/>
<arg name="height" type="int"/>
</request>
<request name="ack_configure">
<arg name="serial" type="uint"/>
</request>
<event name="configure">
<arg name="serial" type="uint"/>
</event>
</interface>
<interface name="xdg_popup" version="5">
<request name="destroy" type="destructor"/>
</interface>
<interface name="xdg_toplevel" version="5">
<request name="destroy" type="destructor"/>
<request name="set_parent">
<arg name="parent" type="object" interface="xdg_toplevel" allow-null="true"/>
</request>
<request name="set_title">
<arg name="title" type="string"/>
</request>
<request name="set_app_id">
<arg name="app_id" type="string"/>
</request>
<request name="show_window_menu">
<arg name="seat" type="object" interface="wl_seat"/>
<arg name="serial" type="uint"/>
<arg name="x" type="int"/>
<arg name="y" type="int"/>
</request>
<request name="move">
<arg name="seat" type="object" interface="wl_seat"/>
<arg name="serial" type="uint"/>
</request>
<request name="resize">
<arg name="seat" type="object" interface="wl_seat"/>
<arg name="serial" type="uint"/>
</request>
<request name="set_max_size">
<arg name="width" type="int"/>
<arg name="height" type="int"/>
</request>
<request name="set_min_size">
<arg name="width" type="int"/>
<arg name="height" type="int"/>
</request>
<request name="set_maximized"/>
<request name="unset_maximized"/>
<request name="set_fullscreen">
<arg name="output" type="object" interface="wl_output" allow-null="true"/>
</request>
<event name="configure">
<arg name="width" type="int"/>
<arg name="height" type="int"/>
<arg name="states" type="array"/>
</event>
<event name="close"/>
<event name="configure_bounds" since="4">
<arg name="width" type="int"/>
<arg name="height" type="int"/>
</event>
<event name="wm_capabilities" since="5">
<arg name="capabilities" type="array"/>
</event>
</interface>
</protocol>
"""
PRESENTATION_TIME_XML = """<?xml version="1.0" encoding="UTF-8"?>
<protocol name="presentation_time">
<interface name="wp_presentation" version="2">
<request name="destroy" type="destructor"/>
<request name="feedback">
<arg name="surface" type="object" interface="wl_surface"/>
<arg name="callback" type="new_id" interface="wp_presentation_feedback"/>
</request>
<event name="clock_id">
<arg name="clk_id" type="uint"/>
</event>
</interface>
<interface name="wp_presentation_feedback" version="2">
<event name="sync_output">
<arg name="output" type="object" interface="wl_output"/>
</event>
<event name="presented">
<arg name="tv_sec_hi" type="uint"/>
<arg name="tv_sec_lo" type="uint"/>
<arg name="tv_nsec" type="uint"/>
<arg name="refresh" type="uint"/>
<arg name="seq_hi" type="uint"/>
<arg name="seq_lo" type="uint"/>
<arg name="flags" type="uint"/>
</event>
<event name="discarded"/>
</interface>
</protocol>
"""
LINUX_DMABUF_XML = """<?xml version="1.0" encoding="UTF-8"?>
<protocol name="linux_dmabuf_unstable_v1">
<interface name="zwp_linux_dmabuf_v1" version="4">
<request name="destroy" type="destructor"/>
<request name="create_params">
<arg name="params_id" type="new_id" interface="zwp_linux_buffer_params_v1"/>
</request>
<request name="get_default_feedback" since="4">
<arg name="id" type="new_id" interface="zwp_linux_dmabuf_feedback_v1"/>
</request>
<request name="get_surface_feedback" since="4">
<arg name="id" type="new_id" interface="zwp_linux_dmabuf_feedback_v1"/>
<arg name="surface" type="object" interface="wl_surface"/>
</request>
<event name="format">
<arg name="format" type="uint"/>
</event>
<event name="modifier">
<arg name="format" type="uint"/>
<arg name="modifier_hi" type="uint"/>
<arg name="modifier_lo" type="uint"/>
</event>
</interface>
<interface name="zwp_linux_buffer_params_v1" version="4">
<request name="destroy" type="destructor"/>
<request name="add">
<arg name="fd" type="fd"/>
<arg name="plane_idx" type="uint"/>
<arg name="offset" type="uint"/>
<arg name="stride" type="uint"/>
<arg name="modifier_hi" type="uint"/>
<arg name="modifier_lo" type="uint"/>
</request>
<request name="create">
<arg name="width" type="int"/>
<arg name="height" type="int"/>
<arg name="format" type="uint"/>
<arg name="flags" type="uint"/>
</request>
<request name="create_immed">
<arg name="buffer_id" type="new_id" interface="wl_buffer"/>
<arg name="width" type="int"/>
<arg name="height" type="int"/>
<arg name="format" type="uint"/>
<arg name="flags" type="uint"/>
</request>
<event name="created">
<arg name="buffer" type="new_id" interface="wl_buffer"/>
</event>
<event name="failed"/>
</interface>
<interface name="zwp_linux_dmabuf_feedback_v1" version="4">
<request name="destroy" type="destructor"/>
<event name="done"/>
<event name="format_table">
<arg name="fd" type="fd"/>
<arg name="size" type="uint"/>
</event>
<event name="main_device">
<arg name="device" type="array"/>
</event>
<event name="tranche_done"/>
<event name="tranche_target_device">
<arg name="device" type="array"/>
</event>
<event name="tranche_formats">
<arg name="indices" type="array"/>
</event>
<event name="tranche_flags">
<arg name="flags" type="uint"/>
</event>
</interface>
</protocol>
"""
PROTOCOL_XMLS = [WAYLAND_STUB_XML, XDG_SHELL_XML, PRESENTATION_TIME_XML,
LINUX_DMABUF_XML]
def ensure_protocol_modules():
try:
import pywayland
from pywayland.scanner import Protocol
except ImportError as exc:
raise RuntimeError(
f"pywayland is too old or missing ({exc}): install it with "
"'pip install pywayland' (0.4.12 or newer recommended)")
digest = hashlib.sha256("".join(PROTOCOL_XMLS).encode()).hexdigest()[:12]
cache = os.path.join(
tempfile.gettempdir(),
f"scanout-monitor-protocols-{pywayland.__version__}-{digest}")
package = os.path.join(cache, "protocol")
marker = os.path.join(package, ".complete")
if not os.path.exists(marker):
shutil.rmtree(cache, ignore_errors=True)
os.makedirs(package)
open(os.path.join(package, "__init__.py"), "w").close()
xml_paths = []
for i, xml in enumerate(PROTOCOL_XMLS):
path = os.path.join(cache, f"protocol-{i}.xml")
with open(path, "w") as f:
f.write(xml)
xml_paths.append(path)
try:
protocols = [Protocol.parse_file(path) for path in xml_paths]
try:
# newer scanner: cross-protocol imports need a mapping of
# interface name to protocol module name
all_imports = {iface.name: proto.name
for proto in protocols
for iface in proto.interface}
for proto in protocols:
imports = dict(all_imports)
imports.update({iface.name: proto.name
for iface in proto.interface})
proto.output(package, imports)
except TypeError:
# older scanner: output(output_dir) only
for proto in protocols:
proto.output(package)
except Exception:
traceback.print_exc()
raise RuntimeError(
f"failed to generate the protocol bindings with pywayland "
f"{pywayland.__version__}: try 'pip install -U pywayland'")
# The wayland stub is only needed for the scanner to resolve core
# interface references; use pywayland's built-in core at runtime.
alias = "from pywayland.protocol.wayland import * # noqa: F401,F403\n"
for candidate in (os.path.join(package, "wayland.py"),
os.path.join(package, "wayland", "__init__.py")):
if os.path.exists(candidate):
with open(candidate, "w") as f:
f.write(alias)
break
open(marker, "w").close()
if cache not in sys.path:
sys.path.insert(0, cache)
try:
ensure_protocol_modules()
except RuntimeError as exc:
sys.exit(f"scanout-monitor: {exc}")
from pywayland.client import Display # noqa: E402
from pywayland.protocol.wayland import ( # noqa: E402
WlCompositor, WlOutput, WlRegistry, WlSeat)
from protocol.linux_dmabuf_unstable_v1 import ZwpLinuxDmabufV1 # noqa: E402
from protocol.presentation_time import WpPresentation # noqa: E402
from protocol.xdg_shell import XdgWmBase # noqa: E402
KEY_ESC = 1
WL_SEAT_KEYBOARD = 1 << 1
class Client:
def __init__(self, gbm: Gbm, duration: float):
self.gbm = gbm
self.duration = duration
self.running = True
self.width = self.height = 0
self.output_w = self.output_h = 0
self.compositor = self.wm_base = None
self.dmabuf = self.presentation = None
self.dmabuf_version = None
self.surface = self.xdg_surface = self.toplevel = None
self.buffers = []
self.buf_idx = 0
self.buffer_mods = None
# dmabuf feedback assembly state (tranche_flags arrives *after*
# tranche_formats, so keep them separately until tranche_done)
self.fb_table = None
self.fb_entries = None
self.fb_flags = 0
self.fb_tranches = []
self.has_scanout_tranche = None # None = unknown, else bool
self.commits = 0
self.presented_frames = 0
self.zero_copy_frames = 0
self.last_zero_copy = None
self.next_render = 0.0
self.t_start = time.monotonic()
self.t_last_status = self.t_start
self.started = False
# ------------------------------------------------------------- registry
def on_global(self, registry, name, interface, version):
if interface == WlCompositor.name:
self.compositor = registry.bind(name, WlCompositor,
min(version, 4))
elif interface == XdgWmBase.name:
self.wm_base = registry.bind(name, XdgWmBase, min(version, 5))
self.wm_base.dispatcher["ping"] = self.on_ping
elif interface == ZwpLinuxDmabufV1.name:
self.dmabuf_version = version
if version >= 4:
self.dmabuf = registry.bind(name, ZwpLinuxDmabufV1,
min(version, 4))
elif interface == WpPresentation.name:
self.presentation = registry.bind(name, WpPresentation,
min(version, 2))
elif interface == WlOutput.name:
output = registry.bind(name, WlOutput, min(version, 2))
output.dispatcher["mode"] = self.on_output_mode
elif interface == WlSeat.name:
seat = registry.bind(name, WlSeat, min(version, 1))
seat.dispatcher["capabilities"] = self.on_seat_capabilities
def on_seat_capabilities(self, seat, caps):
if caps & WL_SEAT_KEYBOARD:
keyboard = seat.get_keyboard()
keyboard.dispatcher["keymap"] = self.on_keyboard_keymap
keyboard.dispatcher["key"] = self.on_keyboard_key
def on_keyboard_keymap(self, keyboard, fmt, fd, size):
os.close(fd) # we only care about raw keycodes
def on_keyboard_key(self, keyboard, serial, time, key, state):
# WL_KEYBOARD_KEY_STATE_PRESSED == 1
if key == KEY_ESC and state == 1:
print("\nEscape pressed, exiting...")
self.running = False
def on_ping(self, wm_base, serial):
wm_base.pong(serial)
def on_output_mode(self, output, flags, width, height, refresh):
self.output_w, self.output_h = width, height
# ------------------------------------------------------ dmabuf feedback
def setup_feedback(self):
fb = self.dmabuf.get_surface_feedback(self.surface)
fb.dispatcher["format_table"] = self.on_format_table
fb.dispatcher["main_device"] = lambda *a: None
fb.dispatcher["tranche_target_device"] = lambda *a: None
fb.dispatcher["tranche_formats"] = self.on_tranche_formats
fb.dispatcher["tranche_flags"] = self.on_tranche_flags
fb.dispatcher["tranche_done"] = self.on_tranche_done
fb.dispatcher["done"] = self.on_feedback_done
def on_format_table(self, fb, fd, size):
with mmap.mmap(fd, size, mmap.MAP_PRIVATE, mmap.PROT_READ) as data:
self.fb_table = []
for off in range(0, size, 16):
fmt, _pad, mod = struct.unpack_from("<IIQ", data, off)
self.fb_table.append((fmt, mod))
os.close(fd)
def on_tranche_formats(self, fb, indices):
if self.fb_table is None:
return
self.fb_entries = [self.fb_table[i] for i in indices
if i < len(self.fb_table)]
def on_tranche_flags(self, fb, flags):
self.fb_flags = flags
def on_tranche_done(self, fb):
if self.fb_entries is not None:
self.fb_tranches.append({
"scanout": bool(self.fb_flags & TRANCHE_SCANOUT),
"entries": self.fb_entries})
self.fb_entries = None
self.fb_flags = 0
def on_feedback_done(self, fb):
tranches, self.fb_tranches = self.fb_tranches, []
scanout = next((t for t in tranches if t["scanout"]), None)
had = self.has_scanout_tranche
self.has_scanout_tranche = scanout is not None
if had is not None and had != self.has_scanout_tranche:
if self.has_scanout_tranche:
print(">>> scanout tranche ADVERTISED: the compositor now "
"considers this window a direct-scanout candidate")
else:
print(">>> scanout tranche GONE: the window is no longer "
"a scanout candidate (lost fullscreen/focus, or "
"unredirection inhibited?)")
# pick modifiers, preferring the scanout tranche of XRGB8888
source = scanout or (tranches[0] if tranches else None)
mods = [m for f, m in (source["entries"] if source else [])
if f == DRM_FORMAT_XRGB8888]
if DRM_FORMAT_MOD_LINEAR not in mods:
mods.append(DRM_FORMAT_MOD_LINEAR)
if mods != self.buffer_mods and self.width:
self.allocate_buffers(mods)
# ------------------------------------------------------------- surfaces
def setup_window(self):
self.surface = self.compositor.create_surface()
self.setup_feedback()
self.xdg_surface = self.wm_base.get_xdg_surface(self.surface)
self.toplevel = self.xdg_surface.get_toplevel()
self.toplevel.set_title("scanout-monitor")
self.toplevel.set_app_id("scanout-monitor")
self.toplevel.dispatcher["configure"] = self.on_toplevel_configure
self.toplevel.set_fullscreen(None)
self.xdg_surface.dispatcher["configure"] = self.on_xdg_configure
self.surface.commit()
def on_toplevel_configure(self, toplevel, width, height, states):
if width and height:
self.width, self.height = width, height
elif self.output_w and self.output_h:
self.width, self.height = self.output_w, self.output_h
def on_xdg_configure(self, xdg_surface, serial):
if self.width and not self.buffers:
self.allocate_buffers(self.buffer_mods or [DRM_FORMAT_MOD_LINEAR])
xdg_surface.ack_configure(serial)
if self.buffers and not self.started:
self.started = True
self.render()
# -------------------------------------------------------------- buffers
def allocate_buffers(self, modifiers):
mods = list(dict.fromkeys(m & 0xFFFFFFFFFFFFFFFF for m in modifiers))
print(f"Allocating {self.width}x{self.height} buffers, modifiers: "
+ ", ".join(f"0x{m:016x}" for m in mods))
new_buffers = []
for _ in range(2):
info = self.gbm.create_bo(self.width, self.height, mods)
if info is None:
raise RuntimeError("gbm buffer allocation failed")
bg_row = BG_PIXEL * self.width
if not self.gbm.paint(info, 0, 0, self.width, self.height,
lambda r: bg_row):
print("warning: cannot map buffer for painting, frames "
"will not change visually")
params = self.dmabuf.create_params()
params.add(info["fd"], 0, info["offset"], info["stride"],
info["modifier"] >> 32, info["modifier"] & 0xFFFFFFFF)
buf = params.create_immed(self.width, self.height,
DRM_FORMAT_XRGB8888, 0)
params.destroy()
new_buffers.append({"wl": buf, "info": info})
for old in self.buffers:
old["wl"].destroy()
self.buffers = new_buffers
self.buffer_mods = mods
# ----------------------------------------------------------------- loop
def render(self):
entry = self.buffers[self.buf_idx]
# centered square showing the live state (orange: direct scanout,
# grey: composited) with a scrolling bar to keep frames changing
size = min(SQUARE_SIZE, self.width, self.height)
x0 = (self.width - size) // 2
y0 = (self.height - size) // 2
if self.last_zero_copy is None:
color = UNKNOWN_PIXEL
else:
color = DIRECT_PIXEL if self.last_zero_copy else COMPOSITED_PIXEL
square_row = color * size
bar_row = BAR_PIXEL * size
bar_y = (self.commits * 2) % size
self.gbm.paint(entry["info"], x0, y0, size, size,
lambda r: bar_row if bar_y <= r < bar_y + BAR_HEIGHT
else square_row)
fb = self.presentation.feedback(self.surface)
fb.dispatcher["presented"] = self.on_presented
fb.dispatcher["discarded"] = lambda f: None
self.surface.attach(entry["wl"], 0, 0)
self.surface.damage(0, 0, self.width, self.height)
self.surface.commit()
self.buf_idx ^= 1
self.commits += 1
def on_presented(self, fb, sec_hi, sec_lo, nsec, refresh, seq_hi, seq_lo,
flags):
self.presented_frames += 1
zero = bool(flags & ZERO_COPY)
if zero:
self.zero_copy_frames += 1
if zero != self.last_zero_copy:
names = " ".join(n for bit, n in enumerate(FLAG_NAMES)
if flags & (1 << bit))
print(f"commit {self.presented_frames}: "
f"{'DIRECT SCANOUT (zero-copy)' if zero else 'composited'}"
f" flags=0x{flags:x} [{names}] "
f"seq={seq_hi << 32 | seq_lo}")
self.last_zero_copy = zero
def maybe_status(self):
now = time.monotonic()
if now - self.t_last_status < 2.0:
return
self.t_last_status = now
if self.last_zero_copy is None:
mode = "no presentation feedback yet"
else:
mode = ("DIRECT SCANOUT (direct rendering)" if
self.last_zero_copy else "composited")
print(f"[{now - self.t_start:6.1f}s] {mode}: "
f"{self.zero_copy_frames}/{self.presented_frames} zero-copy, "
f"{self.commits} commits, scanout tranche: "
f"{self.has_scanout_tranche}")
def summary(self):
percent = round(100 * self.zero_copy_frames /
max(self.presented_frames, 1))
print("\n===== summary =====")
print(f"frames presented: {self.presented_frames}/{self.commits} "
f"commits, {self.zero_copy_frames} in zero-copy ({percent}%)")
if self.zero_copy_frames:
print("VERDICT: the window WAS directly scanned out "
"(direct rendering active)")
elif self.has_scanout_tranche:
print("VERDICT: the compositor considered the window a scanout "
"candidate but never scanned it out: check buffer "
"format/modifier support")
else:
print("VERDICT: no direct rendering: the compositor never "
"considered this fullscreen window a scanout candidate. "
"While the window is fullscreen, something is inhibiting "
"unredirection (e.g. Ubuntu Dock autohide/intellihide)")
def run(self):
display = Display()
try:
display.connect()
except ValueError as exc:
raise RuntimeError(
f"cannot connect to the Wayland display ({exc}): are you "
"running this inside a Wayland session?") from None
registry = display.get_registry()
registry.dispatcher["global"] = self.on_global
display.roundtrip()
missing = [n for n, obj in [
("wl_compositor", self.compositor),
("xdg_wm_base", self.wm_base),
("zwp_linux_dmabuf_v1 >= 4", self.dmabuf),
("wp_presentation", self.presentation)] if obj is None]
if missing:
hint = ""
if self.dmabuf is None:
hint = ("\n linux-dmabuf " +
(f"is only v{self.dmabuf_version}"
if self.dmabuf_version else "is not advertised") +
": the compositor has no hardware GPU rendering "
"(software rendering / VM?), so direct scanout "
"cannot happen on this session")
raise RuntimeError(
f"compositor lacks required globals: "
f"{', '.join(missing)}{hint}")
self.setup_window()
fd = display.get_fd()
while self.running:
# Non-blocking loop: blocking inside libwayland's poll() would
# keep the interpreter from running signal handlers (Ctrl+C).
# Rendering is self-paced: while the compositor scans us out and
# the content is unchanged, no KMS flip happens and neither frame
# callbacks nor presentation events fire, so we cannot wait for
# them to schedule the next commit.
display.flush()
display.dispatch(block=False)
now = time.monotonic()
if self.started and now >= self.next_render:
self.next_render = now + 1.0 / 60
self.render()
self.maybe_status()
if self.duration and now - self.t_start > self.duration:
self.running = False
break
readable, _, _ = select.select([fd], [], [], 0.008)
if readable and self.running:
display.read()
self.summary()
display.disconnect()
def main():
parser = argparse.ArgumentParser(
description="Fullscreen direct-scanout (direct rendering) monitor")
parser.add_argument("--drm", default="/dev/dri/renderD128",
help="DRM render node (default: %(default)s)")
parser.add_argument("--duration", type=float, default=0,
help="exit after N seconds (default: until Ctrl+C)")
args = parser.parse_args()
gbm = Gbm(args.drm)
client = Client(gbm, args.duration)
signal.signal(signal.SIGINT,
lambda *_: setattr(client, "running", False))
status = 0
try:
client.run()
except RuntimeError as exc:
print(f"scanout-monitor: {exc}", file=sys.stderr)
status = 1
# skip interpreter teardown: pywayland may destroy proxies after the
# display connection is gone and crash
sys.stdout.flush()
sys.stderr.flush()
os._exit(status)
if __name__ == "__main__":
main()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment