Last active
September 26, 2026 00:07
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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