/* KWin - the KDE window manager This file is part of the KDE project. SPDX-FileCopyrightText: 2025 Xaver Hugl SPDX-License-Identifier: GPL-2.0-or-later */ #include "backends/drm/drm_gpu.h" #include "backends/drm/drm_object.h" #include "backends/drm/drm_output.h" #include "backends/drm/drm_pointer.h" #include "compositor.h" #include "core/drmdevice.h" #include "core/graphicsbuffer.h" #include "core/outputbackend.h" #include "core/outputconfiguration.h" #include "core/outputlayer.h" #include "core/renderbackend.h" #include "kwin_wayland_test.h" #include "scene/surfaceitem.h" #include "wayland-client/linuxdmabuf.h" #include "wayland-client/viewporter.h" #include "wayland_server.h" #include "workspace.h" #include #include #include #include using namespace std::chrono_literals; namespace KWin { static const QString s_socketName = QStringLiteral("test_drm"); class DrmTest : public QObject { Q_OBJECT private Q_SLOTS: void init(); void initTestCase(); void cleanup(); void testModesets(); void testPresentation(); void testCursorLayer(); void testDirectScanout_data(); void testDirectScanout(); void testOverlay_data(); void testOverlay(); void testDpms(); }; struct DrmCrtcState { bool active; QSize modeSize; uint32_t refreshRateMilliHertz; static std::optional read(int fd, uint32_t id); }; std::optional DrmCrtcState::read(int fd, uint32_t id) { DrmUniquePtr crtc(drmModeGetCrtc(fd, id)); if (!crtc) { return std::nullopt; } auto props = DrmObject::queryProperties(fd, id, DRM_MODE_OBJECT_CRTC); const auto active = props.takeProperty(QByteArrayLiteral("ACTIVE")); if (!active.has_value()) { return std::nullopt; } return DrmCrtcState{ .active = active->second == 1, .modeSize = crtc->mode_valid ? QSize(crtc->mode.hdisplay, crtc->mode.vdisplay) : QSize(), .refreshRateMilliHertz = crtc->mode_valid ? DrmConnector::refreshRateForMode(&crtc->mode) : 0, }; } struct DrmPlaneState { uint32_t crtcId; Rect destinationRect; uint32_t frambufferId; std::array framebufferGemNames; static std::optional read(int fd, uint32_t id); }; std::optional DrmPlaneState::read(int fd, uint32_t id) { auto props = DrmObject::queryProperties(fd, id, DRM_MODE_OBJECT_PLANE); const auto crtcId = props.takeProperty(QByteArrayLiteral("CRTC_ID")); const auto crtcX = props.takeProperty(QByteArrayLiteral("CRTC_X")); const auto crtcY = props.takeProperty(QByteArrayLiteral("CRTC_Y")); const auto crtcW = props.takeProperty(QByteArrayLiteral("CRTC_W")); const auto crtcH = props.takeProperty(QByteArrayLiteral("CRTC_H")); const auto fbId = props.takeProperty(QByteArrayLiteral("FB_ID")); if (!crtcId || !crtcX || !crtcY || !crtcW || !crtcH || !fbId) { return std::nullopt; } std::array framebufferGemNames = {0}; if (auto ptr = drmModeGetFB2(fd, fbId->second)) { std::unordered_set handles; const auto guard = qScopeGuard([&]() { // NOTE that drmModeGetFB2 always creates new handles for (uint32_t handle : handles) { drmCloseBufferHandle(fd, handle); } drmModeFreeFB2(ptr); }); for (int i = 0; i < 4; i++) { if (ptr->handles[i] == 0) { break; } handles.insert(ptr->handles[i]); drm_gem_flink flink{ .handle = ptr->handles[i], .name = 0, }; if (drmIoctl(fd, DRM_IOCTL_GEM_FLINK, &flink) != 0) { return std::nullopt; } framebufferGemNames[i] = flink.name; } } return DrmPlaneState{ .crtcId = uint32_t(crtcId->second), .destinationRect = Rect(crtcX->second, crtcY->second, crtcW->second, crtcH->second), .frambufferId = uint32_t(fbId->second), .framebufferGemNames = framebufferGemNames, }; } struct DrmConnectorState { uint32_t crtcId; static std::optional read(int fd, uint32_t id); }; std::optional DrmConnectorState::read(int fd, uint32_t id) { auto props = DrmObject::queryProperties(fd, id, DRM_MODE_OBJECT_CONNECTOR); const auto crtcId = props.takeProperty(QByteArrayLiteral("CRTC_ID")); if (!crtcId.has_value()) { return std::nullopt; } return DrmConnectorState{ .crtcId = uint32_t(crtcId->second), }; } struct DrmState { std::unordered_map crtcs; std::unordered_map planes; std::unordered_map connectors; static std::optional read(int fd); }; std::optional DrmState::read(int fd) { DrmUniquePtr resources(drmModeGetResources(fd)); DrmUniquePtr planeResources(drmModeGetPlaneResources(fd)); if (!resources || !planeResources) { return std::nullopt; } DrmState ret; for (uint32_t id : std::span(resources->connectors, resources->count_connectors)) { const auto state = DrmConnectorState::read(fd, id); if (!state) { return std::nullopt; } ret.connectors[id] = *state; } for (uint32_t id : std::span(resources->crtcs, resources->count_crtcs)) { const auto state = DrmCrtcState::read(fd, id); if (!state) { return std::nullopt; } ret.crtcs[id] = *state; } for (uint32_t id : std::span(planeResources->planes, planeResources->count_planes)) { const auto state = DrmPlaneState::read(fd, id); if (!state) { return std::nullopt; } ret.planes[id] = *state; } return ret; } struct DrmOutputState { DrmConnectorState connector; std::optional crtc; std::unordered_map planes; static std::optional read(BackendOutput *output); }; std::optional DrmOutputState::read(BackendOutput *output) { const auto drmOutput = static_cast(output); const auto allState = DrmState::read(drmOutput->connector()->gpu()->fd()); if (!allState) { return std::nullopt; } const auto connector = allState->connectors.find(drmOutput->connector()->id()); if (connector == allState->connectors.end()) { return std::nullopt; } DrmOutputState ret; ret.connector = connector->second; if (connector->second.crtcId == 0) { return ret; } const auto crtc = allState->crtcs.find(connector->second.crtcId); if (crtc == allState->crtcs.end()) { return std::nullopt; } ret.crtc = crtc->second; ret.planes = allState->planes | std::views::filter([crtcId = connector->second.crtcId](const auto &state) { return state.second.crtcId == crtcId; }) | std::ranges::to(); return ret; } // TODO move this stuff into XdgToplevelWindow instead? class DmabufWindow : public QObject { Q_OBJECT public: explicit DmabufWindow(std::function setup = [](Test::XdgToplevel *toplevel) { }) : m_surface(Test::createSurface()) , m_shellSurface(Test::createXdgToplevelSurface(m_surface.get(), setup)) , m_device(DrmDevice::open(Test::linuxDmabuf()->mainDevice())) , m_surfaceFeedback(Test::linuxDmabuf()->getSurfaceFeedback(*m_surface)) { connect(m_surfaceFeedback.get(), &WaylandClient::LinuxDmabufFeedbackV1::changed, this, &DmabufWindow::reactToDmabufFeedback); } bool renderAndWaitForShown(const QSize &size) { const auto formats = m_surfaceFeedback->formats().empty() ? Test::linuxDmabuf()->formats() : m_surfaceFeedback->formats(); m_buffer = m_device->allocator()->allocate(GraphicsBufferOptions{ .size = size, .format = DRM_FORMAT_XRGB8888, .modifiers = formats[DRM_FORMAT_XRGB8888], .software = false, }); auto wlbuffer = Test::linuxDmabuf()->importBuffer(m_buffer.buffer()); m_surface->attachBuffer(wlbuffer); m_surface->damage(QRect(QPoint(0, 0), size)); m_surface->commit(KWayland::Client::Surface::CommitFlag::None); wl_buffer_destroy(wlbuffer); m_window = Test::waitForWaylandWindowShown(); return m_window; } bool presentWait() { wl_buffer *wlbuffer = nullptr; if (m_needsRealloc) { const auto tranches = m_surfaceFeedback->tranches(); for (const auto &tranche : tranches) { if (!tranche.formats.contains(m_buffer->dmabufAttributes()->format)) { continue; } // scanout flag is currently implicit in GraphicsBufferOptions m_buffer = m_device->allocator()->allocate(GraphicsBufferOptions{ .size = m_buffer->size(), .format = m_buffer->dmabufAttributes()->format, .modifiers = tranche.formats[m_buffer->dmabufAttributes()->format], .software = false, }); auto wlbuffer = Test::linuxDmabuf()->importBuffer(m_buffer.buffer()); m_surface->attachBuffer(wlbuffer); m_surface->damage(QRect(QPoint(0, 0), m_buffer->size())); m_needsRealloc = false; break; } } auto feedback = std::make_unique(Test::presentationTime()->feedback(*m_surface)); m_surface->commit(KWayland::Client::Surface::CommitFlag::None); if (wlbuffer) { wl_buffer_destroy(wlbuffer); } QSignalSpy spy(feedback.get(), &Test::WpPresentationFeedback::presented); return spy.wait(); } void reactToDmabufFeedback() { m_needsRealloc = bool(m_buffer); } std::unique_ptr m_surface; std::unique_ptr m_shellSurface; std::unique_ptr m_device; std::unique_ptr m_surfaceFeedback; GraphicsBufferRef m_buffer; bool m_needsRealloc = false; Window *m_window = nullptr; }; void DrmTest::init() { QVERIFY(Test::setupWaylandConnection(Test::AdditionalWaylandInterface::PresentationTime | Test::AdditionalWaylandInterface::Seat | Test::AdditionalWaylandInterface::CursorShapeV1 | Test::AdditionalWaylandInterface::LinuxDmabuf | Test::AdditionalWaylandInterface::Viewporter)); } void DrmTest::initTestCase() { if (!Test::primaryNodeAvailable()) { QSKIP("no primary node available"); return; } #ifdef FORCE_DRM_LEGACY qputenv("KWIN_DRM_NO_AMS", "1"); #endif #ifdef FORCE_NO_DRM_MODIFIERS qputenv("KWIN_DRM_USE_MODIFIERS", "0"); #endif // make sure overlays are allowed qputenv("KWIN_USE_OVERLAYS", "1"); QVERIFY(waylandServer()->init(s_socketName)); kwinApp()->start(); const auto allOutputs = kwinApp()->outputBackend()->outputs(); QVERIFY(!allOutputs.isEmpty()); // Create a simple setup that's useful for most tests. OutputConfiguration cfg; cfg.changeSet(allOutputs.front())->enabled = true; cfg.changeSet(allOutputs.front())->scaleSetting = 1; for (BackendOutput *output : allOutputs | std::views::drop(1)) { cfg.changeSet(output)->enabled = false; } QCOMPARE(workspace()->applyOutputConfiguration(cfg), OutputConfigurationError::None); const auto state = DrmOutputState::read(allOutputs.front()); QVERIFY(state.has_value()); QVERIFY(state->crtc.has_value()); for (BackendOutput *output : allOutputs | std::views::drop(1)) { const auto state = DrmOutputState::read(output); QVERIFY(state.has_value()); QVERIFY(!state->crtc.has_value()); } } void DrmTest::cleanup() { Test::destroyWaylandConnection(); } void DrmTest::testModesets() { // verify that modesetting works as expected // and doesn't cause crashes or issues with presentation time Test::XdgToplevelWindow window; QVERIFY(window.show()); BackendOutput *output = kwinApp()->outputBackend()->outputs().front(); window.m_window->move(output->position()); // verify that we can cycle through modes for (const auto &mode : output->modes()) { OutputConfiguration cfg; cfg.changeSet(output)->mode = mode; QCOMPARE(workspace()->applyOutputConfiguration(cfg), OutputConfigurationError::None); QVERIFY(window.presentWait()); const auto state = DrmOutputState::read(output); QVERIFY(state); QVERIFY(state->crtc.has_value()); QCOMPARE(state->crtc->modeSize, mode->size()); QCOMPARE(state->crtc->refreshRateMilliHertz, mode->refreshRate()); } } void DrmTest::testPresentation() { // verify that normal presentation works as expected Test::XdgToplevelWindow window; QVERIFY(window.show()); BackendOutput *output = kwinApp()->outputBackend()->outputs().front(); window.m_window->move(output->position()); for (int i = 0; i < 5; i++) { QVERIFY(window.presentWait()); } } void DrmTest::testCursorLayer() { // verify that the cursor layer is used, if it is available uint32_t time = 0; BackendOutput *output = kwinApp()->outputBackend()->outputs().front(); const auto layers = Compositor::self()->backend()->compatibleOutputLayers(output); if (layers.size() == 1) { QSKIP("The driver only advertises a primary plane"); } Test::XdgToplevelWindow window; QVERIFY(window.show()); window.m_window->move(output->position()); // if there's a visible cursor, a non-primary plane should be used to present it std::unique_ptr pointer{Test::waylandSeat()->createPointer()}; QSignalSpy enteredSpy(pointer.get(), &KWayland::Client::Pointer::entered); Test::pointerMotion(window.m_window->frameGeometry().center(), time++); QVERIFY(enteredSpy.wait()); auto cursorShapeDevice = Test::createCursorShapeDeviceV1(pointer.get()); cursorShapeDevice->set_shape(enteredSpy.last().at(0).value(), Test::CursorShapeDeviceV1::shape_default); QVERIFY(window.presentWait()); const int enabledLayers = std::ranges::count_if(layers, [](OutputLayer *layer) { return layer->isEnabled(); }); QCOMPARE(enabledLayers, 2); #ifndef FORCE_DRM_LEGACY // NOTE that this *should* in principle work, but in practice doesn't work // with all drivers in legacy modesetting. VKMS doesn't report the cursor // plane as enabled if it was enabled through the legacy interface const auto state = DrmOutputState::read(output); QVERIFY(state); QVERIFY(state->crtc.has_value()); auto enabledPlanes = state->planes | std::views::values | std::views::filter([](const auto &state) { return state.crtcId != 0; }); QCOMPARE(std::distance(enabledPlanes.begin(), enabledPlanes.end()), 2); #endif } static std::array gemNames(int fd, const DmaBufAttributes *attributes) { std::array ret = {0, 0, 0, 0}; for (int i = 0; i < attributes->planeCount; i++) { uint32_t handle = 0; if (drmPrimeFDToHandle(fd, attributes->fd[i].get(), &handle) != 0) { return {0}; } drm_gem_flink flink{ .handle = handle, .name = 0, }; if (drmIoctl(fd, DRM_IOCTL_GEM_FLINK, &flink) != 0) { return {0}; } ret[i] = flink.name; drmCloseBufferHandle(fd, handle); } return ret; } bool findBufferOnPlane(BackendOutput *output, GraphicsBuffer *buffer) { const auto state = DrmOutputState::read(output); if (!state || !state->crtc.has_value()) { return false; } const int gpuFd = static_cast(output)->connector()->gpu()->fd(); const auto framebufferGemNames = gemNames(gpuFd, buffer->dmabufAttributes()); return std::ranges::any_of(state->planes | std::views::values, [&framebufferGemNames](const DrmPlaneState &state) { return state.framebufferGemNames == framebufferGemNames; }); } void DrmTest::testDirectScanout_data() { BackendOutput *output = kwinApp()->outputBackend()->outputs().front(); QTest::addColumn("outputScale"); QTest::addColumn("bufferSize"); QTest::addColumn("sourceViewport"); QTest::addRow("no scaling") << 1.0 << QSize(output->pixelSize()) << Rect(QPoint(), output->pixelSize()); QTest::addRow("no effective scaling") << 2.0 << QSize(output->pixelSize()) << Rect(QPoint(), output->pixelSize()); #ifndef FORCE_DRM_LEGACY // TODO maybe also test that direct scanout does *not* happen with these cases and legacy modesetting? QTest::addRow("scaled up by 2x") << 2.0 << QSize(output->pixelSize() / 2) << Rect(QPoint(), output->pixelSize() / 2); QTest::addRow("scaled up by 2x + partial source") << 2.0 << output->pixelSize() << Rect(QPoint(output->pixelSize().width() / 4, output->pixelSize().height() / 2), output->pixelSize() / 2); QTest::addRow("scaled down by 2x") << 2.0 << QSize(output->pixelSize() * 2) << Rect(QPoint(), output->pixelSize() * 2); #endif } void DrmTest::testDirectScanout() { #ifdef FORCE_DRM_LEGACY QSKIP("This test is known to be broken with legacy modesetting"); #endif QVERIFY2(Test::linuxDmabuf(), "This test needs dmabuf support"); uint32_t time = 0; BackendOutput *output = kwinApp()->outputBackend()->outputs().front(); QFETCH(double, outputScale); QFETCH(QSize, bufferSize); QFETCH(Rect, sourceViewport); { OutputConfiguration cfg; cfg.changeSet(output)->scaleSetting = outputScale; QCOMPARE(workspace()->applyOutputConfiguration(cfg), OutputConfigurationError::None); } const auto layers = Compositor::self()->backend()->compatibleOutputLayers(output); if (layers.size() == 1) { QSKIP("The driver only advertises a primary plane"); } DmabufWindow window{[](Test::XdgToplevel *toplevel) { toplevel->set_fullscreen(nullptr); }}; auto viewport = Test::viewporter()->createViewport(*window.m_surface); viewport->setSource(sourceViewport); viewport->setDestination(output->pixelSize() / outputScale); QVERIFY(window.renderAndWaitForShown(bufferSize)); // if there's a visible cursor, a non-primary plane should be used to present it std::unique_ptr pointer{Test::waylandSeat()->createPointer()}; QSignalSpy enteredSpy(pointer.get(), &KWayland::Client::Pointer::entered); Test::pointerMotion(window.m_window->frameGeometry().center(), time++); QVERIFY(enteredSpy.wait()); auto cursorShapeDevice = Test::createCursorShapeDeviceV1(pointer.get()); cursorShapeDevice->set_shape(enteredSpy.last().at(0).value(), Test::CursorShapeDeviceV1::shape_default); // NOTE that a single presentWait is not enough, as reallocation may // be necessary to put the buffer on a drm plane QElapsedTimer timer; timer.start(); while (timer.durationElapsed() < 5s) { window.m_surface->damageBuffer(QRect(QPoint(), QSize(100, 100))); QVERIFY(window.presentWait()); if (findBufferOnPlane(output, window.m_buffer.buffer())) { break; } } QCOMPARE_LE(timer.durationElapsed(), 5s); const int enabledLayers = std::ranges::count_if(layers, [](OutputLayer *layer) { return layer->isEnabled(); }); QCOMPARE(enabledLayers, 2); const auto state = DrmOutputState::read(output); QVERIFY(state); QVERIFY(state->crtc.has_value()); #ifndef FORCE_DRM_LEGACY auto enabledPlanes = state->planes | std::views::values | std::views::filter([](const DrmPlaneState &state) { return state.crtcId != 0; }); // NOTE that this *should* in principle work, but in practice doesn't work // with all drivers in legacy modesetting. VKMS doesn't report the cursor // plane as enabled if it was enabled through the legacy interface QCOMPARE(std::distance(enabledPlanes.begin(), enabledPlanes.end()), 2); #endif } void DrmTest::testOverlay_data() { QTest::addColumn("occluded"); QTest::addColumn("scale"); QTest::addColumn("windowGeometry"); QTest::addColumn("planeGeometry"); QTest::addRow("overlay") << false << 1.0 << Rect(51, 51, 100, 100) << Rect(0, 0, 100, 100); QTest::addRow("underlay") << true << 1.0 << Rect(51, 51, 100, 100) << Rect(0, 0, 100, 100); // this case verifies (among other things) that output position is properly rounded for the plane position QTest::addRow("scaling + overlay") << false << 1.6 << Rect(52, 52, 63, 63) << Rect(1, 1, 101, 101); // TODO also add a test case for occluded == false + SSD with rounded corners? } void DrmTest::testOverlay() { QVERIFY2(Test::linuxDmabuf(), "This test needs dmabuf support"); uint32_t time = 0; BackendOutput *output = kwinApp()->outputBackend()->outputs().front(); const auto layers = Compositor::self()->backend()->compatibleOutputLayers(output); if (layers.size() < 3) { QSKIP("The driver doesn't advertise an overlay plane"); } QFETCH(bool, occluded); QFETCH(double, scale); QFETCH(Rect, windowGeometry); QFETCH(Rect, planeGeometry); Test::XdgToplevelWindow dummy; QVERIFY(dummy.show()); dummy.m_window->move(windowGeometry.topLeft() + QPoint(50, 50)); { OutputConfiguration cfg; cfg.changeSet(output)->scaleSetting = scale; cfg.changeSet(output)->pos = QPoint(51, 51); QCOMPARE(workspace()->applyOutputConfiguration(cfg), OutputConfigurationError::None); } DmabufWindow window; auto viewport = Test::viewporter()->createViewport(*window.m_surface); viewport->setSource(planeGeometry.size()); viewport->setDestination(windowGeometry.size()); QVERIFY(window.renderAndWaitForShown(planeGeometry.size())); window.m_window->move(windowGeometry.topLeft()); if (occluded) { workspace()->raiseWindow(dummy.m_window); } // if there's a visible cursor, a non-primary plane should be used to present it std::unique_ptr pointer{Test::waylandSeat()->createPointer()}; QSignalSpy enteredSpy(pointer.get(), &KWayland::Client::Pointer::entered); Test::pointerMotion(window.m_window->frameGeometry().center(), time++); QVERIFY(enteredSpy.wait()); auto cursorShapeDevice = Test::createCursorShapeDeviceV1(pointer.get()); cursorShapeDevice->set_shape(enteredSpy.last().at(0).value(), Test::CursorShapeDeviceV1::shape_default); // NOTE that a single presentWait is not enough, as reallocation may // be necessary to put the buffer on a drm plane QElapsedTimer timer; timer.start(); while (timer.durationElapsed() < 5s) { window.m_surface->damageBuffer(QRect(QPoint(), planeGeometry.size())); QVERIFY(window.presentWait()); if (findBufferOnPlane(output, window.m_buffer.buffer())) { break; } } QCOMPARE_LE(timer.durationElapsed(), 5s); const int enabledLayers = std::ranges::count_if(layers, [](OutputLayer *layer) { return layer->isEnabled(); }); QCOMPARE(enabledLayers, 3); const auto state = DrmOutputState::read(output); QVERIFY(state); QVERIFY(state->crtc.has_value()); auto enabledPlanes = state->planes | std::views::values | std::views::filter([](const DrmPlaneState &state) { return state.crtcId != 0; }); QCOMPARE(std::distance(enabledPlanes.begin(), enabledPlanes.end()), 3); const auto sceneIt = std::ranges::find_if(enabledPlanes, [output](const DrmPlaneState &state) { return state.destinationRect == Rect(QPoint(), output->modeSize()); }); QVERIFY(sceneIt != enabledPlanes.end()); const int gpuFd = static_cast(output)->connector()->gpu()->fd(); const auto framebufferGemNames = gemNames(gpuFd, window.m_buffer->dmabufAttributes()); const auto overlayIt = std::ranges::find_if(enabledPlanes, [&framebufferGemNames](const DrmPlaneState &state) { return state.framebufferGemNames == framebufferGemNames; }); QVERIFY(overlayIt != enabledPlanes.end()); const auto &overlayPlane = *overlayIt; QCOMPARE(overlayPlane.destinationRect, planeGeometry); // TODO uncomment this once vkms supports the zpos property // QCOMPARE_GE(overlayPlane.zpos, (*sceneIt).zpos); } void DrmTest::testDpms() { // the output should be enabled BackendOutput *output = kwinApp()->outputBackend()->outputs().front(); auto state = DrmOutputState::read(output); QVERIFY(state.has_value()); QVERIFY(state->crtc.has_value()); Test::XdgToplevelWindow dummy; QVERIFY(dummy.show()); workspace()->requestDpmsState(Workspace::DpmsState::Off); QSignalSpy stateChange(workspace(), &Workspace::dpmsStateChanged); // requesting dpms off multiple times must not mess up renderloop inhibition // or cause further state changes! workspace()->requestDpmsState(Workspace::DpmsState::Off); workspace()->requestDpmsState(Workspace::DpmsState::Off); QCOMPARE(stateChange.count(), 0); QVERIFY(stateChange.wait()); #ifndef FORCE_DRM_LEGACY QCOMPARE(workspace()->dpmsState(), Workspace::DpmsState::TurningOff); QVERIFY(stateChange.wait()); #endif QCOMPARE(workspace()->dpmsState(), Workspace::DpmsState::Off); state = DrmOutputState::read(output); QVERIFY(state.has_value()); QVERIFY(!state->crtc.has_value() || !state->crtc->active); workspace()->requestDpmsState(Workspace::DpmsState::On); QVERIFY(dummy.presentWait()); state = DrmOutputState::read(output); QVERIFY(state.has_value()); QVERIFY(state->crtc.has_value()); QVERIFY(state->crtc->active); } } WAYLAND_DRM_TEST_MAIN(KWin::DrmTest) #include "drm_test.moc"