diff --git a/src/Snap.Hutao/Snap.Hutao/Core/ExceptionService/HutaoException.cs b/src/Snap.Hutao/Snap.Hutao/Core/ExceptionService/HutaoException.cs index ced0474d..923a9481 100644 --- a/src/Snap.Hutao/Snap.Hutao/Core/ExceptionService/HutaoException.cs +++ b/src/Snap.Hutao/Snap.Hutao/Core/ExceptionService/HutaoException.cs @@ -51,6 +51,12 @@ internal sealed class HutaoException : Exception throw new InvalidCastException(message, innerException); } + [DoesNotReturn] + public static InvalidOperationException InvalidOperation(string message, Exception? innerException = default) + { + throw new InvalidOperationException(message, innerException); + } + [DoesNotReturn] public static NotSupportedException NotSupported(string? message = default, Exception? innerException = default) { diff --git a/src/Snap.Hutao/Snap.Hutao/Core/ReadOnlySpan2D.cs b/src/Snap.Hutao/Snap.Hutao/Core/ReadOnlySpan2D.cs new file mode 100644 index 00000000..cda9ae54 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Core/ReadOnlySpan2D.cs @@ -0,0 +1,27 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +namespace Snap.Hutao.Core; + +internal readonly ref struct ReadOnlySpan2D + where T : unmanaged +{ + private readonly ref T reference; + private readonly int length; + private readonly int columns; + + public unsafe ReadOnlySpan2D(void* pointer, int length, int columns) + { + reference = ref *(T*)pointer; + this.length = length; + this.columns = columns; + } + + public ReadOnlySpan this[int row] + { + get => MemoryMarshal.CreateReadOnlySpan(ref Unsafe.Add(ref reference, row * columns), columns); + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Automation/ScreenCapture/GameScreenCaptureContext.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Automation/ScreenCapture/GameScreenCaptureContext.cs new file mode 100644 index 00000000..f5700180 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Automation/ScreenCapture/GameScreenCaptureContext.cs @@ -0,0 +1,63 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Snap.Hutao.Win32.Foundation; +using Snap.Hutao.Win32.Graphics.Gdi; +using Snap.Hutao.Win32.System.WinRT.Graphics.Capture; +using Windows.Graphics.Capture; +using Windows.Graphics.DirectX; +using Windows.Graphics.DirectX.Direct3D11; +using static Snap.Hutao.Win32.Gdi32; +using static Snap.Hutao.Win32.User32; + +namespace Snap.Hutao.Service.Game.Automation.ScreenCapture; + +internal readonly struct GameScreenCaptureContext +{ + public readonly GraphicsCaptureItem Item; + + private readonly IDirect3DDevice direct3DDevice; + private readonly HWND hwnd; + + public GameScreenCaptureContext(IDirect3DDevice direct3DDevice, HWND hwnd) + { + this.direct3DDevice = direct3DDevice; + this.hwnd = hwnd; + + GraphicsCaptureItem.As().CreateForWindow(hwnd, out Item); + } + + public Direct3D11CaptureFramePool CreatePool() + { + return Direct3D11CaptureFramePool.CreateFreeThreaded(direct3DDevice, DeterminePixelFormat(hwnd), 2, Item.Size); + } + + public void RecreatePool(Direct3D11CaptureFramePool framePool) + { + framePool.Recreate(direct3DDevice, DeterminePixelFormat(hwnd), 2, Item.Size); + } + + public GraphicsCaptureSession CreateSession(Direct3D11CaptureFramePool framePool) + { + GraphicsCaptureSession session = framePool.CreateCaptureSession(Item); + session.IsCursorCaptureEnabled = false; + session.IsBorderRequired = false; + return session; + } + + private static DirectXPixelFormat DeterminePixelFormat(HWND hwnd) + { + HDC hdc = GetDC(hwnd); + if (hdc != HDC.NULL) + { + int bitsPerPixel = GetDeviceCaps(hdc, GET_DEVICE_CAPS_INDEX.BITSPIXEL); + _ = ReleaseDC(hwnd, hdc); + if (bitsPerPixel >= 32) + { + return DirectXPixelFormat.R16G16B16A16Float; + } + } + + return DirectXPixelFormat.B8G8R8A8UIntNormalized; + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Automation/ScreenCapture/GameScreenCaptureMemoryPool.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Automation/ScreenCapture/GameScreenCaptureMemoryPool.cs new file mode 100644 index 00000000..11f30fa4 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Automation/ScreenCapture/GameScreenCaptureMemoryPool.cs @@ -0,0 +1,82 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Snap.Hutao.Core; +using Snap.Hutao.Core.ExceptionService; +using System.Buffers; + +namespace Snap.Hutao.Service.Game.Automation.ScreenCapture; + +internal sealed class GameScreenCaptureMemoryPool : MemoryPool +{ + private static LazySlim lazyShared = new(() => new()); + + private readonly object syncRoot = new(); + private readonly LinkedList unrentedBuffers = []; + private readonly LinkedList rentedBuffers = []; + + private int bufferCount; + + public new static GameScreenCaptureMemoryPool Shared { get => lazyShared.Value; } + + public override int MaxBufferSize { get => Array.MaxLength; } + + public int BufferCount { get => bufferCount; } + + public override IMemoryOwner Rent(int minBufferSize = -1) + { + ArgumentOutOfRangeException.ThrowIfLessThan(minBufferSize, 0); + + lock (syncRoot) + { + foreach (GameScreenCaptureBuffer buffer in unrentedBuffers) + { + if (buffer.Memory.Length >= minBufferSize) + { + unrentedBuffers.Remove(buffer); + rentedBuffers.AddLast(buffer); + return buffer; + } + } + + GameScreenCaptureBuffer newBuffer = new(this, minBufferSize); + rentedBuffers.AddLast(newBuffer); + ++bufferCount; + return newBuffer; + } + } + + protected override void Dispose(bool disposing) + { + lock (syncRoot) + { + if (rentedBuffers.Count > 0) + { + HutaoException.InvalidOperation("There are still rented buffers."); + } + } + } + + internal sealed class GameScreenCaptureBuffer : IMemoryOwner + { + private readonly GameScreenCaptureMemoryPool pool; + private readonly byte[] buffer; + + public GameScreenCaptureBuffer(GameScreenCaptureMemoryPool pool, int bufferSize) + { + this.pool = pool; + buffer = GC.AllocateUninitializedArray(bufferSize); + } + + public Memory Memory { get => buffer.AsMemory(); } + + public void Dispose() + { + lock (pool.syncRoot) + { + pool.rentedBuffers.Remove(this); + pool.unrentedBuffers.AddLast(this); + } + } + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Automation/ScreenCapture/GameScreenCaptureService.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Automation/ScreenCapture/GameScreenCaptureService.cs index 5a3bdb1a..b1d4376e 100644 --- a/src/Snap.Hutao/Snap.Hutao/Service/Game/Automation/ScreenCapture/GameScreenCaptureService.cs +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Automation/ScreenCapture/GameScreenCaptureService.cs @@ -7,10 +7,9 @@ using Snap.Hutao.Win32.Graphics.Direct3D; using Snap.Hutao.Win32.Graphics.Direct3D11; using Snap.Hutao.Win32.Graphics.Dxgi; using Snap.Hutao.Win32.System.Com; -using Snap.Hutao.Win32.System.WinRT.Graphics.Capture; using Windows.Graphics.Capture; -using Windows.Graphics.DirectX; using Windows.Graphics.DirectX.Direct3D11; +using WinRT; using static Snap.Hutao.Win32.ConstValues; using static Snap.Hutao.Win32.D3D11; using static Snap.Hutao.Win32.Macros; @@ -39,6 +38,7 @@ internal sealed partial class GameScreenCaptureService return true; } + [SuppressMessage("", "SH002")] public unsafe bool TryStartCapture(HWND hwnd, [NotNullWhen(true)] out GameScreenCaptureSession? session) { session = default; @@ -64,6 +64,8 @@ internal sealed partial class GameScreenCaptureService return false; } + IUnknownMarshal.Release(pDXGIDevice); + hr = CreateDirect3D11DeviceFromDXGIDevice(pDXGIDevice, out Win32.System.WinRT.IInspectable* inspectable); if (FAILED(hr)) { @@ -71,29 +73,13 @@ internal sealed partial class GameScreenCaptureService return false; } - IDirect3DDevice direct3DDevice = WinRT.IInspectable.FromAbi((nint)inspectable).ObjRef.AsInterface(); - GraphicsCaptureItem.As().CreateForWindow(hwnd, out GraphicsCaptureItem item); + IUnknownMarshal.Release(inspectable); - // Note - Direct3D11CaptureFramePool framePool = Direct3D11CaptureFramePool.CreateFreeThreaded(direct3DDevice, DirectXPixelFormat.B8G8R8A8UIntNormalized, 2, item.Size); + IDirect3DDevice direct3DDevice = IInspectable.FromAbi((nint)inspectable).ObjRef.AsInterface(); + + GameScreenCaptureContext captureContext = new(direct3DDevice, hwnd); + session = new(captureContext, logger); - IUnknownMarshal.Release(pDXGIDevice); return true; } -} - -internal sealed class GameScreenCaptureSession : IDisposable -{ - private readonly Direct3D11CaptureFramePool framePool; - private readonly GraphicsCaptureSession session; - - public GameScreenCaptureSession(Direct3D11CaptureFramePool framePool) - { - this.session = session; - } - - public void Dispose() - { - session.Dispose(); - } } \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Service/Game/Automation/ScreenCapture/GameScreenCaptureSession.cs b/src/Snap.Hutao/Snap.Hutao/Service/Game/Automation/ScreenCapture/GameScreenCaptureSession.cs new file mode 100644 index 00000000..f2a0bf24 --- /dev/null +++ b/src/Snap.Hutao/Snap.Hutao/Service/Game/Automation/ScreenCapture/GameScreenCaptureSession.cs @@ -0,0 +1,197 @@ +// Copyright (c) DGP Studio. All rights reserved. +// Licensed under the MIT license. + +using Snap.Hutao.Core; +using Snap.Hutao.Core.ExceptionService; +using Snap.Hutao.Win32.Graphics.Direct3D11; +using Snap.Hutao.Win32.Graphics.Dxgi; +using Snap.Hutao.Win32.Graphics.Dxgi.Common; +using Snap.Hutao.Win32.System.WinRT.Graphics.Capture; +using System.Buffers; +using System.Runtime.CompilerServices; +using Windows.Graphics; +using Windows.Graphics.Capture; +using Windows.Graphics.DirectX.Direct3D11; +using WinRT; +using static Snap.Hutao.Win32.Macros; + +namespace Snap.Hutao.Service.Game.Automation.ScreenCapture; + +internal sealed class GameScreenCaptureSession : IDisposable +{ + private readonly GameScreenCaptureContext captureContext; + private readonly Direct3D11CaptureFramePool framePool; + private readonly GraphicsCaptureSession session; + private readonly ILogger logger; + + private TaskCompletionSource>? frameRawPixelDataTaskCompletionSource; + private bool isFrameRawPixelDataRequested; + private SizeInt32 contentSize; + + private bool isDisposed; + + [SuppressMessage("", "SH002")] + public GameScreenCaptureSession(GameScreenCaptureContext captureContext, ILogger logger) + { + this.captureContext = captureContext; + this.logger = logger; + + contentSize = captureContext.Item.Size; + + captureContext.Item.Closed += OnItemClosed; + + framePool = captureContext.CreatePool(); + framePool.FrameArrived += OnFrameArrived; + + session = captureContext.CreateSession(framePool); + session.StartCapture(); + } + + public async ValueTask> RequestFrameRawPixelDataAsync() + { + if (Volatile.Read(ref isFrameRawPixelDataRequested)) + { + HutaoException.InvalidOperation("The frame raw pixel data has already been requested."); + } + + if (isDisposed) + { + HutaoException.InvalidOperation("The session has been disposed."); + } + + frameRawPixelDataTaskCompletionSource = new(); + Volatile.Write(ref isFrameRawPixelDataRequested, true); + + return await frameRawPixelDataTaskCompletionSource.Task.ConfigureAwait(false); + } + + public void Dispose() + { + if (isDisposed) + { + return; + } + + session.Dispose(); + framePool.Dispose(); + isDisposed = true; + } + + private void OnItemClosed(GraphicsCaptureItem sender, object args) + { + Dispose(); + } + + private unsafe void OnFrameArrived(Direct3D11CaptureFramePool sender, object args) + { + // Simply ignore the frame if the frame raw pixel data is not requested. + if (!Volatile.Read(ref isFrameRawPixelDataRequested)) + { + return; + } + + using (Direct3D11CaptureFrame? frame = sender.TryGetNextFrame()) + { + if (frame is null) + { + return; + } + + bool needsReset = false; + + if (frame.ContentSize != contentSize) + { + needsReset = true; + contentSize = frame.ContentSize; + } + + try + { + UnsafeProcessFrameSurface(frame.Surface); + } + catch (Exception ex) // TODO: test if it's device lost. + { + logger.LogError(ex, "Failed to process the frame surface."); + needsReset = true; + } + + if (needsReset) + { + captureContext.RecreatePool(sender); + } + } + } + + private unsafe void UnsafeProcessFrameSurface(IDirect3DSurface surface) + { + IDirect3DDxgiInterfaceAccess access = surface.As(); + if (FAILED(access.GetInterface(in IDXGISurface.IID, out IDXGISurface* pDXGISurface))) + { + return; + } + + if (FAILED(pDXGISurface->GetDesc(out DXGI_SURFACE_DESC dxgiSurfaceDesc))) + { + return; + } + + // Should be the same device used to create the frame pool. + if (FAILED(pDXGISurface->GetDevice(in ID3D11Device.IID, out ID3D11Device* pD3D11Device))) + { + return; + } + + D3D11_TEXTURE2D_DESC d3d11Texture2DDesc = default; + d3d11Texture2DDesc.Width = dxgiSurfaceDesc.Width; + d3d11Texture2DDesc.Height = dxgiSurfaceDesc.Height; + d3d11Texture2DDesc.ArraySize = 1; + + // We have to copy out the resource to a CPU readable texture. + d3d11Texture2DDesc.CPUAccessFlags = D3D11_CPU_ACCESS_FLAG.D3D11_CPU_ACCESS_READ; + + // DirectX will automatically convert any format to B8G8R8A8_UNORM. + d3d11Texture2DDesc.Format = DXGI_FORMAT.DXGI_FORMAT_B8G8R8A8_UNORM; + d3d11Texture2DDesc.MipLevels = 1; + d3d11Texture2DDesc.SampleDesc.Count = 1; + d3d11Texture2DDesc.Usage = D3D11_USAGE.D3D11_USAGE_STAGING; + + if (FAILED(pD3D11Device->CreateTexture2D(ref d3d11Texture2DDesc, ref Unsafe.NullRef(), out ID3D11Texture2D* pD3D11Texture2D))) + { + return; + } + + if (FAILED(access.GetInterface(in ID3D11Resource.IID, out ID3D11Resource* pD3D11Resource))) + { + return; + } + + pD3D11Device->GetImmediateContext(out ID3D11DeviceContext* pD3D11DeviceContext); + pD3D11DeviceContext->CopyResource((ID3D11Resource*)pD3D11Texture2D, pD3D11Resource); + + if (FAILED(pD3D11DeviceContext->Map((ID3D11Resource*)pD3D11Texture2D, 0U, D3D11_MAP.D3D11_MAP_READ, 0U, out D3D11_MAPPED_SUBRESOURCE d3d11MappedSubresource))) + { + return; + } + + // The D3D11_MAPPED_SUBRESOURCE data is arranged as follows: + // |--------- Row pitch ----------| + // |---- Data width ----|- Blank -| + // ┌────────────────────┬─────────┐ + // │ │ │ + // │ Actual data │ Stride │ + // │ │ │ + // └────────────────────┴─────────┘ + ReadOnlySpan2D subresource = new(d3d11MappedSubresource.pData, (int)d3d11Texture2DDesc.Height, (int)d3d11MappedSubresource.RowPitch); + + int rowLength = contentSize.Width * 4; + IMemoryOwner buffer = GameScreenCaptureMemoryPool.Shared.Rent(contentSize.Height * rowLength); + + for (int row = 0; row < contentSize.Height; row++) + { + subresource[row][..rowLength].CopyTo(buffer.Memory.Span.Slice(row * rowLength, rowLength)); + } + + ArgumentNullException.ThrowIfNull(frameRawPixelDataTaskCompletionSource); + frameRawPixelDataTaskCompletionSource.SetResult(buffer); + } +} \ No newline at end of file diff --git a/src/Snap.Hutao/Snap.Hutao/Win32/Foundation/HWND.cs b/src/Snap.Hutao/Snap.Hutao/Win32/Foundation/HWND.cs index 6343d9a0..56b95bf5 100644 --- a/src/Snap.Hutao/Snap.Hutao/Win32/Foundation/HWND.cs +++ b/src/Snap.Hutao/Snap.Hutao/Win32/Foundation/HWND.cs @@ -9,8 +9,6 @@ internal readonly struct HWND public HWND(nint value) => Value = value; - public bool IsNull => Value is 0; - public static unsafe implicit operator HWND(nint value) => *(HWND*)&value; public static unsafe implicit operator nint(HWND value) => *(nint*)&value; diff --git a/src/Snap.Hutao/Snap.Hutao/Win32/Graphics/Gdi/HDC.cs b/src/Snap.Hutao/Snap.Hutao/Win32/Graphics/Gdi/HDC.cs index dad74c93..4ba4a673 100644 --- a/src/Snap.Hutao/Snap.Hutao/Win32/Graphics/Gdi/HDC.cs +++ b/src/Snap.Hutao/Snap.Hutao/Win32/Graphics/Gdi/HDC.cs @@ -5,5 +5,13 @@ namespace Snap.Hutao.Win32.Graphics.Gdi; internal readonly struct HDC { + public static readonly HDC NULL = 0; + public readonly nint Value; + + public static unsafe implicit operator HDC(nint value) => *(HDC*)&value; + + public static unsafe bool operator ==(HDC left, HDC right) => *(nint*)&left == *(nint*)&right; + + public static unsafe bool operator !=(HDC left, HDC right) => *(nint*)&left != *(nint*)&right; } \ No newline at end of file