From e801dd3e506e8901db0515e5736ed90fa4a02206 Mon Sep 17 00:00:00 2001 From: Matthew Schwartz Date: Fri, 22 May 2026 12:22:26 -0700 Subject: [PATCH 1/4] gamescope-control: Introduce display_selection interface for output selection Add runtime display output selection to gamescope-control. Previously the output was only selectable via --prefer-output at startup, which is not adjustable in gamescope-session. The interface adds available_display_info / available_display_info_done events to enumerate the connected outputs a client can switch to, set_display / unset_display requests to pick one by connector name or fall back to --prefer-output, and a matching set_display console command taking either an exact connector name or a make/model/serial substring. Displays are tracked as follows: the connector name is authoritative for the actual switch (as --prefer-output already is), but a connector number can't always be relied upon as a stable identifier. Connectors have a possibility to be reenumerated when a cable is replugged, across reboots, or on MST topology changes, so what was DP-1 may become DP-4. This also depends on what type of device and dock/cable are being used. Instead, the selection is persisted by an EDID-derived identifier, "{make} {model} {serial}", and resolved identifier-first. Among the connected outputs, the one whose identifier matches is chosen, so the monitor is found regardless of its current connector. One corner case is that physically identical, serial-less monitors could collide as the same identifier. In this case, a " [connector]" suffix is appended, and matching falls back to the connector as a secondary key where the hinted connector wins if present. The available-display list also flags the output currently being driven (a "current" display flag), so a client can show which display is active, including which of two otherwise-identical monitors. With this in mind, the expected user experience should be consistent across multiple scenarios: - Desktop setups: connectors are stable across boots, so identical monitors are individually selectable and the choice persists. - Handheld connected to dock: connectors may reenumerate on replug, so the EDID identifier carries the selection across regardless of how the connectors come back. display_selection is implemented in its own header in order to support tests. I've also been able to test this on a Steam Deck with third-party dock, a 7900XTX, an RTX 5090, and an Intel Lunar Lake APU with a Steam Deck dock to confirm that all scenarios behave as expected. --- protocol/gamescope-control.xml | 26 ++++- src/Apps/gamescopectl.cpp | 57 ++++++++- src/Backends/DRMBackend.cpp | 193 ++++++++++++++++++++++++++++++- src/Backends/DeferredBackend.h | 20 ++++ src/backend.h | 12 ++ src/display_selection.h | 87 ++++++++++++++ src/wlserver.cpp | 69 ++++++++++- tests/meson.build | 8 ++ tests/test_display_selection.cpp | 100 ++++++++++++++++ 9 files changed, 563 insertions(+), 9 deletions(-) create mode 100644 src/display_selection.h create mode 100644 tests/test_display_selection.cpp diff --git a/protocol/gamescope-control.xml b/protocol/gamescope-control.xml index 42fc9666e2..06635d907b 100644 --- a/protocol/gamescope-control.xml +++ b/protocol/gamescope-control.xml @@ -29,7 +29,7 @@ it. - + @@ -44,6 +44,7 @@ + @@ -59,6 +60,7 @@ + @@ -143,5 +145,27 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Apps/gamescopectl.cpp b/src/Apps/gamescopectl.cpp index e31030357e..077d06aa56 100644 --- a/src/Apps/gamescopectl.cpp +++ b/src/Apps/gamescopectl.cpp @@ -33,6 +33,15 @@ namespace gamescope std::vector ValidRefreshRates; }; + struct GamescopeAvailableDisplayInfo + { + std::string szConnectorName; + std::string szDisplayMake; + std::string szDisplayModel; + uint32_t uDisplayFlags; + std::string szDisplayIdentifier; + }; + class GamescopeCtl { public: @@ -44,6 +53,7 @@ namespace gamescope std::span GetFeatures() { return std::span{ m_Features }; } const std::optional &GetActiveDisplayInfo() { return m_ActiveDisplayInfo; } + std::span GetAvailableDisplays() { return m_AvailableDisplays; } private: bool m_bInitControl = false; bool m_bInitPrivate = false; @@ -56,6 +66,7 @@ namespace gamescope std::vector m_Features; std::optional m_ActiveDisplayInfo; + std::vector m_AvailableDisplays; void Wayland_Registry_Global( wl_registry *pRegistry, uint32_t uName, const char *pInterface, uint32_t uVersion ); static const wl_registry_listener s_RegistryListener; @@ -63,6 +74,7 @@ namespace gamescope void Wayland_GamescopeControl_FeatureSupport( gamescope_control *pGamescopeControl, uint32_t uFeature, uint32_t uVersion, uint32_t uFlags ); void Wayland_GamescopeControl_ActiveDisplayInfo( gamescope_control *pGamescopeControl, const char *pConnectorName, const char *pDisplayMake, const char *pDisplayModel, uint32_t uDisplayFlags, wl_array *pValidRefreshRatesArray ); void Wayland_GamescopeControl_ScreenshotTaken( gamescope_control *pGamescopeControl, const char *pPath ); + void Wayland_GamescopeControl_AvailableDisplayInfo( gamescope_control *pGamescopeControl, const char *pConnectorName, const char *pDisplayMake, const char *pDisplayModel, uint32_t uDisplayFlags, const char *pDisplayIdentifier ); static const gamescope_control_listener s_GamescopeControlListener; void Wayland_GamescopePrivate_Log( gamescope_private *pGamescopePrivate, const char *pText ); @@ -187,12 +199,26 @@ namespace gamescope fprintf( stderr, "Screenshot taken to: %s\n", pPath ); } + void GamescopeCtl::Wayland_GamescopeControl_AvailableDisplayInfo( gamescope_control *pGamescopeControl, const char *pConnectorName, const char *pDisplayMake, const char *pDisplayModel, uint32_t uDisplayFlags, const char *pDisplayIdentifier ) + { + m_AvailableDisplays.emplace_back( GamescopeAvailableDisplayInfo + { + .szConnectorName = pConnectorName, + .szDisplayMake = pDisplayMake, + .szDisplayModel = pDisplayModel, + .uDisplayFlags = uDisplayFlags, + .szDisplayIdentifier = pDisplayIdentifier, + } ); + } + const gamescope_control_listener GamescopeCtl::s_GamescopeControlListener = { - .feature_support = WAYLAND_USERDATA_TO_THIS( GamescopeCtl, Wayland_GamescopeControl_FeatureSupport ), - .active_display_info = WAYLAND_USERDATA_TO_THIS( GamescopeCtl, Wayland_GamescopeControl_ActiveDisplayInfo ), - .screenshot_taken = WAYLAND_USERDATA_TO_THIS( GamescopeCtl, Wayland_GamescopeControl_ScreenshotTaken ), - .app_performance_stats = WAYLAND_NULL(), + .feature_support = WAYLAND_USERDATA_TO_THIS( GamescopeCtl, Wayland_GamescopeControl_FeatureSupport ), + .active_display_info = WAYLAND_USERDATA_TO_THIS( GamescopeCtl, Wayland_GamescopeControl_ActiveDisplayInfo ), + .screenshot_taken = WAYLAND_USERDATA_TO_THIS( GamescopeCtl, Wayland_GamescopeControl_ScreenshotTaken ), + .app_performance_stats = WAYLAND_NULL(), + .available_display_info = WAYLAND_USERDATA_TO_THIS( GamescopeCtl, Wayland_GamescopeControl_AvailableDisplayInfo ), + .available_display_info_done = WAYLAND_NULL(), }; void GamescopeCtl::Wayland_GamescopePrivate_Log( gamescope_private *pGamescopePrivate, const char *pText ) @@ -231,6 +257,8 @@ namespace gamescope return "Look"; case GAMESCOPE_CONTROL_FEATURE_PERF_QUERY: return "Performance Query"; + case GAMESCOPE_CONTROL_FEATURE_DISPLAY_SELECTION: + return "Display Selection"; default: return "Unknown"; } @@ -266,6 +294,27 @@ namespace gamescope } fprintf( stdout, "\n" ); } + auto availableDisplays = gamescopeCtl.GetAvailableDisplays(); + if ( !availableDisplays.empty() ) + { + fprintf( stdout, " Available Displays:\n" ); + for ( const GamescopeAvailableDisplayInfo &avail : availableDisplays ) + { + fprintf( stdout, " - %s", avail.szConnectorName.c_str() ); + std::string szMakeModel = avail.szDisplayMake; + if ( !szMakeModel.empty() && !avail.szDisplayModel.empty() ) + szMakeModel += " "; + szMakeModel += avail.szDisplayModel; + if ( !szMakeModel.empty() ) + fprintf( stdout, " (%s)", szMakeModel.c_str() ); + fprintf( stdout, " - Flags: 0x%x", avail.uDisplayFlags ); + if ( !avail.szDisplayIdentifier.empty() ) + fprintf( stdout, " - Identifier: \"%s\"", avail.szDisplayIdentifier.c_str() ); + if ( avail.uDisplayFlags & GAMESCOPE_CONTROL_DISPLAY_FLAG_CURRENT ) + fprintf( stdout, " [current]" ); + fprintf( stdout, "\n" ); + } + } fprintf( stdout, " Features:\n" ); for ( const GamescopeFeature &feature : gamescopeCtl.GetFeatures() ) { diff --git a/src/Backends/DRMBackend.cpp b/src/Backends/DRMBackend.cpp index 96e1a3812c..c79c20c676 100644 --- a/src/Backends/DRMBackend.cpp +++ b/src/Backends/DRMBackend.cpp @@ -54,6 +54,8 @@ #include "gamescope-control-protocol.h" +#include "display_selection.h" + static constexpr bool k_bUseCursorPlane = false; extern int g_nPreferredOutputWidth; @@ -157,6 +159,9 @@ struct drm_t { std::atomic < bool > needs_modeset = { false }; std::unordered_map< std::string, int > connector_priorities; + std::mutex connectors_mutex; + std::mutex preferred_display_mutex; + std::string preferred_display; char *device_name = nullptr; }; @@ -388,6 +393,8 @@ namespace gamescope const char *GetMake() const override { return m_Mutable.pszMake; } const char *GetModel() const override { return m_Mutable.szModel; } const char *GetDataString() const { return m_Mutable.szDataString; } + uint32_t GetEDIDSerial() const { return m_Mutable.uEDIDSerial; } + const char *GetEDIDSerialString() const { return m_Mutable.szEDIDSerial; } uint32_t GetPossibleCRTCMask() const { return m_Mutable.uPossibleCRTCMask; } std::span GetValidDynamicRefreshRates() const override { return m_Mutable.ValidDynamicRefreshRates; } const displaycolorimetry_t& GetDisplayColorimetry() const { return m_Mutable.DisplayColorimetry; } @@ -511,6 +518,8 @@ namespace gamescope char szMakePNP[4]{}; char szModel[16]{}; char szDataString[16]{}; + uint32_t uEDIDSerial = 0; + char szEDIDSerial[16]{}; const char *pszMake = ""; // Not owned, no free. This is a pointer to pnp db or szMakePNP. DRMModeGenerator fnDynamicModeGenerator; std::vector ValidDynamicRefreshRates{}; @@ -816,6 +825,8 @@ static bool refresh_state( drm_t *drm ) } defer( drmModeFreeResources( pResources ) ); + std::lock_guard lock( drm->connectors_mutex ); + // Add connectors which appeared for ( int i = 0; i < pResources->count_connectors; i++ ) { @@ -985,6 +996,40 @@ static std::unordered_map parse_connector_priorities(const cha return priorities; } +static std::string get_display_identifier(const gamescope::CDRMConnector *pConnector) +{ + const char *pszMake = pConnector->GetMake(); + const char *pszModel = pConnector->GetModel(); + const char *pszName = pConnector->GetName(); + const char *pszSerialStr = pConnector->GetEDIDSerialString(); + return gamescope::BuildDisplayIdentifier( + pConnector->GetScreenType() == gamescope::GAMESCOPE_SCREEN_TYPE_INTERNAL, + pszMake ? pszMake : "", + pszModel ? pszModel : "", + pszName ? pszName : "", + pConnector->GetEDIDSerial(), + pszSerialStr ? pszSerialStr : "" ); +} + +static std::string get_display_key(struct drm_t *drm, const gamescope::CDRMConnector *pConnector) +{ + std::string base = get_display_identifier(pConnector); + bool bShared = false; + for (auto &iter : drm->connectors) + { + gamescope::CDRMConnector *pOther = &iter.second; + if (pOther != pConnector + && pOther->GetModeConnector()->connection == DRM_MODE_CONNECTED + && get_display_identifier(pOther) == base) + { + bShared = true; + break; + } + } + const char *pszName = pConnector->GetName(); + return gamescope::BuildDisplaySelectionKey( base, pszName ? pszName : "", bShared ); +} + static int get_connector_priority(struct drm_t *drm, const char *name) { if (drm->connector_priorities.count(name) > 0) { @@ -996,6 +1041,29 @@ static int get_connector_priority(struct drm_t *drm, const char *name) return drm->connector_priorities.size(); } +static std::string get_saved_preferred_display() +{ + const char *path = getenv("GAMESCOPE_DISPLAY_SELECTION_FILE"); + if (!path || !*path) + return {}; + + FILE *file = fopen(path, "r"); + if (!file) + return {}; + + char line[256] = {}; + std::string result; + if (fgets(line, sizeof(line), file)) + { + size_t len = strlen(line); + while (len > 0 && (line[len-1] == '\n' || line[len-1] == '\r' || line[len-1] == ' ' || line[len-1] == '\t')) + line[--len] = '\0'; + result = line; + } + fclose(file); + return result; +} + static bool get_saved_mode(const char *description, saved_mode &mode_info) { const char *mode_file = getenv("GAMESCOPE_MODE_SAVE_FILE"); @@ -1029,6 +1097,39 @@ static bool get_saved_mode(const char *description, saved_mode &mode_info) static GamescopeBroadcastRGBMode_t s_ExternalBroadcastRGBMode = GAMESCOPE_BROADCAST_RGB_MODE_AUTOMATIC; +static gamescope::CDRMConnector *resolve_preferred_connector(struct drm_t *drm) +{ + std::string pref; + { + std::lock_guard lock( drm->preferred_display_mutex ); + pref = drm->preferred_display; + } + if (pref.empty()) + return nullptr; + + std::vector candidates; + std::vector pConnectors; + for (auto &iter : drm->connectors) + { + gamescope::CDRMConnector *pConnector = &iter.second; + if (pConnector->GetModeConnector()->connection != DRM_MODE_CONNECTED) + continue; + const char *pszName = pConnector->GetName(); + candidates.push_back( { pszName ? pszName : "", get_display_identifier(pConnector) } ); + pConnectors.push_back( pConnector ); + } + + std::optional oIndex = gamescope::ResolveDisplaySelection( pref, candidates ); + if (!oIndex) { + drm_log.infof("preferred display '%s' not connected, using output priority", pref.c_str()); + return nullptr; + } + + gamescope::CDRMConnector *pConnector = pConnectors[*oIndex]; + drm_log.infof("preferred display '%s' -> connector %s", pref.c_str(), pConnector->GetName()); + return pConnector; +} + static bool setup_best_connector(struct drm_t *drm, bool force, bool initial) { if (drm->pConnector && drm->pConnector->GetModeConnector()->connection != DRM_MODE_CONNECTED) { @@ -1036,6 +1137,8 @@ static bool setup_best_connector(struct drm_t *drm, bool force, bool initial) drm->pConnector = nullptr; } + gamescope::CDRMConnector *pPreferred = resolve_preferred_connector( drm ); + gamescope::CDRMConnector *best = nullptr; int nBestPriority = INT_MAX; for ( auto &iter : drm->connectors ) @@ -1048,7 +1151,7 @@ static bool setup_best_connector(struct drm_t *drm, bool force, bool initial) if ( g_bForceInternal && pConnector->GetScreenType() == gamescope::GAMESCOPE_SCREEN_TYPE_EXTERNAL ) continue; - int nPriority = get_connector_priority( drm, pConnector->GetName() ); + int nPriority = ( pConnector == pPreferred ) ? INT_MIN : get_connector_priority( drm, pConnector->GetName() ); if ( nPriority < nBestPriority ) { best = pConnector; @@ -1146,8 +1249,6 @@ static bool setup_best_connector(struct drm_t *drm, bool force, bool initial) if (!initial) WritePatchedEdid( best->GetRawEDID(), best->GetHDRInfo(), g_bRotated ); - update_connector_display_info_wl( drm ); - return true; } @@ -1321,6 +1422,15 @@ bool init_drm(struct drm_t *drm, int width, int height, int refresh) drm->connector_priorities = parse_connector_priorities( g_sOutputName ); + { + std::string saved = get_saved_preferred_display(); + if (!saved.empty()) { + drm_log.infof("using saved preferred display: %s", saved.c_str()); + std::lock_guard lock( drm->preferred_display_mutex ); + drm->preferred_display = std::move(saved); + } + } + if (!setup_best_connector(drm, true, true)) { return false; } @@ -1432,6 +1542,64 @@ void drm_sleep_screen( gamescope::GamescopeScreenType eType, bool bSleep ) cv_drm_sleep_screens[ eType ] = bSleep; } +void drm_set_preferred_connector( const char *pszName ) +{ + std::string identifier; + if ( pszName && *pszName ) + { + std::lock_guard lock( g_DRM.connectors_mutex ); + for ( auto &iter : g_DRM.connectors ) + { + const char *pszConnName = iter.second.GetName(); + if ( pszConnName && strcmp( pszConnName, pszName ) == 0 ) + { + identifier = get_display_key( &g_DRM, &iter.second ); + break; + } + } + } + + { + std::lock_guard lock( g_DRM.preferred_display_mutex ); + if ( !pszName || !*pszName ) + g_DRM.preferred_display.clear(); + else if ( !identifier.empty() ) + g_DRM.preferred_display = std::move(identifier); + // else: name given but didn't match any connector - no change. + } + GetBackend()->DirtyState(); +} + +std::vector< gamescope::GamescopeKnownDisplay > drm_get_connected_outputs() +{ + std::vector< gamescope::GamescopeKnownDisplay > outputs; + std::lock_guard lock( g_DRM.connectors_mutex ); + for ( auto &iter : g_DRM.connectors ) + { + gamescope::CDRMConnector *pConn = &iter.second; + if ( pConn->GetModeConnector()->connection != DRM_MODE_CONNECTED ) + continue; + + uint32_t flags = 0; + if ( pConn->GetScreenType() == gamescope::GAMESCOPE_SCREEN_TYPE_INTERNAL ) + flags |= GAMESCOPE_CONTROL_DISPLAY_FLAG_INTERNAL_DISPLAY; + if ( pConn->GetHDRInfo().bExposeHDRSupport ) + flags |= GAMESCOPE_CONTROL_DISPLAY_FLAG_SUPPORTS_HDR; + if ( pConn->SupportsVRR() ) + flags |= GAMESCOPE_CONTROL_DISPLAY_FLAG_SUPPORTS_VRR; + if ( pConn == g_DRM.pConnector ) + flags |= GAMESCOPE_CONTROL_DISPLAY_FLAG_CURRENT; + + outputs.emplace_back( gamescope::GamescopeKnownDisplay{ + .szConnectorName = pConn->GetName() ? pConn->GetName() : "", + .szMake = pConn->GetMake() ? pConn->GetMake() : "", + .szModel = pConn->GetModel() ? pConn->GetModel() : "", + .szIdentifier = get_display_key( &g_DRM, pConn ), + .uFlags = flags, + } ); + } + return outputs; +} void finish_drm(struct drm_t *drm) @@ -2268,6 +2436,7 @@ namespace gamescope m_Mutable.szMakePNP[1] = pProduct->manufacturer[1]; m_Mutable.szMakePNP[2] = pProduct->manufacturer[2]; m_Mutable.szMakePNP[3] = '\0'; + m_Mutable.uEDIDSerial = pProduct->serial; m_Mutable.pszMake = m_Mutable.szMakePNP; auto pnpIter = pnps.find( m_Mutable.szMakePNP ); @@ -2291,6 +2460,12 @@ namespace gamescope const char *pszDataString = di_edid_display_descriptor_get_string( pDesc ); strncpy( m_Mutable.szDataString, pszDataString, sizeof( m_Mutable.szDataString ) ); } + else if ( eTag == DI_EDID_DISPLAY_DESCRIPTOR_PRODUCT_SERIAL ) + { + // Descriptor strings are <= 14 chars, so szEDIDSerial[16] is never truncated. + const char *pszSerial = di_edid_display_descriptor_get_string( pDesc ); + strncpy( m_Mutable.szEDIDSerial, pszSerial, sizeof( m_Mutable.szEDIDSerial ) ); + } } drm_log.infof("Connector %s -> %s - %s", m_Mutable.szName, m_Mutable.szMakePNP, m_Mutable.szModel ); @@ -3079,6 +3254,8 @@ bool drm_poll_state( struct drm_t *drm ) setup_best_connector(drm, out_of_date >= 2, false); + update_connector_display_info_wl( drm ); + return true; } @@ -3796,6 +3973,16 @@ namespace gamescope return g_DRM.pConnector; } + virtual std::vector< gamescope::GamescopeKnownDisplay > GetConnectedOutputs() override + { + return drm_get_connected_outputs(); + } + + virtual void SetPreferredConnector( const char *pszConnectorName ) override + { + drm_set_preferred_connector( pszConnectorName ); + } + virtual IBackendConnector *GetConnector( GamescopeScreenType eScreenType ) override { if ( GetCurrentConnector() && GetCurrentConnector()->GetScreenType() == eScreenType ) diff --git a/src/Backends/DeferredBackend.h b/src/Backends/DeferredBackend.h index 79d1f0aefe..755507eed2 100644 --- a/src/Backends/DeferredBackend.h +++ b/src/Backends/DeferredBackend.h @@ -193,6 +193,26 @@ namespace gamescope return nullptr; } + virtual std::vector GetConnectedOutputs() override + { + { + std::shared_lock lock{ m_mutInit }; + if ( m_bInittedChild ) + return m_pChild->GetConnectedOutputs(); + } + + return {}; + } + + virtual void SetPreferredConnector( const char *pszConnectorName ) override + { + { + std::shared_lock lock{ m_mutInit }; + if ( m_bInittedChild ) + return m_pChild->SetPreferredConnector( pszConnectorName ); + } + } + virtual IBackendConnector *GetConnector( GamescopeScreenType eScreenType ) override { { diff --git a/src/backend.h b/src/backend.h index 880aa97240..e6a8735328 100644 --- a/src/backend.h +++ b/src/backend.h @@ -33,6 +33,15 @@ namespace gamescope extern ConVar cv_backend; + struct GamescopeKnownDisplay + { + std::string szConnectorName; + std::string szMake; + std::string szModel; + std::string szIdentifier; + uint32_t uFlags = 0; + }; + namespace VirtualConnectorStrategies { enum VirtualConnectorStrategy : uint32_t @@ -359,6 +368,9 @@ namespace gamescope } virtual IBackendConnector *GetConnector( GamescopeScreenType eScreenType ) = 0; + virtual std::vector GetConnectedOutputs() { return {}; } + virtual void SetPreferredConnector( const char *pszConnectorName ) { } + virtual bool SupportsPlaneHardwareCursor() const = 0; virtual bool SupportsTearing() const = 0; diff --git a/src/display_selection.h b/src/display_selection.h new file mode 100644 index 0000000000..dc82da6204 --- /dev/null +++ b/src/display_selection.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace gamescope +{ + // Base EDID key: "{make} {model} {serial}", else model-only, else "[{connector}]". Numeric serial wins over the descriptor string. + inline std::string BuildDisplayIdentifier( bool bInternal, const std::string &szMake, const std::string &szModel, const std::string &szConnectorName, uint32_t uEDIDSerial, const std::string &szEDIDSerial ) + { + if ( bInternal ) + return "Internal screen"; + + std::string szBase; + if ( !szMake.empty() && !szModel.empty() ) + szBase = szMake + " " + szModel; + else if ( !szModel.empty() ) + szBase = szModel; + else + return "[" + szConnectorName + "]"; + + if ( uEDIDSerial != 0 ) + szBase += " " + std::to_string( uEDIDSerial ); + else if ( !szEDIDSerial.empty() ) + szBase += " " + szEDIDSerial; + return szBase; + } + + struct DisplaySelectionCandidate + { + std::string szConnectorName; + std::string szIdentifier; + }; + + // Appends the connector only when the identifier collides, to disambiguate identical monitors. + inline std::string BuildDisplaySelectionKey( const std::string &szIdentifier, const std::string &szConnectorName, bool bSharedWithOtherOutput ) + { + if ( bSharedWithOtherOutput ) + return szIdentifier + " [" + szConnectorName + "]"; + return szIdentifier; + } + + inline void SplitDisplaySelectionKey( const std::string &szKey, std::string &szBaseOut, std::string &szConnectorHintOut ) + { + szBaseOut = szKey; + szConnectorHintOut.clear(); + if ( !szKey.empty() && szKey.back() == ']' ) + { + size_t uOpen = szKey.rfind( " [" ); + if ( uOpen != std::string::npos ) + { + szBaseOut = szKey.substr( 0, uOpen ); + szConnectorHintOut = szKey.substr( uOpen + 2, szKey.size() - uOpen - 3 ); + } + } + } + + // Identifier match is primary, the connector hint only breaks ties. nullopt = no match (fall back to --prefer-output). + inline std::optional ResolveDisplaySelection( const std::string &szKey, const std::vector &candidates ) + { + if ( szKey.empty() ) + return std::nullopt; + + // Exact identifier match wins first, so a model ending in "[...]" isn't split. + for ( size_t i = 0; i < candidates.size(); i++ ) + if ( candidates[i].szIdentifier == szKey ) + return i; + + std::string szBase, szConnectorHint; + SplitDisplaySelectionKey( szKey, szBase, szConnectorHint ); + + std::optional oBaseMatch; + for ( size_t i = 0; i < candidates.size(); i++ ) + { + if ( candidates[i].szIdentifier != szBase ) + continue; + if ( !szConnectorHint.empty() && candidates[i].szConnectorName == szConnectorHint ) + return i; + if ( !oBaseMatch ) + oBaseMatch = i; + } + return oBaseMatch; + } +} diff --git a/src/wlserver.cpp b/src/wlserver.cpp index 2d73927ee2..ca73445c2a 100644 --- a/src/wlserver.cpp +++ b/src/wlserver.cpp @@ -1229,6 +1229,54 @@ static void gamescope_control_request_app_performance_stats( struct wl_client *c wlserver.app_perf_requests[ app_id ].push_back( resource ); } +static gamescope::ConCommand cc_set_display("set_display", "Switch to a connected output by connector name (DP-1) or a substring of its make/model/serial (e.g. ULTRAGEAR). No argument clears the override and falls back to --prefer-output.", +[]( std::span args ) +{ + if ( args.size() < 2 || args[1].empty() ) + { + GetBackend()->SetPreferredConnector( nullptr ); + return; + } + + std::string query{ args[1] }; + + std::string exact; + std::vector matches; + for ( const auto &display : GetBackend()->GetConnectedOutputs() ) + { + if ( query == display.szConnectorName ) + { + exact = display.szConnectorName; + break; + } + if ( strcasestr( display.szIdentifier.c_str(), query.c_str() ) ) + matches.push_back( display ); + } + + if ( !exact.empty() ) + GetBackend()->SetPreferredConnector( exact.c_str() ); + else if ( matches.size() == 1 ) + GetBackend()->SetPreferredConnector( matches[0].szConnectorName.c_str() ); + else if ( matches.empty() ) + console_log.errorf( "set_display: no connected display matches '%s'", query.c_str() ); + else + { + console_log.errorf( "set_display: '%s' is ambiguous:", query.c_str() ); + for ( const auto &match : matches ) + console_log.errorf( " %s (%s)", match.szConnectorName.c_str(), match.szIdentifier.c_str() ); + } +}); + +static void gamescope_control_set_display( struct wl_client *client, struct wl_resource *resource, const char *connector_name ) +{ + GetBackend()->SetPreferredConnector( connector_name ); +} + +static void gamescope_control_unset_display( struct wl_client *client, struct wl_resource *resource ) +{ + GetBackend()->SetPreferredConnector( nullptr ); +} + void wlserver_app_presented( uint32_t app_id, uint64_t frametime_ns ) { assert( wlserver_is_lock_held() ); @@ -1258,6 +1306,8 @@ static const struct gamescope_control_interface gamescope_control_impl = { .set_look = gamescope_control_set_look, .unset_look = gamescope_control_unset_look, .request_app_performance_stats = gamescope_control_request_app_performance_stats, + .set_display = gamescope_control_set_display, + .unset_display = gamescope_control_unset_display, }; static uint32_t get_conn_display_info_flags() @@ -1282,6 +1332,22 @@ void wlserver_send_gamescope_control( wl_resource *control ) { assert( wlserver_is_lock_held() ); + // Sent before the early-return so the done event fires when no connector is active. + if ( wl_resource_get_version( control ) >= GAMESCOPE_CONTROL_AVAILABLE_DISPLAY_INFO_SINCE_VERSION ) + { + for ( const auto &display : GetBackend()->GetConnectedOutputs() ) + { + gamescope_control_send_available_display_info( + control, + display.szConnectorName.c_str(), + display.szMake.c_str(), + display.szModel.c_str(), + display.uFlags, + display.szIdentifier.c_str() ); + } + gamescope_control_send_available_display_info_done( control ); + } + gamescope::IBackendConnector *pConn = GetBackend()->GetCurrentConnector(); if ( !pConn ) return; @@ -1328,6 +1394,7 @@ static void gamescope_control_bind( struct wl_client *client, void *data, uint32 gamescope_control_send_feature_support( resource, GAMESCOPE_CONTROL_FEATURE_MURA_CORRECTION, 1, 0 ); gamescope_control_send_feature_support( resource, GAMESCOPE_CONTROL_FEATURE_LOOK, 1, 0 ); gamescope_control_send_feature_support( resource, GAMESCOPE_CONTROL_FEATURE_PERF_QUERY, 1, 0 ); + gamescope_control_send_feature_support( resource, GAMESCOPE_CONTROL_FEATURE_DISPLAY_SELECTION, 1, 0 ); gamescope_control_send_feature_support( resource, GAMESCOPE_CONTROL_FEATURE_DONE, 0, 0 ); wlserver_send_gamescope_control( resource ); @@ -1337,7 +1404,7 @@ static void gamescope_control_bind( struct wl_client *client, void *data, uint32 static void create_gamescope_control( void ) { - uint32_t version = 6; + uint32_t version = 7; wl_global_create( wlserver.display, &gamescope_control_interface, version, NULL, gamescope_control_bind ); } diff --git a/tests/meson.build b/tests/meson.build index c74aef1baf..f90673cdca 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -14,3 +14,11 @@ test_convar = executable( cpp_args: gamescope_cpp_args, ) test('convar', test_convar) + +test_display_selection = executable( + 'test_display_selection', + ['test_display_selection.cpp'], + include_directories: [srcdir], + dependencies: [catch2_dep], +) +test('display_selection', test_display_selection) diff --git a/tests/test_display_selection.cpp b/tests/test_display_selection.cpp new file mode 100644 index 0000000000..b2443036e4 --- /dev/null +++ b/tests/test_display_selection.cpp @@ -0,0 +1,100 @@ +#include + +#include "display_selection.h" + +using namespace gamescope; + +TEST_CASE("BuildDisplayIdentifier", "[display_selection]") { + REQUIRE( BuildDisplayIdentifier(true, "Some", "Panel", "eDP-1", 42, "x") == "Internal screen" ); + + REQUIRE( BuildDisplayIdentifier(false, "Dell Inc.", "Dell AW3423DW", "DP-3", 810309971, "ignored") == "Dell Inc. Dell AW3423DW 810309971" ); + REQUIRE( BuildDisplayIdentifier(false, "LG Electronics", "LG ULTRAGEAR+", "DP-1", 0, "ABC123") == "LG Electronics LG ULTRAGEAR+ ABC123" ); + REQUIRE( BuildDisplayIdentifier(false, "Dell Inc.", "Dell AW3423DW", "DP-3", 0, "") == "Dell Inc. Dell AW3423DW" ); + + REQUIRE( BuildDisplayIdentifier(false, "", "MysteryModel", "DP-2", 0, "") == "MysteryModel" ); + REQUIRE( BuildDisplayIdentifier(false, "", "", "DP-2", 0, "") == "[DP-2]" ); + REQUIRE( BuildDisplayIdentifier(false, "Dell Inc.", "", "DP-2", 0, "") == "[DP-2]" ); +} + +TEST_CASE("BuildDisplayIdentifier round-trips through SplitDisplaySelectionKey", "[display_selection]") { + std::string id = BuildDisplayIdentifier(false, "", "", "DP-2", 0, ""); + std::string base, hint; + SplitDisplaySelectionKey(id, base, hint); + REQUIRE( base == "[DP-2]" ); + REQUIRE( hint.empty() ); +} + +TEST_CASE("BuildDisplaySelectionKey", "[display_selection]") { + REQUIRE( BuildDisplaySelectionKey("Dell AW3423DW", "DP-1", false) == "Dell AW3423DW" ); + REQUIRE( BuildDisplaySelectionKey("Dell AW3423DW", "DP-1", true) == "Dell AW3423DW [DP-1]" ); +} + +TEST_CASE("SplitDisplaySelectionKey", "[display_selection]") { + std::string base, hint; + + SplitDisplaySelectionKey("Dell AW3423DW", base, hint); + REQUIRE( base == "Dell AW3423DW" ); + REQUIRE( hint.empty() ); + + SplitDisplaySelectionKey("Dell AW3423DW [DP-1]", base, hint); + REQUIRE( base == "Dell AW3423DW" ); + REQUIRE( hint == "DP-1" ); + + SplitDisplaySelectionKey("[DP-2]", base, hint); + REQUIRE( base == "[DP-2]" ); + REQUIRE( hint.empty() ); +} + +TEST_CASE("ResolveDisplaySelection picks the named connector for identical twins", "[display_selection]") { + std::vector twins = { + { "DP-1", "Dell AW3423DW" }, + { "DP-2", "Dell AW3423DW" }, + }; + + REQUIRE( ResolveDisplaySelection("Dell AW3423DW [DP-1]", twins) == 0u ); + REQUIRE( ResolveDisplaySelection("Dell AW3423DW [DP-2]", twins) == 1u ); + + REQUIRE( ResolveDisplaySelection("Dell AW3423DW", twins) == 0u ); + + REQUIRE( ResolveDisplaySelection("Dell AW3423DW [DP-9]", twins) == 0u ); + + std::vector triplets = { + { "DP-1", "Dell AW3423DW" }, + { "DP-2", "Dell AW3423DW" }, + { "DP-3", "Dell AW3423DW" }, + }; + REQUIRE( ResolveDisplaySelection("Dell AW3423DW [DP-3]", triplets) == 2u ); +} + +TEST_CASE("ResolveDisplaySelection keeps identifier match primary over the hint", "[display_selection]") { + std::vector mixed = { + { "DP-1", "Dell AW3423DW" }, + { "DP-2", "LG ULTRAGEAR+ 175706" }, + }; + REQUIRE( ResolveDisplaySelection("Dell AW3423DW [DP-2]", mixed) == 0u ); +} + +TEST_CASE("ResolveDisplaySelection matches a unique monitor by identifier", "[display_selection]") { + std::vector mixed = { + { "DP-1", "Dell AW3423DW" }, + { "DP-2", "LG ULTRAGEAR+ 175706" }, + }; + + REQUIRE( ResolveDisplaySelection("LG ULTRAGEAR+ 175706", mixed) == 1u ); + + REQUIRE( ResolveDisplaySelection("Sony Bravia", mixed) == std::nullopt ); + + REQUIRE( ResolveDisplaySelection("", mixed) == std::nullopt ); +} + +TEST_CASE("ResolveDisplaySelection handles a model that itself ends in brackets", "[display_selection]") { + REQUIRE( BuildDisplayIdentifier(false, "Aperture Science", "Monitor [Pro]", "DP-1", 0, "") == "Aperture Science Monitor [Pro]" ); + + std::vector bracketed = { + { "DP-1", "Aperture Science Monitor [Pro]" }, + { "DP-2", "Aperture Science Monitor [Pro]" }, + }; + + REQUIRE( ResolveDisplaySelection("Aperture Science Monitor [Pro]", bracketed) == 0u ); + REQUIRE( ResolveDisplaySelection("Aperture Science Monitor [Pro] [DP-2]", bracketed) == 1u ); +} From caf65b4e43e3599351526033bb40965e80fcba74 Mon Sep 17 00:00:00 2001 From: Matthew Schwartz Date: Mon, 25 May 2026 16:14:27 -0700 Subject: [PATCH 2/4] DRMBackend: further disambiguate identical displays by MST path Physically identical, serial-less monitors collide on the same EDID identifier, so the selection key falls back to a " [connector]" suffix to tell them apart. However, the connector name keys on connector_type_id, which the kernel reassigns when the MST sink connectors are torn down and recreated on reprobe, so the selection jumps around unexpectedly. Instead, read the DRM MST PATH blob ("mst:-") and use it as the tiebreak hint when present. The port numbers are topology-derived (which port of the dock the sink hangs off), and the "mst:" prefix is the upstream connector id, which is fixed at output setup and not re-init'd on a downstream reprobe. So the PATH survives the connector renumber that connector_type_id does not. Non-MST and single-stream connections have no PATH and fall back to the connector name as before. --- src/Backends/DRMBackend.cpp | 24 ++++++++++++++++++++++-- src/display_selection.h | 5 +++-- tests/test_display_selection.cpp | 25 +++++++++++++++++++++++++ 3 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/Backends/DRMBackend.cpp b/src/Backends/DRMBackend.cpp index c79c20c676..b8472fee38 100644 --- a/src/Backends/DRMBackend.cpp +++ b/src/Backends/DRMBackend.cpp @@ -383,6 +383,7 @@ namespace gamescope std::optional vrr_capable; std::optional EDID; std::optional Broadcast_RGB; + std::optional PATH; std::optional DUMMY_END; }; ConnectorProperties &GetProperties() { return m_Props; } @@ -395,6 +396,7 @@ namespace gamescope const char *GetDataString() const { return m_Mutable.szDataString; } uint32_t GetEDIDSerial() const { return m_Mutable.uEDIDSerial; } const char *GetEDIDSerialString() const { return m_Mutable.szEDIDSerial; } + const char *GetMstPath() const { return m_Mutable.szMstPath.c_str(); } uint32_t GetPossibleCRTCMask() const { return m_Mutable.uPossibleCRTCMask; } std::span GetValidDynamicRefreshRates() const override { return m_Mutable.ValidDynamicRefreshRates; } const displaycolorimetry_t& GetDisplayColorimetry() const { return m_Mutable.DisplayColorimetry; } @@ -520,6 +522,7 @@ namespace gamescope char szDataString[16]{}; uint32_t uEDIDSerial = 0; char szEDIDSerial[16]{}; + std::string szMstPath; const char *pszMake = ""; // Not owned, no free. This is a pointer to pnp db or szMakePNP. DRMModeGenerator fnDynamicModeGenerator; std::vector ValidDynamicRefreshRates{}; @@ -1026,8 +1029,10 @@ static std::string get_display_key(struct drm_t *drm, const gamescope::CDRMConne break; } } + const char *pszMstPath = pConnector->GetMstPath(); const char *pszName = pConnector->GetName(); - return gamescope::BuildDisplaySelectionKey( base, pszName ? pszName : "", bShared ); + const char *pszHint = ( pszMstPath && *pszMstPath ) ? pszMstPath : pszName; + return gamescope::BuildDisplaySelectionKey( base, pszHint ? pszHint : "", bShared ); } static int get_connector_priority(struct drm_t *drm, const char *name) @@ -1115,7 +1120,8 @@ static gamescope::CDRMConnector *resolve_preferred_connector(struct drm_t *drm) if (pConnector->GetModeConnector()->connection != DRM_MODE_CONNECTED) continue; const char *pszName = pConnector->GetName(); - candidates.push_back( { pszName ? pszName : "", get_display_identifier(pConnector) } ); + const char *pszMstPath = pConnector->GetMstPath(); + candidates.push_back( { pszName ? pszName : "", get_display_identifier(pConnector), pszMstPath ? pszMstPath : "" } ); pConnectors.push_back( pConnector ); } @@ -2346,6 +2352,20 @@ namespace gamescope m_Props.vrr_capable = CDRMAtomicProperty::Instantiate( "vrr_capable", this, *rawProperties ); m_Props.EDID = CDRMAtomicProperty::Instantiate( "EDID", this, *rawProperties ); m_Props.Broadcast_RGB = CDRMAtomicProperty::Instantiate( "Broadcast RGB", this, *rawProperties ); + m_Props.PATH = CDRMAtomicProperty::Instantiate( "PATH", this, *rawProperties ); + } + + if ( m_Props.PATH ) + { + if ( uint64_t ulBlobId = m_Props.PATH->GetCurrentValue() ) + { + if ( drmModePropertyBlobRes *pBlob = drmModeGetPropertyBlob( g_DRM.fd, ulBlobId ) ) + { + const char *pszMstPath = reinterpret_cast( pBlob->data ); + m_Mutable.szMstPath.assign( pszMstPath, strnlen( pszMstPath, pBlob->length ) ); + drmModeFreePropertyBlob( pBlob ); + } + } } ParseEDID(); diff --git a/src/display_selection.h b/src/display_selection.h index dc82da6204..e1d2c2df23 100644 --- a/src/display_selection.h +++ b/src/display_selection.h @@ -33,9 +33,10 @@ namespace gamescope { std::string szConnectorName; std::string szIdentifier; + std::string szMstPath; }; - // Appends the connector only when the identifier collides, to disambiguate identical monitors. + // Appends a tiebreak only when the identifier collides, to disambiguate identical monitors. inline std::string BuildDisplaySelectionKey( const std::string &szIdentifier, const std::string &szConnectorName, bool bSharedWithOtherOutput ) { if ( bSharedWithOtherOutput ) @@ -77,7 +78,7 @@ namespace gamescope { if ( candidates[i].szIdentifier != szBase ) continue; - if ( !szConnectorHint.empty() && candidates[i].szConnectorName == szConnectorHint ) + if ( !szConnectorHint.empty() && ( candidates[i].szConnectorName == szConnectorHint || candidates[i].szMstPath == szConnectorHint ) ) return i; if ( !oBaseMatch ) oBaseMatch = i; diff --git a/tests/test_display_selection.cpp b/tests/test_display_selection.cpp index b2443036e4..7076e77b5a 100644 --- a/tests/test_display_selection.cpp +++ b/tests/test_display_selection.cpp @@ -98,3 +98,28 @@ TEST_CASE("ResolveDisplaySelection handles a model that itself ends in brackets" REQUIRE( ResolveDisplaySelection("Aperture Science Monitor [Pro]", bracketed) == 0u ); REQUIRE( ResolveDisplaySelection("Aperture Science Monitor [Pro] [DP-2]", bracketed) == 1u ); } + +TEST_CASE("ResolveDisplaySelection follows the MST path across a dock replug-renumber", "[display_selection]") { + std::string key = BuildDisplaySelectionKey("Sceptre Z27", "mst:1-5", /*bShared=*/true); + REQUIRE( key == "Sceptre Z27 [mst:1-5]" ); + + std::vector before = { + { "DP-9", "Sceptre Z27", "mst:1-5" }, + { "DP-10", "Sceptre Z27", "mst:1-6" }, + }; + REQUIRE( ResolveDisplaySelection(key, before) == 0u ); + + std::vector after = { + { "DP-7", "Sceptre Z27", "mst:1-5" }, + { "DP-12", "Sceptre Z27", "mst:1-6" }, + }; + REQUIRE( ResolveDisplaySelection(key, after) == 0u ); + + REQUIRE( ResolveDisplaySelection("Sceptre Z27 [DP-10]", before) == 1u ); + + std::vector moved = { + { "DP-7", "Sceptre Z27", "mst:1-9" }, + { "DP-12", "Sceptre Z27", "mst:1-6" }, + }; + REQUIRE( ResolveDisplaySelection(key, moved) == 0u ); +} From 31aa5131444b7a383b5a9f0b8e679fb06894a40a Mon Sep 17 00:00:00 2001 From: Matthew Schwartz Date: Mon, 25 May 2026 16:42:00 -0700 Subject: [PATCH 3/4] xprops: hook up atoms for display selection --- src/Backends/DRMBackend.cpp | 17 +++++++++++++++++ src/Backends/DeferredBackend.h | 9 +++++++++ src/backend.h | 1 + src/steamcompmgr.cpp | 33 +++++++++++++++++++++++++++++++++ src/xwayland_ctx.hpp | 2 ++ 5 files changed, 62 insertions(+) diff --git a/src/Backends/DRMBackend.cpp b/src/Backends/DRMBackend.cpp index b8472fee38..e13735dfc4 100644 --- a/src/Backends/DRMBackend.cpp +++ b/src/Backends/DRMBackend.cpp @@ -1576,6 +1576,18 @@ void drm_set_preferred_connector( const char *pszName ) GetBackend()->DirtyState(); } +void drm_set_preferred_display_identifier( const char *pszIdentifier ) +{ + { + std::lock_guard lock( g_DRM.preferred_display_mutex ); + if ( !pszIdentifier || !*pszIdentifier ) + g_DRM.preferred_display.clear(); + else + g_DRM.preferred_display = pszIdentifier; + } + GetBackend()->DirtyState(); +} + std::vector< gamescope::GamescopeKnownDisplay > drm_get_connected_outputs() { std::vector< gamescope::GamescopeKnownDisplay > outputs; @@ -4003,6 +4015,11 @@ namespace gamescope drm_set_preferred_connector( pszConnectorName ); } + virtual void SetPreferredDisplayIdentifier( const char *pszIdentifier ) override + { + drm_set_preferred_display_identifier( pszIdentifier ); + } + virtual IBackendConnector *GetConnector( GamescopeScreenType eScreenType ) override { if ( GetCurrentConnector() && GetCurrentConnector()->GetScreenType() == eScreenType ) diff --git a/src/Backends/DeferredBackend.h b/src/Backends/DeferredBackend.h index 755507eed2..ede428e615 100644 --- a/src/Backends/DeferredBackend.h +++ b/src/Backends/DeferredBackend.h @@ -213,6 +213,15 @@ namespace gamescope } } + virtual void SetPreferredDisplayIdentifier( const char *pszIdentifier ) override + { + { + std::shared_lock lock{ m_mutInit }; + if ( m_bInittedChild ) + return m_pChild->SetPreferredDisplayIdentifier( pszIdentifier ); + } + } + virtual IBackendConnector *GetConnector( GamescopeScreenType eScreenType ) override { { diff --git a/src/backend.h b/src/backend.h index e6a8735328..9c456038ae 100644 --- a/src/backend.h +++ b/src/backend.h @@ -370,6 +370,7 @@ namespace gamescope virtual std::vector GetConnectedOutputs() { return {}; } virtual void SetPreferredConnector( const char *pszConnectorName ) { } + virtual void SetPreferredDisplayIdentifier( const char *pszIdentifier ) { } virtual bool SupportsPlaneHardwareCursor() const = 0; virtual bool SupportsTearing() const = 0; diff --git a/src/steamcompmgr.cpp b/src/steamcompmgr.cpp index b64d4597dd..370227beaf 100644 --- a/src/steamcompmgr.cpp +++ b/src/steamcompmgr.cpp @@ -6318,6 +6318,11 @@ handle_property_notify(xwayland_ctx_t *ctx, XPropertyEvent *ev) GetBackend()->DirtyState( true ); XDeleteProperty( ctx->dpy, ctx->root, ctx->atoms.gamescopeDisplayModeNudge ); } + if ( ev->atom == ctx->atoms.gamescopeDisplayPreferredIdentifier ) + { + std::string identifier = get_string_prop( ctx, ctx->root, ctx->atoms.gamescopeDisplayPreferredIdentifier ); + GetBackend()->SetPreferredDisplayIdentifier( identifier.c_str() ); + } if ( ev->atom == ctx->atoms.gamescopeNewScalingFilter ) { GamescopeUpscaleFilter nScalingFilter = ( GamescopeUpscaleFilter ) get_prop( ctx, ctx->root, ctx->atoms.gamescopeNewScalingFilter, 0 ); @@ -7853,9 +7858,11 @@ void init_xwayland_ctx(uint32_t serverId, gamescope_xwayland_server_t *xwayland_ ctx->atoms.gamescopeDisplayForceInternal = XInternAtom( ctx->dpy, "GAMESCOPE_DISPLAY_FORCE_INTERNAL", false ); ctx->atoms.gamescopeDisplayModeNudge = XInternAtom( ctx->dpy, "GAMESCOPE_DISPLAY_MODE_NUDGE", false ); + ctx->atoms.gamescopeDisplayPreferredIdentifier = XInternAtom( ctx->dpy, "GAMESCOPE_DISPLAY_PREFERRED_IDENTIFIER", false ); ctx->atoms.gamescopeDisplayIsExternal = XInternAtom( ctx->dpy, "GAMESCOPE_DISPLAY_IS_EXTERNAL", false ); ctx->atoms.gamescopeDisplayModeListExternal = XInternAtom( ctx->dpy, "GAMESCOPE_DISPLAY_MODE_LIST_EXTERNAL", false ); + ctx->atoms.gamescopeDisplayAvailableList = XInternAtom( ctx->dpy, "GAMESCOPE_DISPLAY_AVAILABLE_LIST", false ); ctx->atoms.gamescopeCursorVisibleFeedback = XInternAtom( ctx->dpy, "GAMESCOPE_CURSOR_VISIBLE_FEEDBACK", false ); @@ -8063,6 +8070,30 @@ void update_vrr_atoms(xwayland_ctx_t *root_ctx, bool force, bool* needs_flush = } } +void update_available_displays_atom(xwayland_ctx_t *root_ctx, bool* needs_flush = nullptr) +{ + if (needs_flush) + *needs_flush = true; + + // One display per line: identifier|connector_name|flags_hex|make|model + auto sanitize = []( std::string s ) { + for ( char &c : s ) if ( c == '|' || c == '\n' || c == '\r' ) c = ' '; + return s; + }; + std::string out; + for ( const auto &display : GetBackend()->GetConnectedOutputs() ) + { + char line[512]; + snprintf( line, sizeof( line ), "%s|%s|0x%x|%s|%s\n", + sanitize( display.szIdentifier ).c_str(), sanitize( display.szConnectorName ).c_str(), + display.uFlags, sanitize( display.szMake ).c_str(), sanitize( display.szModel ).c_str() ); + out += line; + } + + XChangeProperty( root_ctx->dpy, root_ctx->root, root_ctx->atoms.gamescopeDisplayAvailableList, XA_STRING, 8, PropModeReplace, + (unsigned char *)out.c_str(), out.size() + 1 ); +} + void update_mode_atoms(xwayland_ctx_t *root_ctx, bool* needs_flush = nullptr) { if (needs_flush) @@ -8449,6 +8480,7 @@ steamcompmgr_main(int argc, char **argv) update_vrr_atoms(root_ctx, true); update_mode_atoms(root_ctx); + update_available_displays_atom(root_ctx); XFlush(root_ctx->dpy); if ( !GetBackend()->PostInit() ) @@ -8689,6 +8721,7 @@ steamcompmgr_main(int argc, char **argv) hasRepaint = true; update_mode_atoms(root_ctx, &flush_root); + update_available_displays_atom(root_ctx, &flush_root); } g_uCompositeDebug = cv_composite_debug; diff --git a/src/xwayland_ctx.hpp b/src/xwayland_ctx.hpp index d680fbfe90..fdb6b0dc66 100644 --- a/src/xwayland_ctx.hpp +++ b/src/xwayland_ctx.hpp @@ -181,9 +181,11 @@ struct xwayland_ctx_t final : public gamescope::IWaitable Atom gamescopeAllowTearing; Atom gamescopeDisplayForceInternal; Atom gamescopeDisplayModeNudge; + Atom gamescopeDisplayPreferredIdentifier; Atom gamescopeDisplayIsExternal; Atom gamescopeDisplayModeListExternal; + Atom gamescopeDisplayAvailableList; Atom gamescopeCursorVisibleFeedback; From d85e510b678f14e31f79ee9a0e1265e75e8aced6 Mon Sep 17 00:00:00 2001 From: Matthew Schwartz Date: Fri, 22 May 2026 12:22:38 -0700 Subject: [PATCH 4/4] [HACK] DRMBackend: Temporarily write preferred-display file on set_display Until Steam learns about GAMESCOPE_DISPLAY_SELECTION_FILE, write it from both setter paths (drm_set_preferred_connector for the protocol/CLI path, drm_set_preferred_display_identifier for the GAMESCOPE_DISPLAY_PREFERRED_IDENTIFIER xprop path) so runtime selections stick across restarts regardless of which channel set them. Revert once Steam owns the writes. --- src/Backends/DRMBackend.cpp | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/Backends/DRMBackend.cpp b/src/Backends/DRMBackend.cpp index e13735dfc4..f8671af75c 100644 --- a/src/Backends/DRMBackend.cpp +++ b/src/Backends/DRMBackend.cpp @@ -1565,6 +1565,7 @@ void drm_set_preferred_connector( const char *pszName ) } } + std::string snapshot; { std::lock_guard lock( g_DRM.preferred_display_mutex ); if ( !pszName || !*pszName ) @@ -1572,18 +1573,43 @@ void drm_set_preferred_connector( const char *pszName ) else if ( !identifier.empty() ) g_DRM.preferred_display = std::move(identifier); // else: name given but didn't match any connector - no change. + snapshot = g_DRM.preferred_display; + } + + // FIXME: Steam should own this write; revert once it does. + if ( const char *pszPath = getenv("GAMESCOPE_DISPLAY_SELECTION_FILE"); pszPath && *pszPath ) + { + if ( FILE *file = fopen(pszPath, "w") ) + { + if ( !snapshot.empty() ) + fprintf(file, "%s\n", snapshot.c_str()); + fclose(file); + } } GetBackend()->DirtyState(); } void drm_set_preferred_display_identifier( const char *pszIdentifier ) { + std::string snapshot; { std::lock_guard lock( g_DRM.preferred_display_mutex ); if ( !pszIdentifier || !*pszIdentifier ) g_DRM.preferred_display.clear(); else g_DRM.preferred_display = pszIdentifier; + snapshot = g_DRM.preferred_display; + } + + // FIXME: Steam should own this write; revert once it does. + if ( const char *pszPath = getenv("GAMESCOPE_DISPLAY_SELECTION_FILE"); pszPath && *pszPath ) + { + if ( FILE *file = fopen(pszPath, "w") ) + { + if ( !snapshot.empty() ) + fprintf(file, "%s\n", snapshot.c_str()); + fclose(file); + } } GetBackend()->DirtyState(); }