From bc747601727d85cdcaa0a1c5bd31a322425f5302 Mon Sep 17 00:00:00 2001 From: rope Date: Thu, 8 Oct 2026 10:23:32 +0100 Subject: [PATCH] HDT: Wine overlay plugin, no_focus on overlay Co-Authored-By: Claude Opus 5.5 --- apps/hdt-wine-overlay/HdtWineOverlay.csproj | 22 +++ apps/hdt-wine-overlay/WineOverlayPlugin.cs | 171 ++++++++++++++++++++ settings/hyprland.nix | 9 +- 3 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 apps/hdt-wine-overlay/HdtWineOverlay.csproj create mode 100644 apps/hdt-wine-overlay/WineOverlayPlugin.cs diff --git a/apps/hdt-wine-overlay/HdtWineOverlay.csproj b/apps/hdt-wine-overlay/HdtWineOverlay.csproj new file mode 100644 index 0000000..415183e --- /dev/null +++ b/apps/hdt-wine-overlay/HdtWineOverlay.csproj @@ -0,0 +1,22 @@ + + + + net472 + latest + disable + HdtWineOverlay + false + + + + + + + + + $(HDT_EXE) + false + + + diff --git a/apps/hdt-wine-overlay/WineOverlayPlugin.cs b/apps/hdt-wine-overlay/WineOverlayPlugin.cs new file mode 100644 index 0000000..fa418f3 --- /dev/null +++ b/apps/hdt-wine-overlay/WineOverlayPlugin.cs @@ -0,0 +1,171 @@ +// HDT plugin: makes the in-game overlay work under Wine/Proton on Hyprland. +// +// Wine's X11 driver decides per window whether it is "managed" (handed to the +// compositor, which tiles/floats/focuses it) or override-redirect (placed where +// the app says, stacked on top, left alone). A popup becomes managed for good +// once it is the active window during a show or SetWindowPos. WPF's Show() +// activates by default, so HDT's overlay ends up managed: Hyprland places it +// wherever it likes instead of over the game. +// +// Separately, Wine cuts every alpha-0 pixel of a layered window out of the X11 +// shape, and XWayland shows those pixels as opaque black. Painting the window +// background at alpha 1/255 keeps every pixel inside the shape while staying +// invisible. +// +// Both fixes have to land before the overlay is first shown. HDT loads enabled +// plugins (plugins.xml) before it starts its overlay update loop, so OnLoad is +// early enough; enabling the plugin mid-session needs an HDT restart. +// +// Approach from alexzfe/hdt-omarchy (Utility/Wine.cs), done as a plugin so the +// official HDT build and its updater stay in place. + +using System; +using System.Runtime.InteropServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Interop; +using System.Windows.Media; +using Hearthstone_Deck_Tracker; +using Hearthstone_Deck_Tracker.Plugins; +using Hearthstone_Deck_Tracker.Utility.Logging; + +namespace HdtWineOverlay +{ + public class WineOverlayPlugin : IPlugin + { + public string Name => "Wine Overlay Fix"; + public string Description => "Transparent, correctly placed overlay under Wine/Proton (Wayland). Restart HDT after enabling."; + public string ButtonText => ""; + public string Author => "fred"; + public Version Version => new Version(0, 1, 0); + public MenuItem MenuItem => null; + + private HwndSource _hookedSource; + private RECT _lastGameRect; + private DateTime _nextPoll = DateTime.MinValue; + + public void OnLoad() + { + if(!IsWine()) + { + Log.Info("Not running under Wine, nothing to do"); + return; + } + var overlay = Core.Overlay; + var handle = new WindowInteropHelper(overlay).Handle; + if(handle != IntPtr.Zero && overlay.IsVisible) + Log.Warn("Overlay was already shown before this plugin loaded; restart HDT for the fix to apply"); + + // Never activate on show: keeps Wine from making the overlay a managed window. + overlay.ShowActivated = false; + + if(!(overlay.Background is SolidColorBrush { Color.A: > 0 })) + { + var brush = new SolidColorBrush(Color.FromArgb(1, 0, 0, 0)); + brush.Freeze(); + overlay.Background = brush; + } + + if(handle != IntPtr.Zero) + HookMouseActivate(handle); + else + overlay.SourceInitialized += OnOverlaySourceInitialized; + + Log.Info("Applied Wine overlay workarounds"); + } + + public void OnUnload() + { + Core.Overlay.SourceInitialized -= OnOverlaySourceInitialized; + _hookedSource?.RemoveHook(WndProc); + _hookedSource = null; + } + + public void OnButtonPress() + { + } + + // Called by HDT every 100ms on the UI thread. + public void OnUpdate() + { + if(_hookedSource == null || DateTime.UtcNow < _nextPoll) + return; + _nextPoll = DateTime.UtcNow.AddMilliseconds(250); + + // HDT follows the game window through an out-of-context WinEvent hook + // (EVENT_OBJECT_LOCATIONCHANGE), which Wine doesn't deliver for + // compositor-driven moves. Poll the game's client rect instead. + var overlay = Core.Overlay; + if(!overlay.IsContentVisible) + return; + var game = FindWindow("UnityWndClass", "Hearthstone"); + if(game == IntPtr.Zero || !GetClientRect(game, out var rect)) + return; + var origin = new POINT(); + if(!ClientToScreen(game, ref origin)) + return; + rect.Left += origin.X; + rect.Right += origin.X; + rect.Top += origin.Y; + rect.Bottom += origin.Y; + if(rect.Equals(_lastGameRect)) + return; + _lastGameRect = rect; + overlay.UpdatePosition(); + } + + private void OnOverlaySourceInitialized(object sender, EventArgs e) + => HookMouseActivate(new WindowInteropHelper(Core.Overlay).Handle); + + private void HookMouseActivate(IntPtr handle) + { + _hookedSource = HwndSource.FromHwnd(handle); + _hookedSource?.AddHook(WndProc); + } + + // Clicking an overlay button would otherwise activate the window, which + // makes Wine turn it into a managed window on the next position update. + private static IntPtr WndProc(IntPtr hwnd, int msg, IntPtr wParam, IntPtr lParam, ref bool handled) + { + const int wmMouseActivate = 0x0021; + const int maNoActivate = 3; + if(msg != wmMouseActivate) + return IntPtr.Zero; + handled = true; + return new IntPtr(maNoActivate); + } + + private static bool IsWine() + { + var ntdll = GetModuleHandle("ntdll.dll"); + return ntdll != IntPtr.Zero && GetProcAddress(ntdll, "wine_get_version") != IntPtr.Zero; + } + + [StructLayout(LayoutKind.Sequential)] + private struct RECT + { + public int Left, Top, Right, Bottom; + } + + [StructLayout(LayoutKind.Sequential)] + private struct POINT + { + public int X, Y; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Ansi)] + private static extern IntPtr GetModuleHandle(string lpModuleName); + + [DllImport("kernel32.dll", CharSet = CharSet.Ansi)] + private static extern IntPtr GetProcAddress(IntPtr hModule, string lpProcName); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + private static extern IntPtr FindWindow(string lpClassName, string lpWindowName); + + [DllImport("user32.dll")] + private static extern bool GetClientRect(IntPtr hWnd, out RECT lpRect); + + [DllImport("user32.dll")] + private static extern bool ClientToScreen(IntPtr hWnd, ref POINT lpPoint); + } +} diff --git a/settings/hyprland.nix b/settings/hyprland.nix index 8ccda80..b4777a6 100644 --- a/settings/hyprland.nix +++ b/settings/hyprland.nix @@ -380,12 +380,17 @@ in -- Hearthstone Deck Tracker's overlay: a transparent WPF window HDT -- itself keeps moved and sized over Hearthstone (title from its - -- OverlayWindow XAML). The generic tile rule above would tile it - -- like a game; float it bare and never hand it focus. + -- OverlayWindow XAML). The Wine plugin in apps/hdt-wine-overlay + -- keeps it override-redirect; this is the safety net if Wine ever + -- hands it over as a managed window, which the tile rule above + -- would tile. no_focus matters either way: Hyprland hit-tests the + -- window box, not the X11 input shape, so focus-follows-mouse + -- handed focus to the overlay whenever the cursor was over the game. hl.window_rule({ name = "hdt-overlay", match = { class = "^steam_app_(0|default)$", title = "^HearthstoneOverlay$" }, float = true, + no_focus = true, no_initial_focus = true, decorate = false, border_size = 0,