diff --git a/BlazorServer/Program.cs b/BlazorServer/Program.cs index 6f2233595..9089f1104 100644 --- a/BlazorServer/Program.cs +++ b/BlazorServer/Program.cs @@ -113,7 +113,7 @@ private static void ConfigureServices(IConfiguration configuration, IServiceColl services.AddWoWProcess(log); - services.AddCoreBase(); + services.AddCoreBase(log); if (AddonConfig.Exists() && FrameConfig.Exists()) { diff --git a/BlazorServer/run.bat b/BlazorServer/run.bat index deb244b9e..a78991a8b 100644 --- a/BlazorServer/run.bat +++ b/BlazorServer/run.bat @@ -1,5 +1,5 @@ start "" "http://localhost:5000" cd /D "%~dp0" -dotnet run --configuration Release --no-build +dotnet run --configuration Release --no-build -- --Reader:Type=%~1 pause \ No newline at end of file diff --git a/Core/DependencyInjection.cs b/Core/DependencyInjection.cs index a5b3d9675..8988ac5e7 100644 --- a/Core/DependencyInjection.cs +++ b/Core/DependencyInjection.cs @@ -238,7 +238,7 @@ public static IServiceCollection AddCoreNormal( return s; } - public static IServiceCollection AddCoreBase(this IServiceCollection s) + public static IServiceCollection AddCoreBase(this IServiceCollection s, ILogger log) { s.AddSingleton(x => new(false)); s.AddSingleton(); @@ -247,7 +247,9 @@ public static IServiceCollection AddCoreBase(this IServiceCollection s) s.AddSingleton(x => DataConfig.Load( x.GetRequiredService().Path)); - s.ForwardSingleton(); + s.AddSingleton(x => CreateWowScreen(x.GetRequiredService(), log)); + s.AddSingleton(x => x.GetRequiredService()); + s.AddSingleton(x => x.GetRequiredService()); s.ForwardSingleton(); @@ -264,6 +266,34 @@ public static IServiceCollection AddCoreBase(this IServiceCollection s) return s; } + private static IWowScreen CreateWowScreen(IServiceProvider sp, ILogger log) + { + var scr = sp.GetRequiredService>().Value; + var loggerFactory = sp.GetRequiredService(); + var process = sp.GetRequiredService(); + var frames = sp.GetRequiredService(); + + // Use WGC if configured and supported (Windows 10 2004+) + if (scr.ReaderType == AddonDataProviderType.WGC) + { + if (OperatingSystem.IsWindowsVersionAtLeast(10, 0, 19041) && + GraphicsCaptureInterop.IsSupported) + { + var wgcLogger = loggerFactory.CreateLogger(); + log.LogInformation("Using WGC (Windows Graphics Capture) - supports background capture"); + return new WowScreenWGC(wgcLogger, process, frames); + } + + log.LogWarning( + "WGC requested but not supported (requires Windows 10 2004+). Falling back to DXGI."); + } + + // Default: DXGI + var dxgiLogger = loggerFactory.CreateLogger(); + log.LogInformation("Using DXGI Desktop Duplication"); + return new WowScreenDXGI(dxgiLogger, process, frames); + } + public static bool AddWoWProcess( this IServiceCollection services, ILogger log) @@ -343,15 +373,10 @@ private static IScreenCapture GetScreenCapture( private static IAddonDataProvider GetAddonDataProvider( IServiceProvider sp, ILogger log) { - var scr = sp.GetRequiredService>().Value; var screen = sp.GetRequiredService(); - IAddonDataProvider value = scr.ReaderType switch - { - AddonDataProviderType.DXGI => - (IAddonDataProvider)screen, - _ => throw new NotImplementedException(), - }; + // Both WowScreenDXGI and WowScreenWGC implement IAddonDataProvider + IAddonDataProvider value = (IAddonDataProvider)screen; log.LogInformation(value.GetType().Name); return value; diff --git a/Core/WoWScreen/GraphicsCaptureInterop.cs b/Core/WoWScreen/GraphicsCaptureInterop.cs new file mode 100644 index 000000000..e94feba5d --- /dev/null +++ b/Core/WoWScreen/GraphicsCaptureInterop.cs @@ -0,0 +1,163 @@ +using System; +using System.Runtime.InteropServices; + +using Vortice.Direct3D11; + +using Windows.Graphics.Capture; +using Windows.Graphics.DirectX.Direct3D11; + +using WinRT; + +namespace Core; + +/// +/// Provides interop helpers for Windows Graphics Capture API. +/// +public static class GraphicsCaptureInterop +{ + [ComImport] + [Guid("3628E81B-3CAC-4C60-B7F4-23CE0E0C3356")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [ComVisible(true)] + private interface IGraphicsCaptureItemInterop + { + IntPtr CreateForWindow( + [In] IntPtr window, + [In] ref Guid iid); + + IntPtr CreateForMonitor( + [In] IntPtr monitor, + [In] ref Guid iid); + } + + [ComImport] + [Guid("A9B3D012-3DF2-4EE3-B8D1-8695F457D3C1")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + [ComVisible(true)] + private interface IDirect3DDxgiInterfaceAccess + { + // HRESULT GetInterface(REFIID iid, void** p) + [PreserveSig] + int GetInterface([In] ref Guid iid, out IntPtr p); + } + + [DllImport("d3d11.dll", EntryPoint = "CreateDirect3D11DeviceFromDXGIDevice", + SetLastError = true, CharSet = CharSet.Unicode, ExactSpelling = true, + CallingConvention = CallingConvention.StdCall)] + private static extern uint CreateDirect3D11DeviceFromDXGIDevice( + IntPtr dxgiDevice, out IntPtr graphicsDevice); + + private static readonly Guid GraphicsCaptureItemGuid = + new("79C3F95B-31F7-4EC2-A464-632EF5D30760"); + + private static readonly Guid IDXGIDeviceGuid = + new("54ec77fa-1377-44e6-8c32-88fd5f44c84c"); + + /// + /// Creates a GraphicsCaptureItem for the specified window handle. + /// + /// The window handle to capture. + /// A GraphicsCaptureItem for the window, or null if creation fails. + public static GraphicsCaptureItem? CreateCaptureItemForWindow(IntPtr hwnd) + { + if (hwnd == IntPtr.Zero) + return null; + + try + { + object factory = GraphicsCaptureItem.As(); + var interop = (IGraphicsCaptureItemInterop)factory; + + Guid guid = GraphicsCaptureItemGuid; + IntPtr itemPointer = interop.CreateForWindow(hwnd, ref guid); + + if (itemPointer == IntPtr.Zero) + return null; + + return MarshalInterface.FromAbi(itemPointer); + } + catch + { + return null; + } + } + + /// + /// Creates a WinRT IDirect3DDevice from a Vortice D3D11 device. + /// + /// The Vortice D3D11 device. + /// A WinRT Direct3D device for use with Graphics Capture, or null if creation fails. + public static IDirect3DDevice? CreateDirect3DDeviceFromD3D11(ID3D11Device d3dDevice) + { + try + { + // Get the DXGI device from the D3D11 device + using Vortice.DXGI.IDXGIDevice dxgiDevice = d3dDevice.QueryInterface(); + + uint hr = CreateDirect3D11DeviceFromDXGIDevice( + dxgiDevice.NativePointer, + out IntPtr graphicsDevice); + + if (hr != 0 || graphicsDevice == IntPtr.Zero) + return null; + + return MarshalInterface.FromAbi(graphicsDevice); + } + catch + { + return null; + } + } + + /// + /// Gets the underlying DXGI surface from a WinRT Direct3D surface. + /// + /// The WinRT Direct3D surface. + /// A pointer to the DXGI surface, or IntPtr.Zero if failed. + public static IntPtr GetDXGISurface(IDirect3DSurface surface) + { + return GetDXGISurface(surface, out _); + } + + /// + /// Gets the underlying DXGI surface from a WinRT Direct3D surface. + /// + /// The WinRT Direct3D surface. + /// The HRESULT from the COM call. + /// A pointer to the DXGI surface, or IntPtr.Zero if failed. + public static IntPtr GetDXGISurface(IDirect3DSurface surface, out int hresult) + { + hresult = 0; + try + { + object access = surface.As(); + var dxgiAccess = (IDirect3DDxgiInterfaceAccess)access; + + Guid dxgiSurfaceGuid = typeof(Vortice.DXGI.IDXGISurface).GUID; + hresult = dxgiAccess.GetInterface(ref dxgiSurfaceGuid, out IntPtr surfacePtr); + + // S_OK = 0, anything else is failure + return hresult >= 0 ? surfacePtr : IntPtr.Zero; + } + catch (Exception ex) + { + System.Diagnostics.Debug.WriteLine($"GetDXGISurface exception: {ex.Message}"); + hresult = ex.HResult; + return IntPtr.Zero; + } + } + + /// + /// Checks if Windows Graphics Capture is supported on this system. + /// Requires Windows 10 version 1903 (build 18362) or later for basic support, + /// and version 2004 (build 19041) for borderless capture (no yellow border). + /// + public static bool IsSupported => GraphicsCaptureSession.IsSupported(); + + /// + /// Checks if borderless capture is supported (Windows 10 20H2 build 20348+). + /// When supported, the yellow capture border can be disabled. + /// + public static bool IsBorderlessSupported => + OperatingSystem.IsWindowsVersionAtLeast(10, 0, 20348); +} diff --git a/Core/WoWScreen/ScreenCaptureHelper.cs b/Core/WoWScreen/ScreenCaptureHelper.cs new file mode 100644 index 000000000..700e28771 --- /dev/null +++ b/Core/WoWScreen/ScreenCaptureHelper.cs @@ -0,0 +1,32 @@ +using System; +using System.Runtime.CompilerServices; + +namespace Core; + +internal static class ScreenCaptureHelper +{ + public const int Bgra32Size = 4; + + [SkipLocalsInit] + public static void CopyRegion( + ReadOnlySpan src, int srcRowPitch, + int srcX, int srcY, + Span dest, + int width, int height) + { + int bytesPerRow = width * Bgra32Size; + + // Fast path: source region is contiguous (no X offset, no pitch padding) + if (srcX == 0 && srcRowPitch == bytesPerRow) + { + src.Slice(srcY * srcRowPitch, bytesPerRow * height).CopyTo(dest); + return; + } + + for (int y = 0; y < height; y++) + { + src.Slice((srcY + y) * srcRowPitch + srcX * Bgra32Size, bytesPerRow) + .CopyTo(dest.Slice(y * bytesPerRow, bytesPerRow)); + } + } +} diff --git a/Core/WoWScreen/WowScreenDXGI.cs b/Core/WoWScreen/WowScreenDXGI.cs index 5f77c8bfc..9f32c80ef 100644 --- a/Core/WoWScreen/WowScreenDXGI.cs +++ b/Core/WoWScreen/WowScreenDXGI.cs @@ -31,7 +31,7 @@ public sealed class WowScreenDXGI : IWowScreen, IAddonDataProvider { private readonly ILogger logger; private readonly WowProcess process; - private readonly int Bgra32Size; + private const int Bgra32Size = ScreenCaptureHelper.Bgra32Size; public event Action? OnChanged; @@ -90,8 +90,6 @@ public WowScreenDXGI(ILogger logger, this.logger = logger; this.process = process; - Bgra32Size = Unsafe.SizeOf(); - GetRectangle(out screenRect); ScreenImage = new(ContiguousJpegConfiguration, screenRect.Width, screenRect.Height); @@ -289,29 +287,22 @@ private void UpdateAddonImage(ID3D11Texture2D texture) MappedSubresource resource = device.ImmediateContext .Map(addonTexture, 0, MapMode.Read, Vortice.Direct3D11.MapFlags.None); - int rowPitch = (int)resource.RowPitch; - ReadOnlySpan src = resource.AsSpan(addonSize.Height * rowPitch); - Span dest = MemoryMarshal.Cast(memory.Span); - - if (addonSize.Height == 1 && src.TryCopyTo(dest)) + try { - goto Cleanup; - } + int rowPitch = (int)resource.RowPitch; + ReadOnlySpan src = resource.AsSpan(addonSize.Height * rowPitch); + Span dest = MemoryMarshal.Cast(memory.Span); - int bytesToCopy = addonSize.Width * Bgra32Size; - for (int y = 0; y < addonSize.Height; y++) - { - ReadOnlySpan srcRow = src.Slice(y * rowPitch, bytesToCopy); - Span destRow = dest.Slice(y * bytesToCopy, bytesToCopy); - srcRow.TryCopyTo(destRow); - } + ScreenCaptureHelper.CopyRegion(src, rowPitch, 0, 0, dest, addonSize.Width, addonSize.Height); #if SAVE_ADDON_IMAGE - addonImage.SaveAsJpeg("addon.jpg"); + addonImage.SaveAsJpeg("addon.jpg"); #endif - - Cleanup: - device.ImmediateContext.Unmap(addonTexture, 0); + } + finally + { + device.ImmediateContext.Unmap(addonTexture, 0); + } } [SkipLocalsInit] @@ -330,34 +321,22 @@ private void UpdateScreenImage(ID3D11Texture2D texture) MappedSubresource resource = device.ImmediateContext .Map(screenTexture, 0, MapMode.Read, Vortice.Direct3D11.MapFlags.None); - int rowPitch = (int)resource.RowPitch; - ReadOnlySpan src = resource.AsSpan(screenRect.Height * rowPitch); - Span dest = MemoryMarshal.Cast(memory.Span); - - // Issue: at 3440x1440 resolution game fullscreen - // the dest Span.Length much smaller then the src Span.Length - // this fails to copy the buffer - // so when TryCopyTo fails just fallback - // to copy by row - if (!windowedMode && src.TryCopyTo(dest)) - { - } - else + try { - int bytesToCopy = screenRect.Width * Bgra32Size; - for (int y = 0; y < screenRect.Height; y++) - { - ReadOnlySpan srcRow = src.Slice(y * rowPitch, bytesToCopy); - Span destRow = dest.Slice(y * bytesToCopy, bytesToCopy); - srcRow.TryCopyTo(destRow); - } - } + int rowPitch = (int)resource.RowPitch; + ReadOnlySpan src = resource.AsSpan(screenRect.Height * rowPitch); + Span dest = MemoryMarshal.Cast(memory.Span); + + ScreenCaptureHelper.CopyRegion(src, rowPitch, 0, 0, dest, screenRect.Width, screenRect.Height); #if SAVE_SCREEN_IMAGE - ScreenImage.SaveAsJpeg("screen.jpg"); + ScreenImage.SaveAsJpeg("screen.jpg"); #endif - - device.ImmediateContext.Unmap(screenTexture, 0); + } + finally + { + device.ImmediateContext.Unmap(screenTexture, 0); + } } [SkipLocalsInit] @@ -376,19 +355,18 @@ private void UpdateMinimapImage(ID3D11Texture2D texture) MappedSubresource resource = device.ImmediateContext .Map(minimapTexture, 0, MapMode.Read, Vortice.Direct3D11.MapFlags.None); - int rowPitch = (int)resource.RowPitch; - ReadOnlySpan src = resource.AsSpan(MiniMapRect.Height * rowPitch); - Span dest = MemoryMarshal.Cast(memory.Span); + try + { + int rowPitch = (int)resource.RowPitch; + ReadOnlySpan src = resource.AsSpan(MiniMapRect.Height * rowPitch); + Span dest = MemoryMarshal.Cast(memory.Span); - int bytesToCopy = MiniMapRect.Width * Bgra32Size; - for (int y = 0; y < MiniMapRect.Height; y++) + ScreenCaptureHelper.CopyRegion(src, rowPitch, 0, 0, dest, MiniMapRect.Width, MiniMapRect.Height); + } + finally { - ReadOnlySpan srcRow = src.Slice(y * rowPitch, bytesToCopy); - Span destRow = dest.Slice(y * bytesToCopy, bytesToCopy); - srcRow.TryCopyTo(destRow); + device.ImmediateContext.Unmap(minimapTexture, 0); } - - device.ImmediateContext.Unmap(minimapTexture, 0); } public void UpdateData() diff --git a/Core/WoWScreen/WowScreenWGC.cs b/Core/WoWScreen/WowScreenWGC.cs new file mode 100644 index 000000000..8ff4bcf47 --- /dev/null +++ b/Core/WoWScreen/WowScreenWGC.cs @@ -0,0 +1,520 @@ +//#define SAVE_ADDON_IMAGE +//#define SAVE_SCREEN_IMAGE +//#define SAVE_RAW_FRAME + +using Game; + +using Microsoft.Extensions.Logging; + +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.PixelFormats; + +using System; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +using Vortice.Direct3D; +using Vortice.Direct3D11; +using Vortice.DXGI; + +using WinAPI; + +using Windows.Graphics; +using Windows.Graphics.Capture; +using Windows.Graphics.DirectX; +using Windows.Graphics.DirectX.Direct3D11; + +using static WinAPI.NativeMethods; + +namespace Core; + +/// +/// Windows Graphics Capture based screen capture implementation. +/// Supports capturing WoW window even when it's behind other windows. +/// Requires Windows 10 version 2004 (build 19041) or later for borderless capture. +/// +public sealed class WowScreenWGC : IWowScreen, IAddonDataProvider +{ + private readonly ILogger logger; + private readonly WowProcess process; + private const int Bgra32Size = ScreenCaptureHelper.Bgra32Size; + + public event Action? OnChanged; + + public bool Enabled { get; set; } = true; + public bool EnablePostProcess { get; set; } = true; + public bool MinimapEnabled { get; set; } + + public Rectangle ScreenRect => screenRect; + private Rectangle screenRect; + + public Image ScreenImage { get; init; } + + private readonly SixLabors.ImageSharp.Configuration ContiguousJpegConfiguration + = new(new JpegConfigurationModule()) { PreferContiguousImageBuffers = true }; + + public const int MiniMapSize = 200; + public Rectangle MiniMapRect { get; private set; } + public Image MiniMapImage { get; init; } + + // D3D11 resources + private static readonly FeatureLevel[] s_featureLevels = + [ + FeatureLevel.Level_12_1, + FeatureLevel.Level_12_0, + FeatureLevel.Level_11_0, + ]; + + // Cached reflection for borderless capture (IsBorderRequired not in SDK 19041) + private static readonly PropertyInfo? s_borderRequiredProp = typeof(GraphicsCaptureSession) + .GetProperty("IsBorderRequired", BindingFlags.Public | BindingFlags.Instance); + + private readonly ID3D11Device device; + private readonly ID3D11DeviceContext deviceContext; + + // WGC resources + private readonly IDirect3DDevice winrtDevice; + private GraphicsCaptureItem? captureItem; + private Direct3D11CaptureFramePool? framePool; + private GraphicsCaptureSession? captureSession; + + // Double-buffer: WGC writes async, Update() reads + private readonly Lock frameLock = new(); + private ID3D11Texture2D? writeStagingTexture; + private ID3D11Texture2D? readStagingTexture; + private SizeInt32 stagingTextureSize; + private SizeInt32 latestFrameSize; + private bool hasNewFrame; + private bool processingFrame; + + // Client area offset (WGC captures full window including title bar) + private Point clientOffset; + + // IAddonDataProvider + private SixLabors.ImageSharp.Size addonSize; + private DataFrame[] frames = null!; + private Image addonImage = null!; + + public int[] Data { get; private set; } = []; + public StringBuilder TextBuilder { get; } = new(3); + + public WowScreenWGC(ILogger logger, WowProcess process, DataFrame[] frames) + { + this.logger = logger; + this.process = process; + + GetRectangle(out screenRect); + clientOffset = NativeMethods.GetClientAreaOffset(process.MainWindowHandle); + ScreenImage = new(ContiguousJpegConfiguration, screenRect.Width, screenRect.Height); + + MiniMapRect = new(0, 0, MiniMapSize, MiniMapSize); + MiniMapImage = new(ContiguousJpegConfiguration, MiniMapSize, MiniMapSize); + + // Create D3D11 device + D3D11.D3D11CreateDevice( + null, + DriverType.Hardware, + DeviceCreationFlags.BgraSupport, + s_featureLevels, + out device!); + + deviceContext = device.ImmediateContext; + + // Create WinRT device for WGC + winrtDevice = GraphicsCaptureInterop.CreateDirect3DDeviceFromD3D11(device) + ?? throw new InvalidOperationException("Failed to create WinRT Direct3D device"); + + InitFrames(frames); + InitializeCapture(); + + logger.LogInformation( + $"WGC initialized - {screenRect} - ClientOffset: ({clientOffset.X}, {clientOffset.Y}) - " + + $"Borderless: {GraphicsCaptureInterop.IsBorderlessSupported}"); + } + + private void InitializeCapture() + { + // Create capture item for WoW window + captureItem = GraphicsCaptureInterop.CreateCaptureItemForWindow(process.MainWindowHandle) + ?? throw new InvalidOperationException( + $"Failed to create GraphicsCaptureItem for window handle {process.MainWindowHandle}"); + + // Subscribe to size changes + captureItem.Closed += OnCaptureItemClosed; + + // Create frame pool with room for 2 frames + framePool = Direct3D11CaptureFramePool.CreateFreeThreaded( + winrtDevice, + DirectXPixelFormat.B8G8R8A8UIntNormalized, + 2, + captureItem.Size); + + framePool.FrameArrived += OnFrameArrived; + + // Create capture session + captureSession = framePool.CreateCaptureSession(captureItem); + + TryConfigureCaptureSession(captureSession); + + captureSession.StartCapture(); + } + + private void OnCaptureItemClosed(GraphicsCaptureItem sender, object args) + { + logger.LogWarning("Capture item closed - WoW window may have been closed"); + StopCapture(); + } + + private int frameCount; + private int successfulFrameCount; + + private void OnFrameArrived(Direct3D11CaptureFramePool sender, object args) + { + frameCount++; + + using Direct3D11CaptureFrame? frame = sender.TryGetNextFrame(); + if (frame == null) + { + logger.LogWarning("OnFrameArrived: TryGetNextFrame returned null (frame #{FrameCount})", frameCount); + return; + } + + // Get the surface and convert to D3D11 texture + IDirect3DSurface surface = frame.Surface; + IntPtr dxgiSurfacePtr = GraphicsCaptureInterop.GetDXGISurface(surface, out int hresult); + + if (dxgiSurfacePtr == IntPtr.Zero) + { + logger.LogWarning("OnFrameArrived: GetDXGISurface returned Zero, HRESULT=0x{Hr:X8} (frame #{FrameCount})", + hresult, frameCount); + return; + } + + try + { + using IDXGISurface dxgiSurface = new(dxgiSurfacePtr); + using ID3D11Texture2D frameTexture = dxgiSurface.QueryInterface(); + + using (frameLock.EnterScope()) + { + SizeInt32 contentSize = frame.ContentSize; + + // Recreate staging textures only when frame size changes + if (writeStagingTexture == null || + stagingTextureSize.Width != contentSize.Width || + stagingTextureSize.Height != contentSize.Height) + { + writeStagingTexture?.Dispose(); + readStagingTexture?.Dispose(); + + Texture2DDescription desc = frameTexture.Description; + desc.Usage = ResourceUsage.Staging; + desc.BindFlags = BindFlags.None; + desc.CPUAccessFlags = CpuAccessFlags.Read; + desc.MiscFlags = ResourceOptionFlags.None; + + writeStagingTexture = device.CreateTexture2D(desc); + readStagingTexture = device.CreateTexture2D(desc); + stagingTextureSize = contentSize; + } + + deviceContext.CopyResource(writeStagingTexture, frameTexture); + + // Only swap if Update() isn't actively reading from readStagingTexture. + // If processingFrame is true, we just overwrote writeStagingTexture in place + // and the next Update() call will pick up the latest frame after swap. + if (!processingFrame) + { + (writeStagingTexture, readStagingTexture) = (readStagingTexture, writeStagingTexture); + } + + latestFrameSize = contentSize; + hasNewFrame = true; + successfulFrameCount++; + } + + if (successfulFrameCount == 1) + { + logger.LogInformation("OnFrameArrived: First successful frame captured! Size: {Width}x{Height}, SurfacePtr: 0x{Ptr:X}", + latestFrameSize.Width, latestFrameSize.Height, dxgiSurfacePtr); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "OnFrameArrived: Error processing captured frame #{FrameCount}", frameCount); + } + } + + /// + /// Configures capture session: disables cursor capture (available since SDK 19041) + /// and attempts borderless capture via reflection (build 20348+). + /// + private void TryConfigureCaptureSession(GraphicsCaptureSession session) + { + session.IsCursorCaptureEnabled = false; + + if (!GraphicsCaptureInterop.IsBorderlessSupported) + return; + + try + { + s_borderRequiredProp?.SetValue(session, false); + logger.LogDebug("Borderless capture enabled via reflection"); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Could not enable borderless capture - yellow border may appear"); + } + } + + public void Dispose() + { + StopCapture(); + + winrtDevice?.Dispose(); + writeStagingTexture?.Dispose(); + readStagingTexture?.Dispose(); + deviceContext?.Dispose(); + device?.Dispose(); + } + + private void StopCapture() + { + try { captureSession?.Dispose(); } + catch (Exception ex) { logger.LogDebug(ex, "Error disposing capture session"); } + captureSession = null; + + try { framePool?.Dispose(); } + catch (Exception ex) { logger.LogDebug(ex, "Error disposing frame pool"); } + framePool = null; + + if (captureItem != null) + { + captureItem.Closed -= OnCaptureItemClosed; + captureItem = null; + } + } + + public void InitFrames(DataFrame[] frames) + { + this.frames = frames; + Data = new int[frames.Length]; + + addonSize = new(); + for (int i = 0; i < frames.Length; i++) + { + addonSize.Width = Math.Max(addonSize.Width, frames[i].X); + addonSize.Height = Math.Max(addonSize.Height, frames[i].Y); + } + addonSize.Width++; + addonSize.Height++; + + addonImage = new(ContiguousJpegConfiguration, addonSize.Width, addonSize.Height); + + logger.LogDebug($"DataFrames {frames.Length} - Addon: {addonSize}"); + } + + [SkipLocalsInit] + public void Update() + { + // Get latest window rect + GetRectangle(out Rectangle newRect); + + // Handle window resize + if (newRect.Width != screenRect.Width || newRect.Height != screenRect.Height) + { + screenRect = newRect; + clientOffset = NativeMethods.GetClientAreaOffset(process.MainWindowHandle); + RecreateFramePool(); + } + + ID3D11Texture2D? frameToProcess; + SizeInt32 frameSize; + + using (frameLock.EnterScope()) + { + if (!hasNewFrame || readStagingTexture == null) + return; + + processingFrame = true; + frameToProcess = readStagingTexture; + frameSize = latestFrameSize; + hasNewFrame = false; + } + + try + { + MappedSubresource resource = deviceContext.Map(frameToProcess, 0, MapMode.Read, Vortice.Direct3D11.MapFlags.None); + try + { + int rowPitch = (int)resource.RowPitch; + ReadOnlySpan fullFrame = resource.AsSpan(frameSize.Height * rowPitch); + +#if SAVE_RAW_FRAME + SaveRawFrame(fullFrame, rowPitch, frameSize); +#endif + + if (frames.Length > 2) + UpdateAddonImage(fullFrame, rowPitch, frameSize); + + if (Enabled) + UpdateScreenImage(fullFrame, rowPitch, frameSize); + + if (MinimapEnabled) + UpdateMinimapImage(fullFrame, rowPitch, frameSize); + } + finally + { + deviceContext.Unmap(frameToProcess, 0); + } + } + finally + { + using (frameLock.EnterScope()) + { + processingFrame = false; + } + } + } + +#if SAVE_RAW_FRAME + private bool rawFrameSaved; + private void SaveRawFrame(ReadOnlySpan fullFrame, int rowPitch, SizeInt32 frameSize) + { + if (rawFrameSaved) + return; + + try + { + using Image rawImage = new(frameSize.Width, frameSize.Height); + if (rawImage.DangerousTryGetSinglePixelMemory(out Memory memory)) + { + Span dest = MemoryMarshal.Cast(memory.Span); + ScreenCaptureHelper.CopyRegion(fullFrame, rowPitch, 0, 0, dest, frameSize.Width, frameSize.Height); + + rawImage.SaveAsJpeg("raw_frame_wgc.jpg"); + logger.LogInformation("Saved raw frame: {Width}x{Height}", frameSize.Width, frameSize.Height); + } + + rawFrameSaved = true; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to save raw frame"); + } + } +#endif + + private void RecreateFramePool() + { + if (captureItem == null || framePool == null) + return; + + try + { + SizeInt32 size = new() + { + Width = screenRect.Width, + Height = screenRect.Height + }; + + framePool.Recreate( + winrtDevice, + DirectXPixelFormat.B8G8R8A8UIntNormalized, + 2, + size); + + logger.LogDebug("Frame pool recreated for size: {Width}x{Height}", screenRect.Width, screenRect.Height); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to recreate frame pool"); + } + } + + [SkipLocalsInit] + private void UpdateAddonImage(ReadOnlySpan fullFrame, int rowPitch, SizeInt32 frameSize) + { + if (!addonImage.DangerousTryGetSinglePixelMemory(out Memory memory)) + return; + + // WGC captures full window including title bar/borders, offset to client area + if (!RegionFitsInFrame(clientOffset.X, clientOffset.Y, addonSize.Width, addonSize.Height, frameSize)) + return; + + Span dest = MemoryMarshal.Cast(memory.Span); + ScreenCaptureHelper.CopyRegion(fullFrame, rowPitch, clientOffset.X, clientOffset.Y, dest, addonSize.Width, addonSize.Height); + +#if SAVE_ADDON_IMAGE + addonImage.SaveAsJpeg("addon_wgc.jpg"); +#endif + } + + [SkipLocalsInit] + private void UpdateScreenImage(ReadOnlySpan fullFrame, int rowPitch, SizeInt32 frameSize) + { + if (!ScreenImage.DangerousTryGetSinglePixelMemory(out Memory memory)) + return; + + // Copy client area (offset past title bar/borders) + if (!RegionFitsInFrame(clientOffset.X, clientOffset.Y, screenRect.Width, screenRect.Height, frameSize)) + return; + + Span dest = MemoryMarshal.Cast(memory.Span); + ScreenCaptureHelper.CopyRegion(fullFrame, rowPitch, clientOffset.X, clientOffset.Y, dest, screenRect.Width, screenRect.Height); + +#if SAVE_SCREEN_IMAGE + ScreenImage.SaveAsJpeg("screen_wgc.jpg"); +#endif + } + + [SkipLocalsInit] + private void UpdateMinimapImage(ReadOnlySpan fullFrame, int rowPitch, SizeInt32 frameSize) + { + if (!MiniMapImage.DangerousTryGetSinglePixelMemory(out Memory memory)) + return; + + // Minimap is at top-right of client area + int minimapX = clientOffset.X + screenRect.Width - MiniMapSize; + int minimapY = clientOffset.Y; + + if (!RegionFitsInFrame(minimapX, minimapY, MiniMapRect.Width, MiniMapRect.Height, frameSize)) + return; + + Span dest = MemoryMarshal.Cast(memory.Span); + ScreenCaptureHelper.CopyRegion(fullFrame, rowPitch, minimapX, minimapY, dest, MiniMapRect.Width, MiniMapRect.Height); + } + + private static bool RegionFitsInFrame( + int srcX, int srcY, int width, int height, SizeInt32 frameSize) + => srcX >= 0 && srcY >= 0 + && srcX + width <= frameSize.Width + && srcY + height <= frameSize.Height; + + public void UpdateData() + { + if (frames.Length <= 2) + return; + + IAddonDataProvider.InternalUpdate(addonImage, frames, Data); + } + + public void PostProcess() + { + OnChanged?.Invoke(); + } + + public void GetPosition(ref Point point) + { + NativeMethods.GetPosition(process.MainWindowHandle, ref point); + } + + public void GetRectangle(out Rectangle rect) + { + NativeMethods.GetWindowRect(process.MainWindowHandle, out rect); + } +} diff --git a/Directory.Packages.props b/Directory.Packages.props index 4705cefb4..69d985270 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -1,6 +1,6 @@ - net10.0 + net10.0-windows10.0.19041.0 true AnyCPU;x64;x86 diff --git a/Frontend/wwwroot/css/site.css b/Frontend/wwwroot/css/site.css index 65be0bb61..686503688 100644 --- a/Frontend/wwwroot/css/site.css +++ b/Frontend/wwwroot/css/site.css @@ -208,12 +208,14 @@ app { .img-filled { width: 100%; - aspect-ratio: 2.5/1; + max-height: 80vh; + object-fit: contain; } .img-filled-half { - width: 100%; - aspect-ratio: 1.5/1; + width: 50%; + max-height: 80vh; + object-fit: contain; } .poor { diff --git a/HeadlessServer/Program.cs b/HeadlessServer/Program.cs index c2fd20d1f..bafa189bb 100644 --- a/HeadlessServer/Program.cs +++ b/HeadlessServer/Program.cs @@ -130,7 +130,7 @@ private static bool ConfigureServices( if (!services.AddWoWProcess(log)) return false; - services.AddCoreBase(); + services.AddCoreBase(log); services.AddCoreNormal(log); return true; diff --git a/SharedLib/AddonDataProviderType/AddonDataProviderType.cs b/SharedLib/AddonDataProviderType/AddonDataProviderType.cs index a1b268ad3..cf823d555 100644 --- a/SharedLib/AddonDataProviderType/AddonDataProviderType.cs +++ b/SharedLib/AddonDataProviderType/AddonDataProviderType.cs @@ -2,5 +2,6 @@ public enum AddonDataProviderType { - DXGI + DXGI, + WGC // Windows Graphics Capture - supports background window capture } diff --git a/WinAPI/NativeMethods.cs b/WinAPI/NativeMethods.cs index 2775610eb..2f0c0155c 100644 --- a/WinAPI/NativeMethods.cs +++ b/WinAPI/NativeMethods.cs @@ -283,6 +283,15 @@ public static bool IsExtendedKey(int virtualKey) [return: MarshalAs(UnmanagedType.Bool)] public static partial bool ScreenToClient(nint hWnd, ref Point lpPoint); + [LibraryImport("user32.dll", EntryPoint = "GetWindowRect", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static partial bool GetWindowRectNative(nint hWnd, out RECT lpRect); + + [LibraryImport("dwmapi.dll")] + private static partial int DwmGetWindowAttribute(nint hwnd, int dwAttribute, out RECT pvAttribute, int cbAttribute); + + private const int DWMWA_EXTENDED_FRAME_BOUNDS = 9; + [LibraryImport("user32.dll")] private static partial int GetSystemMetrics(int nIndexn); @@ -318,6 +327,34 @@ public static void GetWindowRect(nint hWnd, out Rectangle rect) } } + /// + /// Gets the offset from the WGC captured window top-left to the client area top-left. + /// WGC captures the visible window frame (DWM extended frame bounds), not the full + /// window rect which includes invisible drop shadows on Windows 10+. + /// + /// The window handle. + /// Point containing (borderWidth, titleBarHeight + borderWidth). + public static Point GetClientAreaOffset(nint hWnd) + { + // Get the visible window bounds (what WGC captures) + // DWM extended frame bounds excludes the invisible drop shadow + int hr = DwmGetWindowAttribute(hWnd, DWMWA_EXTENDED_FRAME_BOUNDS, + out RECT frameRect, Marshal.SizeOf()); + + // Fall back to GetWindowRect if DWM fails + if (hr != 0) + GetWindowRectNative(hWnd, out frameRect); + + // Get client area top-left in screen coordinates + Point clientTopLeft = new(); + ClientToScreen(hWnd, ref clientTopLeft); + + // Calculate the offset from visible frame top-left to client top-left + return new Point( + clientTopLeft.X - frameRect.left, + clientTopLeft.Y - frameRect.top); + } + public static int GetDpi() { using System.Drawing.Graphics g = System.Drawing.Graphics.FromHwnd(nint.Zero);