diff --git a/.github/workflows/neopic-capture-compile.yml b/.github/workflows/neopic-capture-compile.yml new file mode 100644 index 00000000..f768b9e2 --- /dev/null +++ b/.github/workflows/neopic-capture-compile.yml @@ -0,0 +1,26 @@ +name: NeoPic capture Windows checks +on: + pull_request: + paths: + - 'Extensions/NeoPic/**' + - 'Extensions/_DeLib/**' + - 'Extensions/_3rdLib/D3DUtilities/**' + - 'Inc/**' + - '.github/workflows/neopic-capture-compile.yml' +permissions: + contents: read +jobs: + capture: + runs-on: windows-2022 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v4 + - name: Check WGC, GPU pixels/lifetime, inheritance and edit-data migration + shell: pwsh + run: | + $vswhere = "${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" + $vs = & $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath + if (-not $vs) { throw 'VS C++ tools not found' } + Import-Module "$vs\Common7\Tools\Microsoft.VisualStudio.DevShell.dll" + Enter-VsDevShell -VsInstallPath $vs -SkipAutomaticLocation -DevCmdArguments '-arch=x86 -host_arch=x64' + ./Extensions/NeoPic/tests/CompileCapture.ps1 -NativeOnly -RunGpuSmokeTest diff --git a/Extensions/NeoPic/CaptureBackend.h b/Extensions/NeoPic/CaptureBackend.h new file mode 100644 index 00000000..09122f2a --- /dev/null +++ b/Extensions/NeoPic/CaptureBackend.h @@ -0,0 +1,14 @@ +#pragma once +#include + +// Stable property values, also used by the runtime factory and diagnostics. +enum class CaptureBackend : std::uint32_t { + Dc = 0, + Secondary = 1, + WindowsGraphicsCapture = 2, +}; + +constexpr CaptureBackend NormalizeCaptureBackend(std::uint32_t value) { + return value <= static_cast(CaptureBackend::WindowsGraphicsCapture) + ? static_cast(value) : CaptureBackend::Dc; +} diff --git a/Extensions/NeoPic/CaptureBenchmark.h b/Extensions/NeoPic/CaptureBenchmark.h new file mode 100644 index 00000000..7d1faace --- /dev/null +++ b/Extensions/NeoPic/CaptureBenchmark.h @@ -0,0 +1,66 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +namespace neopic_capture { +struct TimingStats { + std::size_t count = 0; + double mean = 0, minimum = 0, median = 0, p95 = 0, maximum = 0; +}; +inline TimingStats Summarize(std::vector values) { + TimingStats s; + s.count = values.size(); + if (values.empty()) return s; + std::sort(values.begin(), values.end()); + s.minimum = values.front(); s.maximum = values.back(); + s.mean = std::accumulate(values.begin(), values.end(), 0.0) / static_cast(values.size()); + s.median = values.size() % 2 ? values[values.size() / 2] + : (values[values.size() / 2 - 1] + values[values.size() / 2]) / 2; + s.p95 = values[(values.size() * 95 + 99) / 100 - 1]; // nearest rank + return s; +} +struct BenchmarkResult { + TimingStats success, failure; + std::size_t warmupSuccess = 0, warmupFailure = 0; + double firstMs = 0, totalMs = 0; + bool firstSuccess = false, noReadySource = false; +}; + +// The callback is the whole Capture + _TempCapture handoff. The clock supplies +// monotonic ticks; per-iteration output and file I/O belong outside this runner. +template +BenchmarkResult RunBenchmark(std::size_t iterations, std::size_t warmup, + Capture&& capture, Clock&& now, std::int64_t ticksPerSecond) { + if (!iterations || ticksPerSecond <= 0) throw std::invalid_argument("benchmark count/timer"); + BenchmarkResult r; + std::vector good, bad; + good.reserve(iterations); bad.reserve(iterations); + auto ms = [ticksPerSecond](auto delta) { return static_cast(delta) * 1000.0 / static_cast(ticksPerSecond); }; + for (std::size_t i = 0; i < warmup; ++i) { + const auto start = now(); + const bool ok = capture(); + const auto stop = now(); + if (i == 0) { r.firstMs = ms(stop - start); r.firstSuccess = ok; } + if (ok) ++r.warmupSuccess; else ++r.warmupFailure; + } + // E_PENDING must not become an impressive-looking fast benchmark result. + if (warmup && !r.warmupSuccess) { r.noReadySource = true; return r; } + const auto batchStart = now(); + for (std::size_t i = 0; i < iterations; ++i) { + const auto start = now(); + const bool ok = capture(); + const auto stop = now(); + const double elapsed = ms(stop - start); + if (!warmup && i == 0) { r.firstMs = elapsed; r.firstSuccess = ok; } + (ok ? good : bad).push_back(elapsed); + } + r.totalMs = ms(now() - batchStart); + r.success = Summarize(std::move(good)); + r.failure = Summarize(std::move(bad)); + return r; +} +} diff --git a/Extensions/NeoPic/CaptureBlit.h b/Extensions/NeoPic/CaptureBlit.h new file mode 100644 index 00000000..5642326f --- /dev/null +++ b/Extensions/NeoPic/CaptureBlit.h @@ -0,0 +1,237 @@ +#pragma once +#include +#include +#include +#include +#include +#include "D3DUtilities/ShaderCompiler.h" +#pragma comment(lib, "dxguid.lib") + +// Normalize a native RGB capture into a Fusion RTT: crop valid source pixels, +// resize to scene coordinates, convert channel layout and write opaque alpha. +// Reuse the repository shader compiler; isolate all changed pipeline state. +class CaptureBlit { + template using Ptr = Microsoft::WRL::ComPtr; + Ptr owner; + Ptr scratch; + Ptr vertex; + Ptr pixel; + Ptr pointSampler, linearSampler; + Ptr rasterizer; + Ptr blend; + Ptr depth; + Ptr sampleTexture, resolved; + Ptr sampleView; + bool debugNames = false; + + void Name(ID3D11DeviceChild* object, const char* value) const { + if (debugNames && object) + object->SetPrivateData(WKPDID_D3DDebugObjectName, static_cast(std::strlen(value)), value); + } + + HRESULT Initialize(ID3D11Device* device) { + if (owner.Get() == device && scratch) return S_OK; + Reset(); + { + Ptr debug; + debugNames = SUCCEEDED(device->QueryInterface(IID_PPV_ARGS(debug.GetAddressOf()))); + } + Ptr device1; + HRESULT hr = device->QueryInterface(IID_PPV_ARGS(device1.GetAddressOf())); + if (FAILED(hr)) return hr; + const D3D_FEATURE_LEVEL level = device->GetFeatureLevel(); + D3D_FEATURE_LEVEL selected{}; + Ptr nextState; + hr = device1->CreateDeviceContextState(0, &level, 1, D3D11_SDK_VERSION, + __uuidof(ID3D11Device), &selected, nextState.GetAddressOf()); + if (FAILED(hr)) return hr; + Name(nextState.Get(), "NeoPic.Capture.ContextState"); + constexpr const char* code = R"( +struct V { float4 p : SV_Position; float2 uv : TEXCOORD0; }; +V VS(uint id : SV_VertexID) { + V v; float2 uv = float2((id << 1) & 2, id & 2); + v.p = float4(uv.x * 2 - 1, 1 - uv.y * 2, 0, 1); v.uv = uv; return v; +} +Texture2D image : register(t0); +SamplerState imageSampler : register(s0); +float4 PS(V v) : SV_Target { return float4(image.Sample(imageSampler, v.uv).rgb, 1); } +)"; + ShaderCompiler compiler(device); + ShaderCompiler::Blob vs, ps; + if (!compiler.CompileShader(code, std::strlen(code), "VS", "vs_4_0", vs) + || !compiler.CompileShader(code, std::strlen(code), "PS", "ps_4_0", ps)) { + OutputDebugStringA(compiler.GetLastError().c_str()); + return E_FAIL; + } + hr = device->CreateVertexShader(vs->GetBufferPointer(), vs->GetBufferSize(), nullptr, vertex.ReleaseAndGetAddressOf()); + if (FAILED(hr)) return hr; + Name(vertex.Get(), "NeoPic.Capture.VertexShader"); + hr = device->CreatePixelShader(ps->GetBufferPointer(), ps->GetBufferSize(), nullptr, pixel.ReleaseAndGetAddressOf()); + if (FAILED(hr)) return hr; + Name(pixel.Get(), "NeoPic.Capture.PixelShader"); + D3D11_SAMPLER_DESC sampler{}; + sampler.AddressU = sampler.AddressV = sampler.AddressW = D3D11_TEXTURE_ADDRESS_CLAMP; + sampler.MaxLOD = D3D11_FLOAT32_MAX; + sampler.ComparisonFunc = D3D11_COMPARISON_NEVER; + sampler.Filter = D3D11_FILTER_MIN_MAG_MIP_POINT; + hr = device->CreateSamplerState(&sampler, pointSampler.ReleaseAndGetAddressOf()); + if (FAILED(hr)) return hr; + Name(pointSampler.Get(), "NeoPic.Capture.PointSampler"); + sampler.Filter = D3D11_FILTER_MIN_MAG_MIP_LINEAR; + hr = device->CreateSamplerState(&sampler, linearSampler.ReleaseAndGetAddressOf()); + if (FAILED(hr)) return hr; + Name(linearSampler.Get(), "NeoPic.Capture.LinearSampler"); + D3D11_RASTERIZER_DESC rs{}; + rs.FillMode = D3D11_FILL_SOLID; rs.CullMode = D3D11_CULL_NONE; rs.DepthClipEnable = TRUE; + hr = device->CreateRasterizerState(&rs, rasterizer.ReleaseAndGetAddressOf()); + if (FAILED(hr)) return hr; + Name(rasterizer.Get(), "NeoPic.Capture.Rasterizer"); + D3D11_BLEND_DESC bs{}; + auto& rt = bs.RenderTarget[0]; + rt.RenderTargetWriteMask = D3D11_COLOR_WRITE_ENABLE_ALL; + rt.SrcBlend = rt.SrcBlendAlpha = D3D11_BLEND_ONE; + rt.DestBlend = rt.DestBlendAlpha = D3D11_BLEND_ZERO; + rt.BlendOp = rt.BlendOpAlpha = D3D11_BLEND_OP_ADD; + hr = device->CreateBlendState(&bs, blend.ReleaseAndGetAddressOf()); + if (FAILED(hr)) return hr; + Name(blend.Get(), "NeoPic.Capture.Blend"); + D3D11_DEPTH_STENCIL_DESC ds{}; + ds.DepthFunc = D3D11_COMPARISON_ALWAYS; + ds.StencilReadMask = D3D11_DEFAULT_STENCIL_READ_MASK; + ds.StencilWriteMask = D3D11_DEFAULT_STENCIL_WRITE_MASK; + ds.FrontFace.StencilFailOp = ds.FrontFace.StencilDepthFailOp = ds.FrontFace.StencilPassOp = D3D11_STENCIL_OP_KEEP; + ds.FrontFace.StencilFunc = D3D11_COMPARISON_ALWAYS; + ds.BackFace = ds.FrontFace; + hr = device->CreateDepthStencilState(&ds, depth.ReleaseAndGetAddressOf()); + if (FAILED(hr)) return hr; + Name(depth.Get(), "NeoPic.Capture.DepthStencil"); + owner = device; + scratch = nextState; + return S_OK; + } + + static DXGI_FORMAT SampleFormat(DXGI_FORMAT format) { + switch (format) { + case DXGI_FORMAT_B8G8R8A8_UNORM: + case DXGI_FORMAT_B8G8R8A8_UNORM_SRGB: + case DXGI_FORMAT_B8G8R8A8_TYPELESS: return DXGI_FORMAT_B8G8R8A8_UNORM; + case DXGI_FORMAT_R8G8B8A8_UNORM: + case DXGI_FORMAT_R8G8B8A8_UNORM_SRGB: + case DXGI_FORMAT_R8G8B8A8_TYPELESS: return DXGI_FORMAT_R8G8B8A8_UNORM; + case DXGI_FORMAT_B8G8R8X8_UNORM: + case DXGI_FORMAT_B8G8R8X8_UNORM_SRGB: + case DXGI_FORMAT_B8G8R8X8_TYPELESS: return DXGI_FORMAT_B8G8R8X8_UNORM; + default: return DXGI_FORMAT_UNKNOWN; // no implicit YUV/HDR conversion + } + } +public: + bool IsInitialized() const { return owner && scratch; } + + // Caller drains submitted work first. Release only our cached objects; + // never modify the shared context's restored Fusion pipeline state. + void Reset() { + sampleView.Reset(); + sampleTexture.Reset(); + resolved.Reset(); + scratch.Reset(); + vertex.Reset(); + pixel.Reset(); + pointSampler.Reset(); + linearSampler.Reset(); + rasterizer.Reset(); + blend.Reset(); + depth.Reset(); + owner.Reset(); + debugNames = false; + } + + HRESULT Draw(ID3D11DeviceContext* context, ID3D11Texture2D* source, + const D3D11_BOX& box, ID3D11RenderTargetView* destination, + UINT width, UINT height, bool resample) { + if (!context || !source || !destination || !width || !height) return E_INVALIDARG; + D3D11_TEXTURE2D_DESC input{}; + source->GetDesc(&input); + if (box.left >= box.right || box.top >= box.bottom || box.front != 0 || box.back != 1 + || box.right > input.Width || box.bottom > input.Height || input.ArraySize != 1) return E_INVALIDARG; + const auto format = SampleFormat(input.Format); + if (format == DXGI_FORMAT_UNKNOWN) return E_NOTIMPL; + D3D11_RENDER_TARGET_VIEW_DESC targetView{}; + destination->GetDesc(&targetView); + if (targetView.ViewDimension != D3D11_RTV_DIMENSION_TEXTURE2D + || targetView.Texture2D.MipSlice != 0 + || (targetView.Format != DXGI_FORMAT_B8G8R8A8_UNORM + && targetView.Format != DXGI_FORMAT_R8G8B8A8_UNORM)) return E_NOTIMPL; + Ptr device, inputDevice, outputDevice; + context->GetDevice(device.GetAddressOf()); + source->GetDevice(inputDevice.GetAddressOf()); + destination->GetDevice(outputDevice.GetAddressOf()); + if (device.Get() != inputDevice.Get() || device.Get() != outputDevice.Get()) return E_INVALIDARG; + Ptr resource; + destination->GetResource(resource.GetAddressOf()); + Ptr output; + HRESULT hr = resource.As(&output); + if (FAILED(hr) || output.Get() == source) return E_INVALIDARG; + D3D11_TEXTURE2D_DESC target{}; + output->GetDesc(&target); + if (target.Width < width || target.Height < height || target.SampleDesc.Count != 1) return E_INVALIDARG; + hr = Initialize(device.Get()); + if (FAILED(hr)) return hr; + Name(output.Get(), "NeoPic.Capture.OutputTexture"); + Name(destination, "NeoPic.Capture.OutputView"); + Ptr context1; + hr = context->QueryInterface(IID_PPV_ARGS(context1.GetAddressOf())); + if (FAILED(hr)) return hr; + D3D11_TEXTURE2D_DESC sample{}; + sample.Width = box.right - box.left; sample.Height = box.bottom - box.top; + sample.MipLevels = sample.ArraySize = sample.SampleDesc.Count = 1; + sample.Format = format; sample.Usage = D3D11_USAGE_DEFAULT; + sample.BindFlags = D3D11_BIND_SHADER_RESOURCE; + hr = device->CreateTexture2D(&sample, nullptr, sampleTexture.ReleaseAndGetAddressOf()); + if (FAILED(hr)) return hr; + Name(sampleTexture.Get(), "NeoPic.Capture.SampleTexture"); + ID3D11Texture2D* copySource = source; + if (input.SampleDesc.Count > 1) { + UINT support = 0; + hr = device->CheckFormatSupport(input.Format, &support); + if (FAILED(hr) || !(support & D3D11_FORMAT_SUPPORT_MULTISAMPLE_RESOLVE)) return E_NOTIMPL; + auto resolve = input; + resolve.MipLevels = 1; resolve.SampleDesc.Count = 1; resolve.SampleDesc.Quality = 0; + resolve.Usage = D3D11_USAGE_DEFAULT; resolve.BindFlags = resolve.CPUAccessFlags = resolve.MiscFlags = 0; + hr = device->CreateTexture2D(&resolve, nullptr, resolved.ReleaseAndGetAddressOf()); + if (FAILED(hr)) return hr; + Name(resolved.Get(), "NeoPic.Capture.ResolvedTexture"); + context->ResolveSubresource(resolved.Get(), 0, source, 0, input.Format); + copySource = resolved.Get(); + } + context->CopySubresourceRegion(sampleTexture.Get(), 0, 0, 0, 0, copySource, 0, &box); + hr = device->CreateShaderResourceView(sampleTexture.Get(), nullptr, sampleView.ReleaseAndGetAddressOf()); + if (FAILED(hr)) return hr; + Name(sampleView.Get(), "NeoPic.Capture.SampleView"); + struct Restore { + ID3D11DeviceContext1* context; + Ptr saved; + ~Restore() { context->SwapDeviceContextState(saved.Get(), nullptr); } + } restore{context1.Get(), {}}; + context1->SwapDeviceContextState(scratch.Get(), restore.saved.GetAddressOf()); + const D3D11_VIEWPORT viewport{0, 0, static_cast(width), static_cast(height), 0, 1}; + context->RSSetViewports(1, &viewport); + context->RSSetState(rasterizer.Get()); + context->OMSetRenderTargets(1, &destination, nullptr); + context->OMSetBlendState(blend.Get(), nullptr, 0xffffffffu); + context->OMSetDepthStencilState(depth.Get(), 0); + context->IASetInputLayout(nullptr); + context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_TRIANGLELIST); + context->VSSetShader(vertex.Get(), nullptr, 0); + context->PSSetShader(pixel.Get(), nullptr, 0); + auto sampler = resample ? linearSampler.Get() : pointSampler.Get(); + context->PSSetSamplers(0, 1, &sampler); + auto view = sampleView.Get(); + context->PSSetShaderResources(0, 1, &view); + const FLOAT black[4]{0, 0, 0, 1}; + context->ClearRenderTargetView(destination, black); + context->Draw(3, 0); + view = nullptr; + context->PSSetShaderResources(0, 1, &view); + return S_OK; + } +}; diff --git a/Extensions/NeoPic/CaptureCompletion.h b/Extensions/NeoPic/CaptureCompletion.h new file mode 100644 index 00000000..d1d3ce14 --- /dev/null +++ b/Extensions/NeoPic/CaptureCompletion.h @@ -0,0 +1,62 @@ +#pragma once +#include +#include + +// An event query marks GPU execution completion, not the validity of the pixels. +// Prepare before issuing GPU work; Submit afterwards; Wait for synchronous capture. +class CaptureCompletion { + Microsoft::WRL::ComPtr context; + Microsoft::WRL::ComPtr query; + bool submitted = false; +public: + CaptureCompletion() = default; + CaptureCompletion(const CaptureCompletion&) = delete; + CaptureCompletion& operator=(const CaptureCompletion&) = delete; + + HRESULT Prepare(ID3D11Device* device, ID3D11DeviceContext* immediate) { + if (!device || !immediate) return E_POINTER; + if (query) return HRESULT_FROM_WIN32(ERROR_BUSY); + if (immediate->GetType() != D3D11_DEVICE_CONTEXT_IMMEDIATE) return E_INVALIDARG; + const D3D11_QUERY_DESC desc{D3D11_QUERY_EVENT, 0}; + const HRESULT hr = device->CreateQuery(&desc, query.GetAddressOf()); + if (FAILED(hr)) return hr; + context = immediate; + return S_OK; + } + + void Submit() { + context->End(query.Get()); + // Submit once so DONOTFLUSH polling can make progress even if Fusion is + // idle. Flush submits asynchronously; it does not wait for GPU completion. + context->Flush(); + submitted = true; + } + + bool InFlight() const { return submitted; } + + HRESULT Poll() const { + if (!submitted) return E_UNEXPECTED; + BOOL done = FALSE; + const HRESULT hr = context->GetData(query.Get(), &done, sizeof(done), + D3D11_ASYNC_GETDATA_DONOTFLUSH); + if (hr == S_OK) return done ? S_OK : S_FALSE; + if (hr == S_FALSE || FAILED(hr)) return hr; + return E_UNEXPECTED; + } + + // Synchronous capture and teardown must not release a borrowed WGC frame + // until the submitted work finishes. Flush submits; this actually waits. + HRESULT Wait() const { + HRESULT hr; + while ((hr = Poll()) == S_FALSE) Sleep(1); + return hr; + } + + HRESULT WaitForTeardown() const { return Wait(); } + + void Reset() { + submitted = false; + query.Reset(); + context.Reset(); + } +}; diff --git a/Extensions/NeoPic/CaptureCrop.h b/Extensions/NeoPic/CaptureCrop.h new file mode 100644 index 00000000..7132239b --- /dev/null +++ b/Extensions/NeoPic/CaptureCrop.h @@ -0,0 +1,28 @@ +#pragma once +#include + +namespace neopic_capture { +struct Crop { std::uint32_t left, top, right, bottom; }; + +// Map physical client edges into the WGC content size. Keep this independent +// of Win32 so resize/DPI/negative-monitor-coordinate cases can be tested. +inline bool ClientCrop(std::int64_t windowLeft, std::int64_t windowTop, + std::int64_t windowWidth, std::int64_t windowHeight, + std::int64_t clientLeft, std::int64_t clientTop, + std::int64_t clientWidth, std::int64_t clientHeight, + std::int32_t contentWidth, std::int32_t contentHeight, Crop& result) { + if (windowWidth <= 0 || windowHeight <= 0 || clientWidth <= 0 || clientHeight <= 0 + || contentWidth <= 0 || contentHeight <= 0) return false; + const auto x = clientLeft - windowLeft, y = clientTop - windowTop; + if (x < 0 || y < 0 || x > windowWidth || y > windowHeight + || clientWidth > windowWidth - x || clientHeight > windowHeight - y) return false; + auto scale = [](std::int64_t value, std::int64_t size, std::int64_t extent) { + return static_cast((value * size + extent / 2) / extent); + }; + const Crop candidate{scale(x, contentWidth, windowWidth), scale(y, contentHeight, windowHeight), + scale(x + clientWidth, contentWidth, windowWidth), scale(y + clientHeight, contentHeight, windowHeight)}; + if (candidate.right <= candidate.left || candidate.bottom <= candidate.top) return false; + result = candidate; + return true; +} +} diff --git a/Extensions/NeoPic/CaptureTesting.md b/Extensions/NeoPic/CaptureTesting.md new file mode 100644 index 00000000..9e4d8375 --- /dev/null +++ b/Extensions/NeoPic/CaptureTesting.md @@ -0,0 +1,226 @@ +# NeoPic frame capture + +Select **Display Settings → Frame Capture → Capture Backend** in the object's +properties. One MFX contains all three backends; there are no backend-selection +or benchmark macros. A normal Capture Frame Area action performs one capture and +publishes `_TempCapture` synchronously. + +| Property value | Backend | Source | +| --- | --- | --- | +| 0 (default) | Window DC | Existing `rhHEditWin` DC | +| 1 | Secondary Surface (DX11) | The Frame's valid secondary Surface region | +| 2 | Windows Graphics Capture | Root window cropped to the same child client area | + +The selection belongs to each object, including library objects, and is saved in +the MFA/exported runtime data. Unknown stored values use DC. In a Fusion DX9 +runtime, selecting either GPU backend resolves to DC at object creation. The +ordinary and optional benchmark factories share this rule and log the fallback; +the saved property is retained so switching the project back to DX11 restores the +requested backend. DX11 captures still report their own failures rather than +falling back on an unavailable frame or failed copy. + +WGC itself can capture DX9 windows. This particular backend reuses Fusion's DX11 +device and publishes a DX11 RTT; DX9 interop is not implemented, hence its current +DX9 fallback to DC. The renderer is read from the window Surface metadata, or its +app Surface prototype if the window Surface is not created yet. No pixels are +copied from the current layer canvas to decide compatibility. + +## Structure and ownership + +`FrameCapture` owns the common capture-and-publish transaction. `DcFrameCapture`, +`SecondaryFrameCapture` and `WgcFrameCapture` supply their respective images; +`Dx11FrameCapture` shares native normalization and GPU completion handling. +The action calls `CaptureAndLoad` once. `Update` only prepares an asynchronous +source when needed; it never defers an action's capture request. + +The DC backend reuses its internal bitmap and clones it before publication. GPU +backends transfer an owned RTT directly. Both reach +`LoadFromPointer(..., L"_TempCapture", ..., TakeOwnership)` without an additional +clone. Save or Show may follow in the same event. On a capture failure the old +image remains unchanged. Native queries submit with one Flush and wait for GPU +completion before releasing borrowed frames; this is not a latency deadline. + +Native WGC code remains in its own `/Zc:wchar_t` translation unit. Fusion sources +keep their existing `/Zc:wchar_t-` ABI. The production source/header dependency +graph does not include the optional benchmark subclass or statistics runner. + +## Output sizing and source limits + +DC retains its normal client-sized output. GPU output uses the Fusion logical +viewport (`rh3WindowSx/rh3WindowSy`), preventing a physical-pixel screenshot from +being displayed oversized in a scaled scene. Client size, valid source image size, +native allocation size and destination size are separate quantities. + +The full valid source rectangle is stretched to the destination size, following +the DC path's independent source/destination rectangles. Source dimensions need +not equal the viewport and aspect ratios need not match. For example, a 1920x1080 +logical capture remains that size as a window is dragged through 1281x720, +1365x768 or other dimensions. Native allocation padding is excluded. The shared +GPU pass uses point/linear sampling according to the resize-filter option, writes +opaque alpha, supports the listed 8-bit RGBA/BGRA layouts and rejects unsupported +YUV/HDR formats. DX11.1 context-state swapping preserves Fusion's pipeline. + +Secondary is a conditional runtime render target, not a documented final-window +buffer. Missing/non-RTT sources, nonzero origins, unsupported Frame effects and +nonidentity Frame transforms remain explicit failures. An accepted Surface and a +completed GPU operation do not prove that its pixels contain every final effect +or border. Diagnostic output includes app/frame always-secondary flags, source +and old-transition Surface identities, layer-effect-data presence, dimensions +and the failure stage. These flags are never changed to force a capture. The old +secondary belongs to frame transitions and is not substituted as a previous +Display buffer. A black image is not automatically classified as failure. + +WGC's producer is asynchronous: a GPU-complete snapshot can still be older than +the current compositor frame. With no ready image, capture returns `E_PENDING` +instead of waiting for a future Display. A warm WGC session shows Windows' yellow +capture indicator; this prototype requests no borderless permission. Startup +waits until the child/root are visible and reports the initialization stage and +HRESULT on failure. Handled first-chance WinRT exceptions can still interrupt a +VS debugger before its catch runs. + +The author confirmed the rendering and resize changes in their application. +This does not establish final-composition equivalence for every Fusion project. + +## Edit-data compatibility + +`Main.h` includes the versioned layout in `EditData.h`. V1 is frozen. V2 adds a +four-byte `CaptureBackend` and **64 reserved bytes**. Future fixed-size fields +must consume that reserve, reducing its length by their actual size/alignment; +the Win32 payload-budget assertion detects unintended growth. + +`UpdateEditStructure` uses the same `UpdateEditData` helper as Fini. It copies all +V1 settings, defaults the new selector to DC, clears the reserve and updates the +allocation limit. The shared helper preserves the other SDK header fields and +sets the new size/version. New objects also initialize the selector and reserve +explicitly: Fusion's raw data allocation does not run C++ constructors. + +## Optional derived benchmark + +Production captures once and contains no timer, loop count or benchmark state. +To run the example, include the optional header in `Runtime.cpp`, after Common: + +```cpp +#include "FrameCaptureBenchmark.h" +``` + +Replace the normal factory call in CreateRunObject with: + +```cpp +rdPtr->pFrameCapture = CreateFrameCaptureBenchmark(rdPtr, + NormalizeCaptureBackend(static_cast(edPtr->captureBackend)), + {100, 10}).release(); // Measured iterations, warmup calls. +``` + +The factory wraps the selected backend in `FrameCaptureBenchmark`. Counts +are constructor arguments, not macros. Restore `CreateFrameCapture` and remove +the optional include to return to the production implementation. No action, +property, serialized benchmark flag or saved event changes are required. + +Let the scene render first (allow roughly a second for WGC), then call Capture +Frame Area once. The subclass performs warmup and measurement inside that action, +suppresses per-capture diagnostics and prints QPC wall-time statistics to VS debug +output/DebugView. Keep the same Release build, viewport, client size/DPI, filter, +effects, GPU and HWA/library settings when comparing backends. Keep any displayed +snapshot hidden to avoid recursion. + +Samples include capture, allocation/normalization, synchronization and the +`_TempCapture` ownership handoff. DC is scaled to the same logical output size, +flushes GDI before cloning, and waits for its HWA upload under DX11. Saving a file +is outside measurement. Warmup and summary output are excluded. Success and +failure timings are separate; all-failed warmup skips measurement. P95 is nearest +rank. The first-call time is the first call of the batch, not necessarily a cold +process start. The last successful snapshot remains available after the loop. + +There is no new Fusion Display between iterations. The subclass retains a ready +WGC input until the batch finishes, refreshing queued frames when available. +Results measure snapshot processing throughput, not unique-frame capture FPS. +CPU work and the completion helper's 1 ms polling wait are part of wall time. +Exception-safe session cleanup restores ordinary input release behavior. + +## Build and verification + +Use VS2022, Win32, the existing private Fusion SDK/dependencies and Fusion 296 / +DX11. WGC requires Windows 10 1903+ and current C++/WinRT headers (Windows SDK +10.0.19041+ is suitable). From an x86 VS Developer PowerShell: + +```powershell +./Extensions/NeoPic/tests/CompileCapture.ps1 -RunGpuSmokeTest +msbuild Extensions/NeoPic/NeoPic.vcxproj /t:Rebuild /p:Configuration="Release Unicode" /p:Platform=Win32 +``` + +The default script checks Main, Runtime, Edittime, General, the common capture +implementation and optional derived header against the real SDK, and compiles +the property resources. It fails if Inc/ccxhdr.h is absent. MFX linking is a +separate build step. Close Fusion before installing the rebuilt editor MFX; +rebuild/install Run_Only Unicode too when testing exported EXEs. + +Public Windows CI uses `-NativeOnly -RunGpuSmokeTest`, with explicit scope: + +- The actual independent WGC implementation compiles with native wchar_t. +- Production normalization runs with WARP: padded source cropping, logical + resizing, RGBA/BGRA order, opaque alpha, completion and state restoration. + Repeated 6x3, 2x6 and 2x2 outputs exercise independent aspect ratios and resizing. +- With the D3D debug layer available, a real WARP lifetime test checks named + capture objects after one capture, 32 captures and repeated recreation. It + verifies that live objects are observable during use, then that no named objects + retain external references after destruction, without using ClearState. Missing + debug-layer support is printed as SKIP, not represented as lifetime validation. +- Statistics tests check warmup, percentiles, failed samples and unavailable input. +- Production V1/V2 payload definitions and upgrade defaults are tested with a mock + SDK header, including 64-byte reserve and backend-value validation. +- Production capture classes and optional subclass compile/run against small + SDK test doubles: factory selection, virtual dispatch, owned publication, + old-image preservation, failed warmup and exception/session cleanup. + +Test doubles never enter the plugin include path. They do not reproduce Fusion +layout/rendering, prove a real SDK build, or establish actual WGC/Fusion image +correctness. The Linux environment runs the portable crop, completion, statistics +and edit-data tests; native/compiler/GPU evidence comes from the Windows workflow. + +After installation, verify an old V1 MFA retains its settings and selects DC. +Select each new backend, save/reopen, and export to check persistence. On one +keypress, Capture then Save `_TempCapture` in the same event. Compare edge markers, +layers and image size against the intended child area. Test DX9 with each GPU property choice (the log must show DC fallback), then +switch back to DX11 without changing the saved choice. Test fixed resolution +presets, drag smaller/larger, then return to a preset. Capture failures must +preserve the old snapshot. Runtime resizing and the optional benchmark subclass +need actual application retesting after this refactor. + +## Investigating D3D live-object warnings + +The author reported exit-time live-object warnings appearing after GPU capture +was introduced. Treat this as a capture-resource regression to investigate, not +as evidence that the warnings are harmless. The old anonymous shutdown report +cannot identify owners or distinguish intended snapshot retention from a leak. + +When the device debug layer is enabled, objects created by the normalization pass +are named `NeoPic.Capture.*`: shaders, context state, samplers, pipeline states, +sampling/resolve textures and views, and the owned output texture/view. The shared +Fusion device and input secondary are not renamed. Immutable sampler/blend/ +rasterizer/depth-state creation can return an existing shared interface, so a +capture-prefixed state name does not by itself prove exclusive ownership or a +NeoPic leak. Compare ownership and checkpoint reports. Production code never calls +ClearState on Fusion's context to suppress live-object reports. + +Simple process-termination reporting may still omit names. Obtain a detailed +`ID3D11Debug::ReportLiveDeviceObjects(D3D11_RLDO_DETAIL)` report while the process is +alive, at a controlled checkpoint after capture objects and published snapshots +are released. A second report with `D3D11_RLDO_IGNORE_INTERNAL` helps separate +objects with external references from driver/runtime internal retention. Compare +no-capture, single-capture and repeated-capture runs at the same checkpoint. +A snapshot intentionally retained in the image library is expected to remain live +until the library releases it. + +The standalone lifetime test isolates the native pass and its COM ownership. +It cannot prove that Fusion calls every extension destructor or releases its +image cache/WGC session in the same order. A passing test therefore does not by +itself resolve the application's original exit warnings. + +DX11 source destruction now explicitly drains work and resets its owned native +cache. A used source logs `native capture cache released (published snapshots +have separate ownership)`. This confirms the native-cache teardown path ran; it +does not claim that the separately owned `_TempCapture` image/library is gone. +Compare this marker's presence/order with the final live-object report. The same +reset is used when changing devices, so a failed reinitialization does not keep +old-device cache objects alive. The lifetime test covers idempotent reset and +reinitialization as well as ordinary destruction. diff --git a/Extensions/NeoPic/Common.h b/Extensions/NeoPic/Common.h index e9a23019..456579a9 100644 --- a/Extensions/NeoPic/Common.h +++ b/Extensions/NeoPic/Common.h @@ -40,7 +40,7 @@ enum class HotSpotPos; struct ZoomScale; -struct FrameCapture; +class FrameCapture; struct AnimationInterface; struct NineSliceInterface; diff --git a/Extensions/NeoPic/EditData.h b/Extensions/NeoPic/EditData.h new file mode 100644 index 00000000..526b59b4 --- /dev/null +++ b/Extensions/NeoPic/EditData.h @@ -0,0 +1,71 @@ +#pragma once +#include +#include +#include +#include "CaptureBackend.h" + +// Frozen V1 layout. The SDK preamble supplies extHeader. +struct tagEDATA_V1 { + extHeader eHeader; + short swidth; + short sheight; + bool isLib = false; + bool bLoadCallback = false; + bool bLoadKeepAngle = false; + bool HWA = false; + bool stretchQuality = false; + std::size_t memoryLimit; + std::size_t sizeLimit; + bool autoClean; + std::size_t hotSpotComboID; +}; +using EDITDATA_V1 = tagEDATA_V1; + +struct tagEDATA_V2 { + extHeader eHeader; + short swidth; + short sheight; + bool isLib = false; + bool bLoadCallback = false; + bool bLoadKeepAngle = false; + bool HWA = false; + bool stretchQuality = false; + std::size_t memoryLimit; + std::size_t sizeLimit; + bool autoClean; + std::size_t hotSpotComboID; + + CaptureBackend captureBackend = CaptureBackend::Dc; + // Future fixed-size fields consume this byte budget; reduce it by sizeof(field). + std::uint8_t reserved[64] = {}; +}; +using EDITDATA = tagEDATA_V2; +using LPEDATA = EDITDATA*; + +static_assert(sizeof(CaptureBackend) == 4); +static_assert(offsetof(EDITDATA, hotSpotComboID) == offsetof(EDITDATA_V1, hotSpotComboID)); +static_assert(offsetof(EDITDATA, captureBackend) == sizeof(EDITDATA_V1)); +// Keep this Win32 payload budget fixed when future fields consume the reserve. +static_assert(sizeof(void*) != 4 || sizeof(EDITDATA) == sizeof(EDITDATA_V1) + 68); + +#define KCX_VERSION_V1 1 +#define KCX_VERSION_V2 2 +#define KCX_CURRENT_VERSION KCX_VERSION_V2 + +inline void UpgradeNeoPicEditData(const EDITDATA_V1* old, EDITDATA* current) { + current->swidth = old->swidth; + current->sheight = old->sheight; + current->isLib = old->isLib; + current->bLoadCallback = old->bLoadCallback; + current->bLoadKeepAngle = old->bLoadKeepAngle; + current->HWA = old->HWA; + current->stretchQuality = old->stretchQuality; + current->memoryLimit = old->memoryLimit; + current->sizeLimit = old->sizeLimit; + current->autoClean = old->autoClean; + current->hotSpotComboID = old->hotSpotComboID; + current->captureBackend = CaptureBackend::Dc; + std::fill_n(current->reserved, sizeof(current->reserved), std::uint8_t{}); + // UpdateEditData copies the other header fields and sets size/version. + current->eHeader.extMaxSize = sizeof(EDITDATA); +} diff --git a/Extensions/NeoPic/Edittime.cpp b/Extensions/NeoPic/Edittime.cpp index 66e2dd38..8ae3d452 100644 --- a/Extensions/NeoPic/Edittime.cpp +++ b/Extensions/NeoPic/Edittime.cpp @@ -46,6 +46,9 @@ enum { PROPID_LOADCALLBACK_CHECK, PROPID_LOADKEEPANGLE_CHECK, + PROPID_CAPTURE_TEXTTITLE, + PROPID_CAPTURE_BACKEND, + }; // Example of content of the PROPID_COMBO combo box @@ -71,6 +74,14 @@ LPCWSTR HotSpotComboList[] = { NULL }; +LPCWSTR CaptureBackendComboList[] = { + 0, // reserved by the property manager + MAKEINTRESOURCE(IDS_PROP_CAPTURE_DC), + MAKEINTRESOURCE(IDS_PROP_CAPTURE_SECONDARY), + MAKEINTRESOURCE(IDS_PROP_CAPTURE_WGC), + NULL +}; + // Property definitions // // Type, ID, Text, Text of Info box [, Options, Init Param] @@ -124,6 +135,9 @@ PropData PropertiesDisplay[] = { PropData_Group(PROPID_HOTSPOT_TEXTTITLE, IDS_HOTSPOT_TEXTTITLE, IDS_HOTSPOT_TEXTTITLE), PropData_ComboBox(PROPID_HOTSPOT, IDS_PROP_HOTSPOT, IDS_PROP_HOTSPOT_INFO, HotSpotComboList), + + PropData_Group(PROPID_CAPTURE_TEXTTITLE, IDS_PROP_CAPTURE_TEXTTITLE, IDS_PROP_CAPTURE_TEXTTITLE), + PropData_ComboBox(PROPID_CAPTURE_BACKEND, IDS_PROP_CAPTURE_BACKEND, IDS_PROP_CAPTURE_BACKEND_INFO, CaptureBackendComboList), PropData_End() }; @@ -416,6 +430,8 @@ int WINAPI DLLExport CreateObject(mv _far *mV, fpLevObj loPtr, LPEDATA edPtr) edPtr->sizeLimit = CLEAR_NUMTHRESHOLD; edPtr->hotSpotComboID = (size_t)HotSpotPos::MM; + edPtr->captureBackend = CaptureBackend::Dc; + std::fill_n(edPtr->reserved, sizeof(edPtr->reserved), std::uint8_t{}); // // // Call setup (remove this and return 0 if your object does not need a setup) @@ -823,6 +839,8 @@ LPVOID WINAPI DLLExport GetPropValue(LPMV mV, LPEDATA edPtr, UINT nPropID) return new CPropDWordValue(edPtr->sizeLimit); case PROPID_HOTSPOT: return new CPropDWordValue(edPtr->hotSpotComboID); + case PROPID_CAPTURE_BACKEND: + return new CPropDWordValue(static_cast(NormalizeCaptureBackend(static_cast(edPtr->captureBackend)))); } #endif // !defined(RUN_ONLY) @@ -930,6 +948,10 @@ void WINAPI DLLExport SetPropValue(LPMV mV, LPEDATA edPtr, UINT nPropID, LPVOID case PROPID_HOTSPOT: { edPtr->hotSpotComboID= ((CPropDWordValue*)pValue)->m_dwValue; } + break; + case PROPID_CAPTURE_BACKEND: + edPtr->captureBackend = NormalizeCaptureBackend(static_cast(static_cast(pValue)->m_dwValue)); + break; } // You may want to have your object redrawn in the frame editor after the modifications, diff --git a/Extensions/NeoPic/Ext.rc b/Extensions/NeoPic/Ext.rc index 33edfee2..a5d34f44 100644 --- a/Extensions/NeoPic/Ext.rc +++ b/Extensions/NeoPic/Ext.rc @@ -666,6 +666,13 @@ BEGIN IDS_PROP_HOTSPOT_RB "Right Bottom" IDS_PROP_HOTSPOT "Default Hot Spot" IDS_PROP_HOTSPOT_INFO "Default Hot Spot" + + IDS_PROP_CAPTURE_TEXTTITLE "Frame Capture" + IDS_PROP_CAPTURE_BACKEND "Capture Backend" + IDS_PROP_CAPTURE_BACKEND_INFO "Select DC, Secondary Surface (DX11), or Windows Graphics Capture (Windows 10 1903+). DX9 uses DC for either GPU option. WGC shows the Windows capture indicator." + IDS_PROP_CAPTURE_DC "Window DC" + IDS_PROP_CAPTURE_SECONDARY "Secondary Surface (DX11)" + IDS_PROP_CAPTURE_WGC "Windows Graphics Capture" END #endif // English (U.S.) resources diff --git a/Extensions/NeoPic/FrameCapture.cpp b/Extensions/NeoPic/FrameCapture.cpp new file mode 100644 index 00000000..7085e11a --- /dev/null +++ b/Extensions/NeoPic/FrameCapture.cpp @@ -0,0 +1,58 @@ +#include "Common.h" +#include "FrameCaptureDC.h" +#include "FrameCaptureDX11.h" + +SIZE FrameCapture::OutputSize(LPRDATA rdPtr, SIZE) const { + const auto rh = rdPtr->rHo.hoAdRunHeader; + return {rh->rh3.rh3WindowSx, rh->rh3.rh3WindowSy}; +} + +void FrameCapture::Report(HRESULT hr, const char* stage) const { + if (!DiagnosticsEnabled()) return; + char message[256]; + sprintf_s(message, "NeoPic capture backend=%u %s hr=0x%08lX\n", + static_cast(backend), stage, static_cast(hr)); + OutputDebugStringA(message); +} + +HRESULT FrameCapture::CaptureAndLoad(LPRDATA rdPtr, SIZE* outputSize) { + std::unique_ptr surface; + HRESULT hr = CaptureSurface(rdPtr, surface); + if (SUCCEEDED(hr) && (!surface || !surface->IsValid())) hr = E_FAIL; + if (FAILED(hr)) { + Report(hr, "failed (image unchanged)"); + return hr; + } + if (outputSize) *outputSize = {surface->GetWidth(), surface->GetHeight()}; + LoadFromPointer(rdPtr, L"_TempCapture", surface.release(), LoadFromPointerFlags_TakeOwnership); + Report(S_OK, "ready (synchronous capture completion)"); + return S_OK; +} + +CaptureBackend ResolveFrameCaptureBackend(LPRDATA rdPtr, CaptureBackend requested) { + requested = NormalizeCaptureBackend(static_cast(requested)); + if (requested == CaptureBackend::Dc) return requested; + int driver = -1; + if (const auto surface = WinGetSurface(rdPtr)) driver = surface->GetDriver(); + else { + // The window surface may not exist yet during object creation. + const auto app = rdPtr->rHo.hoAdRunHeader->rhApp; + if (app && app->m_protoLogScreen) driver = app->m_protoLogScreen->GetDriver(); + } + if (driver == SD_D3D9) { + char message[160]; + sprintf_s(message, "NeoPic capture requested backend=%u -> DC (Fusion DX9 runtime)\n", + static_cast(requested)); + OutputDebugStringA(message); + return CaptureBackend::Dc; + } + return requested; +} + +std::unique_ptr CreateFrameCapture(LPRDATA rdPtr, CaptureBackend backend) { + switch (ResolveFrameCaptureBackend(rdPtr, backend)) { + case CaptureBackend::Secondary: return std::make_unique(rdPtr); + case CaptureBackend::WindowsGraphicsCapture: return std::make_unique(rdPtr); + default: return std::make_unique(rdPtr); + } +} diff --git a/Extensions/NeoPic/FrameCapture.h b/Extensions/NeoPic/FrameCapture.h index b5171050..4ea584cf 100644 --- a/Extensions/NeoPic/FrameCapture.h +++ b/Extensions/NeoPic/FrameCapture.h @@ -1,77 +1,26 @@ #pragma once - -struct FrameCapture { - LPSURFACE pCaptureSf = nullptr; - HDC pCaptureDC = nullptr; - - HWND hFrameWindowHandle = nullptr; - HDC hdcWindow = nullptr; - - int frameWidth = -1; - int frameHeight = -1; - - FrameCapture(HWND hFrameWindowHandle) { - this->hFrameWindowHandle = hFrameWindowHandle; - } - ~FrameCapture() { - ReleaseDC(); - ReleaseSurface(); - } - - inline void ReleaseDC() { - if (hdcWindow == nullptr) { return; } - - ::ReleaseDC(hFrameWindowHandle, hdcWindow); - hdcWindow = nullptr; - } - - - inline void NewDC() { - if (hdcWindow == nullptr) { - hdcWindow = GetDC(hFrameWindowHandle); - } - } - - inline void ReleaseSurface() { - if (pCaptureSf == nullptr) { return; } - - if (pCaptureDC != nullptr) { - pCaptureSf->ReleaseDC(pCaptureDC); - pCaptureDC = nullptr; - } - - delete pCaptureSf; - pCaptureSf = nullptr; - } - - inline void NewSurface() { - RECT frameRect; - ::GetWindowRect(hFrameWindowHandle, &frameRect); - frameWidth = frameRect.right - frameRect.left; - frameHeight = frameRect.bottom - frameRect.top; - - if (pCaptureSf == nullptr - || pCaptureSf->GetWidth() != frameWidth - || pCaptureSf->GetHeight() != frameHeight) { - ReleaseSurface(); - - pCaptureSf = CreateSurface(24, frameWidth, frameHeight); - _AddAlpha(pCaptureSf); - - pCaptureDC = pCaptureSf->GetDC(); - } - } - - inline void Capture() { - NewDC(); - NewSurface(); - StretchBlt(pCaptureDC, 0, 0, frameWidth, frameHeight - , hdcWindow, 0, 0, frameWidth, frameHeight - , SRCCOPY); - //_AddAlpha(pCaptureSf); - -#ifdef _DEBUG - //__SavetoClipBoard(pCaptureSf); -#endif // _DEBUG - } +#include +#include "CaptureBackend.h" + +// One synchronous capture and ownership handoff, independent of its source. +// Optional subclasses can decorate this operation without changing the action. +class FrameCapture { + CaptureBackend backend; +protected: + explicit FrameCapture(CaptureBackend value) : backend(value) {} + virtual HRESULT CaptureSurface(LPRDATA rdPtr, std::unique_ptr& surface) = 0; + virtual SIZE OutputSize(LPRDATA rdPtr, SIZE sourceSize) const; + virtual bool DiagnosticsEnabled() const { return true; } + virtual void ReleaseInput() {} + void Report(HRESULT hr, const char* stage) const; +public: + virtual ~FrameCapture() = default; + FrameCapture(const FrameCapture&) = delete; + FrameCapture& operator=(const FrameCapture&) = delete; + CaptureBackend Backend() const { return backend; } + virtual void Update(LPRDATA rdPtr) { (void)rdPtr; } + virtual HRESULT CaptureAndLoad(LPRDATA rdPtr, SIZE* outputSize = nullptr); }; + +CaptureBackend ResolveFrameCaptureBackend(LPRDATA rdPtr, CaptureBackend requested); +std::unique_ptr CreateFrameCapture(LPRDATA rdPtr, CaptureBackend backend); diff --git a/Extensions/NeoPic/FrameCaptureBenchmark.h b/Extensions/NeoPic/FrameCaptureBenchmark.h new file mode 100644 index 00000000..2f8b8c2a --- /dev/null +++ b/Extensions/NeoPic/FrameCaptureBenchmark.h @@ -0,0 +1,133 @@ +#pragma once + +// Optional example. Production sources do not include this header. +#include "FrameCaptureDC.h" +#include "FrameCaptureDX11.h" +#include "CaptureBenchmark.h" +#include + +struct CaptureBenchmarkOptions { + std::size_t iterations = 100; + std::size_t warmup = 10; +}; + +template +class FrameCaptureBenchmark : public Base { + CaptureBenchmarkOptions options; + bool running = false; + + void Stop() { + running = false; + Base::ReleaseInput(); + } + + HRESULT WaitForUpload(LPRDATA rdPtr) { + if constexpr (std::is_base_of_v) { + if (rdPtr->HWA && D3D11(rdPtr)) { + const auto info = GetD3D11Info(rdPtr); + CaptureCompletion completed; + const HRESULT hr = completed.Prepare(static_cast(info.m_pD3D11Device), + static_cast(info.m_pD3D11Context)); + if (FAILED(hr)) return hr; + completed.Submit(); + return completed.Wait(); + } + } + return S_OK; + } + + void Print(LPRDATA rdPtr, const neopic_capture::BenchmarkResult& result, SIZE output, + HRESULT firstFailure) const { + const auto rh = rdPtr->rHo.hoAdRunHeader; + RECT client{}; + GetClientRect(rh->rhHEditWin, &client); + const bool resample = rh->rhApp->m_pHdr2 && (rh->rhApp->m_pHdr2->dwOptions & AH2OPT_RESAMPLESTRETCH); + char text[1800]; + sprintf_s(text, "NeoPic capture benchmark backend=%u iterations=%llu warmup=%llu HWA=%d library=%d viewport=%dx%d client=%ldx%ld output=%ldx%ld resample=%d\n" + "warmup_ok=%llu warmup_failed=%llu first_ms=%.6f first_ok=%d first_failure_hr=0x%08lX\n" + "attempted=%llu ok=%llu failed=%llu total_ms=%.3f status=%s\n", + static_cast(this->Backend()), static_cast(options.iterations), + static_cast(options.warmup), rdPtr->HWA ? 1 : 0, rdPtr->isLib ? 1 : 0, + rh->rh3.rh3WindowSx, rh->rh3.rh3WindowSy, client.right - client.left, client.bottom - client.top, + output.cx, output.cy, resample ? 1 : 0, + static_cast(result.warmupSuccess), static_cast(result.warmupFailure), + result.firstMs, result.firstSuccess ? 1 : 0, static_cast(firstFailure), + static_cast(result.success.count + result.failure.count), + static_cast(result.success.count), static_cast(result.failure.count), + result.totalMs, result.noReadySource ? "no_ready_source" : (result.success.count ? (result.failure.count ? "partial" : "ok") : "all_failed")); + OutputDebugStringA(text); + if (result.success.count) { + sprintf_s(text, "success mean_ms=%.6f min_ms=%.6f median_ms=%.6f p95_ms=%.6f max_ms=%.6f copies_per_s=%.2f\n", + result.success.mean, result.success.minimum, result.success.median, result.success.p95, result.success.maximum, + result.totalMs > 0 ? static_cast(result.success.count) * 1000.0 / result.totalMs : 0.0); + OutputDebugStringA(text); + } else OutputDebugStringA("success timing: n/a (no successful measured capture)\n"); + if (result.failure.count) { + sprintf_s(text, "failure mean_ms=%.6f (excluded from success percentiles)\n", result.failure.mean); + OutputDebugStringA(text); + } + } +protected: + bool DiagnosticsEnabled() const override { return !running; } + SIZE OutputSize(LPRDATA rdPtr, SIZE) const override { + const auto rh = rdPtr->rHo.hoAdRunHeader; + return {rh->rh3.rh3WindowSx, rh->rh3.rh3WindowSy}; + } + void ReleaseInput() override { + // During a batch, WGC may reuse its already-ready frame between copies. + if (!running) Base::ReleaseInput(); + } + HRESULT CaptureSurface(LPRDATA rdPtr, std::unique_ptr& output) override { + if constexpr (std::is_base_of_v) { + const HRESULT hr = this->CopyBitmap(rdPtr); + if (FAILED(hr)) return hr; + if (!GdiFlush()) return this->LastWin32Error(); + return this->CloneBitmap(rdPtr, output); + } else return Base::CaptureSurface(rdPtr, output); + } +public: + explicit FrameCaptureBenchmark(LPRDATA rdPtr, CaptureBenchmarkOptions value = {}) : Base(rdPtr), options(value) { + if (!options.iterations || options.iterations > 100000 || options.warmup > 10000) + throw std::invalid_argument("capture benchmark count"); + } + + HRESULT CaptureAndLoad(LPRDATA rdPtr, SIZE* outputSize = nullptr) override { + // A nested capture remains a single operation, not another benchmark. + if (running) return Base::CaptureAndLoad(rdPtr, outputSize); + LARGE_INTEGER frequency{}; + if (!QueryPerformanceFrequency(&frequency) || frequency.QuadPart <= 0) { + OutputDebugStringA("NeoPic capture benchmark: QPC unavailable; doing one capture\n"); + return Base::CaptureAndLoad(rdPtr, outputSize); + } + auto now = []() { LARGE_INTEGER ticks{}; QueryPerformanceCounter(&ticks); return ticks.QuadPart; }; + SIZE output{}; + HRESULT firstFailure = S_OK; + auto capture = [&]() { + HRESULT hr = Base::CaptureAndLoad(rdPtr, &output); + if (SUCCEEDED(hr)) hr = WaitForUpload(rdPtr); + if (FAILED(hr) && firstFailure == S_OK) firstFailure = hr; + return SUCCEEDED(hr); + }; + neopic_capture::BenchmarkResult result; + { + struct Session { + FrameCaptureBenchmark& owner; + explicit Session(FrameCaptureBenchmark& value) : owner(value) { owner.running = true; } + ~Session() { owner.Stop(); } + } session(*this); + result = neopic_capture::RunBenchmark(options.iterations, options.warmup, capture, now, frequency.QuadPart); + } + if (outputSize) *outputSize = output; + Print(rdPtr, result, output, firstFailure); + return result.success.count ? S_OK : (FAILED(firstFailure) ? firstFailure : E_FAIL); + } +}; + +inline std::unique_ptr CreateFrameCaptureBenchmark(LPRDATA rdPtr, CaptureBackend backend, + CaptureBenchmarkOptions options = {}) { + switch (ResolveFrameCaptureBackend(rdPtr, backend)) { + case CaptureBackend::Secondary: return std::make_unique>(rdPtr, options); + case CaptureBackend::WindowsGraphicsCapture: return std::make_unique>(rdPtr, options); + default: return std::make_unique>(rdPtr, options); + } +} diff --git a/Extensions/NeoPic/FrameCaptureDC.h b/Extensions/NeoPic/FrameCaptureDC.h new file mode 100644 index 00000000..9648d941 --- /dev/null +++ b/Extensions/NeoPic/FrameCaptureDC.h @@ -0,0 +1,64 @@ +#pragma once +#include "Common.h" + +class DcFrameCapture : public FrameCapture { + std::unique_ptr bitmap; + HDC bitmapDC = nullptr; + HWND window = nullptr; + HDC windowDC = nullptr; + + void ReleaseBitmap() { + if (bitmapDC) bitmap->ReleaseDC(bitmapDC); + bitmapDC = nullptr; + bitmap.reset(); + } +protected: + SIZE OutputSize(LPRDATA, SIZE sourceSize) const override { return sourceSize; } + + HRESULT CopyBitmap(LPRDATA rdPtr) { + RECT rect{}; + if (!GetWindowRect(window, &rect)) return LastWin32Error(); + const SIZE source{rect.right - rect.left, rect.bottom - rect.top}; + const auto output = OutputSize(rdPtr, source); + if (source.cx <= 0 || source.cy <= 0 || output.cx <= 0 || output.cy <= 0) return E_INVALIDARG; + if (!windowDC) windowDC = GetDC(window); + if (!windowDC) return LastWin32Error(); + if (!bitmap || bitmap->GetWidth() != output.cx || bitmap->GetHeight() != output.cy || !bitmapDC) { + ReleaseBitmap(); + bitmap.reset(CreateSurface(24, output.cx, output.cy)); + if (!bitmap || !bitmap->IsValid()) return E_FAIL; + _AddAlpha(bitmap.get()); + bitmapDC = bitmap->GetDC(); + if (!bitmapDC) return E_FAIL; + // At native size this is a 1:1 copy. A custom output size may scale. + const auto app = rdPtr->rHo.hoAdRunHeader->rhApp; + const bool resample = app->m_pHdr2 && (app->m_pHdr2->dwOptions & AH2OPT_RESAMPLESTRETCH); + SetStretchBltMode(bitmapDC, resample ? HALFTONE : COLORONCOLOR); + SetBrushOrgEx(bitmapDC, 0, 0, nullptr); + } + return StretchBlt(bitmapDC, 0, 0, output.cx, output.cy, + windowDC, 0, 0, source.cx, source.cy, SRCCOPY) ? S_OK : LastWin32Error(); + } + + HRESULT CloneBitmap(LPRDATA rdPtr, std::unique_ptr& output) { + output.reset(CreateCloneSurface(rdPtr, bitmap.get())); + return output && output->IsValid() ? S_OK : E_FAIL; + } + + HRESULT CaptureSurface(LPRDATA rdPtr, std::unique_ptr& output) override { + const HRESULT hr = CopyBitmap(rdPtr); + return FAILED(hr) ? hr : CloneBitmap(rdPtr, output); + } + + static HRESULT LastWin32Error() { + const DWORD error = GetLastError(); + return error ? HRESULT_FROM_WIN32(error) : E_FAIL; + } +public: + explicit DcFrameCapture(LPRDATA rdPtr) : FrameCapture(CaptureBackend::Dc), + window(rdPtr->rHo.hoAdRunHeader->rhHEditWin) {} + ~DcFrameCapture() override { + ReleaseBitmap(); + if (windowDC) ::ReleaseDC(window, windowDC); + } +}; diff --git a/Extensions/NeoPic/FrameCaptureDX11.h b/Extensions/NeoPic/FrameCaptureDX11.h new file mode 100644 index 00000000..228db98b --- /dev/null +++ b/Extensions/NeoPic/FrameCaptureDX11.h @@ -0,0 +1,219 @@ +#pragma once + +// Native WGC implementation remains isolated in its native-wchar_t .cpp. +#include "Common.h" +#include +#include +#include "CaptureCompletion.h" +#include "CaptureBlit.h" +#include "WgcCapture.h" + +class Dx11FrameCapture : public FrameCapture { + Microsoft::WRL::ComPtr sourceTexture; + CaptureCompletion completion; + CaptureBlit blit; +protected: + using Texture = Microsoft::WRL::ComPtr; + explicit Dx11FrameCapture(CaptureBackend backend) : FrameCapture(backend) {} + virtual HRESULT ReadSource(LPRDATA rdPtr, Texture& texture, D3D11_BOX& region) = 0; + + // Borrowed source frames must outlive their submitted GPU work. + void Drain() { + if (completion.InFlight()) { + const HRESULT hr = completion.WaitForTeardown(); + if (FAILED(hr)) Report(hr, "teardown GPU failure"); + } + sourceTexture.Reset(); + completion.Reset(); + ReleaseInput(); + } + + HRESULT CaptureSurface(LPRDATA rdPtr, std::unique_ptr& output) override { + HRESULT hr = E_NOTIMPL; + if (D3D11(rdPtr)) { + Texture texture; + D3D11_BOX region{}; + hr = ReadSource(rdPtr, texture, region); + if (SUCCEEDED(hr)) hr = Normalize(rdPtr, texture.Get(), region, output); + } + if (completion.InFlight()) { + const HRESULT finished = completion.Wait(); + if (FAILED(finished)) hr = finished; + } + sourceTexture.Reset(); + completion.Reset(); + ReleaseInput(); + if (FAILED(hr)) output.reset(); + return hr; + } + + HRESULT Normalize(LPRDATA rdPtr, ID3D11Texture2D* source, const D3D11_BOX& region, + std::unique_ptr& output) { + if (!source) return E_POINTER; + auto rh = rdPtr->rHo.hoAdRunHeader; + // Client pixels identify the capture AREA, not NeoPic's scene-unit size. + const auto size = OutputSize(rdPtr, {static_cast(region.right - region.left), static_cast(region.bottom - region.top)}); + const int width = size.cx, height = size.cy; + if (width <= 0 || height <= 0) return E_INVALIDARG; + if (DiagnosticsEnabled()) + { + D3D11_TEXTURE2D_DESC input{}; + source->GetDesc(&input); + char details[320]; + sprintf_s(details, "NeoPic capture native-source=%ux%u format=%u valid=%u,%u..%u,%u output-logical=%dx%d\n", + input.Width, input.Height, unsigned(input.Format), region.left, region.top, region.right, region.bottom, width, height); + OutputDebugStringA(details); + } + auto candidate = std::unique_ptr(CreateHWASurface(rdPtr, 32, width, height, ST_HWA_RTTEXTURE)); + if (!candidate || !candidate->IsValid()) return E_FAIL; + // Mark the image as alpha-capable without initializing a CPU alpha plane; + // the shader writes alpha=1 for the whole logical image. + candidate->CreateAlpha(); + HRESULT rendered = E_FAIL; + { + constexpr RGBAREF opaqueBlack = static_cast(0xFF000000u); + RenderHelper rendering{candidate.get(), TRUE, opaqueBlack}; + const auto info = GetD3D11SurfaceInfo(candidate.get()); + if (info.m_nD3DVersion != 11 || !info.m_pD3D11Device || !info.m_pD3D11Context + || !info.m_ppD3D11RenderTargetView) return E_NOINTERFACE; + auto target = *static_cast(info.m_ppD3D11RenderTargetView); + if (!target) return E_POINTER; + auto context = static_cast(info.m_pD3D11Context); + const HRESULT prepared = completion.Prepare(static_cast(info.m_pD3D11Device), context); + if (FAILED(prepared)) return prepared; + const bool resample = rh->rhApp->m_pHdr2 + && (rh->rhApp->m_pHdr2->dwOptions & AH2OPT_RESAMPLESTRETCH); + rendered = blit.Draw(context, source, region, target, static_cast(width), static_cast(height), resample); + } + output = std::move(candidate); + sourceTexture = source; + completion.Submit(); // Drain even a failed pass's intermediate GPU work. + return rendered; + } + +public: + ~Dx11FrameCapture() override { + Drain(); + const bool initialized = blit.IsInitialized(); + blit.Reset(); + if (initialized) Report(S_OK, "native capture cache released (published snapshots have separate ownership)"); + } +}; + +class SecondaryFrameCapture : public Dx11FrameCapture { +protected: + // A Surface's logical dimensions describe the valid image independently + // of its native allocation padding and the physical window dimensions. + HRESULT ReadSource(LPRDATA rdPtr, Texture& native, D3D11_BOX& region) override { + auto rh = rdPtr->rHo.hoAdRunHeader; + auto frame = rh->rhFrame; + if (!frame) return E_PENDING; + auto source = frame->m_pSecondarySurface; + if (DiagnosticsEnabled()) + { + const auto app = rh->rhApp; + char state[384]; + sprintf_s(state, "NeoPic secondary state appAlwaysSecondary=%d frameAlwaysSecondary=%d source=%p oldTransitionSurface=%p layerEffectData=%d appFrameMatches=%d\n", + app ? int(app->m_bAlwaysUseSecondarySurface) : -1, + int(frame->m_bAlwaysUseSecondarySurface), static_cast(source), + static_cast(app ? app->m_pOldSecondarySurface : nullptr), + frame->m_pLayerEffects != nullptr, app && app->m_Frame == frame); + OutputDebugStringA(state); + } + // Existence/type checks do not establish that this conditional runtime + // render target contains the last fully composed child-client image. + if (!source || !source->IsValid()) { + Report(E_PENDING, "secondary source unavailable"); + return E_PENDING; + } + if (source->GetDriver() != SD_D3D11 || source->GetType() != ST_HWA_RTTEXTURE) { + Report(E_NOTIMPL, "secondary source is not a DX11 RTT"); + return E_NOTIMPL; + } + RECT client{}; + if (!GetClientRect(rh->rhHEditWin, &client)) return E_FAIL; + const int width = client.right - client.left, height = client.bottom - client.top; + const int sourceWidth = source->GetWidth(), sourceHeight = source->GetHeight(); + POINT origin{}; + source->GetOrigin(origin); + if (DiagnosticsEnabled()) + { + char details[384]; + sprintf_s(details, "NeoPic secondary logical=%dx%d depth=%d type=%d origin=%ld,%ld viewport=%dx%d rhHEditWin-client=%dx%d\n", + sourceWidth, sourceHeight, source->GetDepth(), source->GetType(), origin.x, origin.y, + rh->rh3.rh3WindowSx, rh->rh3.rh3WindowSy, width, height); + OutputDebugStringA(details); + } + if (width <= 0 || height <= 0 || sourceWidth <= 0 || sourceHeight <= 0) return E_INVALIDARG; + // Match the DC path's size contract: consume the full valid source + // rectangle and stretch it to the output Surface. Client, source and + // output dimensions/aspect ratios are independent, including drag resize. + // A pre-effect/offscreen scene is not necessarily what rhHEditWin shows. + // Refuse cases whose final composition cannot be reconstructed here. + if (frame->m_pEffectEx || (frame->m_pEffect && frame->m_pEffect->ffxInkEffect != 0) + || frame->m_angle != 0 || frame->m_xScale != 1.0f || frame->m_yScale != 1.0f + || frame->m_destPoint.x != frame->m_hotSpot.x || frame->m_destPoint.y != frame->m_hotSpot.y + || origin.x != 0 || origin.y != 0) { + if (DiagnosticsEnabled()) + { + char composition[320]; + sprintf_s(composition, "NeoPic secondary rejected frameEffect=%u effectEx=%d scale=%.4f,%.4f angle=%.4f hotspot=%ld,%ld dest=%ld,%ld\n", + frame->m_pEffect ? unsigned(frame->m_pEffect->ffxInkEffect) : 0u, frame->m_pEffectEx != nullptr, + double(frame->m_xScale), double(frame->m_yScale), double(frame->m_angle), + frame->m_hotSpot.x, frame->m_hotSpot.y, frame->m_destPoint.x, frame->m_destPoint.y); + OutputDebugStringA(composition); + } + Report(E_NOTIMPL, "secondary cannot establish final child-client composition"); + return E_NOTIMPL; + } + // Capture the resource while this source RTT is active, then keep its + // native identity before activating the destination. Do not sample a + // Surface through mutable/current-target bookkeeping during the draw. + { + RenderHelper reading{source, FALSE}; + const auto info = GetD3D11SurfaceInfo(source); + if (info.m_nD3DVersion != 11 || !info.m_ppD3D11RenderTargetTexture) return E_NOINTERFACE; + native = *static_cast(info.m_ppD3D11RenderTargetTexture); + } + if (!native) return E_POINTER; + region = {0, 0, 0, static_cast(sourceWidth), static_cast(sourceHeight), 1}; + return S_OK; + } +public: + explicit SecondaryFrameCapture(LPRDATA rdPtr) : Dx11FrameCapture(CaptureBackend::Secondary) { (void)rdPtr; } +}; + +class WgcFrameCapture : public Dx11FrameCapture { + bool started = false; + bool startAttempted = false; + WgcCapture wgc; + + HRESULT Start(LPRDATA rdPtr, bool retry) { + if (started) return S_OK; + if (!D3D11(rdPtr)) return E_NOTIMPL; + const HWND frame = rdPtr->rHo.hoAdRunHeader->rhHEditWin; + const HWND root = GetAncestor(frame, GA_ROOT); + if (!IsWindow(frame) || !IsWindowVisible(frame) || !IsWindowVisible(root)) return E_PENDING; + if (startAttempted && !retry) return E_PENDING; + startAttempted = true; + const HRESULT hr = wgc.Start(frame, static_cast(GetD3D11Device(rdPtr)), DiagnosticsEnabled()); + started = SUCCEEDED(hr); + Report(hr, "start"); + return hr; + } +protected: + HRESULT ReadSource(LPRDATA rdPtr, Texture& texture, D3D11_BOX& region) override { + const HRESULT hr = Start(rdPtr, true); + return FAILED(hr) ? hr : wgc.LatestTexture(texture.GetAddressOf(), ®ion); + } + void ReleaseInput() override { wgc.ReleaseCurrent(); } +public: + explicit WgcFrameCapture(LPRDATA rdPtr) : Dx11FrameCapture(CaptureBackend::WindowsGraphicsCapture) { (void)rdPtr; } + ~WgcFrameCapture() override { Drain(); } // Drain before the WGC member is destroyed. + void Update(LPRDATA rdPtr) override { + if (FAILED(Start(rdPtr, false))) return; + Texture ignored; + D3D11_BOX region{}; + wgc.LatestTexture(ignored.GetAddressOf(), ®ion); + } +}; diff --git a/Extensions/NeoPic/Func.h b/Extensions/NeoPic/Func.h index 38043133..c5afbf98 100644 --- a/Extensions/NeoPic/Func.h +++ b/Extensions/NeoPic/Func.h @@ -730,6 +730,8 @@ inline void LoadFromDisplay(LPRDATA rdPtr, int Fixed, bool CopyCoef = false) { return LoadFromDisplay(rdPtr, LproFromFixed(rdPtr, Fixed), CopyCoef); } +// Consume an owned Surface instead of cloning it (including on failure). +constexpr DWORD LoadFromPointerFlags_TakeOwnership = 0x2; constexpr auto LoadFromPointerFlags_NoFullPath = 0b0000'0000'0000'0000'0000'0000'0000'0001; inline void LoadFromPointer(LPRDATA rdPtr, LPCWSTR pFileName, LPSURFACE pSf @@ -748,7 +750,8 @@ inline void LoadFromPointer(LPRDATA rdPtr, LPCWSTR pFileName, LPSURFACE pSf // } // }); - const auto pSave = CreateCloneSurface(rdPtr, pSf); + const auto pSave = (dwFlags & LoadFromPointerFlags_TakeOwnership) + ? pSf : CreateCloneSurface(rdPtr, pSf); if (!pSave->IsValid()) { delete pSave; diff --git a/Extensions/NeoPic/General.cpp b/Extensions/NeoPic/General.cpp index 774fc87d..1d8d8276 100644 --- a/Extensions/NeoPic/General.cpp +++ b/Extensions/NeoPic/General.cpp @@ -201,10 +201,10 @@ void WINAPI DLLExport UnloadObject(mv _far *mV, LPEDATA edPtr, int reserved) // For you to update your object structure to newer versions // Called at both edit time and run time HGLOBAL WINAPI DLLExport UpdateEditStructure(mv __far *mV, void __far * OldEdPtr) { - // ((LPEDATA)OldEdPtr)->bLoadKeepAngle = true; - - // We do nothing here - return 0; + HGLOBAL upgraded = nullptr; + UpdateEditData(GetOldEdPtr(static_cast(OldEdPtr), upgraded), + upgraded, KCX_VERSION_V2, UpgradeNeoPicEditData); + return upgraded; } // -------------------- diff --git a/Extensions/NeoPic/Main.cpp b/Extensions/NeoPic/Main.cpp index a5ffbf86..e36317bb 100644 --- a/Extensions/NeoPic/Main.cpp +++ b/Extensions/NeoPic/Main.cpp @@ -867,12 +867,8 @@ short WINAPI DLLExport Action_SaveToFile(LPRDATA rdPtr, long param1, long param2 } short WINAPI DLLExport Action_CaptureFrameArea(LPRDATA rdPtr, long param1, long param2) { - rdPtr->pFrameCapture->Capture(); - - const auto capturedName = L"_TempCapture"; - LoadFromPointer(rdPtr, capturedName, rdPtr->pFrameCapture->pCaptureSf); - - return 0; + rdPtr->pFrameCapture->CaptureAndLoad(rdPtr); + return 0; } short WINAPI DLLExport Action_SaveToFileWithStretch(LPRDATA rdPtr, long param1, long param2) { diff --git a/Extensions/NeoPic/Main.h b/Extensions/NeoPic/Main.h index 666ba33d..e4c032bf 100644 --- a/Extensions/NeoPic/Main.h +++ b/Extensions/NeoPic/Main.h @@ -162,38 +162,8 @@ // --------------------- // Used at edit time and saved in the MFA/CCN/EXE files -typedef struct tagEDATA_V1 -{ - // Header - required - extHeader eHeader; - - // Object's data - short swidth; - short sheight; - - //Lib - bool isLib = false; - - bool bLoadCallback = false; - bool bLoadKeepAngle = false; - - //Display - - //Settings - bool HWA = false; - bool stretchQuality = false; - - size_t memoryLimit; - size_t sizeLimit; - bool autoClean; - - size_t hotSpotComboID; - -} EDITDATA; -typedef EDITDATA * LPEDATA; - -// Object versions -#define KCX_CURRENT_VERSION 1 +// Versioned layout and V1 upgrade defaults; V2 includes 64 reserved bytes. +#include "EditData.h" // -------------------------------- // RUNNING OBJECT DATA STRUCTURE diff --git a/Extensions/NeoPic/NeoPic.vcxproj b/Extensions/NeoPic/NeoPic.vcxproj index db46ea49..a143db05 100644 --- a/Extensions/NeoPic/NeoPic.vcxproj +++ b/Extensions/NeoPic/NeoPic.vcxproj @@ -254,7 +254,11 @@ copy $(TargetPath) "C:\Steam\steamapps\common\Clickteam Fusion 2.5\Data\Runtime\ + + + true + @@ -305,6 +309,16 @@ copy $(TargetPath) "C:\Steam\steamapps\common\Clickteam Fusion 2.5\Data\Runtime\ + + + + + + + + + + @@ -319,4 +333,4 @@ copy $(TargetPath) "C:\Steam\steamapps\common\Clickteam Fusion 2.5\Data\Runtime\ - \ No newline at end of file + diff --git a/Extensions/NeoPic/Resource.h b/Extensions/NeoPic/Resource.h index 41b982a5..a0bd81ef 100644 --- a/Extensions/NeoPic/Resource.h +++ b/Extensions/NeoPic/Resource.h @@ -57,6 +57,13 @@ #define IDS_PROP_HOTSPOT 229 #define IDS_PROP_HOTSPOT_INFO 230 +#define IDS_PROP_CAPTURE_TEXTTITLE 233 +#define IDS_PROP_CAPTURE_BACKEND 234 +#define IDS_PROP_CAPTURE_BACKEND_INFO 235 +#define IDS_PROP_CAPTURE_DC 236 +#define IDS_PROP_CAPTURE_SECONDARY 237 +#define IDS_PROP_CAPTURE_WGC 238 + // Dialog Box ID #define DB_SETUP 20003 diff --git a/Extensions/NeoPic/Runtime.cpp b/Extensions/NeoPic/Runtime.cpp index da3252c2..cde62253 100644 --- a/Extensions/NeoPic/Runtime.cpp +++ b/Extensions/NeoPic/Runtime.cpp @@ -146,7 +146,8 @@ short WINAPI DLLExport CreateRunObject(LPRDATA rdPtr, LPEDATA edPtr, fpcob cobPt rdPtr->hotSpotPos = static_cast(edPtr->hotSpotComboID); rdPtr->zoomScale = { 1.0,1.0 }; - rdPtr->pFrameCapture = new FrameCapture(rdPtr->rHo.hoAdRunHeader->rhHEditWin); + rdPtr->pFrameCapture = CreateFrameCapture(rdPtr, + NormalizeCaptureBackend(static_cast(edPtr->captureBackend))).release(); rdPtr->pAI = new AnimationInterface(rdPtr); rdPtr->pNS = new NineSliceInterface(rdPtr); @@ -264,6 +265,8 @@ short WINAPI DLLExport HandleRunObject(LPRDATA rdPtr) At the end of the loop this code will run */ + rdPtr->pFrameCapture->Update(rdPtr); // Source preparation only; capture stays synchronous. + rdPtr->pAI->UpdateAnimation(); rdPtr->pNS->Render(); diff --git a/Extensions/NeoPic/WgcCapture.cpp b/Extensions/NeoPic/WgcCapture.cpp new file mode 100644 index 00000000..42dae485 --- /dev/null +++ b/Extensions/NeoPic/WgcCapture.cpp @@ -0,0 +1,162 @@ +#include "WgcCapture.h" +#include "CaptureCrop.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#pragma comment(lib, "windowsapp.lib") +#pragma comment(lib, "d3d11.lib") +#pragma comment(lib, "runtimeobject.lib") +#pragma comment(lib, "dwmapi.lib") + +using namespace winrt::Windows::Graphics; +using namespace winrt::Windows::Graphics::Capture; +using namespace winrt::Windows::Graphics::DirectX; +using namespace winrt::Windows::Graphics::DirectX::Direct3D11; + +struct WgcCapture::State { + // Declared first so COM is uninitialized after all WinRT members are released. + struct Apartment { + HRESULT result = RoInitialize(RO_INIT_SINGLETHREADED); + ~Apartment() { if (SUCCEEDED(result)) RoUninitialize(); } + } apartment; + HWND frameWindow = nullptr; + HWND rootWindow = nullptr; + IDirect3DDevice device{nullptr}; + GraphicsCaptureItem item{nullptr}; + Direct3D11CaptureFramePool pool{nullptr}; + GraphicsCaptureSession session{nullptr}; + Direct3D11CaptureFrame current{nullptr}; + SizeInt32 size{}; + ~State() { + try { if (current) current.Close(); } catch (...) {} + try { if (session) session.Close(); } catch (...) {} + try { if (pool) pool.Close(); } catch (...) {} + } +}; + +WgcCapture::WgcCapture() = default; +WgcCapture::~WgcCapture() = default; + +static void ReportWgcFailure(HRESULT hr, const char* stage, HWND frame, HWND root) noexcept { + char text[320]; + sprintf_s(text, "NeoPic WGC %s failed hr=0x%08lX frame=%p root=%p frameVisible=%d rootVisible=%d\n", + stage, static_cast(hr), static_cast(frame), static_cast(root), + IsWindowVisible(frame), IsWindowVisible(root)); + OutputDebugStringA(text); +} + +HRESULT WgcCapture::Start(HWND frameWindow, ID3D11Device* device, bool diagnostics) noexcept { + const HWND root = GetAncestor(frameWindow, GA_ROOT); + const char* stage = "initialize"; + try { + if (!IsWindow(frameWindow) || !IsWindow(root) || !device) return E_INVALIDARG; + if (!IsWindowVisible(frameWindow) || !IsWindowVisible(root)) return E_PENDING; + auto next = std::make_unique(); + if (FAILED(next->apartment.result) && next->apartment.result != RPC_E_CHANGED_MODE) + return next->apartment.result; + if (!GraphicsCaptureSession::IsSupported()) return E_NOTIMPL; + next->frameWindow = frameWindow; + // Capture the top-level window and crop to Fusion's child client area. + next->rootWindow = root; + stage = "GraphicsCaptureItem factory"; + auto factory = winrt::get_activation_factory(); + stage = "CreateForWindow"; + // Handle an expected initialization failure as an HRESULT, rather than + // explicitly throwing a hresult_error at this call site in the debugger. + const HRESULT created = factory->CreateForWindow(next->rootWindow, + winrt::guid_of(), winrt::put_abi(next->item)); + if (FAILED(created)) { + if (diagnostics) ReportWgcFailure(created, stage, frameWindow, root); + return created; + } + stage = "DXGI device interop"; + auto dxgi = winrt::com_ptr{}; + dxgi.copy_from(device); + auto dxgiDevice = dxgi.as(); + winrt::com_ptr inspectable; + winrt::check_hresult(CreateDirect3D11DeviceFromDXGIDevice(dxgiDevice.get(), inspectable.put())); + next->device = inspectable.as(); + next->size = next->item.Size(); + stage = "CreateFreeThreaded frame pool"; + next->pool = Direct3D11CaptureFramePool::CreateFreeThreaded(next->device, + DirectXPixelFormat::B8G8R8A8UIntNormalized, 2, next->size); + stage = "CreateCaptureSession"; + next->session = next->pool.CreateCaptureSession(next->item); + // Default OS capture border/cursor behavior is intentionally retained. + stage = "StartCapture"; + next->session.StartCapture(); + state = std::move(next); + return S_OK; + } catch (...) { + const HRESULT hr = winrt::to_hresult(); + if (diagnostics) ReportWgcFailure(hr, stage, frameWindow, root); + state.reset(); + return hr; + } +} + +void WgcCapture::ReleaseCurrent() noexcept { + if (!state || !state->current) return; + try { state->current.Close(); } catch (...) {} + state->current = nullptr; +} + +HRESULT WgcCapture::LatestTexture(ID3D11Texture2D** texture, D3D11_BOX* crop) noexcept { + if (!texture || !crop) return E_POINTER; + *texture = nullptr; + *crop = {}; + try { + if (!state) return E_UNEXPECTED; + if (!IsWindow(state->frameWindow)) return HRESULT_FROM_WIN32(ERROR_INVALID_WINDOW_HANDLE); + if (IsIconic(state->rootWindow)) return E_PENDING; + // At most two queued frames per Handle; no blocking wait or busy polling. + for (int i = 0; i < 2; ++i) { + auto next = state->pool.TryGetNextFrame(); + if (!next) break; + if (state->current) state->current.Close(); + state->current = next; + } + if (!state->current) return E_PENDING; + const auto size = state->current.ContentSize(); + if (size.Width <= 0 || size.Height <= 0) return E_PENDING; + if (size.Width != state->size.Width || size.Height != state->size.Height) { + state->current.Close(); + state->current = nullptr; + state->pool.Recreate(state->device, DirectXPixelFormat::B8G8R8A8UIntNormalized, 2, size); + state->size = size; + return E_PENDING; + } + auto access = state->current.Surface().as< + ::Windows::Graphics::DirectX::Direct3D11::IDirect3DDxgiInterfaceAccess>(); + winrt::com_ptr source; + winrt::check_hresult(access->GetInterface(__uuidof(ID3D11Texture2D), source.put_void())); + + // DWM bounds and client origin must both be physical pixels, regardless + // of the application's DPI mode. Restore the original thread context. + struct DpiScope { + DPI_AWARENESS_CONTEXT previous = SetThreadDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + ~DpiScope() { if (previous) SetThreadDpiAwarenessContext(previous); } + } dpi; + RECT bounds{}, client{}; + POINT origin{}; + winrt::check_hresult(DwmGetWindowAttribute(state->rootWindow, DWMWA_EXTENDED_FRAME_BOUNDS, &bounds, sizeof(bounds))); + if (!GetClientRect(state->frameWindow, &client) || !ClientToScreen(state->frameWindow, &origin)) + return E_FAIL; + neopic_capture::Crop region{}; + if (!neopic_capture::ClientCrop(bounds.left, bounds.top, + static_cast(bounds.right) - bounds.left, + static_cast(bounds.bottom) - bounds.top, + origin.x, origin.y, client.right, client.bottom, size.Width, size.Height, region)) + return E_PENDING; + *crop = {region.left, region.top, 0, region.right, region.bottom, 1}; + *texture = source.detach(); + return S_OK; + } catch (...) { return winrt::to_hresult(); } +} diff --git a/Extensions/NeoPic/WgcCapture.h b/Extensions/NeoPic/WgcCapture.h new file mode 100644 index 00000000..c0da0d7c --- /dev/null +++ b/Extensions/NeoPic/WgcCapture.h @@ -0,0 +1,22 @@ +#pragma once + +#include +#include + +// Keep C++/WinRT out of the legacy /Zc:wchar_t- Fusion translation units. +// WgcCapture.cpp is compiled separately with native wchar_t enabled. +class WgcCapture { + struct State; + std::unique_ptr state; +public: + WgcCapture(); + ~WgcCapture(); + WgcCapture(const WgcCapture&) = delete; + WgcCapture& operator=(const WgcCapture&) = delete; + HRESULT Start(HWND frameWindow, ID3D11Device* device, bool diagnostics = true) noexcept; + // Returned texture remains backed by a retained capture frame. Do not call + // again while its GPU copy is pending. ReleaseCurrent only after completion. + // All methods are called on Fusion's runtime thread. + HRESULT LatestTexture(ID3D11Texture2D** texture, D3D11_BOX* crop) noexcept; + void ReleaseCurrent() noexcept; +}; diff --git a/Extensions/NeoPic/tests/CaptureAdapterMocks.h b/Extensions/NeoPic/tests/CaptureAdapterMocks.h new file mode 100644 index 00000000..6e667c7e --- /dev/null +++ b/Extensions/NeoPic/tests/CaptureAdapterMocks.h @@ -0,0 +1,95 @@ +#pragma once +// Test-only SDK stand-ins. Never on the plugin include path. Native D3D/Win32 +// types are real; Fusion layout, rendering and pixel correctness are not emulated. +#define COMMON_H +#include +#include +#include +#include + +constexpr int SD_D3D9 = 9, SD_D3D11 = 11, ST_HWA_RTTEXTURE = 5; +constexpr DWORD AH2OPT_RESAMPLESTRETCH = 1; +inline int mockSurfaceCount = 0; +class cSurface { + int width, height; +public: + int driver = SD_D3D11; + int generation = 0; + cSurface(int w = 1, int h = 1) : width(w), height(h) { ++mockSurfaceCount; } + ~cSurface() { --mockSurfaceCount; } + bool IsValid() const { return width > 0 && height > 0; } + int GetWidth() const { return width; } + int GetHeight() const { return height; } + int GetDepth() const { return 32; } + int GetDriver() const { return driver; } + int GetType() const { return ST_HWA_RTTEXTURE; } + void GetOrigin(POINT& p) const { p = {}; } + void CreateAlpha() {} + HDC GetDC() { return nullptr; } + void ReleaseDC(HDC) {} +}; +using LPSURFACE = cSurface*; +struct MockEffect { DWORD ffxInkEffect = 0; }; +struct MockFrame { + cSurface* m_pSecondarySurface = nullptr; + bool m_bAlwaysUseSecondarySurface = false; + void* m_pLayerEffects = nullptr; + MockEffect* m_pEffect = nullptr; + void* m_pEffectEx = nullptr; + float m_angle = 0, m_xScale = 1, m_yScale = 1; + POINT m_destPoint{}, m_hotSpot{}; +}; +struct MockHeader2 { DWORD dwOptions = 0; }; +struct MockApp { + cSurface* m_protoLogScreen = nullptr; + MockHeader2* m_pHdr2 = nullptr; + bool m_bAlwaysUseSecondarySurface = false; + cSurface* m_pOldSecondarySurface = nullptr; + MockFrame* m_Frame = nullptr; +}; +struct MockRunHeader { + HWND rhHEditWin = nullptr; + MockApp* rhApp = nullptr; + MockFrame* rhFrame = nullptr; + struct { int rh3WindowSx = 640, rh3WindowSy = 480; } rh3; +}; +struct MockRunData { + struct { MockRunHeader* hoAdRunHeader = nullptr; } rHo; + bool HWA = false, isLib = false; + struct Display { + int driver = SD_D3D11; + int GetDriver() const { return driver; } + } display; + bool surfaceReady = true; +}; +using LPRDATA = MockRunData*; +struct MockSurfaceInfo { + int m_nD3DVersion = 11; + void* m_pD3D11Device = nullptr; + void* m_pD3D11Context = nullptr; + void* m_ppD3D11RenderTargetTexture = nullptr; + void* m_ppD3D11RenderTargetView = nullptr; +}; +inline auto WinGetSurface(LPRDATA rdPtr) { return rdPtr->surfaceReady ? &rdPtr->display : nullptr; } +inline bool D3D11(LPRDATA rdPtr) { return rdPtr->display.driver == SD_D3D11; } +inline void* GetD3D11Device(LPRDATA) { return nullptr; } +inline MockSurfaceInfo GetD3D11Info(LPRDATA) { return {}; } +inline MockSurfaceInfo GetD3D11SurfaceInfo(cSurface*) { return {}; } +inline cSurface* CreateSurface(int, int w, int h) { return new cSurface(w, h); } +inline cSurface* CreateHWASurface(LPRDATA, int, int w, int h, int) { return new cSurface(w, h); } +inline cSurface* CreateCloneSurface(LPRDATA, cSurface* p) { return new cSurface(p->GetWidth(), p->GetHeight()); } +inline void _AddAlpha(cSurface*) {} +struct RenderHelper { RenderHelper(cSurface*, BOOL, std::uint32_t = 0) {} }; +using RGBAREF = std::uint32_t; +constexpr DWORD LoadFromPointerFlags_TakeOwnership = 2; +inline std::unique_ptr mockPublished; +inline int mockHandoffs = 0; +inline bool mockOwnedHandoff = true; +inline void LoadFromPointer(LPRDATA, LPCWSTR name, cSurface* surface, DWORD flags) { + mockOwnedHandoff &= flags == LoadFromPointerFlags_TakeOwnership; + constexpr auto expected = L"_TempCapture"; + for (unsigned i = 0; i < 13; ++i) mockOwnedHandoff &= name[i] == expected[i]; + mockPublished.reset(surface); + ++mockHandoffs; +} +#include "../FrameCapture.h" diff --git a/Extensions/NeoPic/tests/CaptureBenchmarkTest.cpp b/Extensions/NeoPic/tests/CaptureBenchmarkTest.cpp new file mode 100644 index 00000000..31cbc8f2 --- /dev/null +++ b/Extensions/NeoPic/tests/CaptureBenchmarkTest.cpp @@ -0,0 +1,57 @@ +#include "../CaptureBenchmark.h" +#include +#include +#include + +static bool Near(double a, double b) { return std::abs(a - b) < 1e-9; } + +int main() { + using namespace neopic_capture; + assert(Summarize({}).count == 0); + const auto single = Summarize({7}); + assert(single.count == 1 && single.mean == 7 && single.minimum == 7 + && single.median == 7 && single.p95 == 7 && single.maximum == 7); + const auto even = Summarize({4, 1, 3, 2}); + assert(even.count == 4 && even.mean == 2.5 && even.median == 2.5 + && even.minimum == 1 && even.maximum == 4 && even.p95 == 4); + assert(Summarize({5, 1, 3}).median == 3); + std::vector twenty; + for (int i = 20; i > 0; --i) twenty.push_back(i); + assert(Summarize(twenty).p95 == 19); // nearest-rank boundary + + std::int64_t ticks = 0; + std::size_t calls = 0; + const int durations[] = {100, 50, 1, 1000, 3, 5}; + auto capture = [&]() { ticks += durations[calls]; return calls++ != 3; }; + auto clock = [&]() { return ticks; }; + const auto mixed = RunBenchmark(4, 2, capture, clock, 1000); + assert(calls == 6 && mixed.warmupSuccess == 2 && mixed.warmupFailure == 0); + assert(mixed.firstSuccess && mixed.firstMs == 100 && mixed.totalMs == 1009); + assert(mixed.success.count == 3 && mixed.success.mean == 3 + && mixed.success.minimum == 1 && mixed.success.median == 3 + && mixed.success.p95 == 5 && mixed.success.maximum == 5); + assert(mixed.failure.count == 1 && mixed.failure.mean == 1000 && !mixed.noReadySource); + + calls = 0; + auto fail = [&]() { ++calls; ticks += 2; return false; }; + const auto unavailable = RunBenchmark(100, 3, fail, clock, 1000); + assert(calls == 3 && unavailable.noReadySource && unavailable.warmupFailure == 3 + && unavailable.success.count == 0 && unavailable.failure.count == 0 + && unavailable.totalMs == 0 && unavailable.firstMs == 2 && !unavailable.firstSuccess); + const auto failed = RunBenchmark(4, 0, fail, clock, 1000); + assert(calls == 7 && !failed.noReadySource && failed.success.count == 0 + && failed.failure.count == 4 && failed.totalMs == 8 && failed.firstMs == 2); + + calls = 0; + auto success = [&]() { ++calls; ticks += 1; return true; }; + const auto noWarmup = RunBenchmark(2, 0, success, clock, 2000); + assert(calls == 2 && noWarmup.firstSuccess && Near(noWarmup.firstMs, 0.5) + && Near(noWarmup.totalMs, 1) && noWarmup.success.count == 2); + for (int which = 0; which < 3; ++which) { + bool rejected = false; + try { RunBenchmark(which == 0 ? 0 : 1, 0, success, clock, which == 0 ? 1000 : 1 - which); } + catch (const std::invalid_argument&) { rejected = true; } + assert(rejected && calls == 2); + } + std::cout << "PASS: benchmark warmup, timing, percentiles, failures and unavailable source\n"; +} diff --git a/Extensions/NeoPic/tests/CaptureBlitWarpTest.cpp b/Extensions/NeoPic/tests/CaptureBlitWarpTest.cpp new file mode 100644 index 00000000..d86463cd --- /dev/null +++ b/Extensions/NeoPic/tests/CaptureBlitWarpTest.cpp @@ -0,0 +1,123 @@ +// Standalone Windows test: no Fusion runtime or capture permissions required. +#include "../CaptureBlit.h" +#include "../CaptureCompletion.h" +#include +#include +#include +#pragma comment(lib, "d3d11.lib") + +static void Check(HRESULT hr, const char* stage) { + if (FAILED(hr)) throw std::runtime_error(std::string(stage) + " HRESULT=" + std::to_string(hr)); +} +static void Require(bool ok, const char* message) { + if (!ok) throw std::runtime_error(message); +} +int main() { + try { + using Microsoft::WRL::ComPtr; + ComPtr device; + ComPtr context; + Check(D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_WARP, nullptr, D3D11_CREATE_DEVICE_BGRA_SUPPORT, nullptr, 0, + D3D11_SDK_VERSION, device.GetAddressOf(), nullptr, context.GetAddressOf()), "WARP device"); + std::array pixels{}; + const unsigned char colors[4][4]{{255,0,0,0}, {0,255,0,0}, {0,0,255,0}, {255,128,192,0}}; + for (unsigned y = 0; y < 8; ++y) for (unsigned x = 0; x < 8; ++x) { + auto p = pixels.data() + (y * 8 + x) * 4; + if (x < 4 && y < 4) std::memcpy(p, colors[(y / 2) * 2 + x / 2], 4); + else { p[0] = p[1] = p[2] = 255; p[3] = 0; } // poisoned padding + } + D3D11_TEXTURE2D_DESC desc{}; + desc.Width = desc.Height = 8; + desc.MipLevels = desc.ArraySize = desc.SampleDesc.Count = 1; + desc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = D3D11_BIND_SHADER_RESOURCE; + const D3D11_SUBRESOURCE_DATA data{pixels.data(), 8 * 4, 0}; + ComPtr source, target; + Check(device->CreateTexture2D(&desc, &data, source.GetAddressOf()), "source"); + desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + desc.BindFlags = D3D11_BIND_RENDER_TARGET | D3D11_BIND_SHADER_RESOURCE; + Check(device->CreateTexture2D(&desc, nullptr, target.GetAddressOf()), "target"); + ComPtr view; + Check(device->CreateRenderTargetView(target.Get(), nullptr, view.GetAddressOf()), "RTV"); + const D3D11_VIEWPORT oldViewport{3, 4, 20, 30, 0, 1}; + context->RSSetViewports(1, &oldViewport); + context->IASetPrimitiveTopology(D3D11_PRIMITIVE_TOPOLOGY_LINELIST); + const D3D11_BOX content{0, 0, 0, 4, 4, 1}; + CaptureBlit pass; + CaptureCompletion completed; + Check(completed.Prepare(device.Get(), context.Get()), "completion query"); + Check(pass.Draw(context.Get(), source.Get(), content, view.Get(), 2, 2, false), "normalize pass"); + completed.Submit(); + Check(completed.Wait(), "draw completion"); + completed.Reset(); + D3D11_VIEWPORT restored{}; + UINT count = 1; + context->RSGetViewports(&count, &restored); + D3D11_PRIMITIVE_TOPOLOGY topology{}; + context->IAGetPrimitiveTopology(&topology); + Require(count == 1 && restored.TopLeftX == 3 && restored.TopLeftY == 4 + && restored.Width == 20 && restored.Height == 30 + && topology == D3D11_PRIMITIVE_TOPOLOGY_LINELIST, "Fusion pipeline state was not restored"); + auto invalid = content; + invalid.right = 9; + Require(FAILED(pass.Draw(context.Get(), source.Get(), invalid, view.Get(), 2, 2, false)), "invalid source region accepted"); + desc.Usage = D3D11_USAGE_STAGING; + desc.BindFlags = 0; + desc.CPUAccessFlags = D3D11_CPU_ACCESS_READ; + ComPtr staging; + Check(device->CreateTexture2D(&desc, nullptr, staging.GetAddressOf()), "test readback texture"); + Check(completed.Prepare(device.Get(), context.Get()), "readback query"); + context->CopyResource(staging.Get(), target.Get()); + completed.Submit(); + Check(completed.Wait(), "readback completion"); + completed.Reset(); + D3D11_MAPPED_SUBRESOURCE map{}; + Check(context->Map(staging.Get(), 0, D3D11_MAP_READ, D3D11_MAP_FLAG_DO_NOT_WAIT, &map), "nonblocking test Map"); + bool match = true; + for (unsigned y = 0; y < 2; ++y) for (unsigned x = 0; x < 2; ++x) { + const auto actual = static_cast(map.pData) + y * map.RowPitch + x * 4; + const auto expected = colors[y * 2 + x]; + match &= actual[0] == expected[2] && actual[1] == expected[1] + && actual[2] == expected[0] && actual[3] == 255; + } + const auto padding = static_cast(map.pData) + 3 * map.RowPitch + 3 * 4; + match &= padding[0] == 0 && padding[1] == 0 && padding[2] == 0 && padding[3] == 255; + context->Unmap(staging.Get(), 0); + Require(match, "logical size/crop/orientation/channel order/opaque alpha pixel mismatch"); + std::cout << "PASS: source padding, logical resize, RGBA-to-BGRA, opaque alpha, orientation, completion and state restore\n"; + // DC-style scaling: independent source/output sizes and aspect ratios. + // Reuse the pass and target across size changes to catch stale dimensions. + // 6x3 uses exact sample centers: source x={0,1,1,2,3,3}, y={0,2,3}. + const unsigned outputSizes[][2]{{6,3}, {2,6}, {2,2}}; + for (const auto& size : outputSizes) { + Check(completed.Prepare(device.Get(), context.Get()), "resized draw query"); + Check(pass.Draw(context.Get(), source.Get(), content, view.Get(), size[0], size[1], false), "resized normalize pass"); + completed.Submit(); + Check(completed.Wait(), "resized draw completion"); + completed.Reset(); + Check(completed.Prepare(device.Get(), context.Get()), "resized readback query"); + context->CopyResource(staging.Get(), target.Get()); + completed.Submit(); + Check(completed.Wait(), "resized readback completion"); + completed.Reset(); + Check(context->Map(staging.Get(), 0, D3D11_MAP_READ, D3D11_MAP_FLAG_DO_NOT_WAIT, &map), "resized nonblocking Map"); + match = true; + for (unsigned y = 0; y < size[1]; ++y) for (unsigned x = 0; x < size[0]; ++x) { + const unsigned sourceX = ((2 * x + 1) * 4) / (2 * size[0]); + const unsigned sourceY = ((2 * y + 1) * 4) / (2 * size[1]); + const auto actual = static_cast(map.pData) + y * map.RowPitch + x * 4; + const auto expected = colors[(sourceY / 2) * 2 + sourceX / 2]; + match &= actual[0] == expected[2] && actual[1] == expected[1] + && actual[2] == expected[0] && actual[3] == 255; + } + context->Unmap(staging.Get(), 0); + Require(match, "resized output used stale dimensions, cropped content or changed aspect unexpectedly"); + } + std::cout << "PASS: independent source/output aspect ratios, up/down scaling and repeated output resize\n"; + return 0; + } catch (const std::exception& e) { + std::cerr << e.what() << '\n'; + return 1; + } +} diff --git a/Extensions/NeoPic/tests/CaptureCompletionTest.cpp b/Extensions/NeoPic/tests/CaptureCompletionTest.cpp new file mode 100644 index 00000000..205a71a5 --- /dev/null +++ b/Extensions/NeoPic/tests/CaptureCompletionTest.cpp @@ -0,0 +1,63 @@ +// Compile with -I tests/completion-mocks; the production helper is tested directly. +#include "../CaptureCompletion.h" +#include + +int main() { + ID3D11Device device; + ID3D11DeviceContext context; + CaptureCompletion completion; + assert(completion.Poll() == E_UNEXPECTED); + assert(completion.Prepare(nullptr, &context) == E_POINTER); + context.type = D3D11_DEVICE_CONTEXT_DEFERRED; + assert(completion.Prepare(&device, &context) == E_INVALIDARG); + assert(device.allocations == 0); + context.type = D3D11_DEVICE_CONTEXT_IMMEDIATE; + device.result = E_FAIL; + assert(completion.Prepare(&device, &context) == E_FAIL); + assert(!completion.InFlight() && context.operations.empty()); + device.result = S_OK; + assert(completion.Prepare(&device, &context) == S_OK); + assert(completion.Prepare(&device, &context) == HRESULT_FROM_WIN32(ERROR_BUSY)); + assert(!completion.InFlight()); + context.replies = {{S_FALSE, FALSE}, {S_OK, FALSE}, {S_OK, TRUE}}; + completion.Submit(); + assert(completion.InFlight()); + assert((context.operations == std::vector{1, 2})); // End then one Flush + assert(completion.Poll() == S_FALSE); + assert(completion.Poll() == S_FALSE); // S_OK alone does not mean done + assert(completion.Poll() == S_OK); + assert(context.polls == 3 && context.correctFlags && mockSleeps == 0); + assert(context.operations.size() == 2); // Poll never flushes or waits + completion.Reset(); + assert(!completion.InFlight() && device.event.refs == 1 && context.refs == 1); + + assert(completion.Prepare(&device, &context) == S_OK); + completion.Submit(); + context.polls = 0; + context.replies = {{E_FAIL, FALSE}}; + assert(completion.Poll() == E_FAIL); // propagate device/query errors + completion.Reset(); + + assert(completion.Prepare(&device, &context) == S_OK); + completion.Submit(); + context.polls = 0; + context.replies = {{2, TRUE}}; + assert(completion.Poll() == E_UNEXPECTED); // no other success code publishes + completion.Reset(); + + assert(completion.Prepare(&device, &context) == S_OK); + completion.Submit(); + context.polls = 0; + context.replies = {{S_FALSE, FALSE}, {S_OK, TRUE}}; + assert(completion.Wait() == S_OK); + assert(mockSleeps == 1); // synchronous capture explicitly waits for done + completion.Reset(); + + assert(completion.Prepare(&device, &context) == S_OK); + completion.Submit(); + context.polls = 0; + context.replies = {{S_FALSE, FALSE}, {E_FAIL, FALSE}}; + assert(completion.WaitForTeardown() == E_FAIL); + assert(mockSleeps == 2); // an error ends the wait, never reports success + completion.Reset(); +} diff --git a/Extensions/NeoPic/tests/CaptureCropTest.cpp b/Extensions/NeoPic/tests/CaptureCropTest.cpp new file mode 100644 index 00000000..d8dac703 --- /dev/null +++ b/Extensions/NeoPic/tests/CaptureCropTest.cpp @@ -0,0 +1,23 @@ +#include "../CaptureCrop.h" +#include + +int main() { + using neopic_capture::ClientCrop; + neopic_capture::Crop c{}; + // Client excludes root window title/menu, at native and scaled capture sizes. + assert(ClientCrop(100, 200, 1280, 760, 100, 240, 1280, 720, 1280, 760, c)); + assert(c.left == 0 && c.top == 40 && c.right == 1280 && c.bottom == 760); + assert(ClientCrop(100, 200, 1280, 760, 100, 240, 1280, 720, 1920, 1140, c)); + assert(c.left == 0 && c.top == 60 && c.right == 1920 && c.bottom == 1140); + // A monitor to the left of the primary has negative desktop coordinates. + assert(ClientCrop(-1920, -200, 1280, 760, -1920, -160, 1280, 720, 1280, 760, c)); + assert(c.left == 0 && c.top == 40 && c.right == 1280 && c.bottom == 760); + // Fractional scale: round the two edges independently. + assert(ClientCrop(0, 0, 10, 10, 1, 1, 8, 8, 13, 13, c)); + assert(c.left == 1 && c.top == 1 && c.right == 12 && c.bottom == 12); + assert(!ClientCrop(0, 0, 0, 760, 0, 40, 1280, 720, 1280, 760, c)); + assert(!ClientCrop(0, 0, 1280, 760, -1, 40, 1280, 720, 1280, 760, c)); + assert(!ClientCrop(0, 0, 1280, 760, 0, 50, 1280, 720, 1280, 760, c)); + assert(!ClientCrop(0, 0, 1280, 760, 0, 40, 0, 720, 1280, 760, c)); + assert(!ClientCrop(0, 0, 1280, 760, 0, 40, 1280, 720, 0, 760, c)); +} diff --git a/Extensions/NeoPic/tests/CaptureHeaderCompile.cpp b/Extensions/NeoPic/tests/CaptureHeaderCompile.cpp new file mode 100644 index 00000000..89460bf8 --- /dev/null +++ b/Extensions/NeoPic/tests/CaptureHeaderCompile.cpp @@ -0,0 +1,13 @@ +// Compile the concrete backends and optional derived example with the real SDK. +#include "../FrameCaptureBenchmark.h" + +static_assert(sizeof(CaptureBackend) == 4 && sizeof(EDITDATA::reserved) == 64); +static_assert(offsetof(EDITDATA, captureBackend) == sizeof(EDITDATA_V1)); +static_assert(sizeof(EDITDATA) == sizeof(EDITDATA_V1) + sizeof(CaptureBackend) + 64); + +void CompileFactories(LPRDATA rdPtr) { + auto normal = CreateFrameCapture(rdPtr, CaptureBackend::Dc); + auto measured = CreateFrameCaptureBenchmark(rdPtr, CaptureBackend::Secondary, {100, 10}); + normal->CaptureAndLoad(rdPtr); + measured->CaptureAndLoad(rdPtr); +} diff --git a/Extensions/NeoPic/tests/CaptureInheritanceTest.cpp b/Extensions/NeoPic/tests/CaptureInheritanceTest.cpp new file mode 100644 index 00000000..efd57f3c --- /dev/null +++ b/Extensions/NeoPic/tests/CaptureInheritanceTest.cpp @@ -0,0 +1,117 @@ +#include "CaptureAdapterMocks.h" +#include "../FrameCaptureBenchmark.h" +#include +#include +#include +#pragma comment(lib, "d3d11.lib") +#pragma comment(lib, "user32.lib") +#pragma comment(lib, "gdi32.lib") + +// WGC's real implementation has separate native-wchar_t compiler coverage. +// This test exercises factory/ownership/derived dispatch, not capture permission. +struct WgcCapture::State {}; +WgcCapture::WgcCapture() = default; +WgcCapture::~WgcCapture() = default; +HRESULT WgcCapture::Start(HWND, ID3D11Device*, bool) noexcept { return E_NOTIMPL; } +HRESULT WgcCapture::LatestTexture(ID3D11Texture2D**, D3D11_BOX*) noexcept { return E_PENDING; } +void WgcCapture::ReleaseCurrent() noexcept {} + +inline int scriptedDestructions = 0; +class ScriptedCapture : public FrameCapture { +protected: + HRESULT CaptureSurface(LPRDATA rdPtr, std::unique_ptr& surface) override { + ++calls; + ReleaseInput(); + if (calls == throwAt) throw std::runtime_error("injected capture failure"); + if (failAll || calls == failAt) return E_PENDING; + const auto size = OutputSize(rdPtr, {320, 240}); + surface = std::make_unique(size.cx, size.cy); + surface->generation = calls; + return S_OK; + } + void ReleaseInput() override { ++inputReleases; } +public: + int calls = 0, inputReleases = 0, failAt = 0, throwAt = 0; + bool failAll = false; + explicit ScriptedCapture(LPRDATA) : FrameCapture(CaptureBackend::Secondary) {} + ~ScriptedCapture() override { ++scriptedDestructions; } +}; + +int main() { + MockRunHeader rh; + MockHeader2 header; + MockApp app; + app.m_pHdr2 = &header; + rh.rhApp = &app; + MockRunData rd; + rd.rHo.hoAdRunHeader = &rh; + for (const auto backend : {CaptureBackend::Dc, CaptureBackend::Secondary, CaptureBackend::WindowsGraphicsCapture}) { + auto capture = CreateFrameCapture(&rd, backend); + assert(capture->Backend() == backend); + auto measured = CreateFrameCaptureBenchmark(&rd, backend, {2, 1}); + assert(measured->Backend() == backend); + } + rd.display.driver = SD_D3D9; + for (const auto backend : {CaptureBackend::Dc, CaptureBackend::Secondary, CaptureBackend::WindowsGraphicsCapture}) { + auto capture = CreateFrameCapture(&rd, backend); + auto measured = CreateFrameCaptureBenchmark(&rd, backend, {2, 1}); + assert(capture->Backend() == CaptureBackend::Dc && dynamic_cast(capture.get())); + assert(measured->Backend() == CaptureBackend::Dc + && dynamic_cast*>(measured.get())); + } + rd.surfaceReady = false; + { + cSurface prototype; + prototype.driver = SD_D3D9; + app.m_protoLogScreen = &prototype; + assert(CreateFrameCapture(&rd, CaptureBackend::Secondary)->Backend() == CaptureBackend::Dc); + assert(CreateFrameCaptureBenchmark(&rd, CaptureBackend::WindowsGraphicsCapture)->Backend() == CaptureBackend::Dc); + app.m_protoLogScreen = nullptr; + // Unknown readiness is not proof of DX9; do not guess a different mode. + assert(CreateFrameCapture(&rd, CaptureBackend::Secondary)->Backend() == CaptureBackend::Secondary); + } + rd.surfaceReady = true; + rd.display.driver = 0; // Other renderer modes retain the existing failure policy. + assert(CreateFrameCapture(&rd, CaptureBackend::WindowsGraphicsCapture)->Backend() == CaptureBackend::WindowsGraphicsCapture); + rd.display.driver = SD_D3D11; + assert(CreateFrameCapture(&rd, CaptureBackend::Secondary)->Backend() == CaptureBackend::Secondary); + std::cout << "PASS: DX9 normal/benchmark fallback to DC, prototype readiness and DX11 selection\n"; + { + auto capture = std::make_unique(&rd); + FrameCapture* base = capture.get(); + assert(base->CaptureAndLoad(&rd) == S_OK); + assert(capture->calls == 1 && capture->inputReleases == 1 && mockHandoffs == 1); + } + mockPublished.reset(); + mockHandoffs = 0; + { + auto capture = std::make_unique>(&rd, CaptureBenchmarkOptions{4, 2}); + capture->failAt = 3; + std::unique_ptr base = std::move(capture); + SIZE output{}; + assert(base->CaptureAndLoad(&rd, &output) == S_OK); + const auto derived = static_cast*>(base.get()); + assert(derived->calls == 6 && derived->inputReleases == 1 && mockHandoffs == 5); + assert(mockOwnedHandoff && mockPublished->generation == 6 && mockSurfaceCount == 1); + assert(output.cx == 640 && output.cy == 480); + derived->failAll = true; + const int published = mockHandoffs; + assert(FAILED(base->CaptureAndLoad(&rd))); + assert(derived->calls == 8 && derived->inputReleases == 2 && mockHandoffs == published); + assert(mockPublished->generation == 6); // Failed warmup preserves the old snapshot. + } + { + FrameCaptureBenchmark capture(&rd, {2, 2}); + capture.throwAt = 2; + bool caught = false; + try { capture.CaptureAndLoad(&rd); } catch (const std::runtime_error&) { caught = true; } + assert(caught && capture.calls == 2 && capture.inputReleases == 1); + capture.throwAt = 0; + assert(capture.CaptureAndLoad(&rd) == S_OK); + assert(capture.calls == 6 && capture.inputReleases == 2); // Session state recovered. + } + assert(scriptedDestructions == 3); + mockPublished.reset(); + assert(mockSurfaceCount == 0); + std::cout << "PASS: backend factory, virtual single/benchmark dispatch, owned handoff, failure and exception cleanup\n"; +} diff --git a/Extensions/NeoPic/tests/CaptureLifetimeTest.cpp b/Extensions/NeoPic/tests/CaptureLifetimeTest.cpp new file mode 100644 index 00000000..e0e0654f --- /dev/null +++ b/Extensions/NeoPic/tests/CaptureLifetimeTest.cpp @@ -0,0 +1,110 @@ +// Real D3D debug-layer lifetime check, without Fusion or SDK stand-ins. +#include "../CaptureBlit.h" +#include "../CaptureCompletion.h" +#include +#include +#include +#include +#pragma comment(lib, "d3d11.lib") + +template using LifetimePtr = Microsoft::WRL::ComPtr; +static void Check(HRESULT hr, const char* stage) { + if (FAILED(hr)) throw std::runtime_error(std::string(stage) + " HRESULT=" + std::to_string(hr)); +} + +static std::vector CaptureObjects(ID3D11Debug* debug, ID3D11InfoQueue* queue) { + queue->ClearStoredMessages(); + Check(debug->ReportLiveDeviceObjects(static_cast(D3D11_RLDO_DETAIL | D3D11_RLDO_IGNORE_INTERNAL)), "live-object report"); + std::vector result; + const auto count = queue->GetNumStoredMessagesAllowedByRetrievalFilter(); + for (UINT64 i = 0; i < count; ++i) { + SIZE_T bytes = 0; + Check(queue->GetMessage(i, nullptr, &bytes), "message size"); + std::vector storage(bytes); + auto message = reinterpret_cast(storage.data()); + Check(queue->GetMessage(i, message, &bytes), "message"); + const std::string text(message->pDescription, message->DescriptionByteLength); + if (text.find("NeoPic.Capture.") != std::string::npos) result.push_back(text); + } + return result; +} + +static void Batch(ID3D11Device* device, ID3D11DeviceContext* context, + ID3D11Debug* debug, ID3D11InfoQueue* queue, unsigned captures, bool reset) { + D3D11_TEXTURE2D_DESC desc{}; + desc.Width = desc.Height = 16; + desc.MipLevels = desc.ArraySize = desc.SampleDesc.Count = 1; + desc.Format = DXGI_FORMAT_B8G8R8A8_UNORM; + desc.Usage = D3D11_USAGE_DEFAULT; + desc.BindFlags = D3D11_BIND_SHADER_RESOURCE | D3D11_BIND_RENDER_TARGET; + LifetimePtr source, output; + LifetimePtr view; + Check(device->CreateTexture2D(&desc, nullptr, source.GetAddressOf()), "source"); + Check(device->CreateTexture2D(&desc, nullptr, output.GetAddressOf()), "output"); + Check(device->CreateRenderTargetView(output.Get(), nullptr, view.GetAddressOf()), "output view"); + CaptureBlit pass; + for (unsigned i = 0; i < captures; ++i) { + CaptureCompletion completed; + Check(completed.Prepare(device, context), "query"); + const D3D11_BOX box{0, 0, 0, 16, 16, 1}; + Check(pass.Draw(context, source.Get(), box, view.Get(), 8 + i % 8, 8, false), "capture"); + completed.Submit(); + Check(completed.Wait(), "completion"); + } + if (CaptureObjects(debug, queue).empty()) + throw std::runtime_error("debug names/report collection did not detect the live capture objects"); + if (reset) { + pass.Reset(); + pass.Reset(); // Idempotent teardown. + if (pass.IsInitialized()) throw std::runtime_error("capture reset left its cache initialized"); + CaptureCompletion completed; + Check(completed.Prepare(device, context), "post-reset query"); + const D3D11_BOX box{0, 0, 0, 16, 16, 1}; + Check(pass.Draw(context, source.Get(), box, view.Get(), 8, 8, false), "reinitialize after reset"); + completed.Submit(); + Check(completed.Wait(), "post-reset completion"); + pass.Reset(); + } +} + +int main() { + try { + LifetimePtr device; + LifetimePtr context; + const HRESULT created = D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_WARP, nullptr, + D3D11_CREATE_DEVICE_BGRA_SUPPORT | D3D11_CREATE_DEVICE_DEBUG, nullptr, 0, + D3D11_SDK_VERSION, device.GetAddressOf(), nullptr, context.GetAddressOf()); + if (created == DXGI_ERROR_SDK_COMPONENT_MISSING) { + std::cout << "SKIP: D3D debug layer unavailable; capture lifetime has NOT been verified\n"; + return 0; + } + Check(created, "debug WARP device"); + LifetimePtr debug; + LifetimePtr queue; + Check(device.As(&debug), "debug interface"); + Check(device.As(&queue), "info queue"); + Check(queue->PushEmptyStorageFilter(), "empty storage filter"); + Check(queue->PushEmptyRetrievalFilter(), "empty retrieval filter"); + auto verify = [&](unsigned count, bool reset) { + Batch(device.Get(), context.Get(), debug.Get(), queue.Get(), count, reset); + // Do not ClearState, even in this standalone test: leaked capture + // bindings must remain observable instead of being hidden by cleanup. + context->Flush(); + const auto remaining = CaptureObjects(debug.Get(), queue.Get()); + if (!remaining.empty()) { + for (const auto& text : remaining) std::cerr << text << '\n'; + throw std::runtime_error("capture objects still have external references after destruction"); + } + }; + verify(1, false); + verify(32, false); + verify(32, true); + for (unsigned i = 0; i < 8; ++i) verify(2, true); + queue->PopRetrievalFilter(); + queue->PopStorageFilter(); + std::cout << "PASS: real debug-layer capture objects released after 1/32 captures, reset/reinitialize and repeated recreation, without ClearState\n"; + } catch (const std::exception& error) { + std::cerr << error.what() << '\n'; + return 1; + } +} diff --git a/Extensions/NeoPic/tests/CompileCapture.ps1 b/Extensions/NeoPic/tests/CompileCapture.ps1 new file mode 100644 index 00000000..de22b219 --- /dev/null +++ b/Extensions/NeoPic/tests/CompileCapture.ps1 @@ -0,0 +1,66 @@ +param([switch]$RunGpuSmokeTest, [switch]$NativeOnly) +$ErrorActionPreference = 'Stop' +# Run in an x86 VS2022 Developer PowerShell. MFX linking remains separate. +if (-not (Get-Command cl.exe -ErrorAction SilentlyContinue)) { + throw 'Run this script in an x86 VS2022 Developer PowerShell with a current Windows SDK.' +} +$repo = (Resolve-Path (Join-Path $PSScriptRoot '../../..')).Path +$neo = Join-Path $repo 'Extensions/NeoPic' +$common = @('/nologo', '/std:c++20', '/EHsc', '/Zs', '/DWIN32', '/D_WINDOWS', + '/DSTRICT', '/DUNICODE', '/D_UNICODE', '/DHWABETA', + "/I$repo/Inc", "/I$repo/Extensions/_DeLib", "/I$repo/Extensions/_3rdLib", + "/I$repo/Extensions/_3rdLib/Boost") +if (-not $NativeOnly -and -not (Test-Path "$repo/Inc/ccxhdr.h")) { + throw 'Fusion SDK headers are required in Inc/ for real SDK syntax checks. -NativeOnly explicitly selects independent native components and test-double coverage.' +} +# All backends coexist now. This is always the real WGC implementation, not an +# empty translation unit selected by a disabled backend macro. +Write-Host 'Checking independent WGC implementation (native wchar_t)' +& cl.exe @common '/Zc:wchar_t' "$neo/WgcCapture.cpp" +if ($LASTEXITCODE -ne 0) { throw 'WGC translation unit failed' } +if ($NativeOnly) { + Write-Host 'Native-only scope: real Fusion SDK Main/Runtime/Edittime/General/header checks are NOT selected.' +} else { + foreach ($source in @('Main.cpp', 'Runtime.cpp', 'Edittime.cpp', 'General.cpp', 'FrameCapture.cpp', 'tests/CaptureHeaderCompile.cpp')) { + & cl.exe @common '/Zc:wchar_t-' "$neo/$source" + if ($LASTEXITCODE -ne 0) { throw "$source failed against the Fusion SDK" } + } + $res = Join-Path ([IO.Path]::GetTempPath()) (([guid]::NewGuid().ToString('N')) + '.res') + try { + & rc.exe '/nologo' "/I$repo/Inc" "/I$neo" "/fo$res" "$neo/Ext.rc" + if ($LASTEXITCODE -ne 0) { throw 'NeoPic property resources failed to compile' } + } finally { + if (Test-Path $res) { Remove-Item -Force $res } + } +} + +if ($RunGpuSmokeTest) { + $temp = Join-Path ([IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString('N')) + New-Item -ItemType Directory -Path $temp | Out-Null + try { + foreach ($test in @('CaptureBlitWarpTest', 'CaptureLifetimeTest')) { + $exe = Join-Path $temp "$test.exe" + $obj = Join-Path $temp "$test.obj" + & cl.exe '/nologo' '/std:c++20' '/EHsc' '/Zc:wchar_t' "/I$repo/Extensions/_3rdLib" "/Fe$exe" "/Fo$obj" "$neo/tests/$test.cpp" + if ($LASTEXITCODE -ne 0) { throw "$test compilation failed" } + & $exe + if ($LASTEXITCODE -ne 0) { throw "$test failed" } + } + foreach ($test in @('CaptureBenchmarkTest', 'EditDataUpgradeTest')) { + $exe = Join-Path $temp "$test.exe" + $obj = Join-Path $temp "$test.obj" + & cl.exe '/nologo' '/std:c++20' '/EHsc' '/W4' '/WX' "/Fe$exe" "/Fo$obj" "$neo/tests/$test.cpp" + if ($LASTEXITCODE -ne 0) { throw "$test compilation failed" } + & $exe + if ($LASTEXITCODE -ne 0) { throw "$test failed" } + } + Write-Host 'Checking production adapter inheritance/ownership against test doubles, not the proprietary Fusion SDK.' + $exe = Join-Path $temp 'CaptureInheritanceTest.exe' + & cl.exe '/nologo' '/std:c++20' '/EHsc' '/Zc:wchar_t-' '/DUNICODE' '/D_UNICODE' "/I$repo/Extensions/_3rdLib" "/FI$neo/tests/CaptureAdapterMocks.h" "/Fe$exe" "/Fo$temp/" "$neo/FrameCapture.cpp" "$neo/tests/CaptureInheritanceTest.cpp" + if ($LASTEXITCODE -ne 0) { throw 'Capture inheritance test compilation failed' } + & $exe + if ($LASTEXITCODE -ne 0) { throw 'Capture inheritance/ownership test failed' } + } finally { + Remove-Item -Recurse -Force $temp + } +} diff --git a/Extensions/NeoPic/tests/EditDataUpgradeTest.cpp b/Extensions/NeoPic/tests/EditDataUpgradeTest.cpp new file mode 100644 index 00000000..1198966f --- /dev/null +++ b/Extensions/NeoPic/tests/EditDataUpgradeTest.cpp @@ -0,0 +1,48 @@ +// The public CI does not contain Fusion's proprietary headers. Use only a mock +// SDK header; the versioned payload and upgrade function below are production code. +#include +#include +struct extHeader { + std::uint32_t extSize, extMaxSize, extVersion; + void* extID; + void* extPrivateData; +}; +#include "../EditData.h" +#include +#include +#include +#include + +static_assert(std::is_standard_layout_v && std::is_standard_layout_v); +static_assert(sizeof(CaptureBackend) == 4 && sizeof(EDITDATA::reserved) == 64); +static_assert(offsetof(EDITDATA_V1, swidth) == offsetof(EDITDATA, swidth)); +static_assert(offsetof(EDITDATA_V1, memoryLimit) == offsetof(EDITDATA, memoryLimit)); +static_assert(offsetof(EDITDATA_V1, hotSpotComboID) == offsetof(EDITDATA, hotSpotComboID)); +static_assert(offsetof(EDITDATA, captureBackend) == sizeof(EDITDATA_V1)); +static_assert(offsetof(EDITDATA, reserved) == sizeof(EDITDATA_V1) + sizeof(CaptureBackend)); +#if INTPTR_MAX == INT32_MAX +static_assert(sizeof(EDITDATA_V1) == 48 && sizeof(EDITDATA) == 116); +#endif + +int main() { + EDITDATA_V1 old{}; + old.swidth = 123; old.sheight = 456; + old.isLib = true; old.bLoadCallback = true; old.bLoadKeepAngle = true; + old.HWA = true; old.stretchQuality = false; + old.memoryLimit = 1024; old.sizeLimit = 321; old.autoClean = true; + old.hotSpotComboID = 8; + EDITDATA current{}; + current.captureBackend = CaptureBackend::WindowsGraphicsCapture; + std::fill_n(current.reserved, sizeof(current.reserved), std::uint8_t{0xA5}); + UpgradeNeoPicEditData(&old, ¤t); + assert(current.swidth == 123 && current.sheight == 456 && current.isLib); + assert(current.bLoadCallback && current.bLoadKeepAngle && current.HWA && !current.stretchQuality); + assert(current.memoryLimit == 1024 && current.sizeLimit == 321 && current.autoClean && current.hotSpotComboID == 8); + assert(current.captureBackend == CaptureBackend::Dc && current.eHeader.extMaxSize == sizeof(EDITDATA)); + for (auto byte : current.reserved) assert(byte == 0); + for (std::uint32_t value = 0; value < 3; ++value) + assert(static_cast(NormalizeCaptureBackend(value)) == value); + assert(NormalizeCaptureBackend(3) == CaptureBackend::Dc); + assert(NormalizeCaptureBackend((std::numeric_limits::max)()) == CaptureBackend::Dc); + std::cout << "PASS: V1 payload preservation, DC upgrade default, 64-byte reserve and backend validation\n"; +} diff --git a/Extensions/NeoPic/tests/completion-mocks/d3d11.h b/Extensions/NeoPic/tests/completion-mocks/d3d11.h new file mode 100644 index 00000000..75a2a424 --- /dev/null +++ b/Extensions/NeoPic/tests/completion-mocks/d3d11.h @@ -0,0 +1,56 @@ +#pragma once +// Deliberately tiny DX11 stand-ins for testing CaptureCompletion's control flow. +// They do not simulate GPU execution and are never on the plugin include path. +#include +#include +using HRESULT = std::int32_t; +using BOOL = int; +using UINT = unsigned; +constexpr HRESULT S_OK = 0, S_FALSE = 1, E_UNEXPECTED = -1, E_POINTER = -2, E_INVALIDARG = -3, E_FAIL = -4; +constexpr BOOL FALSE = 0, TRUE = 1; +constexpr int ERROR_BUSY = 170; +#define HRESULT_FROM_WIN32(x) (-static_cast(x)) +#define FAILED(x) ((x) < 0) +constexpr int D3D11_QUERY_EVENT = 0; +constexpr UINT D3D11_ASYNC_GETDATA_DONOTFLUSH = 1; +enum D3D11_DEVICE_CONTEXT_TYPE { D3D11_DEVICE_CONTEXT_IMMEDIATE, D3D11_DEVICE_CONTEXT_DEFERRED }; +struct D3D11_QUERY_DESC { int Query; UINT MiscFlags; }; +struct MockRef { + int refs = 1; + void AddRef() { ++refs; } + void Release() { --refs; } +}; +struct ID3D11Query : MockRef {}; +inline int mockSleeps = 0; +inline void Sleep(int) { ++mockSleeps; } +struct ID3D11Device : MockRef { + ID3D11Query event; + HRESULT result = S_OK; + int allocations = 0; + HRESULT CreateQuery(const D3D11_QUERY_DESC* desc, ID3D11Query** out) { + ++allocations; + if (desc->Query != D3D11_QUERY_EVENT || desc->MiscFlags != 0) return E_FAIL; + if (FAILED(result)) return result; + event.AddRef(); + *out = &event; + return S_OK; + } +}; +struct ID3D11DeviceContext : MockRef { + D3D11_DEVICE_CONTEXT_TYPE type = D3D11_DEVICE_CONTEXT_IMMEDIATE; + struct Reply { HRESULT hr; BOOL done; }; + std::vector replies; + std::vector operations; + unsigned polls = 0; + bool correctFlags = true; + auto GetType() const { return type; } + void End(ID3D11Query*) { operations.push_back(1); } + void Flush() { operations.push_back(2); } + HRESULT GetData(ID3D11Query*, void* data, UINT size, UINT flags) { + correctFlags &= size == sizeof(BOOL) && flags == D3D11_ASYNC_GETDATA_DONOTFLUSH; + Reply reply = polls < replies.size() ? replies[polls] : Reply{S_OK, TRUE}; + ++polls; + *static_cast(data) = reply.done; + return reply.hr; + } +}; diff --git a/Extensions/NeoPic/tests/completion-mocks/wrl/client.h b/Extensions/NeoPic/tests/completion-mocks/wrl/client.h new file mode 100644 index 00000000..b98845ee --- /dev/null +++ b/Extensions/NeoPic/tests/completion-mocks/wrl/client.h @@ -0,0 +1,19 @@ +#pragma once +namespace Microsoft::WRL { +template class ComPtr { + T* ptr = nullptr; +public: + ~ComPtr() { Reset(); } + T* Get() const { return ptr; } + T* operator->() const { return ptr; } + explicit operator bool() const { return ptr != nullptr; } + T** GetAddressOf() { return &ptr; } + ComPtr& operator=(T* value) { + if (value) value->AddRef(); + Reset(); + ptr = value; + return *this; + } + void Reset() { if (ptr) ptr->Release(); ptr = nullptr; } +}; +}