nixos/apps/hdt-wine-overlay/WineOverlayPlugin.cs

172 lines
5.5 KiB
C#
Raw Normal View History

// 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);
}
}