From 2897367d78cef07b536725794295b25ec5bb1dce Mon Sep 17 00:00:00 2001 From: Xian55 <367101+Xian55@users.noreply.github.com> Date: Thu, 5 Feb 2026 03:07:30 +0100 Subject: [PATCH 1/4] Add Windows Graphics Capture (WGC) screen capture backend Implement a new screen capture backend using Windows Graphics Capture API that can capture the WoW window even when it's behind other windows or minimized. Requires Windows 10 version 2004 (build 19041) or later. ## New Files - Core/WoWScreen/WowScreenWGC.cs: Main WGC capture implementation - Core/WoWScreen/GraphicsCaptureInterop.cs: COM interop helpers for WGC ## Performance Optimizations - Double-buffered staging textures: Eliminates per-frame GPU texture allocation/deallocation by reusing two staging textures that swap roles between write (capture) and read (processing) operations - System.Threading.Lock: Uses the new .NET 9+ Lock type instead of object-based locking for more efficient synchronization - Lock.EnterScope() pattern: Uses the ref struct Scope pattern for zero-allocation lock scoping instead of traditional lock statements - Cached reflection PropertyInfo: Static readonly fields for IsBorderRequired and IsCursorCaptureEnabled properties to avoid repeated reflection lookups when enabling borderless capture ## Architecture - Async frame capture via FrameArrived event on WGC thread - Thread-safe double-buffer handoff to Update() on main thread - Direct3D11 texture pipeline for efficient GPU-to-CPU data transfer - Supports addon data reading, screen capture, and minimap capture ## Integration Changes - AddonDataProviderType: Added WGC enum option - DependencyInjection: WGC service registration - NativeMethods: Added GetClientAreaOffset for window border handling - Configuration: WGC option in appsettings Co-Authored-By: Claude Opus 4.5 --- BlazorServer/Program.cs | 2 +- BlazorServer/appsettings.json | 2 +- Core/DependencyInjection.cs | 43 +- Core/WoWScreen/GraphicsCaptureInterop.cs | 163 +++++ Core/WoWScreen/WowScreenWGC.cs | 658 ++++++++++++++++++ Directory.Packages.props | 2 +- HeadlessServer/Program.cs | 2 +- .../AddonDataProviderType.cs | 3 +- WinAPI/NativeMethods.cs | 37 + 9 files changed, 898 insertions(+), 14 deletions(-) create mode 100644 Core/WoWScreen/GraphicsCaptureInterop.cs create mode 100644 Core/WoWScreen/WowScreenWGC.cs 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/appsettings.json b/BlazorServer/appsettings.json index c390740c1..933004111 100644 --- a/BlazorServer/appsettings.json +++ b/BlazorServer/appsettings.json @@ -23,7 +23,7 @@ "Id": -1 }, "Reader": { - "Type": "DXGI" + "Type": "WGC" //DXGI }, "Diagnostics": { "Enabled": false diff --git a/Core/DependencyInjection.cs b/Core/DependencyInjection.cs index 515025c15..c28635690 100644 --- a/Core/DependencyInjection.cs +++ b/Core/DependencyInjection.cs @@ -234,7 +234,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(); @@ -243,7 +243,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(); @@ -260,6 +262,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) @@ -339,15 +369,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/WowScreenWGC.cs b/Core/WoWScreen/WowScreenWGC.cs new file mode 100644 index 000000000..b422cbe60 --- /dev/null +++ b/Core/WoWScreen/WowScreenWGC.cs @@ -0,0 +1,658 @@ +//#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 readonly int Bgra32Size; + + public event Action? OnChanged; + + public bool Enabled { get; set; } + public bool EnablePostProcess { get; set; } + 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 (properties not in SDK 19041) + private static readonly PropertyInfo? s_borderRequiredProp = typeof(GraphicsCaptureSession) + .GetProperty("IsBorderRequired", BindingFlags.Public | BindingFlags.Instance); + private static readonly PropertyInfo? s_cursorEnabledProp = typeof(GraphicsCaptureSession) + .GetProperty("IsCursorCaptureEnabled", BindingFlags.Public | BindingFlags.Instance); + + private readonly ID3D11Device device; + private readonly ID3D11DeviceContext deviceContext; + + private ID3D11Texture2D? minimapTexture; + private ID3D11Texture2D? screenTexture; + private ID3D11Texture2D? addonTexture; + + // 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; + + // 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; + + Bgra32Size = Unsafe.SizeOf(); + + 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); + + // Try to disable yellow border on Windows 10 20348+ using reflection + // (properties not available in SDK 19041, but may be present at runtime) + TrySetBorderlessCapture(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 + { + IDXGISurface dxgiSurface = new(dxgiSurfacePtr); + 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); + + // Swap buffers: write becomes read, read becomes write + (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); + } + + frameTexture.Dispose(); + dxgiSurface.Dispose(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "OnFrameArrived: Error processing captured frame #{FrameCount}", frameCount); + } + } + + /// + /// Attempts to set borderless capture properties using reflection. + /// These properties are only available on Windows 10 build 20348+ but may not be + /// present in the SDK we're targeting (19041). Using reflection allows the code + /// to compile against 19041 while still utilizing newer features at runtime. + /// + private void TrySetBorderlessCapture(GraphicsCaptureSession session) + { + if (!GraphicsCaptureInterop.IsBorderlessSupported) + return; + + try + { + s_borderRequiredProp?.SetValue(session, false); + s_cursorEnabledProp?.SetValue(session, false); + + logger.LogDebug("Borderless capture enabled via reflection"); + } + catch (Exception ex) + { + // Properties not available on this Windows version - yellow border will show + logger.LogDebug(ex, "Could not enable borderless capture - yellow border may appear"); + } + } + + public void Dispose() + { + StopCapture(); + + writeStagingTexture?.Dispose(); + readStagingTexture?.Dispose(); + minimapTexture?.Dispose(); + addonTexture?.Dispose(); + screenTexture?.Dispose(); + deviceContext?.Dispose(); + device?.Dispose(); + } + + private void StopCapture() + { + try { captureSession?.Dispose(); } catch { } + captureSession = null; + + try { framePool?.Dispose(); } catch { } + 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); + + Texture2DDescription addonTextureDesc = new() + { + CPUAccessFlags = CpuAccessFlags.Read, + BindFlags = BindFlags.None, + Format = Format.B8G8R8A8_UNorm, + Width = (uint)addonSize.Width, + Height = (uint)addonSize.Height, + MiscFlags = ResourceOptionFlags.None, + MipLevels = 1, + ArraySize = 1, + SampleDescription = { Count = 1, Quality = 0 }, + Usage = ResourceUsage.Staging + }; + + addonTexture?.Dispose(); + addonTexture = device.CreateTexture2D(addonTextureDesc); + + logger.LogDebug($"DataFrames {frames.Length} - Texture: {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; + RecreateFramePool(); + } + + ID3D11Texture2D? frameToProcess; + SizeInt32 frameSize; + + using (frameLock.EnterScope()) + { + if (!hasNewFrame || readStagingTexture == null) + return; + + frameToProcess = readStagingTexture; + frameSize = latestFrameSize; + hasNewFrame = false; + } + +#if SAVE_RAW_FRAME + SaveRawFrame(frameToProcess, frameSize); +#endif + + if (frames.Length > 2) + UpdateAddonImage(frameToProcess); + + if (Enabled) + UpdateScreenImage(frameToProcess, frameSize); + + if (MinimapEnabled) + UpdateMinimapImage(frameToProcess, frameSize); + } + +#if SAVE_RAW_FRAME + private bool rawFrameSaved; + private void SaveRawFrame(ID3D11Texture2D sourceTexture, SizeInt32 frameSize) + { + if (rawFrameSaved) + return; + + try + { + // Create a staging texture for the full frame + Texture2DDescription desc = new() + { + CPUAccessFlags = CpuAccessFlags.Read, + BindFlags = BindFlags.None, + Format = Format.B8G8R8A8_UNorm, + Width = (uint)frameSize.Width, + Height = (uint)frameSize.Height, + MiscFlags = ResourceOptionFlags.None, + MipLevels = 1, + ArraySize = 1, + SampleDescription = { Count = 1, Quality = 0 }, + Usage = ResourceUsage.Staging + }; + + using ID3D11Texture2D stagingTexture = device.CreateTexture2D(desc); + deviceContext.CopyResource(stagingTexture, sourceTexture); + + MappedSubresource resource = deviceContext.Map(stagingTexture, 0, MapMode.Read, Vortice.Direct3D11.MapFlags.None); + + using Image rawImage = new(frameSize.Width, frameSize.Height); + if (rawImage.DangerousTryGetSinglePixelMemory(out Memory memory)) + { + int rowPitch = (int)resource.RowPitch; + ReadOnlySpan src = resource.AsSpan(frameSize.Height * rowPitch); + Span dest = MemoryMarshal.Cast(memory.Span); + + int bytesToCopy = frameSize.Width * Bgra32Size; + for (int y = 0; y < frameSize.Height; y++) + { + ReadOnlySpan srcRow = src.Slice(y * rowPitch, bytesToCopy); + Span destRow = dest.Slice(y * bytesToCopy, bytesToCopy); + srcRow.TryCopyTo(destRow); + } + + rawImage.SaveAsJpeg("raw_frame_wgc.jpg"); + logger.LogInformation("Saved raw frame: {Width}x{Height}", frameSize.Width, frameSize.Height); + } + + deviceContext.Unmap(stagingTexture, 0); + 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); + + // Recreate screen texture with new size + screenTexture?.Dispose(); + Texture2DDescription screenTextureDesc = new() + { + CPUAccessFlags = CpuAccessFlags.Read, + BindFlags = BindFlags.None, + Format = Format.B8G8R8A8_UNorm, + Width = (uint)screenRect.Width, + Height = (uint)screenRect.Height, + MiscFlags = ResourceOptionFlags.None, + MipLevels = 1, + ArraySize = 1, + SampleDescription = { Count = 1, Quality = 0 }, + Usage = ResourceUsage.Staging + }; + screenTexture = device.CreateTexture2D(screenTextureDesc); + + 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(ID3D11Texture2D sourceTexture) + { + if (!addonImage.DangerousTryGetSinglePixelMemory(out Memory memory)) + return; + + // WGC captures full window including title bar/borders, offset to client area + Vortice.Mathematics.Box areaOnWindow = new( + clientOffset.X, clientOffset.Y, 0, + clientOffset.X + addonSize.Width, clientOffset.Y + addonSize.Height, 1); + + deviceContext.CopySubresourceRegion(addonTexture!, 0, 0, 0, 0, sourceTexture, 0, areaOnWindow); + + MappedSubresource resource = deviceContext.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)) + { + goto Cleanup; + } + + 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); + } + +#if SAVE_ADDON_IMAGE + addonImage.SaveAsJpeg("addon_wgc.jpg"); +#endif + + Cleanup: + deviceContext.Unmap(addonTexture!, 0); + } + + [SkipLocalsInit] + private void UpdateScreenImage(ID3D11Texture2D sourceTexture, SizeInt32 frameSize) + { + if (!ScreenImage.DangerousTryGetSinglePixelMemory(out Memory memory)) + return; + + // Ensure screen texture exists and is correct size for client area + if (screenTexture == null || + screenTexture.Description.Width != (uint)screenRect.Width || + screenTexture.Description.Height != (uint)screenRect.Height) + { + screenTexture?.Dispose(); + Texture2DDescription screenTextureDesc = new() + { + CPUAccessFlags = CpuAccessFlags.Read, + BindFlags = BindFlags.None, + Format = Format.B8G8R8A8_UNorm, + Width = (uint)screenRect.Width, + Height = (uint)screenRect.Height, + MiscFlags = ResourceOptionFlags.None, + MipLevels = 1, + ArraySize = 1, + SampleDescription = { Count = 1, Quality = 0 }, + Usage = ResourceUsage.Staging + }; + screenTexture = device.CreateTexture2D(screenTextureDesc); + } + + // Copy client area (offset past title bar/borders) + Vortice.Mathematics.Box clientArea = new( + clientOffset.X, clientOffset.Y, 0, + clientOffset.X + screenRect.Width, clientOffset.Y + screenRect.Height, 1); + + deviceContext.CopySubresourceRegion(screenTexture, 0, 0, 0, 0, sourceTexture, 0, clientArea); + + MappedSubresource resource = deviceContext.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); + + 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); + } + +#if SAVE_SCREEN_IMAGE + ScreenImage.SaveAsJpeg("screen_wgc.jpg"); +#endif + + deviceContext.Unmap(screenTexture, 0); + } + + [SkipLocalsInit] + private void UpdateMinimapImage(ID3D11Texture2D sourceTexture, SizeInt32 frameSize) + { + if (!MiniMapImage.DangerousTryGetSinglePixelMemory(out Memory memory)) + return; + + // Ensure minimap texture exists + if (minimapTexture == null) + { + Texture2DDescription miniMapTextureDesc = new() + { + CPUAccessFlags = CpuAccessFlags.Read, + BindFlags = BindFlags.None, + Format = Format.B8G8R8A8_UNorm, + Width = (uint)MiniMapRect.Right, + Height = (uint)MiniMapRect.Bottom, + MiscFlags = ResourceOptionFlags.None, + MipLevels = 1, + ArraySize = 1, + SampleDescription = { Count = 1, Quality = 0 }, + Usage = ResourceUsage.Staging + }; + minimapTexture = device.CreateTexture2D(miniMapTextureDesc); + } + + // Minimap is at top-right of client area + int minimapX = Math.Max(clientOffset.X, clientOffset.X + screenRect.Width - MiniMapSize); + Vortice.Mathematics.Box areaOnWindow = new( + minimapX, clientOffset.Y, 0, + minimapX + MiniMapSize, clientOffset.Y + MiniMapRect.Bottom, 1); + + deviceContext.CopySubresourceRegion(minimapTexture, 0, 0, 0, 0, sourceTexture, 0, areaOnWindow); + + MappedSubresource resource = deviceContext.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); + + int bytesToCopy = MiniMapRect.Width * Bgra32Size; + for (int y = 0; y < MiniMapRect.Height; y++) + { + ReadOnlySpan srcRow = src.Slice(y * rowPitch, bytesToCopy); + Span destRow = dest.Slice(y * bytesToCopy, bytesToCopy); + srcRow.TryCopyTo(destRow); + } + + deviceContext.Unmap(minimapTexture, 0); + } + + 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/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); From 96254fdcb16fb267c6b24200eef4bcd2d1ed00c3 Mon Sep 17 00:00:00 2001 From: Xian55 <367101+Xian55@users.noreply.github.com> Date: Thu, 5 Feb 2026 03:07:30 +0100 Subject: [PATCH 2/4] Add Windows Graphics Capture (WGC) screen capture backend Implement a new screen capture backend using Windows Graphics Capture API that can capture the WoW window even when it's behind other windows or minimized. Requires Windows 10 version 2004 (build 19041) or later. ## New Files - Core/WoWScreen/WowScreenWGC.cs: Main WGC capture implementation - Core/WoWScreen/GraphicsCaptureInterop.cs: COM interop helpers for WGC ## Performance Optimizations - Double-buffered staging textures: Eliminates per-frame GPU texture allocation/deallocation by reusing two staging textures that swap roles between write (capture) and read (processing) operations - System.Threading.Lock: Uses the new .NET 9+ Lock type instead of object-based locking for more efficient synchronization - Lock.EnterScope() pattern: Uses the ref struct Scope pattern for zero-allocation lock scoping instead of traditional lock statements - Cached reflection PropertyInfo: Static readonly fields for IsBorderRequired and IsCursorCaptureEnabled properties to avoid repeated reflection lookups when enabling borderless capture ## Architecture - Async frame capture via FrameArrived event on WGC thread - Thread-safe double-buffer handoff to Update() on main thread - Direct3D11 texture pipeline for efficient GPU-to-CPU data transfer - Supports addon data reading, screen capture, and minimap capture ## Integration Changes - AddonDataProviderType: Added WGC enum option - DependencyInjection: WGC service registration - NativeMethods: Added GetClientAreaOffset for window border handling - Configuration: WGC option in appsettings Co-Authored-By: Claude Opus 4.5 --- BlazorServer/Program.cs | 2 +- BlazorServer/appsettings.json | 2 +- Core/DependencyInjection.cs | 43 +- Core/WoWScreen/GraphicsCaptureInterop.cs | 163 +++++ Core/WoWScreen/WowScreenWGC.cs | 658 ++++++++++++++++++ Directory.Packages.props | 2 +- HeadlessServer/Program.cs | 2 +- .../AddonDataProviderType.cs | 3 +- WinAPI/NativeMethods.cs | 37 + 9 files changed, 898 insertions(+), 14 deletions(-) create mode 100644 Core/WoWScreen/GraphicsCaptureInterop.cs create mode 100644 Core/WoWScreen/WowScreenWGC.cs 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/appsettings.json b/BlazorServer/appsettings.json index c390740c1..933004111 100644 --- a/BlazorServer/appsettings.json +++ b/BlazorServer/appsettings.json @@ -23,7 +23,7 @@ "Id": -1 }, "Reader": { - "Type": "DXGI" + "Type": "WGC" //DXGI }, "Diagnostics": { "Enabled": false 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/WowScreenWGC.cs b/Core/WoWScreen/WowScreenWGC.cs new file mode 100644 index 000000000..b422cbe60 --- /dev/null +++ b/Core/WoWScreen/WowScreenWGC.cs @@ -0,0 +1,658 @@ +//#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 readonly int Bgra32Size; + + public event Action? OnChanged; + + public bool Enabled { get; set; } + public bool EnablePostProcess { get; set; } + 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 (properties not in SDK 19041) + private static readonly PropertyInfo? s_borderRequiredProp = typeof(GraphicsCaptureSession) + .GetProperty("IsBorderRequired", BindingFlags.Public | BindingFlags.Instance); + private static readonly PropertyInfo? s_cursorEnabledProp = typeof(GraphicsCaptureSession) + .GetProperty("IsCursorCaptureEnabled", BindingFlags.Public | BindingFlags.Instance); + + private readonly ID3D11Device device; + private readonly ID3D11DeviceContext deviceContext; + + private ID3D11Texture2D? minimapTexture; + private ID3D11Texture2D? screenTexture; + private ID3D11Texture2D? addonTexture; + + // 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; + + // 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; + + Bgra32Size = Unsafe.SizeOf(); + + 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); + + // Try to disable yellow border on Windows 10 20348+ using reflection + // (properties not available in SDK 19041, but may be present at runtime) + TrySetBorderlessCapture(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 + { + IDXGISurface dxgiSurface = new(dxgiSurfacePtr); + 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); + + // Swap buffers: write becomes read, read becomes write + (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); + } + + frameTexture.Dispose(); + dxgiSurface.Dispose(); + } + catch (Exception ex) + { + logger.LogWarning(ex, "OnFrameArrived: Error processing captured frame #{FrameCount}", frameCount); + } + } + + /// + /// Attempts to set borderless capture properties using reflection. + /// These properties are only available on Windows 10 build 20348+ but may not be + /// present in the SDK we're targeting (19041). Using reflection allows the code + /// to compile against 19041 while still utilizing newer features at runtime. + /// + private void TrySetBorderlessCapture(GraphicsCaptureSession session) + { + if (!GraphicsCaptureInterop.IsBorderlessSupported) + return; + + try + { + s_borderRequiredProp?.SetValue(session, false); + s_cursorEnabledProp?.SetValue(session, false); + + logger.LogDebug("Borderless capture enabled via reflection"); + } + catch (Exception ex) + { + // Properties not available on this Windows version - yellow border will show + logger.LogDebug(ex, "Could not enable borderless capture - yellow border may appear"); + } + } + + public void Dispose() + { + StopCapture(); + + writeStagingTexture?.Dispose(); + readStagingTexture?.Dispose(); + minimapTexture?.Dispose(); + addonTexture?.Dispose(); + screenTexture?.Dispose(); + deviceContext?.Dispose(); + device?.Dispose(); + } + + private void StopCapture() + { + try { captureSession?.Dispose(); } catch { } + captureSession = null; + + try { framePool?.Dispose(); } catch { } + 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); + + Texture2DDescription addonTextureDesc = new() + { + CPUAccessFlags = CpuAccessFlags.Read, + BindFlags = BindFlags.None, + Format = Format.B8G8R8A8_UNorm, + Width = (uint)addonSize.Width, + Height = (uint)addonSize.Height, + MiscFlags = ResourceOptionFlags.None, + MipLevels = 1, + ArraySize = 1, + SampleDescription = { Count = 1, Quality = 0 }, + Usage = ResourceUsage.Staging + }; + + addonTexture?.Dispose(); + addonTexture = device.CreateTexture2D(addonTextureDesc); + + logger.LogDebug($"DataFrames {frames.Length} - Texture: {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; + RecreateFramePool(); + } + + ID3D11Texture2D? frameToProcess; + SizeInt32 frameSize; + + using (frameLock.EnterScope()) + { + if (!hasNewFrame || readStagingTexture == null) + return; + + frameToProcess = readStagingTexture; + frameSize = latestFrameSize; + hasNewFrame = false; + } + +#if SAVE_RAW_FRAME + SaveRawFrame(frameToProcess, frameSize); +#endif + + if (frames.Length > 2) + UpdateAddonImage(frameToProcess); + + if (Enabled) + UpdateScreenImage(frameToProcess, frameSize); + + if (MinimapEnabled) + UpdateMinimapImage(frameToProcess, frameSize); + } + +#if SAVE_RAW_FRAME + private bool rawFrameSaved; + private void SaveRawFrame(ID3D11Texture2D sourceTexture, SizeInt32 frameSize) + { + if (rawFrameSaved) + return; + + try + { + // Create a staging texture for the full frame + Texture2DDescription desc = new() + { + CPUAccessFlags = CpuAccessFlags.Read, + BindFlags = BindFlags.None, + Format = Format.B8G8R8A8_UNorm, + Width = (uint)frameSize.Width, + Height = (uint)frameSize.Height, + MiscFlags = ResourceOptionFlags.None, + MipLevels = 1, + ArraySize = 1, + SampleDescription = { Count = 1, Quality = 0 }, + Usage = ResourceUsage.Staging + }; + + using ID3D11Texture2D stagingTexture = device.CreateTexture2D(desc); + deviceContext.CopyResource(stagingTexture, sourceTexture); + + MappedSubresource resource = deviceContext.Map(stagingTexture, 0, MapMode.Read, Vortice.Direct3D11.MapFlags.None); + + using Image rawImage = new(frameSize.Width, frameSize.Height); + if (rawImage.DangerousTryGetSinglePixelMemory(out Memory memory)) + { + int rowPitch = (int)resource.RowPitch; + ReadOnlySpan src = resource.AsSpan(frameSize.Height * rowPitch); + Span dest = MemoryMarshal.Cast(memory.Span); + + int bytesToCopy = frameSize.Width * Bgra32Size; + for (int y = 0; y < frameSize.Height; y++) + { + ReadOnlySpan srcRow = src.Slice(y * rowPitch, bytesToCopy); + Span destRow = dest.Slice(y * bytesToCopy, bytesToCopy); + srcRow.TryCopyTo(destRow); + } + + rawImage.SaveAsJpeg("raw_frame_wgc.jpg"); + logger.LogInformation("Saved raw frame: {Width}x{Height}", frameSize.Width, frameSize.Height); + } + + deviceContext.Unmap(stagingTexture, 0); + 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); + + // Recreate screen texture with new size + screenTexture?.Dispose(); + Texture2DDescription screenTextureDesc = new() + { + CPUAccessFlags = CpuAccessFlags.Read, + BindFlags = BindFlags.None, + Format = Format.B8G8R8A8_UNorm, + Width = (uint)screenRect.Width, + Height = (uint)screenRect.Height, + MiscFlags = ResourceOptionFlags.None, + MipLevels = 1, + ArraySize = 1, + SampleDescription = { Count = 1, Quality = 0 }, + Usage = ResourceUsage.Staging + }; + screenTexture = device.CreateTexture2D(screenTextureDesc); + + 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(ID3D11Texture2D sourceTexture) + { + if (!addonImage.DangerousTryGetSinglePixelMemory(out Memory memory)) + return; + + // WGC captures full window including title bar/borders, offset to client area + Vortice.Mathematics.Box areaOnWindow = new( + clientOffset.X, clientOffset.Y, 0, + clientOffset.X + addonSize.Width, clientOffset.Y + addonSize.Height, 1); + + deviceContext.CopySubresourceRegion(addonTexture!, 0, 0, 0, 0, sourceTexture, 0, areaOnWindow); + + MappedSubresource resource = deviceContext.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)) + { + goto Cleanup; + } + + 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); + } + +#if SAVE_ADDON_IMAGE + addonImage.SaveAsJpeg("addon_wgc.jpg"); +#endif + + Cleanup: + deviceContext.Unmap(addonTexture!, 0); + } + + [SkipLocalsInit] + private void UpdateScreenImage(ID3D11Texture2D sourceTexture, SizeInt32 frameSize) + { + if (!ScreenImage.DangerousTryGetSinglePixelMemory(out Memory memory)) + return; + + // Ensure screen texture exists and is correct size for client area + if (screenTexture == null || + screenTexture.Description.Width != (uint)screenRect.Width || + screenTexture.Description.Height != (uint)screenRect.Height) + { + screenTexture?.Dispose(); + Texture2DDescription screenTextureDesc = new() + { + CPUAccessFlags = CpuAccessFlags.Read, + BindFlags = BindFlags.None, + Format = Format.B8G8R8A8_UNorm, + Width = (uint)screenRect.Width, + Height = (uint)screenRect.Height, + MiscFlags = ResourceOptionFlags.None, + MipLevels = 1, + ArraySize = 1, + SampleDescription = { Count = 1, Quality = 0 }, + Usage = ResourceUsage.Staging + }; + screenTexture = device.CreateTexture2D(screenTextureDesc); + } + + // Copy client area (offset past title bar/borders) + Vortice.Mathematics.Box clientArea = new( + clientOffset.X, clientOffset.Y, 0, + clientOffset.X + screenRect.Width, clientOffset.Y + screenRect.Height, 1); + + deviceContext.CopySubresourceRegion(screenTexture, 0, 0, 0, 0, sourceTexture, 0, clientArea); + + MappedSubresource resource = deviceContext.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); + + 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); + } + +#if SAVE_SCREEN_IMAGE + ScreenImage.SaveAsJpeg("screen_wgc.jpg"); +#endif + + deviceContext.Unmap(screenTexture, 0); + } + + [SkipLocalsInit] + private void UpdateMinimapImage(ID3D11Texture2D sourceTexture, SizeInt32 frameSize) + { + if (!MiniMapImage.DangerousTryGetSinglePixelMemory(out Memory memory)) + return; + + // Ensure minimap texture exists + if (minimapTexture == null) + { + Texture2DDescription miniMapTextureDesc = new() + { + CPUAccessFlags = CpuAccessFlags.Read, + BindFlags = BindFlags.None, + Format = Format.B8G8R8A8_UNorm, + Width = (uint)MiniMapRect.Right, + Height = (uint)MiniMapRect.Bottom, + MiscFlags = ResourceOptionFlags.None, + MipLevels = 1, + ArraySize = 1, + SampleDescription = { Count = 1, Quality = 0 }, + Usage = ResourceUsage.Staging + }; + minimapTexture = device.CreateTexture2D(miniMapTextureDesc); + } + + // Minimap is at top-right of client area + int minimapX = Math.Max(clientOffset.X, clientOffset.X + screenRect.Width - MiniMapSize); + Vortice.Mathematics.Box areaOnWindow = new( + minimapX, clientOffset.Y, 0, + minimapX + MiniMapSize, clientOffset.Y + MiniMapRect.Bottom, 1); + + deviceContext.CopySubresourceRegion(minimapTexture, 0, 0, 0, 0, sourceTexture, 0, areaOnWindow); + + MappedSubresource resource = deviceContext.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); + + int bytesToCopy = MiniMapRect.Width * Bgra32Size; + for (int y = 0; y < MiniMapRect.Height; y++) + { + ReadOnlySpan srcRow = src.Slice(y * rowPitch, bytesToCopy); + Span destRow = dest.Slice(y * bytesToCopy, bytesToCopy); + srcRow.TryCopyTo(destRow); + } + + deviceContext.Unmap(minimapTexture, 0); + } + + 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/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); From 1e5da7fbc9a2e31c84affb90ce2c0308b5775803 Mon Sep 17 00:00:00 2001 From: Xian55 <367101+Xian55@users.noreply.github.com> Date: Fri, 6 Feb 2026 23:30:15 +0100 Subject: [PATCH 3/4] Refactor DXGI and WGC screen capture with shared ScreenCaptureHelper Extract common pixel-copy logic into ScreenCaptureHelper.CopyRegion to eliminate duplicated row-by-row copy loops across both capture backends. ScreenCaptureHelper (new): - CopyRegion: handles sub-region extraction from a source buffer with row pitch, supporting arbitrary X/Y offsets for WGC client-area crops - Bgra32Size: shared const replacing per-instance Unsafe.SizeOf calls WowScreenDXGI: - Replace Bgra32Size instance field with const from ScreenCaptureHelper - Remove Unsafe.SizeOf() call from constructor - Replace manual row-copy loops in UpdateAddonImage, UpdateScreenImage, and UpdateMinimapImage with ScreenCaptureHelper.CopyRegion - Wrap Map/Unmap pairs in try/finally to ensure Unmap on exceptions - Remove goto-based cleanup pattern in UpdateAddonImage WowScreenWGC: - Replace Bgra32Size instance field with const from ScreenCaptureHelper - Remove Unsafe.SizeOf() call from constructor - Eliminate per-region staging textures (addonTexture, screenTexture, minimapTexture) and their CopySubresourceRegion calls - now reads sub-regions directly from the full-frame staging texture via offsets - Map the frame once in Update() and pass the span to all sub-updaters (UpdateAddonImage, UpdateScreenImage, UpdateMinimapImage, SaveRawFrame) - Add RegionFitsInFrame bounds check before extracting sub-regions - Add processingFrame flag to prevent double-buffer swap during reads - Use IsCursorCaptureEnabled directly instead of via reflection - Remove unused s_cursorEnabledProp reflection field - Add using statements for IDXGISurface and ID3D11Texture2D in OnFrameArrived to fix disposal ordering - Dispose winrtDevice in Dispose() - Log exceptions in StopCapture instead of swallowing silently - Refresh clientOffset on screen rect change in Update() - Default Enabled and EnablePostProcess to true Co-Authored-By: Claude Opus 4.6 --- Core/WoWScreen/ScreenCaptureHelper.cs | 32 +++ Core/WoWScreen/WowScreenDXGI.cs | 88 +++----- Core/WoWScreen/WowScreenWGC.cs | 294 +++++++------------------- 3 files changed, 143 insertions(+), 271 deletions(-) create mode 100644 Core/WoWScreen/ScreenCaptureHelper.cs 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 index b422cbe60..8ff4bcf47 100644 --- a/Core/WoWScreen/WowScreenWGC.cs +++ b/Core/WoWScreen/WowScreenWGC.cs @@ -41,12 +41,12 @@ public sealed class WowScreenWGC : IWowScreen, IAddonDataProvider { private readonly ILogger logger; private readonly WowProcess process; - private readonly int Bgra32Size; + private const int Bgra32Size = ScreenCaptureHelper.Bgra32Size; public event Action? OnChanged; - public bool Enabled { get; set; } - public bool EnablePostProcess { get; set; } + public bool Enabled { get; set; } = true; + public bool EnablePostProcess { get; set; } = true; public bool MinimapEnabled { get; set; } public Rectangle ScreenRect => screenRect; @@ -69,19 +69,13 @@ private readonly SixLabors.ImageSharp.Configuration ContiguousJpegConfiguration FeatureLevel.Level_11_0, ]; - // Cached reflection for borderless capture (properties not in SDK 19041) + // 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 static readonly PropertyInfo? s_cursorEnabledProp = typeof(GraphicsCaptureSession) - .GetProperty("IsCursorCaptureEnabled", BindingFlags.Public | BindingFlags.Instance); private readonly ID3D11Device device; private readonly ID3D11DeviceContext deviceContext; - private ID3D11Texture2D? minimapTexture; - private ID3D11Texture2D? screenTexture; - private ID3D11Texture2D? addonTexture; - // WGC resources private readonly IDirect3DDevice winrtDevice; private GraphicsCaptureItem? captureItem; @@ -95,6 +89,7 @@ private readonly SixLabors.ImageSharp.Configuration ContiguousJpegConfiguration 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; @@ -112,8 +107,6 @@ public WowScreenWGC(ILogger logger, WowProcess process, DataFrame[ this.logger = logger; this.process = process; - Bgra32Size = Unsafe.SizeOf(); - GetRectangle(out screenRect); clientOffset = NativeMethods.GetClientAreaOffset(process.MainWindowHandle); ScreenImage = new(ContiguousJpegConfiguration, screenRect.Width, screenRect.Height); @@ -165,9 +158,7 @@ private void InitializeCapture() // Create capture session captureSession = framePool.CreateCaptureSession(captureItem); - // Try to disable yellow border on Windows 10 20348+ using reflection - // (properties not available in SDK 19041, but may be present at runtime) - TrySetBorderlessCapture(captureSession); + TryConfigureCaptureSession(captureSession); captureSession.StartCapture(); } @@ -205,8 +196,8 @@ private void OnFrameArrived(Direct3D11CaptureFramePool sender, object args) try { - IDXGISurface dxgiSurface = new(dxgiSurfacePtr); - ID3D11Texture2D frameTexture = dxgiSurface.QueryInterface(); + using IDXGISurface dxgiSurface = new(dxgiSurfacePtr); + using ID3D11Texture2D frameTexture = dxgiSurface.QueryInterface(); using (frameLock.EnterScope()) { @@ -233,8 +224,13 @@ private void OnFrameArrived(Direct3D11CaptureFramePool sender, object args) deviceContext.CopyResource(writeStagingTexture, frameTexture); - // Swap buffers: write becomes read, read becomes write - (writeStagingTexture, readStagingTexture) = (readStagingTexture, writeStagingTexture); + // 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; @@ -246,9 +242,6 @@ private void OnFrameArrived(Direct3D11CaptureFramePool sender, object args) logger.LogInformation("OnFrameArrived: First successful frame captured! Size: {Width}x{Height}, SurfacePtr: 0x{Ptr:X}", latestFrameSize.Width, latestFrameSize.Height, dxgiSurfacePtr); } - - frameTexture.Dispose(); - dxgiSurface.Dispose(); } catch (Exception ex) { @@ -257,26 +250,23 @@ private void OnFrameArrived(Direct3D11CaptureFramePool sender, object args) } /// - /// Attempts to set borderless capture properties using reflection. - /// These properties are only available on Windows 10 build 20348+ but may not be - /// present in the SDK we're targeting (19041). Using reflection allows the code - /// to compile against 19041 while still utilizing newer features at runtime. + /// Configures capture session: disables cursor capture (available since SDK 19041) + /// and attempts borderless capture via reflection (build 20348+). /// - private void TrySetBorderlessCapture(GraphicsCaptureSession session) + private void TryConfigureCaptureSession(GraphicsCaptureSession session) { + session.IsCursorCaptureEnabled = false; + if (!GraphicsCaptureInterop.IsBorderlessSupported) return; try { s_borderRequiredProp?.SetValue(session, false); - s_cursorEnabledProp?.SetValue(session, false); - logger.LogDebug("Borderless capture enabled via reflection"); } catch (Exception ex) { - // Properties not available on this Windows version - yellow border will show logger.LogDebug(ex, "Could not enable borderless capture - yellow border may appear"); } } @@ -285,21 +275,21 @@ public void Dispose() { StopCapture(); + winrtDevice?.Dispose(); writeStagingTexture?.Dispose(); readStagingTexture?.Dispose(); - minimapTexture?.Dispose(); - addonTexture?.Dispose(); - screenTexture?.Dispose(); deviceContext?.Dispose(); device?.Dispose(); } private void StopCapture() { - try { captureSession?.Dispose(); } catch { } + try { captureSession?.Dispose(); } + catch (Exception ex) { logger.LogDebug(ex, "Error disposing capture session"); } captureSession = null; - try { framePool?.Dispose(); } catch { } + try { framePool?.Dispose(); } + catch (Exception ex) { logger.LogDebug(ex, "Error disposing frame pool"); } framePool = null; if (captureItem != null) @@ -325,24 +315,7 @@ public void InitFrames(DataFrame[] frames) addonImage = new(ContiguousJpegConfiguration, addonSize.Width, addonSize.Height); - Texture2DDescription addonTextureDesc = new() - { - CPUAccessFlags = CpuAccessFlags.Read, - BindFlags = BindFlags.None, - Format = Format.B8G8R8A8_UNorm, - Width = (uint)addonSize.Width, - Height = (uint)addonSize.Height, - MiscFlags = ResourceOptionFlags.None, - MipLevels = 1, - ArraySize = 1, - SampleDescription = { Count = 1, Quality = 0 }, - Usage = ResourceUsage.Staging - }; - - addonTexture?.Dispose(); - addonTexture = device.CreateTexture2D(addonTextureDesc); - - logger.LogDebug($"DataFrames {frames.Length} - Texture: {addonSize}"); + logger.LogDebug($"DataFrames {frames.Length} - Addon: {addonSize}"); } [SkipLocalsInit] @@ -355,6 +328,7 @@ public void Update() if (newRect.Width != screenRect.Width || newRect.Height != screenRect.Height) { screenRect = newRect; + clientOffset = NativeMethods.GetClientAreaOffset(process.MainWindowHandle); RecreateFramePool(); } @@ -366,74 +340,66 @@ public void Update() 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(frameToProcess, frameSize); + SaveRawFrame(fullFrame, rowPitch, frameSize); #endif - if (frames.Length > 2) - UpdateAddonImage(frameToProcess); + if (frames.Length > 2) + UpdateAddonImage(fullFrame, rowPitch, frameSize); - if (Enabled) - UpdateScreenImage(frameToProcess, frameSize); + if (Enabled) + UpdateScreenImage(fullFrame, rowPitch, frameSize); - if (MinimapEnabled) - UpdateMinimapImage(frameToProcess, 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(ID3D11Texture2D sourceTexture, SizeInt32 frameSize) + private void SaveRawFrame(ReadOnlySpan fullFrame, int rowPitch, SizeInt32 frameSize) { if (rawFrameSaved) return; try { - // Create a staging texture for the full frame - Texture2DDescription desc = new() - { - CPUAccessFlags = CpuAccessFlags.Read, - BindFlags = BindFlags.None, - Format = Format.B8G8R8A8_UNorm, - Width = (uint)frameSize.Width, - Height = (uint)frameSize.Height, - MiscFlags = ResourceOptionFlags.None, - MipLevels = 1, - ArraySize = 1, - SampleDescription = { Count = 1, Quality = 0 }, - Usage = ResourceUsage.Staging - }; - - using ID3D11Texture2D stagingTexture = device.CreateTexture2D(desc); - deviceContext.CopyResource(stagingTexture, sourceTexture); - - MappedSubresource resource = deviceContext.Map(stagingTexture, 0, MapMode.Read, Vortice.Direct3D11.MapFlags.None); - using Image rawImage = new(frameSize.Width, frameSize.Height); if (rawImage.DangerousTryGetSinglePixelMemory(out Memory memory)) { - int rowPitch = (int)resource.RowPitch; - ReadOnlySpan src = resource.AsSpan(frameSize.Height * rowPitch); Span dest = MemoryMarshal.Cast(memory.Span); - - int bytesToCopy = frameSize.Width * Bgra32Size; - for (int y = 0; y < frameSize.Height; y++) - { - ReadOnlySpan srcRow = src.Slice(y * rowPitch, bytesToCopy); - Span destRow = dest.Slice(y * bytesToCopy, bytesToCopy); - srcRow.TryCopyTo(destRow); - } + 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); } - deviceContext.Unmap(stagingTexture, 0); rawFrameSaved = true; } catch (Exception ex) @@ -462,23 +428,6 @@ private void RecreateFramePool() 2, size); - // Recreate screen texture with new size - screenTexture?.Dispose(); - Texture2DDescription screenTextureDesc = new() - { - CPUAccessFlags = CpuAccessFlags.Read, - BindFlags = BindFlags.None, - Format = Format.B8G8R8A8_UNorm, - Width = (uint)screenRect.Width, - Height = (uint)screenRect.Height, - MiscFlags = ResourceOptionFlags.None, - MipLevels = 1, - ArraySize = 1, - SampleDescription = { Count = 1, Quality = 0 }, - Usage = ResourceUsage.Staging - }; - screenTexture = device.CreateTexture2D(screenTextureDesc); - logger.LogDebug("Frame pool recreated for size: {Width}x{Height}", screenRect.Width, screenRect.Height); } catch (Exception ex) @@ -488,151 +437,64 @@ private void RecreateFramePool() } [SkipLocalsInit] - private void UpdateAddonImage(ID3D11Texture2D sourceTexture) + 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 - Vortice.Mathematics.Box areaOnWindow = new( - clientOffset.X, clientOffset.Y, 0, - clientOffset.X + addonSize.Width, clientOffset.Y + addonSize.Height, 1); - - deviceContext.CopySubresourceRegion(addonTexture!, 0, 0, 0, 0, sourceTexture, 0, areaOnWindow); - - MappedSubresource resource = deviceContext.Map(addonTexture!, 0, MapMode.Read, Vortice.Direct3D11.MapFlags.None); + if (!RegionFitsInFrame(clientOffset.X, clientOffset.Y, addonSize.Width, addonSize.Height, frameSize)) + return; - 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)) - { - goto Cleanup; - } - - 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(fullFrame, rowPitch, clientOffset.X, clientOffset.Y, dest, addonSize.Width, addonSize.Height); #if SAVE_ADDON_IMAGE addonImage.SaveAsJpeg("addon_wgc.jpg"); #endif - - Cleanup: - deviceContext.Unmap(addonTexture!, 0); } [SkipLocalsInit] - private void UpdateScreenImage(ID3D11Texture2D sourceTexture, SizeInt32 frameSize) + private void UpdateScreenImage(ReadOnlySpan fullFrame, int rowPitch, SizeInt32 frameSize) { if (!ScreenImage.DangerousTryGetSinglePixelMemory(out Memory memory)) return; - // Ensure screen texture exists and is correct size for client area - if (screenTexture == null || - screenTexture.Description.Width != (uint)screenRect.Width || - screenTexture.Description.Height != (uint)screenRect.Height) - { - screenTexture?.Dispose(); - Texture2DDescription screenTextureDesc = new() - { - CPUAccessFlags = CpuAccessFlags.Read, - BindFlags = BindFlags.None, - Format = Format.B8G8R8A8_UNorm, - Width = (uint)screenRect.Width, - Height = (uint)screenRect.Height, - MiscFlags = ResourceOptionFlags.None, - MipLevels = 1, - ArraySize = 1, - SampleDescription = { Count = 1, Quality = 0 }, - Usage = ResourceUsage.Staging - }; - screenTexture = device.CreateTexture2D(screenTextureDesc); - } - // Copy client area (offset past title bar/borders) - Vortice.Mathematics.Box clientArea = new( - clientOffset.X, clientOffset.Y, 0, - clientOffset.X + screenRect.Width, clientOffset.Y + screenRect.Height, 1); - - deviceContext.CopySubresourceRegion(screenTexture, 0, 0, 0, 0, sourceTexture, 0, clientArea); - - MappedSubresource resource = deviceContext.Map(screenTexture, 0, MapMode.Read, Vortice.Direct3D11.MapFlags.None); + if (!RegionFitsInFrame(clientOffset.X, clientOffset.Y, screenRect.Width, screenRect.Height, frameSize)) + return; - int rowPitch = (int)resource.RowPitch; - ReadOnlySpan src = resource.AsSpan(screenRect.Height * rowPitch); Span dest = MemoryMarshal.Cast(memory.Span); - - 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); - } + ScreenCaptureHelper.CopyRegion(fullFrame, rowPitch, clientOffset.X, clientOffset.Y, dest, screenRect.Width, screenRect.Height); #if SAVE_SCREEN_IMAGE ScreenImage.SaveAsJpeg("screen_wgc.jpg"); #endif - - deviceContext.Unmap(screenTexture, 0); } [SkipLocalsInit] - private void UpdateMinimapImage(ID3D11Texture2D sourceTexture, SizeInt32 frameSize) + private void UpdateMinimapImage(ReadOnlySpan fullFrame, int rowPitch, SizeInt32 frameSize) { if (!MiniMapImage.DangerousTryGetSinglePixelMemory(out Memory memory)) return; - // Ensure minimap texture exists - if (minimapTexture == null) - { - Texture2DDescription miniMapTextureDesc = new() - { - CPUAccessFlags = CpuAccessFlags.Read, - BindFlags = BindFlags.None, - Format = Format.B8G8R8A8_UNorm, - Width = (uint)MiniMapRect.Right, - Height = (uint)MiniMapRect.Bottom, - MiscFlags = ResourceOptionFlags.None, - MipLevels = 1, - ArraySize = 1, - SampleDescription = { Count = 1, Quality = 0 }, - Usage = ResourceUsage.Staging - }; - minimapTexture = device.CreateTexture2D(miniMapTextureDesc); - } - // Minimap is at top-right of client area - int minimapX = Math.Max(clientOffset.X, clientOffset.X + screenRect.Width - MiniMapSize); - Vortice.Mathematics.Box areaOnWindow = new( - minimapX, clientOffset.Y, 0, - minimapX + MiniMapSize, clientOffset.Y + MiniMapRect.Bottom, 1); - - deviceContext.CopySubresourceRegion(minimapTexture, 0, 0, 0, 0, sourceTexture, 0, areaOnWindow); + int minimapX = clientOffset.X + screenRect.Width - MiniMapSize; + int minimapY = clientOffset.Y; - MappedSubresource resource = deviceContext.Map(minimapTexture, 0, MapMode.Read, Vortice.Direct3D11.MapFlags.None); + if (!RegionFitsInFrame(minimapX, minimapY, MiniMapRect.Width, MiniMapRect.Height, frameSize)) + return; - 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++) - { - ReadOnlySpan srcRow = src.Slice(y * rowPitch, bytesToCopy); - Span destRow = dest.Slice(y * bytesToCopy, bytesToCopy); - srcRow.TryCopyTo(destRow); - } - - deviceContext.Unmap(minimapTexture, 0); + 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) From b35ea230f0f167a3f3fa33d4fb8763af2e3edd06 Mon Sep 17 00:00:00 2001 From: Xian55 <367101+Xian55@users.noreply.github.com> Date: Fri, 6 Feb 2026 23:31:01 +0100 Subject: [PATCH 4/4] Update default capture backend, run.bat args, and image CSS - appsettings.json: Default Reader.Type back to DXGI - run.bat: Accept capture backend type as CLI argument via --Reader:Type, allowing `run.bat WGC` or `run.bat DXGI` - site.css: Replace fixed aspect-ratio on .img-filled and .img-filled-half with max-height/object-fit for better scaling across different screen resolutions Co-Authored-By: Claude Opus 4.6 --- BlazorServer/appsettings.json | 2 +- BlazorServer/run.bat | 2 +- Frontend/wwwroot/css/site.css | 8 +++++--- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/BlazorServer/appsettings.json b/BlazorServer/appsettings.json index 933004111..c390740c1 100644 --- a/BlazorServer/appsettings.json +++ b/BlazorServer/appsettings.json @@ -23,7 +23,7 @@ "Id": -1 }, "Reader": { - "Type": "WGC" //DXGI + "Type": "DXGI" }, "Diagnostics": { "Enabled": false 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/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 {